diff --git a/README.md b/README.md index e814017..6ac63aa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ the scene JSON there. | GET | `/api/projects//scene/` | `{fps, scene}` | | PUT | `/api/projects//scene/` | replace `scene` and/or `fps` (JSON body) | | GET | `/api/projects//otio/` | serve the uploaded OTIO file | +| GET | `/api/projects//revisions/` | save history (who / when / summary) | + +### Attribution + +Annotations are the authored unit, so that's what's attributed — and the server +is the authority (client-sent stamps are ignored, so authorship can't be +forged). On each scene `PUT` the server diffs incoming annotation groups against +the stored ones and stamps `createdBy`/`editedBy` (+ timestamps) from +`request.user`; it also writes a `Revision` (user, time, `+N ~N −N` summary, +snapshot of the annotation layer). A project is editable by its `owner` and any +`collaborators` (managed in the admin), so different users get distinct stamps. The frontend currently loads `/one_two_three.otio` and persists annotations to localStorage; pointing it at `/api/projects//otio/` and the scene endpoints diff --git a/scenes/admin.py b/scenes/admin.py index 49d3262..25aae7f 100644 --- a/scenes/admin.py +++ b/scenes/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Project +from .models import Project, Revision @admin.register(Project) @@ -8,4 +8,16 @@ class ProjectAdmin(admin.ModelAdmin): list_display = ("name", "owner", "fps", "updated") list_filter = ("owner",) search_fields = ("name",) + filter_horizontal = ("collaborators",) readonly_fields = ("created", "updated") + + +@admin.register(Revision) +class RevisionAdmin(admin.ModelAdmin): + list_display = ("project", "user", "created", "summary") + list_filter = ("project", "user") + date_hierarchy = "created" + readonly_fields = ("project", "user", "created", "summary", "annotations") + + def has_add_permission(self, request): + return False # revisions are written by the API, not by hand diff --git a/scenes/attribution.py b/scenes/attribution.py new file mode 100644 index 0000000..ea2ccc9 --- /dev/null +++ b/scenes/attribution.py @@ -0,0 +1,62 @@ +"""Server-authoritative attribution for the timeline scene. + +Annotations are the only authored thing in a scene (clips/tracks/root come from +the OTIO), so that's what we attribute. On each save we diff the incoming +annotation groups against the stored ones and stamp who/when — always from the +trusted server-side identity, never from values the client sent, so authorship +can't be forged. +""" + +STAMP_KEYS = ("createdBy", "createdAt", "editedBy", "editedAt") + + +def _is_annotation(group): + return isinstance(group, dict) and group.get("type") == "annotation" + + +def _content(group): + """The group minus its attribution stamps — what we compare for changes.""" + return {k: v for k, v in group.items() if k not in STAMP_KEYS} + + +def annotation_layer(scene): + """Just the annotation groups of a scene, as {gid: group}.""" + return {gid: g for gid, g in (scene or {}).get("groups", {}).items() if _is_annotation(g)} + + +def apply_attribution(prev_scene, incoming_scene, who, now): + """Return (new_scene, summary, counts) with annotation stamps reconciled. + + `who` is the authenticated username, `now` an ISO timestamp string. New + annotations get created/edited stamps; changed ones get a fresh edited stamp + (preserving the original creator); unchanged ones keep the server's existing + stamps. Client-supplied stamp values are ignored throughout. + """ + prev = annotation_layer(prev_scene) + groups = dict((incoming_scene or {}).get("groups", {})) + created = edited = 0 + + for gid, g in groups.items(): + if not _is_annotation(g): + continue + g = {k: v for k, v in g.items() if k not in STAMP_KEYS} # drop client stamps + old = prev.get(gid) + if old is None: + g.update(createdBy=who, createdAt=now, editedBy=who, editedAt=now) + created += 1 + elif _content(g) != _content(old): + g["createdBy"] = old.get("createdBy", who) + g["createdAt"] = old.get("createdAt", now) + g["editedBy"], g["editedAt"] = who, now + edited += 1 + else: # unchanged → restore the server's stamps verbatim + for k in STAMP_KEYS: + if k in old: + g[k] = old[k] + groups[gid] = g + + deleted = sum(1 for gid in prev if gid not in groups) + new_scene = dict(incoming_scene or {}, groups=groups) + counts = {"created": created, "edited": edited, "deleted": deleted} + summary = f"+{created} ~{edited} −{deleted} annotations" + return new_scene, summary, counts diff --git a/scenes/migrations/0002_revision.py b/scenes/migrations/0002_revision.py new file mode 100644 index 0000000..b42a5f8 --- /dev/null +++ b/scenes/migrations/0002_revision.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.6 on 2026-06-29 03:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scenes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Revision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('summary', models.CharField(blank=True, max_length=200)), + ('annotations', models.JSONField(blank=True, default=dict)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='scenes.project')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/scenes/migrations/0003_project_collaborators.py b/scenes/migrations/0003_project_collaborators.py new file mode 100644 index 0000000..27d008b --- /dev/null +++ b/scenes/migrations/0003_project_collaborators.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-06-29 04:11 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scenes', '0002_revision'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='collaborators', + field=models.ManyToManyField(blank=True, related_name='shared_projects', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/scenes/models.py b/scenes/models.py index 84a8d87..49f74de 100644 --- a/scenes/models.py +++ b/scenes/models.py @@ -11,6 +11,8 @@ class Project(models.Model): owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects") + collaborators = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, + related_name="shared_projects") name = models.CharField(max_length=200) otio = models.FileField(upload_to="otio/", blank=True, null=True) fps = models.FloatField(default=NTSC_24) @@ -23,3 +25,21 @@ class Project(models.Model): def __str__(self): return f"{self.name} (#{self.pk})" + + +class Revision(models.Model): + """One scene save: who, when, a summary, and a snapshot of the authored + (annotation) layer — the audit trail tying timeline changes to users.""" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="revisions") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, related_name="+") + created = models.DateTimeField(auto_now_add=True) + summary = models.CharField(max_length=200, blank=True) + annotations = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.project_id} @ {self.created:%Y-%m-%d %H:%M} by {self.user_id}" diff --git a/scenes/urls.py b/scenes/urls.py index 553eba8..cf2b311 100644 --- a/scenes/urls.py +++ b/scenes/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("projects/", views.projects), path("projects//scene/", views.scene), path("projects//otio/", views.otio), + path("projects//revisions/", views.revisions), ] diff --git a/scenes/views.py b/scenes/views.py index 974903f..ba85e9d 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -1,10 +1,13 @@ import json +from django.db.models import Q from django.http import FileResponse, HttpResponseNotAllowed, JsonResponse from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.views.decorators.csrf import csrf_exempt -from .models import Project +from .attribution import annotation_layer, apply_attribution +from .models import Project, Revision def _auth(request): @@ -15,15 +18,21 @@ def _auth(request): 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 _owned(request, pk): - return get_object_or_404(Project, pk=pk, owner=request.user) + return get_object_or_404(_visible(request), pk=pk) def projects(request): - """GET /api/projects/ — the current user's projects.""" + """GET /api/projects/ — the projects the user owns or collaborates on.""" if (resp := _auth(request)): return resp - rows = Project.objects.filter(owner=request.user).values("id", "name", "fps", "updated") + rows = _visible(request).values("id", "name", "fps", "updated", "owner__username") return JsonResponse(list(rows), safe=False) @@ -37,15 +46,30 @@ def scene(request, pk): return JsonResponse({"fps": project.fps, "scene": project.scene}) if request.method == "PUT": data = json.loads(request.body or "{}") + summary = None if "scene" in data: - project.scene = data["scene"] + project.scene, summary, _ = apply_attribution( + project.scene, data["scene"], request.user.get_username(), + timezone.now().isoformat()) + Revision.objects.create(project=project, user=request.user, + summary=summary, + annotations=annotation_layer(project.scene)) if "fps" in data: project.fps = data["fps"] project.save(update_fields=["scene", "fps", "updated"]) - return JsonResponse({"ok": True, "updated": project.updated}) + return JsonResponse({"ok": True, "updated": project.updated, "summary": summary}) return HttpResponseNotAllowed(["GET", "PUT"]) +def revisions(request, pk): + """GET /api/projects//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") + return JsonResponse(list(rows), safe=False) + + def otio(request, pk): """GET /api/projects//otio/ — serve the uploaded OTIO file.""" if (resp := _auth(request)):