445def make_router(store: DesignStore, prefix: str, sub: str =
"") -> APIRouter:
446 """The design CRUD + sharing router for one app.
448 ``prefix`` is the APIRouter prefix (e.g. ``/api/engine/documents``); ``sub``
449 is an extra path segment beneath it, which only pid-designer uses -- its
450 router is mounted at ``/api/pid`` and nests these routes under
451 ``/diagrams`` so ``/api/pid/users`` can sit alongside them.
453 Everything is defined in here as closures over ``store`` rather than as
454 module-level functions taking it: the alternative is threading one more
455 argument through twenty helpers, which is exactly the kind of repetition
456 that lets one of them get forgotten.
458 router = APIRouter(prefix=prefix, tags=[store.noun +
"s"])
460 def _now_iso() -> str:
461 return datetime.now(timezone.utc).isoformat()
466 def _index_path(user: str, *, create: bool =
False) -> Path:
467 return store.index_path(user, create=create)
470 def _load_index(user: str) -> list[dict]:
471 return store.load_index(user)
474 def _save_index(user: str, index: list[dict]) ->
None:
475 store.save_index(user, index)
478 def _index_lock(user: str):
479 return store.index_lock(user)
482 def _require_id(doc_id: str) -> str:
483 """Sanitize a path-supplied id into a safe segment, or 404."""
484 safe = slugify(doc_id)
486 raise HTTPException(status_code=404, detail=f
"Unknown {store.noun}")
490 def _unique_id(user: str, name: str) -> str:
491 base = slugify(name)
or store.default_slug
492 existing = {d[
"id"]
for d
in _load_index(user)}
493 if base
not in existing:
496 while f
"{base}-{n}" in existing:
501 def _working_path(user: str, doc_id: str) -> Path:
502 return store.ud.user_dir(user) / doc_id /
"current.json"
505 def _read_working(user: str, doc_id: str) -> dict |
None:
506 p = _working_path(user, doc_id)
510 return json.loads(p.read_text(
"utf-8"))
511 except (OSError, ValueError):
515 def _write_working(user: str, doc_id: str, data: dict) ->
None:
516 p = _working_path(user, doc_id)
517 p.parent.mkdir(parents=
True, exist_ok=
True)
518 fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=
".tmp-", suffix=
".json")
520 with os.fdopen(fd,
"w", encoding=
"utf-8")
as fh:
521 json.dump(data, fh, indent=2)
523 except BaseException:
524 if os.path.exists(tmp):
529 def _write_base(user: str, doc_id: str, data: dict) ->
None:
530 """The copy's common ancestor, beside its working copy. Written once."""
531 p = store.ud.user_dir(user) / doc_id /
"base.json"
532 p.parent.mkdir(parents=
True, exist_ok=
True)
533 p.write_text(json.dumps(data, indent=2),
"utf-8")
535 def _touch_index(user: str, doc_id: str) ->
None:
536 _mutate_record(user, doc_id, updatedAt=_now_iso(), missing_ok=
True)
539 def _mutate_record(user: str, doc_id: str, *, missing_ok: bool =
False, **fields) -> dict:
540 """Apply ``fields`` to one index record, under the index lock.
542 Read-modify-write of a file two people may be editing, so it re-reads inside
543 the lock rather than trusting a record fetched earlier.
545 with _index_lock(user):
546 index = _load_index(user)
548 if record[
"id"] == doc_id:
549 record.update(fields)
550 _save_index(user, index)
554 raise HTTPException(status_code=404, detail=f
"Unknown {store.noun}")
557 def _edit_record(user: str, doc_id: str, fn: Callable[[dict],
None]) -> dict:
558 """``_mutate_record`` for a change that depends on what the record holds
559 now -- a list to filter, say -- so ``fn`` runs on the copy read inside
560 the lock rather than one fetched before it."""
561 with _index_lock(user):
562 index = _load_index(user)
564 if record[
"id"] == doc_id:
566 _save_index(user, index)
568 raise HTTPException(status_code=404, detail=f
"Unknown {store.noun}")
573 @dataclass(frozen=True)
575 """A design the caller is allowed to act on.
577 ``owner`` is the folder the design lives in -- not a privilege level. It only
578 differs from ``viewer`` when someone is editing a design shared with them.
587 def _shared_with(record: dict) -> list[str]:
588 """The record's editor list. Absent on every design created before sharing
589 existed, so a missing key is an empty list, never an error."""
590 value = record.get(
"sharedWith")
591 return [str(e)
for e
in value]
if isinstance(value, list)
else []
595 """Who is asking, and the two facts about the stand-wide state that
596 decide what they may touch. Built once per request: ``admin`` may be a
597 network call, so it is asked at most once, and only if a decision
598 actually turns on it."""
600 def __init__(self, request: Request) ->
None:
601 self.request = request
602 self.viewer = store.ud.current_user(request)
603 main = store.read_featured()
604 self.main = (main[
"owner"], main[
"id"])
if main
else None
605 self.star = store.read_star()
606 self._admin: bool |
None =
None
609 def admin(self) -> bool:
610 if self._admin
is None:
611 self._admin = bool(store.is_admin(self.request))
614 def is_main(self, owner: str, doc_id: str) -> bool:
615 return self.main == (owner, doc_id)
617 def is_star(self, owner: str, doc_id: str) -> bool:
618 return (owner, doc_id)
in self.star
620 def _is_shared(record: dict, viewer: str) -> bool:
621 return any(slug_user(e) == viewer
for e
in _shared_with(record))
623 def _can_edit(record: dict, owner: str, access:
"Access") -> bool:
624 """Emails are stored as written, compared as path slugs -- the same
625 normalization ``current_user`` applies, so the two always line up.
627 An admin-only main design is checked first and overrides everything
628 else: not even its creator may change it without being an admin.
630 if store.main_admin_only
and access.is_main(owner, record.get(
"id")):
632 if store.open_to_all
or access.viewer == owner:
634 if _is_shared(record, access.viewer):
636 return store.curated
and access.admin
638 def _can_manage(record: dict, owner: str, access:
"Access") -> bool:
639 """Decide who else edits it: share, approve and deny. With ``curated``,
640 its creator or an admin. Otherwise any editor, which is the old rule."""
642 return access.viewer == owner
or access.admin
643 return _can_edit(record, owner, access)
645 def _can_view(record: dict, owner: str, access:
"Access") -> bool:
646 """Open it in the editor, read-only if need be. The main design is
647 viewable by everyone; anything else, only by those who can edit it --
648 which with ``featured`` off is exactly the old rule. With ``curated``,
649 the STAR set is viewable by everyone and admins see everything."""
650 doc_id = record.get(
"id")
651 if access.is_main(owner, doc_id):
653 if store.curated
and (access.is_star(owner, doc_id)
or access.admin):
655 return _can_edit(record, owner, access)
658 def _find_record(owner: str, doc_id: str) -> dict |
None:
659 return store.find_record(owner, doc_id)
669 """Resolve ``(?owner=, doc_id)`` into a design the caller may act on.
671 The single place cross-user access is granted. ``need="read"`` is for the
672 two endpoints that exist to look at other people's work (browse and copy);
673 ``"view"`` is for looking at a design in the editor (load, history,
674 releases, who holds it) -- the main design is viewable by all; and
675 everything else needs ``"edit"``, which means being the owner or on the
676 share list (or anyone, with ``open_to_all``), and an admin for the main
677 design. ``"exists"`` asks nothing (rescue only). ``"manage"`` -- deciding
678 who else edits it -- is any editor, or with ``curated`` its creator or
681 Returning the resolved record -- rather than just a folder name -- is what
682 stops a handler that omits ``?owner=`` from silently writing an orphan
683 working copy into the caller's own tree. See the module docstring.
685 access = Access(request)
686 viewer = access.viewer
687 owner_slug = slug_user(owner)
if owner
else viewer
688 doc_id = _require_id(doc_id)
689 record = _find_record(owner_slug, doc_id)
691 raise HTTPException(status_code=404, detail=f
"Unknown {store.noun}")
696 if need ==
"read" and store.curated:
698 if need
in (
"view",
"manage")
and not _can_view(record, owner_slug, access):
699 raise HTTPException(status_code=403, detail=
"This design is not shared with you")
700 if need ==
"edit" and not _can_edit(record, owner_slug, access):
701 if store.main_admin_only
and access.is_main(owner_slug, doc_id):
702 detail = f
"Only an admin can change the main {store.noun}. Take a copy to work on it."
703 elif store.curated
and _can_view(record, owner_slug, access):
704 detail = f
"You need edit access to this {store.noun}. Ask for it, or take a copy."
706 detail =
"This design is not shared with you"
707 raise HTTPException(status_code=403, detail=detail)
708 if need ==
"manage" and not _can_manage(record, owner_slug, access):
711 detail=f
"Only the {store.noun}'s creator or an admin can decide who edits it",
713 return DocRef(owner=owner_slug, doc_id=doc_id, record=record, viewer=viewer)
718 def _lock_holder(record: dict) -> str |
None:
719 return store.lock_holder(record)
721 def _lock_state(record: dict, viewer: str, names: dict[str, str]) -> dict:
722 """The checkout, as the design bar wants to render it.
724 ``lockExpiresAt`` is the moment the hold lapses -- beat + ``lock_ttl`` --
725 not the beat itself. It used to carry the raw heartbeat, which is a time
726 already in the past, so anything counting down to it read as expired the
727 instant it rendered. That is why the bar never showed the user how long
730 holder = _lock_holder(record)
733 beat = record.get(
"lockHeartbeat")
or record.get(
"lockedAt")
737 datetime.fromisoformat(beat) + timedelta(seconds=store.lock_ttl)
739 except (TypeError, ValueError):
748 if expires
is not None:
751 (datetime.fromisoformat(expires) - datetime.now(timezone.utc)).total_seconds(),
755 "lockedByName": (names.get(holder)
or holder)
if holder
else None,
756 "lockedByMe": holder == viewer,
757 "lockExpiresAt": expires,
758 "lockExpiresInSeconds": remaining,
759 "lockTtlSeconds": store.lock_ttl,
762 def _take_lock(owner: str, doc_id: str, viewer: str) -> dict:
763 """Claim the design, or 423 if someone else has it.
765 The compare-and-set that makes concurrent editing impossible. Re-reads
766 the record *inside* the lock rather than trusting one fetched earlier,
767 so two racing takes serialise and exactly one sees a free design.
770 with _index_lock(owner):
771 index = _load_index(owner)
773 if record[
"id"] != doc_id:
775 holder = _lock_holder(record)
776 if holder
and holder != viewer:
779 detail=f
"{record.get('name', doc_id)} is checked out by {holder}",
781 record[
"lockedBy"] = viewer
782 record.setdefault(
"lockedAt", now)
784 record[
"lockedAt"] = now
785 record[
"lockHeartbeat"] = now
786 _save_index(owner, index)
788 raise HTTPException(status_code=404, detail=f
"Unknown {store.noun}")
790 def _release_lock(owner: str, doc_id: str, viewer: str) ->
None:
791 """Give the design back. Only the holder can, and it is idempotent --
792 a lock that already lapsed, or was never held, is simply already free."""
793 with _index_lock(owner):
794 index = _load_index(owner)
796 if record[
"id"] != doc_id:
798 if _lock_holder(record)
not in (
None, viewer):
800 record.pop(
"lockedBy",
None)
801 record.pop(
"lockedAt",
None)
802 record.pop(
"lockHeartbeat",
None)
803 _save_index(owner, index)
806 def _require_lock(ref:
"DocRef") ->
None:
807 """Refuse a content write unless the caller holds the checkout -- or
808 held it, let it lapse, and nobody has taken it since (see
809 ``DesignStore.claim_for_write``).
811 Applied to the endpoints that mutate the design itself. Renaming,
812 sharing and copying are deliberately exempt: they are not concurrent
813 editing of content, and blocking them would mean a checkout could stop
814 someone tidying up a design they can see.
816 store.claim_for_write(ref.owner, ref.doc_id, ref.viewer)
818 def _beat_lock(owner: str, doc_id: str, viewer: str) ->
None:
819 """Refresh the holder's checkout after a successful save.
821 This is what makes "inactivity" mean *not editing* rather than *tab
822 closed*, and it costs no extra request.
824 with _index_lock(owner):
825 index = _load_index(owner)
827 if record[
"id"] == doc_id
and record.get(
"lockedBy") == viewer:
828 record[
"lockHeartbeat"] = _now_iso()
829 _save_index(owner, index)
832 def _scan_all() -> Iterator[tuple[str, dict]]:
833 """``(owner, record)`` for every design on the shared volume.
835 All the design tools mount one volume keyed by email, which is what makes
836 cross-user listing a directory walk instead of a database. At team scale
837 this is a few dozen small JSON reads.
839 for owner
in store.ud.all_users():
840 for record
in _load_index(owner):
845 def _decorated_for(request: Request, ref:
"DocRef", record: dict) -> dict:
846 """One record, shaped exactly like the ones `list` returns.
848 Every endpoint that hands a record back has to go through this. Returning
849 the bare stored record instead drops `owner`/`ownerName`/`mine`, and the
850 design bar reads a missing `mine` as "someone else's" -- which showed up
851 as a design of your own captioned "Config 1 - undefined".
853 names = directory.display_names(request, store.ud)
854 return _decorate(record, ref.owner, Access(request), names)
856 def _decorate(record: dict, owner: str, access:
"Access", names: dict[str, str]) -> dict:
857 """An index record as the UI wants it: who owns it, is that me, and may
859 viewer = access.viewer
862 requests = _requests_of(record)
863 manage = _can_manage(record, owner, access)
865 "star": access.is_star(owner, record.get(
"id")),
870 {**r,
"name": names.get(r[
"email"])
or r[
"email"]}
for r
in requests
872 "requestedByMe": any(r[
"email"] == viewer
for r
in requests),
875 "sharedWithMe": owner != viewer
and _is_shared(record, viewer),
879 "sharedWith": _shared_with(record),
881 "ownerName": names.get(owner)
or owner,
882 "mine": owner == viewer,
883 "editable": _can_edit(record, owner, access),
884 "featured": access.is_main(owner, record.get(
"id")),
888 **_lock_state(record, viewer, names),
891 def _requests_of(record: dict) -> list[dict]:
892 """Pending edit-access requests, ``[{email, at}]``. Absent means none."""
893 value = record.get(
"accessRequests")
894 if not isinstance(value, list):
897 {
"email": slug_user(str(r[
"email"])),
"at": r.get(
"at")}
898 for r
in value
if isinstance(r, dict)
and r.get(
"email")
904 def _listed(record: dict, owner: str, access:
"Access", cutoff: str |
None) -> bool:
905 """Whether a design belongs in the caller's list (else in ``/browse``).
907 The main design always does. Otherwise it is the ones you can edit, and
908 with ``recent_days`` set, other people's drop out once they go quiet --
909 which only moves them to browse, where they still open. Your own, and
910 ones explicitly shared with you, are never aged out.
912 With ``curated`` it is simply everything you can see: the STAR set,
913 your own and shared ones, and for an admin, everything.
915 if access.is_main(owner, record.get(
"id")):
918 return _can_view(record, owner, access)
919 if not _can_edit(record, owner, access):
921 if cutoff
is None or owner == access.viewer:
923 if any(slug_user(e) == access.viewer
for e
in _shared_with(record)):
925 return (record.get(
"updatedAt")
or "") >= cutoff
927 def _cutoff() -> str | None:
928 if not store.open_to_all
or store.recent_days
is None:
930 return (datetime.now(timezone.utc) - timedelta(days=store.recent_days)).isoformat()
933 async def list_documents(request: Request):
934 """Designs the caller may edit -- their own plus any shared with them,
935 or with ``open_to_all`` everyone's recent ones -- and the main design.
936 With ``curated``, everything the caller may see, STAR designs first."""
937 access = Access(request)
938 names = directory.display_names(request, store.ud)
941 _decorate(record, owner, access, names)
942 for owner, record
in _scan_all()
943 if _listed(record, owner, access, cutoff)
945 out.sort(key=
lambda d: d.get(
"updatedAt")
or "", reverse=
True)
947 out.sort(key=
lambda d:
not d[
"star"])
949 out.sort(key=
lambda d:
not d[
"featured"])
953 @router.get(f"{sub}/browse")
954 async def browse_documents(request: Request):
955 """Everyone else's designs, grouped by owner -- the view-only tree.
957 Exactly what the list leaves out: offering to copy something you can just
958 open is noise. Anything listed here can be copied by anyone; that is the
959 point. With ``open_to_all`` it is also where older designs live, and
960 ``editable`` says they open rather than only copy.
962 access = Access(request)
963 names = directory.display_names(request, store.ud)
965 groups: dict[str, list[dict]] = {}
966 for owner, record
in _scan_all():
967 if _listed(record, owner, access, cutoff):
970 if store.curated
and not _can_view(record, owner, access):
972 groups.setdefault(owner, []).append({
974 "name": record.get(
"name", record[
"id"]),
975 "updatedAt": record.get(
"updatedAt"),
976 "editable": _can_edit(record, owner, access),
981 "ownerName": names.get(owner)
or owner,
983 designs, key=
lambda d: d.get(
"updatedAt")
or "", reverse=
True
986 for owner, designs
in sorted(groups.items())
990 def _create(user: str, name: str, data: dict, extra: dict |
None =
None) -> dict:
991 """Add a design to ``user``'s index and seed its working copy.
993 The creator gets the checkout. Nobody else can hold a design that did not
994 exist a moment ago, and the alternative is worse than it sounds: you make
995 a design, start typing, and every edit is refused until you notice a
996 button you had no reason to look at.
999 with _index_lock(user):
1000 doc_id = _unique_id(user, name)
1009 "lockHeartbeat": now,
1017 index = _load_index(user)
1019 _save_index(user, index)
1020 _write_working(user, doc_id, data)
1025 async def create_document(request: Request, payload: store.create_model):
1026 """Create a new design owned by the caller, seeded with an optional config."""
1027 user = store.ud.current_user(request)
1028 meta = _create(user, payload.name.strip()
or "Untitled", store.to_data(payload))
1029 names = directory.display_names(request, store.ud)
1030 return _decorate(meta, user, Access(request), names)
1033 @router.post(f"{sub}/copy")
1034 async def copy_document(request: Request, payload: CopyPayload):
1035 """Copy any design -- yours or anyone else's -- into the caller's own list.
1037 Deliberately not gated on sharing: every design is viewable and copyable by
1038 anyone, which is what the view-only tree offers. What a copy does *not*
1039 inherit is history, releases, or the share list. It is a new design that
1040 happens to start from someone else's state, so editing it can never affect
1043 viewer = store.ud.current_user(request)
1044 ref = _resolve_doc(request, payload.owner, payload.id, need=
"read")
1045 data = _read_working(ref.owner, ref.doc_id)
1047 data = store.backend.latest_micro(ref.owner, ref.doc_id)
or store.empty_payload()
1048 names = directory.display_names(request, store.ud)
1049 who = names.get(ref.owner)
or ref.owner
1050 source = ref.record.get(
"name", ref.doc_id)
1051 name = (payload.name
or "").strip()
or f
"{source} (copy of {who})"
1058 extra = {
"copiedFrom": {
"owner": ref.owner,
"id": ref.doc_id,
"name": source,
1060 meta = _create(viewer, name, data, extra)
1062 _write_base(viewer, meta[
"id"], data)
1063 return _decorate(meta, viewer, Access(request), names)
1065 @router.post(f"{sub}/{{doc_id}}/rescue")
1066 async def rescue_document(
1067 request: Request, doc_id: str, payload: store.body_model, owner: str |
None =
None
1069 """Keep edits the design refused, as a new design of the caller's own.
1071 A save comes back 423 when the checkout has gone to someone else. The
1072 edits that save carried exist nowhere but the caller's screen, and the
1073 two things they do next -- "Take it back", which reloads the shared
1074 copy, or closing the tab -- both throw them away. The client posts them
1075 here first. Nothing is merged into the shared design (that would be the
1076 overwrite checkouts prevent); the caller gets a design with exactly what
1077 they had, named after the one it came from, checked out to them.
1079 Read access is enough: someone unshared mid-edit lost write access too,
1080 and their work is still theirs. Curated, unshared can also mean they no
1081 longer see it, so this asks only that the design exists: it reads
1082 nothing from it but its name, which the caller had open a moment ago.
1084 viewer = store.ud.current_user(request)
1085 ref = _resolve_doc(request, owner, doc_id, need=
"exists")
1086 source = ref.record.get(
"name", ref.doc_id)
1087 stamp = datetime.now(timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC")
1088 name = f
"{source} (unsaved changes, {stamp})"
1089 names = directory.display_names(request, store.ud)
1091 _create(viewer, name, store.to_data(payload)), viewer, Access(request), names
1095 @router.patch(f"{sub}/{{doc_id}}")
1096 async def rename_document(
1097 request: Request, doc_id: str, payload: NamePayload, owner: str |
None =
None
1099 """Rename a design. The id (and all storage keys) stay fixed."""
1100 ref = _resolve_doc(request, owner, doc_id)
1101 name = payload.name.strip()
1103 raise HTTPException(status_code=400, detail=
"Name is required")
1104 return _decorated_for(
1105 request, ref, _mutate_record(ref.owner, ref.doc_id, name=name, updatedAt=_now_iso())
1111 @router.put(f"{sub}/{{doc_id}}/share")
1112 async def share_document(
1113 request: Request, doc_id: str, payload: SharePayload, owner: str |
None =
None
1115 """Replace the design's editor list.
1117 Takes the whole list rather than an add/remove delta, so two editors
1118 changing shares at the same time converge on one of their two lists instead
1119 of interleaving into a third nobody chose.
1121 Any editor may change it, including removing others. There is no
1122 owner/editor distinction to appeal to -- the "owner" is just whoever clicked
1123 New -- and a removed editor can still read and copy the design anyway, so
1124 this is housekeeping, not a security boundary. ``sharedUpdatedBy`` records
1125 who last touched it.
1127 With ``curated`` it is a boundary -- the list is who may edit -- so only
1128 the creator or an admin may change it, and sharing with someone who had
1129 asked answers their request.
1131 ref = _resolve_doc(request, owner, doc_id, need=
"manage")
1132 seen: set[str] = set()
1133 emails: list[str] = []
1134 for raw
in payload.sharedWith:
1135 email = str(raw).strip().lower()
1136 slug = slug_user(email)
if email
else ""
1139 if not email
or slug == ref.owner
or slug
in seen:
1142 emails.append(email)
1144 def apply(record: dict) ->
None:
1145 record.update(sharedWith=emails, sharedUpdatedBy=ref.viewer,
1146 sharedUpdatedAt=_now_iso())
1147 if "accessRequests" in record:
1148 record[
"accessRequests"] = [
1149 r
for r
in _requests_of(record)
if r[
"email"]
not in seen
1152 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1155 @router.delete(f"{sub}/{{doc_id}}/share/me")
1156 async def leave_document(request: Request, doc_id: str, owner: str |
None =
None):
1157 """Remove yourself from a design someone shared with you.
1159 With delete gone this is the only thing that shrinks your list, and it
1160 destroys nothing: the design stays exactly where it is and you can copy it
1161 back out of the view-only tree whenever you like. The owner cannot leave --
1162 the design would end up in nobody's editable list.
1164 ref = _resolve_doc(request, owner, doc_id)
1165 if ref.owner == ref.viewer:
1166 raise HTTPException(
1168 detail=
"You own this design, so you cannot leave it.",
1171 e
for e
in _shared_with(ref.record)
if slug_user(e) != ref.viewer
1176 sharedWith=remaining,
1177 sharedUpdatedBy=ref.viewer,
1178 sharedUpdatedAt=_now_iso(),
1185 @router.post(f"{sub}/{{doc_id}}/checkout")
1186 async def take_checkout(request: Request, doc_id: str, owner: str |
None =
None):
1187 """Take the design's write token, or 423 if someone else has it.
1189 423 rather than 409 on purpose: ``create_release`` already returns 409
1190 for "that label exists", and the two are surfaced differently in the UI.
1192 Note this is *not* called on open -- viewing a design must never block a
1193 colleague. The client calls it when the user presses Take.
1195 ref = _resolve_doc(request, owner, doc_id)
1196 record = _take_lock(ref.owner, ref.doc_id, ref.viewer)
1197 names = directory.display_names(request, store.ud)
1198 return _lock_state(record, ref.viewer, names)
1200 @router.delete(f"{sub}/{{doc_id}}/checkout")
1201 async def release_checkout(request: Request, doc_id: str, owner: str |
None =
None):
1202 """Give the token back. Idempotent, and the target of the on-close beacon
1203 -- which is best-effort, so ``lock_ttl`` remains the real backstop."""
1204 ref = _resolve_doc(request, owner, doc_id)
1205 _release_lock(ref.owner, ref.doc_id, ref.viewer)
1208 @router.post(f"{sub}/{{doc_id}}/checkout/release")
1209 async def release_checkout_beacon(
1210 request: Request, doc_id: str, owner: str |
None =
None
1212 """Release, reachable by ``navigator.sendBeacon`` on tab close.
1214 A separate route only because a beacon can only ever POST -- it cannot
1215 issue the DELETE above. Same effect, and equally idempotent. Nothing can
1216 read a beacon's response, so this is best-effort: the inactivity timeout
1217 is what actually guarantees an abandoned checkout is freed.
1219 ref = _resolve_doc(request, owner, doc_id)
1220 _release_lock(ref.owner, ref.doc_id, ref.viewer)
1223 @router.post(f"{sub}/{{doc_id}}/checkout/beat")
1224 async def beat_checkout(request: Request, doc_id: str, owner: str |
None =
None):
1225 """Refresh your own hold without writing anything.
1227 Until this existed the only thing that refreshed a checkout was a
1228 successful ``/autosave``, so "inactivity" meant "no change to the saved
1229 payload" rather than "not working". Reading a result, panning a canvas,
1230 measuring something or thinking about it all counted as idle, and the
1231 design was taken out from under you mid-task. The client sends this while
1232 you are actually interacting; it is deliberately content-free so it can
1233 never race an autosave or churn a microversion.
1235 423 if you do not hold it -- the client treats that as "you lost it" and
1236 stops, rather than beating into a void.
1238 ref = _resolve_doc(request, owner, doc_id)
1240 _beat_lock(ref.owner, ref.doc_id, ref.viewer)
1241 record = store.find_record(ref.owner, ref.doc_id)
or ref.record
1242 names = directory.display_names(request, store.ud)
1243 return _lock_state(record, ref.viewer, names)
1245 @router.get(f"{sub}/{{doc_id}}/checkout")
1246 async def get_checkout(request: Request, doc_id: str, owner: str |
None =
None):
1247 """Who holds it right now. Polled by the bar while you do not, so that
1248 Take lights up on its own when the holder finishes."""
1249 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1250 names = directory.display_names(request, store.ud)
1251 return _lock_state(ref.record, ref.viewer, names)
1255 @router.get(f"{sub}/{{doc_id}}/load")
1256 async def load_document(request: Request, doc_id: str, owner: str |
None =
None):
1257 """Load the freshest state: the working copy.
1259 Falls back to the latest microversion only if the working copy is missing (a
1260 fresh or lost volume) -- self-healing. Never silently returns an older
1261 snapshot otherwise; that is what /history is for.
1263 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1264 data = _read_working(ref.owner, ref.doc_id)
1266 data = store.backend.latest_micro(ref.owner, ref.doc_id)
1267 if data
is not None:
1268 _write_working(ref.owner, ref.doc_id, data)
1269 return data
or store.empty_payload()
1272 @router.post(f"{sub}/{{doc_id}}/autosave")
1273 async def autosave_document(
1274 request: Request, doc_id: str, payload: store.body_model, owner: str |
None =
None
1276 """Write the working copy; snapshot a microversion only once per store.micro_interval.
1278 A design shared with several people is last-write-wins here, on purpose --
1279 checkouts are the next piece of work. A client that is autosaving a design
1280 it has just been unshared from gets a 403, which is its cue to stop.
1282 ref = _resolve_doc(request, owner, doc_id)
1284 data = store.to_data(payload)
1285 _write_working(ref.owner, ref.doc_id, data)
1286 _beat_lock(ref.owner, ref.doc_id, ref.viewer)
1289 key = (ref.owner, ref.doc_id)
1290 now = time.monotonic()
1291 if now - store.last_micro.get(key, 0.0) >= store.micro_interval:
1293 store.backend.snapshot_micro(ref.owner, ref.doc_id, data)
1294 store.last_micro[key] = now
1295 _touch_index(ref.owner, ref.doc_id)
1301 return {
"ok":
True,
"micro": micro}
1304 @router.post(f"{sub}/{{doc_id}}/flush")
1305 async def flush_document(
1306 request: Request, doc_id: str, payload: store.body_model, owner: str |
None =
None
1308 """Force an immediate microversion. Target of the on-close sendBeacon.
1310 The body is an *override*, not the source of truth. It is honoured only
1311 when it actually carries a document; an empty one falls back to the
1312 working copy already on disk.
1314 That asymmetry is the whole point. This endpoint is the target of a
1315 page-unload `sendBeacon`, which is the single request in the system most
1316 likely to arrive truncated, retried, or synthesised by something that is
1317 not the editor -- and the body model defaults every field to empty, so a
1318 body of `{}` validates perfectly. Before this, `POST /flush {}` replaced
1319 the working copy with nothing, snapshotted the nothing into history, and
1320 returned `{"ok": true}`. A diagram could be destroyed by a request that
1321 looked, from the client side, like a successful save.
1323 Emptying a design deliberately still works: that is `autosave`, which is
1324 a foreground request the editor makes on purpose after a Clear.
1326 ref = _resolve_doc(request, owner, doc_id)
1328 data = store.to_data(payload)
1329 if data == store.empty_payload():
1330 data = _read_working(ref.owner, ref.doc_id)
or data
1331 _write_working(ref.owner, ref.doc_id, data)
1333 store.backend.snapshot_micro(ref.owner, ref.doc_id, data)
1334 store.last_micro[(ref.owner, ref.doc_id)] = time.monotonic()
1335 _touch_index(ref.owner, ref.doc_id)
1336 except Exception
as e:
1337 raise HTTPException(status_code=502, detail=f
"Snapshot failed: {e}")
1343 @router.get(f"{sub}/{{doc_id}}/history")
1344 async def get_history(request: Request, doc_id: str, owner: str |
None =
None):
1345 """Recent automatic microversions, newest first."""
1346 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1347 return store.backend.list_micro(ref.owner, ref.doc_id)
1350 @router.get(f"{sub}/{{doc_id}}/version/{{version_id}}")
1351 async def get_version(
1352 request: Request, doc_id: str, version_id: str, owner: str |
None =
None
1354 """Fetch one microversion (for preview/restore). Does not touch the working copy."""
1355 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1356 data = store.backend.get_micro(ref.owner, ref.doc_id, version_id)
1358 raise HTTPException(status_code=404, detail=
"Version not found")
1364 @router.post(f"{sub}/{{doc_id}}/release")
1365 async def create_release(
1366 request: Request, doc_id: str, payload: store.release_model, owner: str |
None =
None
1368 """Publish the current state as an immutable named release (e.g. "0.1")."""
1369 ref = _resolve_doc(request, owner, doc_id)
1371 label = slugify(payload.label)
1373 raise HTTPException(status_code=400, detail=
"A version label is required")
1378 supplied = store.release_body(payload)
1379 if supplied
is not None:
1381 _write_working(ref.owner, ref.doc_id, data)
1383 data = _read_working(ref.owner, ref.doc_id)
or store.empty_payload()
1386 meta = store.backend.create_release(ref.owner, ref.doc_id, label, data)
1387 except FileExistsError:
1388 raise HTTPException(status_code=409, detail=f
"Release '{label}' already exists")
1389 _touch_index(ref.owner, ref.doc_id)
1393 @router.get(f"{sub}/{{doc_id}}/releases")
1394 async def list_releases(request: Request, doc_id: str, owner: str |
None =
None):
1395 """Published releases, newest first."""
1396 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1397 return store.backend.list_releases(ref.owner, ref.doc_id)
1400 @router.get(f"{sub}/{{doc_id}}/release/{{label}}")
1401 async def get_release(
1402 request: Request, doc_id: str, label: str, owner: str |
None =
None
1404 """Fetch one release snapshot (for preview/restore)."""
1405 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1406 data = store.backend.get_release(ref.owner, ref.doc_id, slugify(label))
1408 raise HTTPException(status_code=404, detail=
"Release not found")
1413 def _add_star(owner: str, doc_id: str, viewer: str) ->
None:
1414 with store.star_lock():
1415 entries = store.read_star_raw()
1416 if not any((e[
"owner"], e[
"id"]) == (owner, doc_id)
for e
in entries):
1417 entries.append({
"owner": owner,
"id": doc_id,
1418 "addedBy": viewer,
"addedAt": _now_iso()})
1419 store.write_star(entries)
1421 def _remove_star(owner: str, doc_id: str) ->
None:
1422 with store.star_lock():
1423 entries = store.read_star_raw()
1424 kept = [e
for e
in entries
if (e[
"owner"], e[
"id"]) != (owner, doc_id)]
1425 if len(kept) != len(entries):
1426 store.write_star(kept)
1430 def _require_star_admin(request: Request) -> str:
1431 access = Access(request)
1432 if not access.admin:
1433 raise HTTPException(
1435 detail=f
"Only an admin can choose the STAR {store.noun}s",
1437 return access.viewer
1439 @router.put(f"{sub}/{{doc_id}}/star")
1440 async def star_document(request: Request, doc_id: str, owner: str |
None =
None):
1441 """Add a design to the STAR set, which everyone sees. Admins only."""
1442 viewer = _require_star_admin(request)
1443 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1444 _add_star(ref.owner, ref.doc_id, viewer)
1445 return _decorated_for(request, ref, ref.record)
1447 @router.delete(f"{sub}/{{doc_id}}/star")
1448 async def unstar_document(request: Request, doc_id: str, owner: str |
None =
None):
1449 """Take a design out of the STAR set. Admins only.
1451 The main design is always a STAR one, so taking it out also stops it
1452 being main -- otherwise the pointer would keep it in the set and the
1453 button would appear to do nothing.
1455 _require_star_admin(request)
1456 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1457 _remove_star(ref.owner, ref.doc_id)
1458 main = store.read_featured()
1459 if main
is not None and (main[
"owner"], main[
"id"]) == (ref.owner, ref.doc_id):
1460 store.write_featured(
None)
1461 return _decorated_for(request, ref, ref.record)
1465 @router.post(f"{sub}/{{doc_id}}/access")
1466 async def request_access(request: Request, doc_id: str, owner: str |
None =
None):
1467 """Ask to edit a design you can see. Its creator or an admin answers.
1469 Idempotent: asking twice is one request, with the first time kept.
1471 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1472 if _can_edit(ref.record, ref.owner, Access(request)):
1473 raise HTTPException(status_code=400, detail=f
"You can already edit this {store.noun}")
1475 def apply(record: dict) ->
None:
1476 requests = _requests_of(record)
1477 if not any(r[
"email"] == ref.viewer
for r
in requests):
1478 requests.append({
"email": ref.viewer,
"at": _now_iso()})
1479 record[
"accessRequests"] = requests
1481 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1483 @router.delete(f"{sub}/{{doc_id}}/access")
1484 async def withdraw_access_request(
1485 request: Request, doc_id: str, owner: str |
None =
None
1487 """Take back your own request. Idempotent."""
1488 ref = _resolve_doc(request, owner, doc_id, need=
"view")
1490 def apply(record: dict) ->
None:
1491 record[
"accessRequests"] = [
1492 r
for r
in _requests_of(record)
if r[
"email"] != ref.viewer
1495 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1497 def _answer(request: Request, doc_id: str, owner: str |
None, email: str,
1498 approve: bool) -> dict:
1499 ref = _resolve_doc(request, owner, doc_id, need=
"manage")
1500 who = slug_user(email.strip())
1502 def apply(record: dict) ->
None:
1503 requests = _requests_of(record)
1504 if not any(r[
"email"] == who
for r
in requests):
1505 raise HTTPException(status_code=404, detail=f
"{who} has not asked to edit this")
1506 record[
"accessRequests"] = [r
for r
in requests
if r[
"email"] != who]
1508 shared = _shared_with(record)
1509 if not _is_shared(record, who)
and who != ref.owner:
1511 record.update(sharedWith=shared, sharedUpdatedBy=ref.viewer,
1512 sharedUpdatedAt=_now_iso())
1514 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1516 @router.post(f"{sub}/{{doc_id}}/access/approve")
1517 async def approve_access(
1518 request: Request, doc_id: str, payload: RequesterPayload, owner: str |
None =
None
1520 """Let someone who asked edit it: they join the share list."""
1521 return _answer(request, doc_id, owner, payload.email, approve=
True)
1523 @router.post(f"{sub}/{{doc_id}}/access/deny")
1524 async def deny_access(
1525 request: Request, doc_id: str, payload: RequesterPayload, owner: str |
None =
None
1527 """Turn a request down. They keep what they had -- seeing it, and
1528 copying it -- and may ask again."""
1529 return _answer(request, doc_id, owner, payload.email, approve=
False)
1531 @router.get(f"{sub}/requests")
1532 async def list_access_requests(request: Request):
1533 """Every pending request the caller may answer: on their own designs,
1534 and for an admin on everyone's. What the badge on Change counts."""
1535 access = Access(request)
1536 names = directory.display_names(request, store.ud)
1538 for owner, record
in _scan_all():
1539 requests = _requests_of(record)
1540 if not requests
or not _can_manage(record, owner, access):
1545 "ownerName": names.get(owner)
or owner,
1547 "name": record.get(
"name", record[
"id"]),
1548 "email": r[
"email"],
1549 "requesterName": names.get(r[
"email"])
or r[
"email"],
1552 out.sort(key=
lambda r: r[
"at"]
or "")
1559 @router.get(f"{sub}/featured")
1560 async def get_featured(request: Request):
1561 """The main design, and whether the caller may choose it."""
1562 access = Access(request)
1563 main = store.read_featured()
1564 if main
is not None:
1565 names = directory.display_names(request, store.ud)
1566 main = {**main,
"ownerName": names.get(main[
"owner"])
or main[
"owner"]}
1569 "isAdmin": access.admin,
1571 "recentDays": store.recent_days
if store.open_to_all
else None,
1573 "curated": store.curated,
1576 def _require_admin(request: Request) -> str:
1577 access = Access(request)
1578 if not access.admin:
1579 raise HTTPException(
1580 status_code=403, detail=f
"Only an admin can choose the main {store.noun}"
1582 return access.viewer
1584 @router.put(f"{sub}/featured")
1585 async def set_featured(request: Request, payload: FeaturedPayload):
1586 """Make a design the main one. Admins only."""
1587 viewer = _require_admin(request)
1588 ref = _resolve_doc(request, payload.owner, payload.id, need=
"read")
1589 data = {
"owner": ref.owner,
"id": ref.doc_id,
"setBy": viewer,
"setAt": _now_iso()}
1590 store.write_featured(data)
1594 _add_star(ref.owner, ref.doc_id, viewer)
1595 return {
"featured": data,
"isAdmin":
True}
1597 @router.delete(f"{sub}/featured")
1598 async def clear_featured(request: Request):
1599 """No main design. Admins only."""
1600 _require_admin(request)
1601 store.write_featured(
None)
1602 return {
"featured":
None,
"isAdmin":
True}