Projects live at URLs, have owners, and are edited together live

A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.

Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.

An empty symbol now survives the leaf round trip with `:nodes {}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 22:04:03 -04:00
parent c17ee138f2
commit 6a53adb5e0
31 changed files with 1918 additions and 158 deletions

87
clips/consumers.py Normal file
View file

@ -0,0 +1,87 @@
"""One socket per open project, and it is tl's, nearly line for line.
Two things ride it. DELTAS, which the server sends after a write commits — the
socket is read-only for the document, and a dropped socket cannot lose a write.
PRESENCE, which peers gossip between themselves: all the server does is hand out
a connection id and stamp the sender's identity onto every message, so nobody can
post as somebody else.
"""
import json
import uuid
from asgiref.sync import async_to_sync
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.layers import get_channel_layer
# Who is connected, per project: {group: {cid: presence}}. A cache of what has
# already been relayed, so a joiner gets the room in one message. Process-local,
# like the in-memory channel layer this runs on.
ROOMS = {}
def group(project_id):
return f"project_{project_id}"
def broadcast(project_id, delta, kind="delta"):
"""Send a committed write to everyone in the project's room. `access` says
only that who may write has changed, and each client asks for itself."""
async_to_sync(get_channel_layer().group_send)(
group(project_id), {"type": "project.delta", "delta": {"kind": kind, **delta}},
)
class ProjectConsumer(AsyncWebsocketConsumer):
RELAYED = ("state",)
@property
def room(self):
return ROOMS.setdefault(self.group, {})
async def connect(self):
self.group = group(self.scope["url_route"]["kwargs"]["project_id"])
self.cid = uuid.uuid4().hex[:12]
user = self.scope.get("user")
self.username = user.get_username() if user and user.is_authenticated else None
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
me = {"cid": self.cid, "user": self.username}
others = list(self.room.values())
self.room[self.cid] = me
await self.send(text_data=json.dumps({"kind": "welcome", **me}))
await self.send(text_data=json.dumps({"kind": "roster", "peers": others}))
await self._relay({"kind": "join"})
async def disconnect(self, code):
if hasattr(self, "cid"):
self.room.pop(self.cid, None)
if not self.room:
ROOMS.pop(self.group, None)
await self._relay({"kind": "leave"})
await self.channel_layer.group_discard(self.group, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
try:
msg = json.loads(text_data or "{}")
except ValueError:
return
if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED:
return
if self.cid in self.room:
self.room[self.cid].update(
{k: v for k, v in msg.items() if k not in ("kind", "cid", "user")}
)
await self._relay(msg)
async def _relay(self, msg):
await self.channel_layer.group_send(
self.group,
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}},
)
async def peer_msg(self, event):
await self.send(text_data=json.dumps(event["msg"]))
async def project_delta(self, event):
await self.send(text_data=json.dumps(event["delta"]))

View file

@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def orphans(apps, schema_editor):
# Every project has an owner, and none of the ones saved before owners did.
apps.get_model("clips", "Project").objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("clips", "0007_symbols_not_timelines"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunPython(orphans, migrations.RunPython.noop),
migrations.AddField(
model_name="leaf",
name="seq",
field=models.PositiveBigIntegerField(
default=0, help_text="the project seq of the write that last changed it"),
),
migrations.AddField(
model_name="project",
name="editors",
field=models.ManyToManyField(blank=True, related_name="shared_projects",
to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="project",
name="owner",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name="projects", to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-30 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0008_owners_editors_leaf_seq'),
]
operations = [
migrations.AddField(
model_name='revision',
name='blocks',
field=models.JSONField(default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them"),
),
]

View file

@ -24,7 +24,9 @@ without parsing its leaves: which footage, which analysis, which blocks.
"""
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class Blob(models.Model):
@ -201,10 +203,19 @@ class Project(models.Model):
`schema_version` identifies the stored document format. `seq` counts writes
to this particular project; it is not a format version. Every write bumps
`seq`, and a client that sees `seq > local + 1` refetches once broadcasts exist.
`seq`, and a client that sees `seq > local + 1` refetches.
ANYONE WITH THE LINK CAN VIEW; the owner and the editors can write. Every
project has an owner.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects",
)
editors = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
)
name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=2)
seq = models.PositiveBigIntegerField(default=0)
@ -219,10 +230,19 @@ class Project(models.Model):
return f"{self.name} ({self.id})"
def bump(self):
self.seq += 1
self.save(update_fields=["seq", "updated"])
"""The next seq, taken with an UPDATE so that inside a transaction it is
also the write lock: two concurrent saves cannot both get the same one."""
Project.objects.filter(id=self.id).update(
seq=models.F("seq") + 1, updated=timezone.now()
)
self.refresh_from_db(fields=["seq", "updated"])
return self.seq
def can_edit(self, user):
return user.is_authenticated and (
user.id == self.owner_id or self.editors.filter(id=user.id).exists()
)
class Clip(models.Model):
"""Tier 1: the unit of work, and the thing leaf paths are scoped by.
@ -271,6 +291,9 @@ class Leaf(models.Model):
path = models.CharField(max_length=300)
value = models.JSONField()
version = models.PositiveBigIntegerField(default=1)
seq = models.PositiveBigIntegerField(
default=0, help_text="the project seq of the write that last changed it",
)
updated = models.DateTimeField(auto_now=True)
class Meta:
@ -288,7 +311,9 @@ class Leaf(models.Model):
class Revision(models.Model):
"""Tier 1: a snapshot of the authored layer, with a user and a summary.
"""Tier 1: a snapshot of the authored layer, with a user and a summary — a
named snapshot, which is how a person marks a version now that every edit
saves itself.
ON AN EXPLICIT TRIGGER, not on every save. tl snapshots a small annotation
layer; arthur's tier 1 will contain cel polygons, so a snapshot per save bloats
@ -302,6 +327,9 @@ class Revision(models.Model):
author = models.CharField(max_length=200, blank=True)
summary = models.CharField(max_length=500, blank=True)
document = models.JSONField(help_text="every leaf of the project, by path")
blocks = models.JSONField(
default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them",
)
created = models.DateTimeField(auto_now_add=True)
class Meta:

7
clips/routing.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from .consumers import ProjectConsumer
websocket_urlpatterns = [
path("ws/projects/<uuid:project_id>", ProjectConsumer.as_asgi()),
]

View file

@ -361,7 +361,10 @@ class DocumentTests(TestCase):
"""Tier 1: load, save, and the conditional write."""
def setUp(self):
self.project = Project.objects.create(name="a project")
from django.contrib.auth import get_user_model
owner = get_user_model().objects.create_user("owner", password="password1")
self.client.force_login(owner)
self.project = Project.objects.create(name="a project", owner=owner)
descriptor = analysis_descriptor()
self.analysis = key_for(descriptor)
self.client.post("/api/analyses", data=json.dumps(
@ -523,6 +526,61 @@ class DocumentTests(TestCase):
# --- revisions ---------------------------------------------------------
def patch(self, base, leaves, removed=()):
return self.put(f"/api/projects/{self.project.id}", {
"base": base,
"clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves,
"removed": list(removed), "blocks": [self.block]}],
})
def test_a_patch_leaves_what_it_does_not_name_alone(self):
seq = self.save().json()["seq"]
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]},
removed=["clip/c1/symbol/main/node/mouth"])
self.assertEqual(200, response.status_code, response.content)
self.assertEqual(["clip/c1/timing"], response.json()["written"])
self.assertEqual(4, Leaf.objects.count())
def test_two_people_on_different_leaves_both_land(self):
seq = self.save().json()["seq"]
self.assertEqual(200, self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}).status_code)
# The second saver has not caught up, and touched a different leaf.
response = self.patch(seq, {"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
self.assertEqual(200, response.status_code, response.content)
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
self.assertEqual(["^ ", "~:fps", 24], leaves["clip/c1/timing"])
self.assertEqual(["^ ", "~:frames", 12], leaves["clip/c1/symbol/main"])
def test_two_people_on_one_leaf_is_a_conflict_that_writes_nothing(self):
seq = self.save().json()["seq"]
self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]})
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 12],
"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
self.assertEqual(409, response.status_code)
self.assertEqual({"clip/c1/timing": ["^ ", "~:fps", 24]}, response.json()["conflicts"])
self.assertEqual(seq + 1, Project.objects.get(id=self.project.id).seq)
self.assertEqual(["^ ", "~:frames", 48],
Leaf.objects.get(path="clip/c1/symbol/main").value)
# Caught up to their seq, the same write is ordinary.
self.assertEqual(200, self.patch(seq + 1, {"clip/c1/timing": ["^ ", "~:fps", 12]}).status_code)
def test_a_named_snapshot_restores_as_an_ordinary_write(self):
self.save()
snap = self.client.post(f"/api/projects/{self.project.id}/revisions",
data=json.dumps({"summary": "before the big change"}),
content_type="application/json").json()
moved = self.leaves()
moved["clip/c1/timing"] = ["^ ", "~:fps", 12]
del moved["clip/c1/symbol/main/node/mouth"]
self.save(moved)
listed = self.client.get(f"/api/projects/{self.project.id}/revisions").json()["revisions"]
self.assertEqual(["before the big change"], [r["summary"] for r in listed])
restored = self.client.post(
f"/api/projects/{self.project.id}/revisions/{snap['id']}/restore").json()
self.assertEqual(2, restored["changed"])
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
self.assertEqual(self.leaves(), leaves)
def test_a_revision_snapshots_the_authored_layer(self):
self.save()
response = self.client.post(
@ -826,3 +884,99 @@ class UploadTests(TestCase):
digest="e" * 64, fps=12, frames=3, width=8, height=6, audio=blob)
manifest = self.client.get(f"/api/footage/{footage.id}").json()
self.assertIsNone(manifest["video"])
@override_settings(BLOB_ROOT=BLOB_DIR)
class OwnershipTests(TestCase):
"""Anyone with the link reads; the owner and the editors write."""
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
self.ann = User.objects.create_user("ann", password="password1")
self.bob = User.objects.create_user("bob", password="password1")
self.project = Project.objects.create(name="ann's", owner=self.ann)
def write(self):
return self.client.put(f"/api/projects/{self.project.id}",
data=json.dumps({"name": "renamed", "clips": []}),
content_type="application/json")
def test_anyone_with_the_link_can_read_and_nobody_else_can_write(self):
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(("ann", False), (loaded["owner"], loaded["can_edit"]))
self.assertEqual(403, self.write().status_code)
self.client.login(username="bob", password="password1")
self.assertEqual(403, self.write().status_code)
def test_the_owner_names_an_editor_who_can_then_write(self):
self.client.login(username="bob", password="password1")
self.assertEqual(403, self.client.post(
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
content_type="application/json").status_code)
self.client.login(username="ann", password="password1")
self.assertEqual(200, self.write().status_code)
self.assertEqual(["bob"], self.client.post(
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
content_type="application/json").json()["editors"])
self.client.login(username="bob", password="password1")
self.assertTrue(self.client.get(f"/api/projects/{self.project.id}").json()["can_edit"])
self.assertEqual(200, self.write().status_code)
self.client.login(username="ann", password="password1")
self.client.delete(f"/api/projects/{self.project.id}/editors/bob")
self.client.login(username="bob", password="password1")
self.assertEqual(403, self.write().status_code)
def test_a_project_is_made_by_somebody_signed_in_and_is_theirs(self):
self.assertEqual(403, self.client.post("/api/projects", data=json.dumps({"name": "x"}),
content_type="application/json").status_code)
self.client.post("/api/signup", data=json.dumps(
{"username": "cat", "password": "password1"}), content_type="application/json")
self.assertEqual("cat", self.client.get("/api/me").json()["username"])
mine = self.client.post("/api/projects", data=json.dumps({"name": "y"}),
content_type="application/json").json()
self.assertEqual(("cat", True), (mine["owner"], mine["can_edit"]))
listed = {p["name"] for p in self.client.get("/api/projects").json()["projects"]}
self.assertEqual({"y"}, listed)
self.client.logout()
self.assertEqual([], self.client.get("/api/projects").json()["projects"])
def test_a_project_has_an_address(self):
response = self.client.get(f"/p/{self.project.id}")
self.assertEqual(200, response.status_code)
# The slug is the name, for people; the id is what finds it.
self.assertEqual(200, self.client.get(f"/p/{self.project.id}/anything-at-all").status_code)
self.assertContains(response, 'id="app"')
class SocketTests(TestCase):
"""A committed write reaches everyone in the room; presence is stamped."""
def test_a_save_is_broadcast_to_the_room(self):
from asgiref.sync import async_to_sync, sync_to_async
from channels.testing import WebsocketCommunicator
from clips.consumers import broadcast
from server.asgi import application
from django.contrib.auth import get_user_model
project = Project.objects.create(
name="shared", owner=get_user_model().objects.create_user("host"))
async def scenario():
peer = WebsocketCommunicator(application, f"/ws/projects/{project.id}",
headers=[(b"origin", b"http://localhost")])
connected, _ = await peer.connect()
self.assertTrue(connected)
self.assertEqual("welcome", (await peer.receive_json_from())["kind"])
self.assertEqual([], (await peer.receive_json_from())["peers"])
self.assertEqual("join", (await peer.receive_json_from())["kind"])
# The server stamps who sent it; a claimed name is overwritten.
await peer.send_json_to({"kind": "state", "frame": 12, "user": "forged"})
state = await peer.receive_json_from()
self.assertEqual((12, None), (state["frame"], state["user"]))
await sync_to_async(broadcast)(project.id, {"seq": 1, "clips": []})
delta = await peer.receive_json_from()
self.assertEqual(("delta", 1), (delta["kind"], delta["seq"]))
await peer.disconnect()
async_to_sync(scenario)()

View file

@ -16,6 +16,10 @@ from django.urls import path
from . import views
urlpatterns = [
path("me", views.me),
path("login", views.login),
path("signup", views.signup),
path("logout", views.logout),
path("detector", views.detector),
path("sources", views.sources),
path("extractions", views.extractions),
@ -27,6 +31,9 @@ urlpatterns = [
path("projects/<uuid:project_id>", views.project_detail),
path("projects/<uuid:project_id>/leaves/<path:leaf_path>", views.leaf_detail),
path("projects/<uuid:project_id>/revisions", views.revisions),
path("projects/<uuid:project_id>/revisions/<int:revision_id>/restore", views.restore),
path("projects/<uuid:project_id>/editors", views.editors),
path("projects/<uuid:project_id>/editors/<str:username>", views.editors),
path("analyses", views.analyses),
path("analyses/<str:key>", views.analysis_detail),
path("blocks", views.blocks),

View file

@ -32,13 +32,17 @@ from pathlib import Path
from uuid import UUID
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from . import blobs, extraction
from .consumers import broadcast
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source
KEY_LENGTH = 71 # "sha256:" + 64 hex
@ -133,7 +137,7 @@ def _asset_version(relative):
return "0"
def page(request):
def page(request, project_id=None, slug=None):
"""The host page. This replaced `frontend/public/index.html` at step 9, and
`:dev-http` in shadow-cljs.edn went away with it."""
return render(request, "clips/index.html", {
@ -619,7 +623,11 @@ def block_detail(request, key):
# tier 1: projects, clips, leaves
def _project_json(project: Project):
def _who(user):
return {"username": user.get_username() if user.is_authenticated else None}
def _project_json(project: Project, user):
leaves = list(project.leaves.all())
clips = []
for clip in project.clips.all():
@ -640,27 +648,103 @@ def _project_json(project: Project):
"schema_version": project.schema_version,
"seq": project.seq,
"palette": project.palette,
"owner": project.owner.get_username(),
"editors": sorted(project.editors.values_list("username", flat=True)),
"can_edit": project.can_edit(user),
"clips": clips,
}
def _project(project_id):
try:
return Project.objects.select_related("owner").get(id=project_id)
except Project.DoesNotExist:
raise Bad("no such project", status=404)
def _writable(request, project_id):
project = _project(project_id)
if not project.can_edit(request.user):
raise Bad("only the owner and the editors can change this project; "
"save a copy instead", status=403)
return project
# ---------------------------------------------------------------------------
# who you are
#
# Django's session cookie, and the page's CSRF cookie on every write. Nothing
# here that a signed-in admin does not already have; the API gains a way in that
# is not the admin's login page.
@require_http_methods(["GET"])
def me(request):
return JsonResponse(_who(request.user))
@require_http_methods(["POST"])
def login(request):
data = json.loads(request.body or b"{}")
user = authenticate(request, username=data.get("username"), password=data.get("password"))
if user is None:
return JsonResponse({"error": "wrong username or password"}, status=400)
auth_login(request, user)
return JsonResponse(_who(user))
@require_http_methods(["POST"])
def signup(request):
data = json.loads(request.body or b"{}")
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or len(password) < 8:
return JsonResponse({"error": "a username, and a password of 8 or more"}, status=400)
User = get_user_model()
if User.objects.filter(username__iexact=username).exists():
return JsonResponse({"error": "that username is taken"}, status=409)
user = User.objects.create_user(username=username, password=password)
auth_login(request, user)
return JsonResponse(_who(user), status=201)
@require_http_methods(["POST"])
def logout(request):
auth_logout(request)
return JsonResponse(_who(request.user))
# ---------------------------------------------------------------------------
# tier 1: projects, clips, leaves
@require_http_methods(["GET", "POST"])
def projects(request):
"""GET lists what you own and are an editor of — nothing, signed out; POST
makes one, owned by you. Every project has an owner, so making one needs you
signed in."""
if request.method == "GET":
if not request.user.is_authenticated:
return JsonResponse({"projects": []})
visible = Q(owner=request.user) | Q(editors=request.user)
return JsonResponse(
{
"projects": [
{"id": str(p.id), "name": p.name,
"schema_version": p.schema_version, "seq": p.seq,
"owner": p.owner.get_username(),
"updated": p.updated.isoformat()}
for p in Project.objects.all()[:100]
for p in Project.objects.filter(visible).distinct()
.select_related("owner")[:100]
]
}
)
if not request.user.is_authenticated:
return JsonResponse({"error": "sign in to make a project"}, status=403)
try:
data = _body(request)
project = Project.objects.create(name=data.get("name") or "untitled")
return JsonResponse(_project_json(project), status=201)
project = Project.objects.create(name=data.get("name") or "untitled", owner=request.user)
return JsonResponse(_project_json(project, request.user), status=201)
except Bad as exc:
return _error(exc)
@ -668,43 +752,74 @@ def projects(request):
@require_http_methods(["GET", "PUT"])
def project_detail(request, project_id):
try:
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return JsonResponse({"error": "no such project"}, status=404)
if request.method == "GET":
return JsonResponse(_project_json(project))
if request.method == "GET":
return JsonResponse(_project_json(_project(project_id), request.user))
project = _writable(request, project_id)
return _save(project, _body(request), request.user)
except Bad as exc:
return _error(exc)
@require_http_methods(["POST", "DELETE"])
def editors(request, project_id, username=None):
"""The owner names who else can write. POST {username} adds; DELETE
`editors/<username>` removes."""
try:
return _save(project, _body(request))
project = _project(project_id)
if not (request.user.is_authenticated and request.user.id == project.owner_id):
raise Bad("only the owner can change who edits", status=403)
if request.method == "POST":
username = _body(request).get("username")
user = get_user_model().objects.filter(username__iexact=username or "").first()
if user is None:
raise Bad(f"nobody is called {username!r}", status=404)
if request.method == "POST":
project.editors.add(user)
else:
project.editors.remove(user)
broadcast(project.id, {}, kind="access")
return JsonResponse({"editors": sorted(project.editors.values_list("username", flat=True))})
except Bad as exc:
return _error(exc)
@transaction.atomic
def _save(project: Project, data):
"""A whole-document save: one clip's leaves replace that clip's leaves.
def _save(project: Project, data, user):
"""A save: one clip's leaves, written.
SCOPED BY CLIP, not by project. A payload that carries clip `a` does not
disturb clip `b`'s leaves, because a save is not the only way the document
changes — a single-leaf conditional write is — and a save that cleared
everything it did not mention would be a save that undoes a collaborator.
disturb clip `b`'s leaves.
Two shapes. Without `base`, a clip's leaves REPLACE that clip's leaves — the
whole-document save. With `base`, the seq the client last caught up to, the
save is a PATCH: `leaves` are the ones it changed, `removed` the ones it
deleted, and nothing it did not mention is touched. A leaf it names that
somebody else changed after `base`, to something else, is a conflict, and the
whole save answers 409 with their values — last-writer-wins per leaf, with the
loser told rather than silently clobbered. docs/architecture.md, "Make the
merge unit small instead of clever".
A leaf whose value is unchanged keeps its VERSION. That is what makes the
entity tag mean something: a save of a document where one channel moved
invalidates one leaf's etag, not all four hundred.
"""
base = data.get("base")
seq = project.bump()
if data.get("name"):
project.name = data["name"]
if data.get("palette"):
project.palette = data["palette"]
project.save(update_fields=["name", "palette"])
written, removed, unchanged = [], [], []
written, removed, unchanged, conflicts, deltas = [], [], [], {}, []
for spec in data.get("clips") or []:
cid = spec.get("cid")
if not cid:
raise Bad("every clip in a save names its cid")
leaves = spec.get("leaves") or {}
gone = spec.get("removed") or [] if base is not None else []
prefix = f"clip/{cid}/"
for path in leaves:
for path in [*leaves, *gone]:
if not path.startswith(prefix):
raise Bad(
f"leaf {path!r} is not addressed to clip {cid!r}",
@ -723,6 +838,16 @@ def _save(project: Project, data):
status=409, missing=missing,
)
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
if base is not None:
for path in [*leaves, *gone]:
theirs = existing.get(path)
if theirs and theirs.seq > base and (
path not in leaves or theirs.value != leaves[path]):
conflicts[path] = theirs.value
if conflicts:
continue
analysis = Analysis.objects.filter(key=spec.get("analysis")).first()
footage = None
if spec.get("footage"):
@ -732,29 +857,41 @@ def _save(project: Project, data):
cid=cid,
defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage},
)
clip.blocks.set(Block.objects.filter(key__in=keys))
blocks = Block.objects.filter(key__in=keys)
if base is None:
clip.blocks.set(blocks)
gone = [path for path in existing if path not in leaves]
else:
clip.blocks.add(*blocks)
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
changed = {}
for path, value in leaves.items():
leaf = existing.get(path)
if leaf is None:
Leaf.objects.create(project=project, path=path, value=value)
written.append(path)
Leaf.objects.create(project=project, path=path, value=value, seq=seq)
elif leaf.value != value:
leaf.value = value
leaf.value, leaf.seq = value, seq
leaf.version += 1
leaf.save(update_fields=["value", "version", "updated"])
written.append(path)
leaf.save(update_fields=["value", "version", "seq", "updated"])
else:
unchanged.append(path)
for path, leaf in existing.items():
if path not in leaves:
leaf.delete()
removed.append(path)
continue
changed[path] = value
dropped = [path for path in gone if path in existing]
project.leaves.filter(path__in=dropped).delete()
written += changed
removed += dropped
deltas.append({"cid": cid, "leaves": changed, "removed": dropped, "blocks": keys})
seq = project.seq + 1
project.seq = seq
project.save()
if conflicts:
raise Bad(
"somebody else changed these since you last caught up",
status=409, seq=seq - 1, conflicts=conflicts,
)
by = user.get_username() if user.is_authenticated else None
transaction.on_commit(lambda: broadcast(project.id, {
"seq": seq, "by": by, "name": project.name, "clips": deltas,
}))
return JsonResponse(
{
"id": str(project.id),
@ -777,9 +914,9 @@ def leaf_detail(request, project_id, leaf_path):
for a painted cel that is the class of bug that ends trust in a tool.
"""
try:
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return JsonResponse({"error": "no such project"}, status=404)
project = _project(project_id) if request.method == "GET" else _writable(request, project_id)
except Bad as exc:
return _error(exc)
leaf = project.leaves.filter(path=leaf_path).first()
if request.method == "GET":
@ -797,35 +934,45 @@ def leaf_detail(request, project_id, leaf_path):
return _error(Bad("a leaf write carries a value"))
match = request.headers.get("If-Match")
if leaf is None:
# ANY `If-Match` on a leaf that does not exist is a failed precondition,
# `*` included: RFC 7232 gives `*` the meaning "the resource must already
# exist", which is exactly the write a client makes when it believes it is
# editing something. Creating it instead would turn "somebody deleted this
# node" into a silent resurrection.
if match:
return JsonResponse(
{"error": "no such leaf", "path": leaf_path}, status=409
)
leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"])
else:
if match and match not in ("*", leaf.etag):
response = JsonResponse(
{
"error": "stale write",
"path": leaf.path,
"version": leaf.version,
"value": leaf.value,
},
status=409,
)
response["ETag"] = leaf.etag
return response
leaf.value = data["value"]
leaf.version += 1
leaf.save(update_fields=["value", "version", "updated"])
with transaction.atomic():
seq = project.bump()
leaf = project.leaves.filter(path=leaf_path).first()
if leaf is None:
# ANY `If-Match` on a leaf that does not exist is a failed precondition,
# `*` included: RFC 7232 gives `*` the meaning "the resource must already
# exist", which is exactly the write a client makes when it believes it
# is editing something. Creating it instead would turn "somebody deleted
# this node" into a silent resurrection.
if match:
transaction.set_rollback(True)
return JsonResponse(
{"error": "no such leaf", "path": leaf_path}, status=409
)
leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"], seq=seq)
else:
if match and match not in ("*", leaf.etag):
transaction.set_rollback(True)
response = JsonResponse(
{
"error": "stale write",
"path": leaf.path,
"version": leaf.version,
"value": leaf.value,
},
status=409,
)
response["ETag"] = leaf.etag
return response
leaf.value, leaf.seq = data["value"], seq
leaf.version += 1
leaf.save(update_fields=["value", "version", "seq", "updated"])
cid = leaf_path.split("/")[1] if leaf_path.startswith("clip/") else None
by = request.user.get_username() if request.user.is_authenticated else None
transaction.on_commit(lambda: broadcast(project.id, {
"seq": seq, "by": by, "name": project.name,
"clips": [{"cid": cid, "leaves": {leaf.path: leaf.value}, "removed": [], "blocks": []}],
}))
seq = project.bump()
response = JsonResponse({"path": leaf.path, "version": leaf.version, "seq": seq})
response["ETag"] = leaf.etag
return response
@ -833,16 +980,17 @@ def leaf_detail(request, project_id, leaf_path):
@require_http_methods(["GET", "POST"])
def revisions(request, project_id):
"""Mark a version: one snapshot of the authored layer, with a summary."""
"""Named snapshots: GET lists them, POST {summary} takes one of the document
as it is now."""
try:
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return JsonResponse({"error": "no such project"}, status=404)
project = _project(project_id) if request.method == "GET" else _writable(request, project_id)
except Bad as exc:
return _error(exc)
if request.method == "GET":
return JsonResponse(
{
"revisions": [
{"seq": r.seq, "author": r.author, "summary": r.summary,
{"id": r.id, "seq": r.seq, "author": r.author, "summary": r.summary,
"created": r.created.isoformat(), "leaves": len(r.document)}
for r in project.revisions.all()[:100]
]
@ -852,8 +1000,57 @@ def revisions(request, project_id):
revision = Revision.objects.create(
project=project,
seq=project.seq,
author=data.get("author") or "",
summary=data.get("summary") or "",
author=request.user.get_username(),
summary=(data.get("summary") or "").strip()[:500],
document={leaf.path: leaf.value for leaf in project.leaves.all()},
blocks={clip.cid: sorted(clip.blocks.values_list("key", flat=True))
for clip in project.clips.all()},
)
return JsonResponse({"seq": revision.seq, "leaves": len(revision.document)}, status=201)
return JsonResponse({"id": revision.id, "seq": revision.seq,
"leaves": len(revision.document)}, status=201)
@require_http_methods(["POST"])
def restore(request, project_id, revision_id):
"""Put a snapshot back: an ordinary write of every leaf that differs, so
everybody in the room receives it the way they receive any other."""
try:
project = _writable(request, project_id)
revision = project.revisions.filter(id=revision_id).first()
if revision is None:
raise Bad("no such snapshot", status=404)
except Bad as exc:
return _error(exc)
with transaction.atomic():
seq = project.bump()
existing = {leaf.path: leaf for leaf in project.leaves.all()}
deltas = {}
def delta(path):
cid = path.split("/")[1]
return deltas.setdefault(cid, {"cid": cid, "leaves": {}, "removed": [],
"blocks": revision.blocks.get(cid, [])})
for path, value in revision.document.items():
leaf = existing.get(path)
if leaf is None:
Leaf.objects.create(project=project, path=path, value=value, seq=seq)
elif leaf.value != value:
leaf.value, leaf.seq = value, seq
leaf.version += 1
leaf.save(update_fields=["value", "version", "seq", "updated"])
else:
continue
delta(path)["leaves"][path] = value
gone = [path for path in existing if path not in revision.document]
project.leaves.filter(path__in=gone).delete()
for path in gone:
delta(path)["removed"].append(path)
for cid, keys in revision.blocks.items():
clip = project.clips.filter(cid=cid).first()
if clip:
clip.blocks.add(*Block.objects.filter(key__in=keys))
by = request.user.get_username()
transaction.on_commit(lambda: broadcast(project.id, {
"seq": seq, "by": by, "name": project.name, "clips": list(deltas.values()),
}))
return JsonResponse({"seq": seq, "changed": sum(len(d["leaves"]) + len(d["removed"])
for d in deltas.values())})