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:
parent
c17ee138f2
commit
6a53adb5e0
31 changed files with 1918 additions and 158 deletions
87
clips/consumers.py
Normal file
87
clips/consumers.py
Normal 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"]))
|
||||
39
clips/migrations/0008_owners_editors_leaf_seq.py
Normal file
39
clips/migrations/0008_owners_editors_leaf_seq.py
Normal 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,
|
||||
),
|
||||
]
|
||||
18
clips/migrations/0009_revision_blocks.py
Normal file
18
clips/migrations/0009_revision_blocks.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
7
clips/routing.py
Normal 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()),
|
||||
]
|
||||
|
|
@ -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)()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
339
clips/views.py
339
clips/views.py
|
|
@ -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())})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue