import uuid from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse from django.utils import timezone class Post(models.Model): KIND_NOTE_UNLOCK = "note_unlock" KIND_USER_POST = "user_post" KIND_SHARE_INVITE = "share_invite" KIND_CHOICES = [ (KIND_NOTE_UNLOCK, "Note unlocks"), (KIND_USER_POST, "User post"), (KIND_SHARE_INVITE, "Share invites"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey( "lyric.User", related_name="posts", blank=True, null=True, on_delete=models.CASCADE, ) shared_with = models.ManyToManyField( "lyric.User", related_name="shared_posts", blank=True, ) # `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends # to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored # composes default to KIND_USER_POST. kind = models.CharField( max_length=32, choices=KIND_CHOICES, default=KIND_USER_POST, ) # Stored title — set explicitly on creation. Note-unlock Posts hardcode # "Notes & recognitions"; user_post Posts truncate first line to 35 chars # (32 + "..." past length). Replaces the legacy `name` property which # gleaned `lines.first().text` lazily and broke if the first Line was # later edited or deleted. title = models.CharField(max_length=35, default="") def get_absolute_url(self): return reverse("billboard:view_post", args=[self.id]) class Line(models.Model): text = models.TextField(default="") post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines") # `author` PROTECTs against accidental sitewide-entity deletion (notably # `adman`, the system-author for note_unlock + share_invite Lines). # User-typed Lines attribute to the typing User; system-rendered Lines # attribute to adman so the per-line "username" column always renders. author = models.ForeignKey( "lyric.User", on_delete=models.PROTECT, related_name="authored_lines", ) created_at = models.DateTimeField(auto_now_add=True) # System-authored Lines on NOTE_UNLOCK Posts must set this True; the # post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o. # this flag (defense-in-depth alongside view_post's POST guard). admin_solicited = models.BooleanField(default=False) class Meta: ordering = ("created_at", "id") unique_together = ("post", "text") def __str__(self): return self.text class Brief(models.Model): """A slide-down notification record. Owner = whose attention; post = where FYI navigates (and where mark-read happens on GET); line = the specific appended Line that triggered it (so the banner can surface its text). `kind` discriminates the affordances the banner renders. NOTE_UNLOCK Briefs get a clickable square that jumps direct to my_notes.html; SHARE_INVITE Briefs render the invitation copy; USER_POST is the legacy user-authored compose flow. Magic-link confirmation + invalid-link banners use the same Gaussian-glass visual styling but ride no Brief row (transient one-shot). """ KIND_NOTE_UNLOCK = "note_unlock" KIND_USER_POST = "user_post" KIND_SHARE_INVITE = "share_invite" KIND_CHOICES = [ (KIND_NOTE_UNLOCK, "Note unlock"), (KIND_USER_POST, "User post"), (KIND_SHARE_INVITE, "Share invite"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey( "lyric.User", related_name="briefs", on_delete=models.CASCADE, ) post = models.ForeignKey( Post, related_name="briefs", on_delete=models.CASCADE, ) # Line is nullable because a share_invite-style Brief can race ahead of its # async-appended Line write; the post FK alone is enough to navigate. line = models.ForeignKey( Line, related_name="briefs", on_delete=models.CASCADE, null=True, blank=True, ) is_unread = models.BooleanField(default=True) kind = models.CharField( max_length=32, choices=KIND_CHOICES, default=KIND_USER_POST, ) title = models.CharField(max_length=255, blank=True) created_at = models.DateTimeField(default=timezone.now) class Meta: ordering = ["-created_at"] def __str__(self): return ( f"Brief({self.kind}, {self.owner.email}, " f"unread={self.is_unread})" ) def to_banner_dict(self): """Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind carries a square_url pointing at /billboard/my-notes/ so the thumbnail-square inside the banner jumps direct to the user's Note collection — other kinds get an empty square_url.""" square_url = "" if self.kind == self.KIND_NOTE_UNLOCK: square_url = reverse("billboard:my_notes") return { "id": str(self.id), "kind": self.kind, "title": self.title, "line_text": self.line.text if self.line else "", "post_url": self.post.get_absolute_url(), "square_url": square_url, "created_at": self.created_at.isoformat(), } # ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ───────────────── # Defense-in-depth alongside view_post's POST guard. A Line saved on a # NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level # write or an API path that bypasses the view) gets deleted right after # the save. Note.grant_if_new sets admin_solicited=True on its Lines so # legitimate system prose survives. @receiver(post_save, sender=Line) def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs): if not created: return if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited: instance.delete()