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 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 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): 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, "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) 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// — 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: groups = dict((p.scene or {}).get("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()) if any(counts.values()): # don't log a no-op save Revision.objects.create(project=p, user=request.user, summary=summary, annotations=annotation_layer(p.scene)) # push the attributed delta to peers viewing this project scene_groups = (p.scene or {}).get("groups", {}) async_to_sync(get_channel_layer().group_send)( f"project_{p.pk}", {"type": "scene.delta", "delta": {"changed": {gid: scene_groups[gid] for gid in changed if gid in scene_groups}, "deleted": list(deleted), "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"]) def revisions(request, pk): """GET /api/projects//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)