Skip to content

Commit 52805af

Browse files
Tim020claude
andcommitted
Add Phase 5 revision lifecycle guards for collaborative editing
Prevents load/create/delete revision operations from running while a collaborative edit session is active or an unsaved draft exists. Backend: - Add _revision_is_locked() helper (checks DB draft + in-memory room) - GET /revisions annotates each revision with has_draft field - POST /revisions returns 409 if parent revision is locked - POST /revisions/current returns 409 if current revision has active room - DELETE /revisions returns 409 if target revision is locked - Broadcast GET_SCRIPT_REVISIONS after every code path that creates or destroys a ScriptDraft (STOP_SCRIPT_EDIT, on_close, SAVE/DISCARD draft) - Add TestRevisionLifecycleGuards (8 tests); fix test_ws_controller for new GET_SCRIPT_REVISIONS message in checkpoint sequence Frontend: - Surface backend 409 error messages in revision action toast errors - Replace canChangeRevisions with targeted canLoadRevision / canDeleteRevision computed props and revisionHasDraft() method - Add v-b-tooltip.hover on span wrappers for disabled buttons; use '' not null so tooltips clear correctly when condition lifts - Add .btn-group-item CSS using :not(:last-child)/:not(:first-child) so solo Load button retains all rounded corners Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2d4917b commit 52805af

7 files changed

Lines changed: 362 additions & 38 deletions

File tree

