import json from django.contrib import messages from django.contrib.auth.decorators import login_required from django.utils.html import mark_safe from django.db.models import Max, Q from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render from apps.applets.utils import applet_context, apply_applet_toggle from apps.billboard.forms import ExistingPostLineForm, LineForm from apps.billboard.models import Brief, Line, Post from apps.dashboard.views import _PALETTE_DEFS from apps.drama.models import GameEvent, Note, ScrollPosition from apps.epic.models import Room from apps.epic.utils import rooms_for_user from apps.lyric.models import User, get_or_create_adman _PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS} def _recent_posts(user, limit=3): return ( Post .objects .filter(Q(owner=user) | Q(shared_with=user)) .annotate(last_line=Max('lines__id')) .order_by('-last_line') .distinct()[:limit] ) def _billboard_context(user): my_rooms = rooms_for_user(user).order_by("-created_at") recent_room = ( Room.objects.filter( Q(owner=user) | Q(gate_slots__gamer=user) ) .annotate(last_event=Max("events__timestamp")) .filter(last_event__isnull=False) .order_by("-last_event") .distinct() .first() ) # SIG_READY+retracted exclusion is done in Python because SQLite's NULL # semantics drop ALL SIG_READY events whose data has no `retracted` key: # `data__retracted=True` resolves to NULL via JSON_EXTRACT for missing keys, # and `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL → row # filtered out. We pull a buffer (100) to absorb any retracted prefix and # then slice to 36 after Python filtering. if recent_room: candidates = list( recent_room.events .select_related("actor") .exclude(verb=GameEvent.SIG_UNREADY) .order_by("-timestamp")[:100] ) visible = [ e for e in candidates if not (e.verb == GameEvent.SIG_READY and e.data.get("retracted")) ] recent_events = visible[:36][::-1] else: recent_events = [] return { "my_rooms": my_rooms, "recent_room": recent_room, "recent_events": recent_events, "viewer": user, "applets": applet_context(user, "billboard"), "form": LineForm(), "recent_posts": _recent_posts(user), } @login_required(login_url="/") def billboard(request): return render(request, "apps/billboard/billboard.html", { **_billboard_context(request.user), "page_class": "page-billboard", }) @login_required(login_url="/") def toggle_billboard_applets(request): checked = request.POST.getlist("applets") apply_applet_toggle(request.user, "billboard", checked) if request.headers.get("HX-Request"): return render( request, "apps/billboard/_partials/_applets.html", _billboard_context(request.user), ) return redirect("billboard:billboard") @login_required(login_url="/") def scroll(request, room_id): room = Room.objects.get(id=room_id) events = room.events.select_related("actor").all() sp = ScrollPosition.objects.filter(user=request.user, room=room).first() return render(request, "apps/billboard/scroll.html", { "room": room, "events": events, "viewer": request.user, "scroll_position": sp.position if sp else 0, "page_class": "page-billscroll", }) def _palette_opts(names): return [{"name": n, "label": _PALETTE_LABELS.get(n, n)} for n in names] _NOTE_META = { "stargazer": { "title": "Stargazer", "description": "You saved your first personal sky chart.", "palette_options": _palette_opts(["palette-bardo", "palette-sheol"]), "swatch_label": None, }, "schizo": { "title": "Schizo", "description": "The socius recognizes the line of flight.", "palette_options": [], "swatch_label": None, }, "nomad": { "title": "Nomad", "description": "The socius recognizes the smooth space.", "palette_options": [], "swatch_label": None, }, "super-schizo": { "title": "Super-Schizo", "description": mark_safe('Admin access granted to I. The Schizo as Significator'), "palette_options": [], "swatch_label": "I", }, "super-nomad": { "title": "Super-Nomad", "description": mark_safe('Admin access granted to 0. The Nomad as Significator'), "palette_options": [], "swatch_label": "0", }, } @login_required(login_url="/") def note_set_palette(request, slug): from django.http import Http404 from apps.dashboard.views import _unlocked_palettes_for_user try: note = Note.objects.get(user=request.user, slug=slug) except Note.DoesNotExist: raise Http404 if request.method == "POST": body = json.loads(request.body) palette = body.get("palette", "") note.palette = palette note.save(update_fields=["palette"]) # Commit as the user's active sitewide palette now that the Note unlocks it. if palette in _unlocked_palettes_for_user(request.user): request.user.palette = palette request.user.save(update_fields=["palette"]) return JsonResponse({"ok": True}) @login_required(login_url="/") def my_notes(request): qs = Note.objects.filter(user=request.user) active_title = request.user.active_title note_items = [ { "obj": n, "title": _NOTE_META.get(n.slug, {}).get("title", n.slug), "recognition_title": n.display_title, "description": _NOTE_META.get(n.slug, {}).get("description", ""), "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"), "palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "", "is_equipped": active_title is not None and active_title.pk == n.pk, } for n in qs ] return render(request, "apps/billboard/my_notes.html", { "notes": qs, "note_items": note_items, "page_class": "page-notes", }) @login_required(login_url="/") def don_title(request, slug): from django.http import Http404 try: note = Note.objects.get(user=request.user, slug=slug) except Note.DoesNotExist: raise Http404 if request.method == "POST": request.user.active_title = note request.user.save(update_fields=["active_title"]) return JsonResponse({"title": note.display_title, "greeting": note.display_greeting}) @login_required(login_url="/") def doff_title(request, slug): if request.method == "POST": request.user.active_title = None request.user.save(update_fields=["active_title"]) return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"}) # ── Post / Line CRUD (relocated from apps.dashboard) ──────────────────────── # Templates also live under templates/apps/billboard/. URL names sit in the # `billboard:` namespace so reversers across the codebase carry the prefix. def _truncate_post_title(text, length=35): """Glean a Post.title from the first user-submitted Line: copy first `length` chars exactly, or truncate to `length-3` chars + "..." past that. Mirrors billboard/migrations/0004 backfill helper.""" if len(text) <= length: return text return text[: length - 3] + "..." def new_post(request): form = LineForm(data=request.POST) if form.is_valid(): # Anonymous compose path (Percival ch. 18 chapters) keeps owner=null # but still needs an author for the Line FK. We require auth on this # view's caller paths in practice; no anonymous Lines reach prod. author = request.user if request.user.is_authenticated else None nupost = Post.objects.create( title=_truncate_post_title(form.cleaned_data["text"]), ) if request.user.is_authenticated: nupost.owner = request.user nupost.save() if author is not None: form.save(for_post=nupost, author=author) return redirect(nupost) else: context = { "form": form, "page_class": "page-billboard", } if request.user.is_authenticated: context["applets"] = applet_context(request.user, "billboard") context["recent_posts"] = _recent_posts(request.user) return render(request, "apps/billboard/billboard.html", context) def view_post(request, post_id): our_post = Post.objects.get(id=post_id) if our_post.owner: if not request.user.is_authenticated: return redirect("/") if request.user != our_post.owner and request.user not in our_post.shared_with.all(): return HttpResponseForbidden() # Admin-Post (note-unlock thread) hard write-rejection — the per-Line # signal in billboard.models nukes any Line that bypasses this guard, # but at the view level we want a clean 403 so the FT/IT contract is # explicit and the client never sees a silent line vanish. if our_post.kind == Post.KIND_NOTE_UNLOCK and request.method == "POST": return HttpResponseForbidden() form = ExistingPostLineForm(for_post=our_post) if request.method == "POST": form = ExistingPostLineForm(for_post=our_post, data=request.POST) if form.is_valid(): form.save(author=request.user) return redirect(our_post) # GET render is the FYI-read contract — flip every unread Brief on this # post for the requesting user. POST (compose) is intentionally excluded # because the user is authoring, not reviewing the new Line. if request.user.is_authenticated: Brief.objects.filter( owner=request.user, post=our_post, is_unread=True, ).update(is_unread=False) # Header-prose branching: post.html shows different self/shared lines # depending on whether the viewer IS the owner. The invitee branch # ("shared with me, @viewer …" + "created by @owner …") only kicks # in when (a) the viewer is authenticated AND (b) the post has an # owner AND (c) the viewer is NOT that owner. Ownerless posts and # anonymous viewers fall through to the owner-style rendering (which # handles missing data gracefully via the at_handle/display_name # filter guards). is_real_invitee = ( request.user.is_authenticated and our_post.owner is not None and request.user != our_post.owner ) viewer_is_owner = not is_real_invitee if is_real_invitee: other_recipients = our_post.shared_with.exclude(pk=request.user.pk) else: other_recipients = our_post.shared_with.all() return render(request, "apps/billboard/post.html", { "post": our_post, "form": form, "viewer_is_owner": viewer_is_owner, "other_recipients": other_recipients, "page_class": "page-billpost", }) def my_posts(request, user_id): owner = User.objects.get(id=user_id) if not request.user.is_authenticated: return redirect("/") if request.user.id != owner.id: return HttpResponseForbidden() handle = owner.username or owner.email return render(request, "apps/billboard/my_posts.html", { "owner": owner, "owner_posts_title": f"@{handle}'s Posts", "others_posts_title": "Posts by Others", "page_class": "page-billposts", }) def share_post(request, post_id): our_post = Post.objects.get(id=post_id) is_ajax = "application/json" in request.headers.get("Accept", "") # Recipient may be email OR username — _resolve_recipient handles both # (email if "@" present, else username lookup). The raw value is kept # for the Line text since users see what they typed in the per-line # rendering (post-refresh + optimistic JS append). recipient_email = (request.POST.get("recipient") or "").strip() recipient = _resolve_recipient(recipient_email) # Sharer-tries-to-share-with-themselves: silent no-op (existing behavior). if recipient is not None and recipient == request.user: if is_ajax: return JsonResponse({"brief": None, "line_text": ""}) return redirect(our_post) # Re-share dedup: if the recipient is already in shared_with (registered # email previously shared), skip the Line + Brief — silent no-op. # `add()` itself is idempotent on M2M, but we want the JSON response to # signal "nothing happened" so the JS can suppress the banner. is_reshare = recipient is not None and recipient in our_post.shared_with.all() if recipient is not None and not is_reshare: our_post.shared_with.add(recipient) # Implicit auto-add to the buds graph — symmetric on shared events # (per-spec): a share-event implies a mutual social link. # `add()` is idempotent on M2M, no need to pre-check membership. if request.user.is_authenticated: request.user.buds.add(recipient) recipient.buds.add(request.user) line = None brief = None line_text = "" if not is_reshare: # Plain "Shared with X" — timestamp display lives on the per-Line # `