Attribute annotations and scene saves to users
Server-authoritative attribution: on each scene PUT the server diffs incoming annotation groups and stamps createdBy/editedBy (+ timestamps) from request.user, ignoring client-sent stamps so authorship can't be forged. Each save also writes a Revision (user, time, +N ~N −N summary, snapshot of the annotation layer), visible in the admin and via /revisions/. Projects gain collaborators so several users can edit one project and get distinct attribution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
57ef570539
commit
36054ac329
8 changed files with 187 additions and 7 deletions
11
README.md
11
README.md
|
|
@ -31,6 +31,17 @@ the scene JSON there.
|
||||||
| GET | `/api/projects/<id>/scene/` | `{fps, scene}` |
|
| GET | `/api/projects/<id>/scene/` | `{fps, scene}` |
|
||||||
| PUT | `/api/projects/<id>/scene/` | replace `scene` and/or `fps` (JSON body) |
|
| PUT | `/api/projects/<id>/scene/` | replace `scene` and/or `fps` (JSON body) |
|
||||||
| GET | `/api/projects/<id>/otio/` | serve the uploaded OTIO file |
|
| GET | `/api/projects/<id>/otio/` | serve the uploaded OTIO file |
|
||||||
|
| GET | `/api/projects/<id>/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
|
The frontend currently loads `/one_two_three.otio` and persists annotations to
|
||||||
localStorage; pointing it at `/api/projects/<id>/otio/` and the scene endpoints
|
localStorage; pointing it at `/api/projects/<id>/otio/` and the scene endpoints
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Project
|
from .models import Project, Revision
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Project)
|
@admin.register(Project)
|
||||||
|
|
@ -8,4 +8,16 @@ class ProjectAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "owner", "fps", "updated")
|
list_display = ("name", "owner", "fps", "updated")
|
||||||
list_filter = ("owner",)
|
list_filter = ("owner",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
filter_horizontal = ("collaborators",)
|
||||||
readonly_fields = ("created", "updated")
|
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
|
||||||
|
|
|
||||||
62
scenes/attribution.py
Normal file
62
scenes/attribution.py
Normal file
|
|
@ -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
|
||||||
30
scenes/migrations/0002_revision.py
Normal file
30
scenes/migrations/0002_revision.py
Normal file
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
20
scenes/migrations/0003_project_collaborators.py
Normal file
20
scenes/migrations/0003_project_collaborators.py
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -11,6 +11,8 @@ class Project(models.Model):
|
||||||
|
|
||||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||||
related_name="projects")
|
related_name="projects")
|
||||||
|
collaborators = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True,
|
||||||
|
related_name="shared_projects")
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
otio = models.FileField(upload_to="otio/", blank=True, null=True)
|
otio = models.FileField(upload_to="otio/", blank=True, null=True)
|
||||||
fps = models.FloatField(default=NTSC_24)
|
fps = models.FloatField(default=NTSC_24)
|
||||||
|
|
@ -23,3 +25,21 @@ class Project(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} (#{self.pk})"
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ urlpatterns = [
|
||||||
path("projects/", views.projects),
|
path("projects/", views.projects),
|
||||||
path("projects/<int:pk>/scene/", views.scene),
|
path("projects/<int:pk>/scene/", views.scene),
|
||||||
path("projects/<int:pk>/otio/", views.otio),
|
path("projects/<int:pk>/otio/", views.otio),
|
||||||
|
path("projects/<int:pk>/revisions/", views.revisions),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
from django.http import FileResponse, HttpResponseNotAllowed, JsonResponse
|
from django.http import FileResponse, HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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):
|
def _auth(request):
|
||||||
|
|
@ -15,15 +18,21 @@ def _auth(request):
|
||||||
return None
|
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):
|
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):
|
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)):
|
if (resp := _auth(request)):
|
||||||
return resp
|
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)
|
return JsonResponse(list(rows), safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,15 +46,30 @@ def scene(request, pk):
|
||||||
return JsonResponse({"fps": project.fps, "scene": project.scene})
|
return JsonResponse({"fps": project.fps, "scene": project.scene})
|
||||||
if request.method == "PUT":
|
if request.method == "PUT":
|
||||||
data = json.loads(request.body or "{}")
|
data = json.loads(request.body or "{}")
|
||||||
|
summary = None
|
||||||
if "scene" in data:
|
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:
|
if "fps" in data:
|
||||||
project.fps = data["fps"]
|
project.fps = data["fps"]
|
||||||
project.save(update_fields=["scene", "fps", "updated"])
|
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"])
|
return HttpResponseNotAllowed(["GET", "PUT"])
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
return JsonResponse(list(rows), safe=False)
|
||||||
|
|
||||||
|
|
||||||
def otio(request, pk):
|
def otio(request, pk):
|
||||||
"""GET /api/projects/<pk>/otio/ — serve the uploaded OTIO file."""
|
"""GET /api/projects/<pk>/otio/ — serve the uploaded OTIO file."""
|
||||||
if (resp := _auth(request)):
|
if (resp := _auth(request)):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue