Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
516 lines
19 KiB
Python
516 lines
19 KiB
Python
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 <span class="card-ref">I. The Schizo</span> as Significator'),
|
|
"palette_options": [],
|
|
"swatch_label": "I",
|
|
},
|
|
"super-nomad": {
|
|
"title": "Super-Nomad",
|
|
"description": mark_safe('Admin access granted to <span class="card-ref">0. The Nomad</span> 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
|
|
# `<time>` element, not in the prose. Author = sharer (post owner)
|
|
# so the per-line "username" column attributes correctly. Anonymous
|
|
# shares (legacy Percival ch. 19 ownerless-post path) fall back to
|
|
# adman since AnonymousUser can't be FK'd. Privacy: we still create
|
|
# the Line + Brief even when the address is unregistered, so the
|
|
# response doesn't leak membership.
|
|
line_text = f"Shared with {recipient_email}"
|
|
author = request.user if request.user.is_authenticated else get_or_create_adman()
|
|
line = Line.objects.create(
|
|
post=our_post, text=line_text, author=author,
|
|
)
|
|
if request.user.is_authenticated:
|
|
brief = Brief.objects.create(
|
|
owner=request.user,
|
|
post=our_post,
|
|
line=line,
|
|
kind=Brief.KIND_SHARE_INVITE,
|
|
title="Invite sent",
|
|
)
|
|
|
|
if is_ajax:
|
|
# recipient_display is populated only when the address resolves to a
|
|
# registered User — same evidence the server-rendered .post-recipient
|
|
# list exposes; doesn't widen the privacy surface beyond what the
|
|
# post detail page already shows publicly.
|
|
recipient_display = None
|
|
recipient_user_id = None
|
|
if recipient is not None:
|
|
recipient_display = recipient.username or recipient.email
|
|
recipient_user_id = str(recipient.id)
|
|
return JsonResponse({
|
|
"brief": brief.to_banner_dict() if brief is not None else None,
|
|
"line_text": line_text,
|
|
"recipient_display": recipient_display,
|
|
"recipient_user_id": recipient_user_id,
|
|
"already_present": is_reshare,
|
|
})
|
|
|
|
messages.success(request, "An invite has been sent if that address is registered.")
|
|
return redirect(our_post)
|
|
|
|
|
|
# ── My Buds ───────────────────────────────────────────────────────────────
|
|
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
|
|
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
|
|
# slide-out. Privacy: when an entered email isn't a registered User, we
|
|
# 200 with {bud: null} so the response shape doesn't leak membership.
|
|
|
|
@login_required(login_url="/")
|
|
def my_buds(request):
|
|
return render(request, "apps/billboard/my_buds.html", {
|
|
"buds": request.user.buds.all(),
|
|
"page_class": "page-billbuds",
|
|
})
|
|
|
|
|
|
def _resolve_recipient(raw):
|
|
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
|
Email match takes precedence — if the input contains '@' we don't even
|
|
try the username lookup, so a username that happens to match an email
|
|
user's local part doesn't get coerced. Used by add_bud + share_post."""
|
|
raw = (raw or "").strip()
|
|
if not raw:
|
|
return None
|
|
if "@" in raw:
|
|
try:
|
|
return User.objects.get(email__iexact=raw)
|
|
except User.DoesNotExist:
|
|
return None
|
|
try:
|
|
return User.objects.get(username__iexact=raw)
|
|
except User.DoesNotExist:
|
|
return None
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def add_bud(request):
|
|
if request.method != "POST":
|
|
from django.http import HttpResponseNotAllowed
|
|
return HttpResponseNotAllowed(["POST"])
|
|
|
|
candidate = _resolve_recipient(request.POST.get("recipient"))
|
|
|
|
bud = None
|
|
already_present = False
|
|
recipient_display = None
|
|
recipient_user_id = None
|
|
if candidate is not None and candidate != request.user:
|
|
already_present = candidate in request.user.buds.all()
|
|
if not already_present:
|
|
request.user.buds.add(candidate)
|
|
display = candidate.username or candidate.email
|
|
bud = {
|
|
"id": str(candidate.id),
|
|
"username": display,
|
|
"email": candidate.email,
|
|
}
|
|
recipient_display = display
|
|
recipient_user_id = str(candidate.id)
|
|
|
|
return JsonResponse({
|
|
"bud": bud,
|
|
"already_present": already_present,
|
|
"recipient_display": recipient_display,
|
|
"recipient_user_id": recipient_user_id,
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def search_buds(request):
|
|
"""Top-3 prefix-match autocomplete pool for #id_recipient inputs.
|
|
Pulls only from request.user.buds — buds that haven't been added yet
|
|
don't appear in the autocomplete (privacy-by-default; new buds enter
|
|
the list via explicit add or implicit auto-add on share/invite).
|
|
Matches case-insensitive on either username or email prefix."""
|
|
from django.db.models import Q
|
|
q = (request.GET.get("q") or "").strip()
|
|
if not q:
|
|
return JsonResponse({"buds": []})
|
|
matches = (
|
|
request.user.buds
|
|
.filter(Q(username__istartswith=q) | Q(email__istartswith=q))
|
|
.order_by("username", "email")[:3]
|
|
)
|
|
return JsonResponse({"buds": [
|
|
{
|
|
"id": str(b.id),
|
|
"username": b.username or b.email,
|
|
"email": b.email,
|
|
}
|
|
for b in matches
|
|
]})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def save_scroll_position(request, room_id):
|
|
if request.method != "POST":
|
|
from django.http import HttpResponseNotAllowed
|
|
return HttpResponseNotAllowed(["POST"])
|
|
room = Room.objects.get(id=room_id)
|
|
position = int(request.POST.get("position", 0))
|
|
ScrollPosition.objects.update_or_create(
|
|
user=request.user, room=room,
|
|
defaults={"position": position},
|
|
)
|
|
from django.http import HttpResponse
|
|
return HttpResponse(status=204)
|