client/src/store/modules/script.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ export default {
7373
context.dispatch('GET_SCRIPT_REVISIONS');
7474
Vue.$toast.success('Added new script revision!');
7575
} else {
76+
const data = await response.json().catch(() => ({}));
7677
log.error('Unable to add new script revision');
77-
Vue.$toast.error('Unable to add new script revision');
78+
Vue.$toast.error(data.message || 'Unable to add new script revision');
7879
}
7980
},
8081
async DELETE_SCRIPT_REVISION(context, revisionID) {
@@ -91,8 +92,9 @@ export default {
9192
context.dispatch('GET_SCRIPT_REVISIONS');
9293
Vue.$toast.success('Deleted script revision!');
9394
} else {
95+
const data = await response.json().catch(() => ({}));
9496
log.error('Unable to delete script revision');
95-
Vue.$toast.error('Unable to delete script revision');
97+
Vue.$toast.error(data.message || 'Unable to delete script revision');
9698
}
9799
},
98100
async LOAD_SCRIPT_REVISION(context, revisionID) {
@@ -109,8 +111,9 @@ export default {
109111
context.dispatch('GET_SCRIPT_REVISIONS');
110112
Vue.$toast.success('Loaded script revision!');
111113
} else {
114+
const data = await response.json().catch(() => ({}));
112115
log.error('Unable to load script revision');
113-
Vue.$toast.error('Unable to load script revision');
116+
Vue.$toast.error(data.message || 'Unable to load script revision');
114117
}
115118
},
116119
async SCRIPT_REVISION_CHANGED(context) {

client/src/vue_components/show/config/script/ScriptRevisions.vue

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,29 @@
2626
<template #cell(current)="data">
2727
<b-icon-check-square-fill v-if="data.item.id === CURRENT_REVISION" variant="success" />
2828
<b-button-group v-else>
29-
<b-button
29+
<span
3030
v-if="IS_SCRIPT_EDITOR"
31-
variant="warning"
32-
:disabled="
33-
!canChangeRevisions ||
34-
data.item.id === CURRENT_REVISION ||
35-
submittingLoadRevision ||
36-
submittingNewRevision ||
37-
deletingRevision
31+
v-b-tooltip.hover="
32+
!canLoadRevision
33+
? 'Cannot load revision while a collaborative editing session is active'
34+
: ''
3835
"
39-
@click="loadRevision(data)"
36+
class="btn-group-item"
4037
>
41-
Load
42-
</b-button>
38+
<b-button
39+
variant="warning"
40+
:disabled="
41+
!canLoadRevision ||
42+
data.item.id === CURRENT_REVISION ||
43+
submittingLoadRevision ||
44+
submittingNewRevision ||
45+
deletingRevision
46+
"
47+
@click="loadRevision(data)"
48+
>
49+
Load
50+
</b-button>
51+
</span>
4352
</b-button-group>
4453
</template>
4554
<template #cell(previous_revision_id)="data">
@@ -53,7 +62,7 @@
5362
<b-button
5463
variant="warning"
5564
:disabled="
56-
!canChangeRevisions ||
65+
!canDeleteRevision ||
5766
submittingLoadRevision ||
5867
submittingNewRevision ||
5968
deletingRevision
@@ -62,36 +71,55 @@
6271
>
6372
Edit
6473
</b-button>
65-
<b-button
66-
variant="danger"
67-
:disabled="
68-
!canChangeRevisions ||
69-
submittingLoadRevision ||
70-
submittingNewRevision ||
71-
deletingRevision
74+
<span
75+
v-b-tooltip.hover="
76+
!canDeleteRevision || revisionHasDraft(data.item.id)
77+
? 'Cannot delete revision — it has an active room or unsaved draft'
78+
: ''
7279
"
73-
@click="deleteRev(data)"
80+
class="btn-group-item"
7481
>
75-
Delete
76-
</b-button>
77-
</b-button-group>
78-
</template>
79-
<template #custom-foot="data">
80-
<b-tr>
81-
<b-td>
8282
<b-button
83-
v-if="IS_SCRIPT_EDITOR"
84-
v-b-modal.new-revision
85-
variant="outline-success"
83+
variant="danger"
8684
:disabled="
87-
!canChangeRevisions ||
85+
!canDeleteRevision ||
86+
revisionHasDraft(data.item.id) ||
8887
submittingLoadRevision ||
8988
submittingNewRevision ||
9089
deletingRevision
9190
"
91+
@click="deleteRev(data)"
9292
>
93-
New Revision
93+
Delete
9494
</b-button>
95+
</span>
96+
</b-button-group>
97+
</template>
98+
<template #custom-foot="data">
99+
<b-tr>
100+
<b-td>
101+
<span
102+
v-if="IS_SCRIPT_EDITOR"
103+
v-b-tooltip.hover="
104+
revisionHasDraft(CURRENT_REVISION)
105+
? 'Cannot create a new revision — the current revision has an unsaved draft'
106+
: ''
107+
"
108+
class="btn-group-item"
109+
>
110+
<b-button
111+
v-b-modal.new-revision
112+
variant="outline-success"
113+
:disabled="
114+
revisionHasDraft(CURRENT_REVISION) ||
115+
submittingLoadRevision ||
116+
submittingNewRevision ||
117+
deletingRevision
118+
"
119+
>
120+
New Revision
121+
</b-button>
122+
</span>
95123
</b-td>
96124
<b-td />
97125
<b-td />
@@ -144,7 +172,11 @@
144172
:revision="selectedRevision"
145173
:revisions="SCRIPT_REVISIONS"
146174
:current-revision-id="CURRENT_REVISION"
147-
:can-edit="IS_SCRIPT_EDITOR && canChangeRevisions"
175+
:can-edit="
176+
IS_SCRIPT_EDITOR &&
177+
canDeleteRevision &&
178+
!revisionHasDraft(selectedRevision && selectedRevision.id)
179+
"
148180
:submitting="modalSubmitting"
149181
@load-revision="handleModalLoadRevision"
150182
@create-from="handleModalCreateFrom"
@@ -256,8 +288,12 @@ export default {
256288
'CUTTERS',
257289
'HAS_DRAFT',
258290
'IS_SCRIPT_EDITOR',
291+
'IS_DRAFT_ACTIVE',
259292
]),
260-
canChangeRevisions() {
293+
canLoadRevision() {
294+
return this.EDITORS.length === 0 && this.CUTTERS.length === 0 && !this.IS_DRAFT_ACTIVE;
295+
},
296+
canDeleteRevision() {
261297
return this.EDITORS.length === 0 && this.CUTTERS.length === 0;
262298
},
263299
},
@@ -270,6 +306,10 @@ export default {
270306
await this.GET_SCRIPT_CONFIG_STATUS();
271307
},
272308
methods: {
309+
revisionHasDraft(revisionId) {
310+
const rev = this.SCRIPT_REVISIONS.find((r) => r.id === revisionId);
311+
return rev ? !!rev.has_draft : false;
312+
},
273313
resetNewRevForm() {
274314
this.newRevFormState = {
275315
description: '',
@@ -451,4 +491,18 @@ export default {
451491
.collapsed-card >>> .card-body {
452492
padding: 0;
453493
}
494+
495+
.btn-group-item {
496+
display: flex;
497+
}
498+
499+
.btn-group-item:not(:last-child) > .btn {
500+
border-top-right-radius: 0;
501+
border-bottom-right-radius: 0;
502+
}
503+
504+
.btn-group-item:not(:first-child) > .btn {
505+
border-top-left-radius: 0;
506+
border-bottom-left-radius: 0;
507+
}
454508
</style>

server/controllers/api/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,7 @@
123123
ERROR_CUTS_BLOCKED_BY_EDITOR = "Cannot enter cuts mode: another user is editing"
124124
ERROR_CUTS_BLOCKED_BY_CUTTER = "Cannot enter cuts mode: another user is already cutting"
125125
ERROR_CUTS_BLOCKED_BY_DRAFT = "Cannot enter cuts mode: unsaved draft exists"
126+
ERROR_ROOM_OPEN_LOAD = "Cannot load revision while a collaborative edit session is active for the current revision"
127+
ERROR_ROOM_OPEN_OR_DRAFT = (
128+
"Cannot perform this operation: target revision has an active room or unsaved draft"
129+
)

server/controllers/api/show/script/revisions.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from controllers.api.constants import (
99
ERROR_DESCRIPTION_MISSING,
10+
ERROR_ROOM_OPEN_LOAD,
11+
ERROR_ROOM_OPEN_OR_DRAFT,
1012
ERROR_SCRIPT_NOT_FOUND,
1113
ERROR_SCRIPT_REVISION_NOT_FOUND,
1214
ERROR_SHOW_NOT_FOUND,
@@ -20,6 +22,7 @@
2022
ScriptLineRevisionAssociation,
2123
ScriptRevision,
2224
)
25+
from models.script_draft import ScriptDraft
2326
from models.show import Show
2427
from rbac.role import Role
2528
from schemas.schemas import ScriptRevisionsSchema
@@ -28,6 +31,25 @@
2831
from utils.web.web_decorators import no_live_session, requires_show
2932

3033

34+
def _revision_is_locked(session, application, rev_id: int) -> bool:
35+
"""Return True if a revision has an active draft or a non-empty room.
36+
37+
:param session: Active SQLAlchemy session.
38+
:param application: Tornado application instance.
39+
:param rev_id: Revision ID to check.
40+
:returns: True if the revision is locked; False otherwise.
41+
"""
42+
draft = session.scalar(select(ScriptDraft).where(ScriptDraft.revision_id == rev_id))
43+
if draft:
44+
return True
45+
room_manager = getattr(application, "room_manager", None)
46+
if room_manager:
47+
room = room_manager.get_room(rev_id)
48+
if room and not room.is_empty:
49+
return True
50+
return False
51+
52+
3153
@ApiRoute("show/script/revisions", ApiVersion.V1)
3254
class ScriptRevisionsController(BaseAPIController):
3355
@requires_show
@@ -44,7 +66,13 @@ def get(self):
4466
).first()
4567

4668
if script:
47-
revisions = [revisions_schema.dump(c) for c in script.revisions]
69+
revisions = []
70+
for c in script.revisions:
71+
rev_dict = revisions_schema.dump(c)
72+
rev_dict["has_draft"] = _revision_is_locked(
73+
session, self.application, c.id
74+
)
75+
revisions.append(rev_dict)
4876
self.set_status(200)
4977
self.finish(
5078
{
@@ -107,6 +135,11 @@ async def post(self):
107135
)
108136
return
109137

138+
if _revision_is_locked(session, self.application, parent_rev.id):
139+
self.set_status(409)
140+
await self.finish({"message": ERROR_ROOM_OPEN_OR_DRAFT})
141+
return
142+
110143
max_rev = session.scalar(
111144
select(func.max(ScriptRevision.revision)).where(
112145
ScriptRevision.script_id == script.id
@@ -222,6 +255,11 @@ async def delete(self):
222255
)
223256
return
224257

258+
if _revision_is_locked(session, self.application, rev.id):
259+
self.set_status(409)
260+
await self.finish({"message": ERROR_ROOM_OPEN_OR_DRAFT})
261+
return
262+
225263
changed_rev = False
226264
if script.current_revision == rev.id:
227265
changed_rev = True
@@ -341,6 +379,14 @@ async def post(self):
341379
)
342380
return
343381

382+
room_manager = getattr(self.application, "room_manager", None)
383+
if room_manager:
384+
room = room_manager.get_room(script.current_revision)
385+
if room and not room.is_empty:
386+
self.set_status(409)
387+
await self.finish({"message": ERROR_ROOM_OPEN_LOAD})
388+
return
389+
344390
script.current_revision = new_rev.id
345391
session.commit()
346392

server/controllers/ws_controller.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ async def _broadcast():
110110
if was_editor and not room.has_editors:
111111
if room._dirty:
112112
await rm._checkpoint_room(room)
113+
await app.ws_send_to_all("NOOP", "GET_SCRIPT_REVISIONS", {})
113114
await rm.close_room(room.revision_id)
114115

115116
IOLoop.current().add_callback(_broadcast)
@@ -496,6 +497,9 @@ async def on_message(self, message: Union[str, bytes]):
496497
if not room.has_editors:
497498
if room._dirty:
498499
await room_manager._checkpoint_room(room)
500+
await self.application.ws_send_to_all(
501+
"NOOP", "GET_SCRIPT_REVISIONS", {}
502+
)
499503
await room_manager.close_room(room.revision_id)
500504

501505
await self.application.ws_send_to_all(
@@ -751,6 +755,7 @@ async def _handle_collab_op(self, ws_op: str, message: dict):
751755
await room.broadcast_awareness(decoded, sender=self)
752756
elif ws_op == "SAVE_SCRIPT_DRAFT":
753757
await room_manager.save_room(self)
758+
await self.application.ws_send_to_all("NOOP", "GET_SCRIPT_REVISIONS", {})
754759
elif ws_op == "DISCARD_SCRIPT_DRAFT":
755760
revision_id = None
756761
current_show_id = await self.application.digi_settings.get("current_show")
@@ -767,6 +772,7 @@ async def _handle_collab_op(self, ws_op: str, message: dict):
767772
await self.application.ws_send_to_all(
768773
"NOOP", "GET_SCRIPT_CONFIG_STATUS", {}
769774
)
775+
await self.application.ws_send_to_all("NOOP", "GET_SCRIPT_REVISIONS", {})
770776

771777
def on_pong(self, data: bytes) -> None:
772778
self._last_pong = IOLoop.current().time()

0 commit comments

Comments
 (0)