Project create/list API, clip uploads, CORS, public read

- Project gains a clip FileField; POST /api/projects/ creates one from an
  uploaded otio + clip (<=100 MB), owned by the requester
- add /api/me/ and project-detail; scene GET is public (view without login),
  PUT stays owner/collaborator-only
- serve media in DEBUG (range requests for video seek); CORS for the cljs dev
  origin with credentials
- seed_demo now builds the Challengers project from the bundled otio + 480p clip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-29 01:39:54 -04:00
parent 60fb4df795
commit 94475da936
9 changed files with 114 additions and 41 deletions

View file

@ -1,7 +1,7 @@
import json
from django.db.models import Q
from django.http import FileResponse, HttpResponseNotAllowed, JsonResponse
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
@ -9,6 +9,8 @@ from django.views.decorators.csrf import csrf_exempt
from .attribution import annotation_layer, apply_attribution
from .models import Project, Revision
MAX_CLIP_BYTES = 100 * 1024 * 1024
def _auth(request):
"""None if logged in, else a 401 JSON response (so fetch() doesn't get a
@ -24,27 +26,65 @@ def _visible(request):
Q(owner=request.user) | Q(collaborators=request.user)).distinct()
def _owned(request, pk):
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):
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,
}
def me(request):
"""GET /api/me/ — who the browser is logged in as (for the UI to gate edits)."""
u = request.user
return JsonResponse({"authenticated": u.is_authenticated,
"username": u.get_username() if u.is_authenticated else None})
@csrf_exempt
def projects(request):
"""GET /api/projects/ — the projects the user owns or collaborates on."""
"""GET — the user's projects; POST — create one from an uploaded otio + clip."""
if (resp := _auth(request)):
return resp
rows = _visible(request).values("id", "name", "fps", "updated", "owner__username")
return JsonResponse(list(rows), safe=False)
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)))
@csrf_exempt # dev convenience: session-auth API without a CSRF token round-trip
def scene(request, pk):
"""GET/PUT /api/projects/<pk>/scene/ — read or replace the timeline scene."""
if (resp := _auth(request)):
return resp
project = _owned(request, pk)
"""GET (public) the scene; PUT (owner/collaborator) merges a delta into it."""
if request.method == "GET":
return JsonResponse({"fps": project.fps, "scene": project.scene})
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: {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.
@ -53,23 +93,21 @@ def scene(request, pk):
deleted = data.get("deleted") or []
summary = None
if changed or deleted:
groups = dict((project.scene or {}).get("groups", {}))
groups = dict((p.scene or {}).get("groups", {}))
groups.update(changed)
for gid in deleted:
groups.pop(gid, None)
merged = dict(project.scene or {}, groups=groups)
project.scene, summary, counts = apply_attribution(
project.scene, merged, request.user.get_username(),
timezone.now().isoformat())
merged = dict(p.scene or {}, groups=groups)
p.scene, summary, counts = apply_attribution(
p.scene, merged, request.user.get_username(), timezone.now().isoformat())
if any(counts.values()): # don't log a no-op save
Revision.objects.create(project=project, user=request.user,
summary=summary,
annotations=annotation_layer(project.scene))
Revision.objects.create(project=p, user=request.user, summary=summary,
annotations=annotation_layer(p.scene))
if "fps" in data:
project.fps = data["fps"]
project.save(update_fields=["scene", "fps", "updated"])
return JsonResponse({"ok": True, "updated": project.updated, "summary": summary,
"annotations": annotation_layer(project.scene)})
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"])
@ -77,16 +115,6 @@ def revisions(request, pk):
"""GET /api/projects/<pk>/revisions/ — the save history (who/when/what)."""
if (resp := _auth(request)):
return resp
project = _owned(request, pk)
rows = project.revisions.values("id", "user__username", "created", "summary")
p = _editable(request, pk)
rows = p.revisions.values("id", "user__username", "created", "summary")
return JsonResponse(list(rows), safe=False)
def otio(request, pk):
"""GET /api/projects/<pk>/otio/ — serve the uploaded OTIO file."""
if (resp := _auth(request)):
return resp
project = _owned(request, pk)
if not project.otio:
return JsonResponse({"detail": "no otio uploaded"}, status=404)
return FileResponse(project.otio.open("rb"), content_type="application/json")