2026-06-28 23:45:49 -04:00
|
|
|
import json
|
|
|
|
|
|
2026-06-29 01:54:22 -04:00
|
|
|
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
|
2026-06-29 00:12:13 -04:00
|
|
|
from django.db.models import Q
|
2026-06-29 01:39:54 -04:00
|
|
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
2026-06-28 23:45:49 -04:00
|
|
|
from django.shortcuts import get_object_or_404
|
2026-06-29 00:12:13 -04:00
|
|
|
from django.utils import timezone
|
2026-06-28 23:45:49 -04:00
|
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
|
|
2026-06-29 00:12:13 -04:00
|
|
|
from .attribution import annotation_layer, apply_attribution
|
|
|
|
|
from .models import Project, Revision
|
2026-06-28 23:45:49 -04:00
|
|
|
|
2026-06-29 01:39:54 -04:00
|
|
|
MAX_CLIP_BYTES = 100 * 1024 * 1024
|
|
|
|
|
|
2026-06-28 23:45:49 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:12:13 -04:00
|
|
|
def _visible(request):
|
|
|
|
|
"""Projects the user owns or collaborates on."""
|
|
|
|
|
return Project.objects.filter(
|
|
|
|
|
Q(owner=request.user) | Q(collaborators=request.user)).distinct()
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:39:54 -04:00
|
|
|
def _editable(request, pk):
|
|
|
|
|
"""The project if the user may edit it (owner/collaborator), else 404."""
|
2026-06-29 00:12:13 -04:00
|
|
|
return get_object_or_404(_visible(request), pk=pk)
|
2026-06-28 23:45:49 -04:00
|
|
|
|
|
|
|
|
|
2026-06-29 01:39:54 -04:00
|
|
|
def _meta(request, project):
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:54:22 -04:00
|
|
|
def _who(user):
|
|
|
|
|
return {"authenticated": user.is_authenticated,
|
|
|
|
|
"username": user.get_username() if user.is_authenticated else None}
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 01:39:54 -04:00
|
|
|
def me(request):
|
|
|
|
|
"""GET /api/me/ — who the browser is logged in as (for the UI to gate edits)."""
|
2026-06-29 01:54:22 -04:00
|
|
|
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))
|
2026-06-29 01:39:54 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
2026-06-28 23:45:49 -04:00
|
|
|
def projects(request):
|
2026-06-29 01:39:54 -04:00
|
|
|
"""GET — the user's projects; POST — create one from an uploaded otio + clip."""
|
2026-06-28 23:45:49 -04:00
|
|
|
if (resp := _auth(request)):
|
|
|
|
|
return resp
|
2026-06-29 01:39:54 -04:00
|
|
|
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)
|
|
|
|
|
project = Project.objects.create(owner=request.user, name=name, otio=otio, clip=clip)
|
|
|
|
|
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)))
|
2026-06-28 23:45:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt # dev convenience: session-auth API without a CSRF token round-trip
|
|
|
|
|
def scene(request, pk):
|
2026-06-29 01:39:54 -04:00
|
|
|
"""GET (public) the scene; PUT (owner/collaborator) merges a delta into it."""
|
2026-06-28 23:45:49 -04:00
|
|
|
if request.method == "GET":
|
2026-06-29 01:39:54 -04:00
|
|
|
p = get_object_or_404(Project, pk=pk)
|
|
|
|
|
return JsonResponse({"fps": p.fps, "scene": p.scene})
|
2026-06-28 23:45:49 -04:00
|
|
|
if request.method == "PUT":
|
2026-06-29 01:39:54 -04:00
|
|
|
if (resp := _auth(request)):
|
|
|
|
|
return resp
|
|
|
|
|
p = _editable(request, pk)
|
2026-06-29 01:34:52 -04:00
|
|
|
# A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}.
|
|
|
|
|
# Merging per-id means two users editing different annotations both land
|
|
|
|
|
# (no clobber); same-annotation edits are last-write-wins by arrival.
|
2026-06-28 23:45:49 -04:00
|
|
|
data = json.loads(request.body or "{}")
|
2026-06-29 01:34:52 -04:00
|
|
|
changed = data.get("changed") or {}
|
|
|
|
|
deleted = data.get("deleted") or []
|
2026-06-29 00:12:13 -04:00
|
|
|
summary = None
|
2026-06-29 01:34:52 -04:00
|
|
|
if changed or deleted:
|
2026-06-29 01:39:54 -04:00
|
|
|
groups = dict((p.scene or {}).get("groups", {}))
|
2026-06-29 01:34:52 -04:00
|
|
|
groups.update(changed)
|
|
|
|
|
for gid in deleted:
|
|
|
|
|
groups.pop(gid, None)
|
2026-06-29 01:39:54 -04:00
|
|
|
merged = dict(p.scene or {}, groups=groups)
|
|
|
|
|
p.scene, summary, counts = apply_attribution(
|
|
|
|
|
p.scene, merged, request.user.get_username(), timezone.now().isoformat())
|
2026-06-29 01:34:52 -04:00
|
|
|
if any(counts.values()): # don't log a no-op save
|
2026-06-29 01:39:54 -04:00
|
|
|
Revision.objects.create(project=p, user=request.user, summary=summary,
|
|
|
|
|
annotations=annotation_layer(p.scene))
|
2026-06-28 23:45:49 -04:00
|
|
|
if "fps" in data:
|
2026-06-29 01:39:54 -04:00
|
|
|
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)})
|
2026-06-28 23:45:49 -04:00
|
|
|
return HttpResponseNotAllowed(["GET", "PUT"])
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:12:13 -04:00
|
|
|
def revisions(request, pk):
|
|
|
|
|
"""GET /api/projects/<pk>/revisions/ — the save history (who/when/what)."""
|
|
|
|
|
if (resp := _auth(request)):
|
|
|
|
|
return resp
|
2026-06-29 01:39:54 -04:00
|
|
|
p = _editable(request, pk)
|
|
|
|
|
rows = p.revisions.values("id", "user__username", "created", "summary")
|
2026-06-29 00:12:13 -04:00
|
|
|
return JsonResponse(list(rows), safe=False)
|