tl/scenes/views.py

207 lines
8.3 KiB
Python
Raw Normal View History

import json
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
2026-06-29 03:19:47 -04:00
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from .attribution import annotation_layer, apply_attribution
from .models import Project, Revision
2026-06-29 03:19:47 -04:00
from .thumbnails import enqueue_thumbnail_generation
MAX_CLIP_BYTES = 100 * 1024 * 1024
def _auth(request):
"""None if logged in, else a 401 JSON response (so fetch() doesn't get a
login redirect)."""
if not request.user.is_authenticated:
return JsonResponse({"detail": "authentication required"}, status=401)
return None
def _visible(request):
"""Projects the user owns or collaborates on."""
return Project.objects.filter(
Q(owner=request.user) | Q(collaborators=request.user)).distinct()
def _editable(request, pk):
"""The project if the user may edit it (owner/collaborator), else 404."""
return get_object_or_404(_visible(request), pk=pk)
def _meta(request, project):
2026-06-29 03:19:47 -04:00
thumbnails = dict(project.thumbnail_manifest or {})
if thumbnails.get("template"):
thumbnails["template"] = request.build_absolute_uri(thumbnails["template"])
if thumbnails.get("clips"):
thumbnails["clips"] = {
clip_id: [
dict(tile, url=request.build_absolute_uri(tile["url"]))
for tile in tiles
]
for clip_id, tiles in thumbnails["clips"].items()
}
return {
"id": project.pk,
"name": project.name,
"fps": project.fps,
"owner": project.owner.get_username(),
"otio": request.build_absolute_uri(project.otio.url) if project.otio else None,
"clip": request.build_absolute_uri(project.clip.url) if project.clip else None,
"script": request.build_absolute_uri(project.script.url) if project.script else None,
2026-06-29 03:19:47 -04:00
"thumbnail_status": project.thumbnail_status,
"thumbnail_error": project.thumbnail_error,
"thumbnails": thumbnails or None,
}
def _who(user):
return {"authenticated": user.is_authenticated,
"username": user.get_username() if user.is_authenticated else None}
def me(request):
"""GET /api/me/ — who the browser is logged in as (for the UI to gate edits)."""
return JsonResponse(_who(request.user))
@csrf_exempt
def login_view(request):
"""POST /api/login/ {username, password} — start a session (httpOnly cookie)."""
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
data = json.loads(request.body or "{}")
user = authenticate(request, username=data.get("username"), password=data.get("password"))
if user is None:
return JsonResponse({"detail": "invalid username or password"}, status=400)
dj_login(request, user)
return JsonResponse(_who(user))
@csrf_exempt
def logout_view(request):
"""POST /api/logout/ — end the session."""
dj_logout(request)
return JsonResponse(_who(request.user))
@csrf_exempt
def projects(request):
"""GET — the user's projects; POST — create one from an uploaded otio + clip."""
if (resp := _auth(request)):
return resp
if request.method == "GET":
rows = list(_visible(request).values("id", "name", "fps", "updated", "owner__username"))
return JsonResponse(rows, safe=False)
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
otio = request.FILES.get("otio")
clip = request.FILES.get("clip")
if not (name and otio and clip):
return JsonResponse({"detail": "name, otio and clip are required"}, status=400)
if clip.size > MAX_CLIP_BYTES:
return JsonResponse({"detail": "clip exceeds 100 MB"}, status=400)
2026-06-29 03:19:47 -04:00
project = Project.objects.create(
owner=request.user,
name=name,
otio=otio,
clip=clip,
thumbnail_status="pending",
)
transaction.on_commit(lambda: enqueue_thumbnail_generation(project.pk))
return JsonResponse(_meta(request, project), status=201)
return HttpResponseNotAllowed(["GET", "POST"])
def project(request, pk):
"""GET /api/projects/<pk>/ — project metadata + media URLs (public to view)."""
return JsonResponse(_meta(request, get_object_or_404(Project, pk=pk)))
@csrf_exempt # dev convenience: session-auth API without a CSRF token round-trip
def scene(request, pk):
"""GET (public) the scene; PUT (owner/collaborator) merges a delta into it."""
if request.method == "GET":
p = get_object_or_404(Project, pk=pk)
return JsonResponse({"fps": p.fps, "scene": p.scene})
if request.method == "PUT":
if (resp := _auth(request)):
return resp
p = _editable(request, pk)
# A delta, not the whole scene. `changed` values may be whole new
# annotations or field patches for existing annotations. Merging per
# field lets same-annotation edits to different fields both land.
data = json.loads(request.body or "{}")
changed = data.get("changed") or {}
deleted = data.get("deleted") or []
summary = None
if changed or deleted:
prev_groups = (p.scene or {}).get("groups", {})
groups = dict(prev_groups)
for gid, patch in changed.items():
if isinstance(patch, dict) and isinstance(groups.get(gid), dict):
groups[gid] = {**groups[gid], **patch}
else:
groups[gid] = patch
for gid in deleted:
groups.pop(gid, None)
merged = dict(p.scene or {}, groups=groups)
p.scene, summary, counts = apply_attribution(
p.scene, merged, request.user.get_username(), timezone.now().isoformat())
scene_groups = (p.scene or {}).get("groups", {})
if any(counts.values()): # history logs annotation edits only
Revision.objects.create(project=p, user=request.user, summary=summary,
annotations=annotation_layer(p.scene))
# Live sync must cover EVERY group kind, not just annotations: script
# notes and drawings aren't counted by attribution, so gating the
# broadcast on `counts` silently dropped their updates to peers (they
# only appeared on a full reload). Broadcast any gid that really moved.
touched = {gid: scene_groups[gid] for gid in changed
if gid in scene_groups and scene_groups[gid] != prev_groups.get(gid)}
removed = [gid for gid in deleted if gid in prev_groups]
if touched or removed:
async_to_sync(get_channel_layer().group_send)(
f"project_{p.pk}",
{"type": "scene.delta",
"delta": {"changed": touched,
"deleted": removed,
"by": request.user.get_username()}})
if "fps" in data:
p.fps = data["fps"]
p.save(update_fields=["scene", "fps", "updated"])
return JsonResponse({"ok": True, "updated": p.updated, "summary": summary,
"annotations": annotation_layer(p.scene)})
return HttpResponseNotAllowed(["GET", "PUT"])
@csrf_exempt
def script(request, pk):
"""POST (owner/collaborator) — upload or replace the project's script PDF."""
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
if (resp := _auth(request)):
return resp
p = _editable(request, pk)
f = request.FILES.get("script")
if not f:
return JsonResponse({"detail": "script file is required"}, status=400)
p.script = f
p.save(update_fields=["script", "updated"])
return JsonResponse(_meta(request, p))
def revisions(request, pk):
"""GET /api/projects/<pk>/revisions/ — the save history (who/when/what)."""
if (resp := _auth(request)):
return resp
p = _editable(request, pk)
rows = p.revisions.values("id", "user__username", "created", "summary")
return JsonResponse(list(rows), safe=False)