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
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