- billboard/0005 adds Line.admin_solicited (BooleanField default False); RunPython backfills existing note_unlock Lines to True. Note.grant_if_new sets admin_solicited=True on its system prose.
- billboard.models post_save signal: any Line saved on a Post.kind=NOTE_UNLOCK without admin_solicited=True is deleted (defense-in-depth alongside the view guard).
- billboard.views.view_post hard-rejects POST on NOTE_UNLOCK kind (HTTP 403) — clean view-level contract; the post_save listener is the safety net for ORM/API paths that bypass it.
- templates/apps/billboard/post.html: NOTE_UNLOCK branch renders the input as readonly w. 'No response needed at this time' placeholder + no method/action; user_post branch keeps the regular composer. Buddy panel include guarded behind `{% if post.kind != 'note_unlock' %}` — friend invites don't apply to admin threads.
- SCSS: .post-line-form input.form-control[readonly]:focus uses --secUser glow (cooler than the regular --terUser composer focus).
- share_post: drop the iso-timestamp suffix on Line.text (just 'Shared with {email}'); author = request.user (anon legacy fallback to adman so AnonymousUser doesn't break the FK); re-share of an already-in-shared_with recipient is a silent no-op (no second Line, brief: null in JSON response). Buddy panel JS now reads data-sharer-name from server-rendered display_name so the optimistic _appendLine matches the post-refresh state.
- new ITs: test_admin_posts (PostRejectsAdminWritesTest, UnsolicitedLineListenerTest, NoteGrantSetsAdminSolicitedTest) — 7 tests; share_post tests rewritten for the new contract (drop ts, author=sharer, silent re-share dedup) — 12 tests; new FT test_admin_post_readonly w. AdminPostInputReadonlyTest + AdminPostHasNoBuddyBtnTest + UserPostInputUnaffectedTest — 4 tests. 827 ITs + 18 buddy/sharing FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
Python
389 lines
14 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)
|
|
return render(request, "apps/billboard/post.html", {
|
|
"post": our_post,
|
|
"form": form,
|
|
"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()
|
|
return render(request, "apps/billboard/my_posts.html", {
|
|
"owner": owner,
|
|
"page_class": "page-billboard",
|
|
})
|
|
|
|
|
|
def share_post(request, post_id):
|
|
our_post = Post.objects.get(id=post_id)
|
|
is_ajax = "application/json" in request.headers.get("Accept", "")
|
|
|
|
recipient_email = request.POST.get("recipient", "")
|
|
recipient = None
|
|
try:
|
|
recipient = User.objects.get(email=recipient_email)
|
|
except User.DoesNotExist:
|
|
pass
|
|
|
|
# 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)
|
|
|
|
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
|
|
if recipient is not None:
|
|
recipient_display = recipient.username or recipient.email
|
|
return JsonResponse({
|
|
"brief": brief.to_banner_dict() if brief is not None else None,
|
|
"line_text": line_text,
|
|
"recipient_display": recipient_display,
|
|
})
|
|
|
|
messages.success(request, "An invite has been sent if that address is registered.")
|
|
return redirect(our_post)
|
|
|
|
|
|
@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)
|