Skip to content

Commit d1d9dd7

Browse files
Tim020claude
andauthored
Allow shows to be deleted (#918)
* Add front end button and back end route for show deletion * Fix show deletion: cascade sessions, circular FKs, load param parsing, and loading state - Fix `bool("false") == True` bug: show creation loaded the show regardless of the `load` query param; replace with `.lower() == "true"` string comparison - Add `Show.show_sessions` cascade relationship and null `current_session_id` before deletion to handle ShowSession → ScriptRevision FK blocking cascade - Add `post_update=True` to `Show.first_act`, `Act.first_scene`, `Act.previous_act`, and `Scene.previous_scene` to resolve CircularDependencyError when deleting shows with multiple acts/scenes - Expand `test_delete_show_with_full_data` to cover multiple acts/scenes with linked-list pointers and a past ShowSession — the cases that only surface with real show data - Add `isDeleting`/`deletingId` loading state to ConfigShows in both Vue 3 and Vue 2 frontends, disabling all delete buttons during a request Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Apply ruff and Prettier formatting fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e271aff commit d1d9dd7

9 files changed

Lines changed: 512 additions & 36 deletions

File tree

client-v3/e2e/tests/03-system-config.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,48 @@ test('creates and loads a show via Save and Load', async () => {
7979
await expect(page.locator('a:has-text("Show Config")')).toBeVisible({ timeout: 10_000 });
8080
});
8181

82+
// ── Show deletion ─────────────────────────────────────────────────────────
83+
84+
test('creates a second show to use as a deletion target', async () => {
85+
// Shows tab is still active after the page reload caused by loading the first show.
86+
await expect(page.locator('button:has-text("Setup New Show")')).toBeVisible({ timeout: 10_000 });
87+
await page.click('button:has-text("Setup New Show")');
88+
await waitForModal(page, 'Setup New Show');
89+
90+
await page.fill('#show-name-input', 'E2E Show to Delete');
91+
await page.fill('#show-start-input', '2025-02-01');
92+
await page.fill('#show-end-input', '2025-04-30');
93+
await page
94+
.waitForSelector('#show-script-mode-input option:not([value=""])', { timeout: 5_000 })
95+
.catch(() => {});
96+
await page.selectOption('#show-script-mode-input', { index: 0 });
97+
98+
// Save only — do not load this show; the primary show must remain loaded for later specs.
99+
await page.locator('.modal.show').getByRole('button', { name: 'Save', exact: true }).click();
100+
await waitForModalClosed(page);
101+
await expect(page.locator('td:has-text("E2E Show to Delete")')).toBeVisible();
102+
});
103+
104+
test('delete button is disabled for the currently loaded show', async () => {
105+
const loadedRow = page.locator('tr', {
106+
has: page.locator('td:has-text("E2E Test Show")'),
107+
});
108+
await expect(loadedRow.locator('button:has-text("Delete")')).toBeDisabled();
109+
});
110+
111+
test('can delete a show', async () => {
112+
const targetRow = page.locator('tr', {
113+
has: page.locator('td:has-text("E2E Show to Delete")'),
114+
});
115+
await targetRow.locator('button:has-text("Delete")').click();
116+
await confirmDialog(page);
117+
await expect(page.locator('td:has-text("E2E Show to Delete")')).not.toBeVisible({
118+
timeout: 5_000,
119+
});
120+
// The primary show must still be present
121+
await expect(page.locator('td:has-text("E2E Test Show")')).toBeVisible();
122+
});
123+
82124
// ── Users tab ──────────────────────────────────────────────────────────────
83125

84126
test('switches to the Users tab', async () => {

client-v3/src/components/config/ConfigShows.vue

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,27 @@
1414
<BButton variant="success" @click="newShowModal?.show()">Setup New Show</BButton>
1515
</template>
1616
<template #cell(btn)="data">
17-
<BButton
18-
variant="primary"
19-
:disabled="
20-
isSubmittingLoad || (currentShow != null && currentShow.id === data.item.id)
21-
"
22-
@click="loadShow(data.item)"
23-
>
24-
{{
25-
currentShow != null && currentShow.id === data.item.id ? 'Loaded' : 'Load Show'
26-
}}
27-
</BButton>
17+
<BButtonGroup>
18+
<BButton
19+
variant="primary"
20+
:disabled="
21+
isSubmittingLoad || (currentShow != null && currentShow.id === data.item.id)
22+
"
23+
@click="loadShow(data.item)"
24+
>
25+
{{
26+
currentShow != null && currentShow.id === data.item.id ? 'Loaded' : 'Load Show'
27+
}}
28+
</BButton>
29+
<BButton
30+
variant="danger"
31+
:disabled="isDeleting || (currentShow != null && currentShow.id === data.item.id)"
32+
@click="deleteShow(data.item)"
33+
>
34+
<BSpinner v-if="isDeleting && deletingId === data.item.id" small />
35+
Delete
36+
</BButton>
37+
</BButtonGroup>
2838
</template>
2939
</BTable>
3040
<BPagination
@@ -118,18 +128,22 @@ import log from 'loglevel';
118128
import { makeURL } from '@/js/utils';
119129
import { useSystemStore } from '@/stores/system';
120130
import { useShowStore } from '@/stores/show';
131+
import { useConfirm } from '@/composables/useConfirm';
121132
import { toast } from '@/js/toast';
122133
import type { Show } from '@/types/api/show';
123134
124135
const systemStore = useSystemStore();
125136
const showStore = useShowStore();
126137
const { availableShows, currentShow } = storeToRefs(systemStore);
127138
const { scriptModes } = storeToRefs(showStore);
139+
const { confirm } = useConfirm();
128140
129141
const loaded = ref(false);
130142
const newShowModal = ref<InstanceType<typeof BModal>>();
131143
const isSubmittingLoad = ref(false);
132144
const isSubmittingShow = ref(false);
145+
const isDeleting = ref(false);
146+
const deletingId = ref<number | null>(null);
133147
const currentPage = ref(1);
134148
const rowsPerPage = 15;
135149
@@ -250,6 +264,34 @@ async function loadShow(show: Show): Promise<void> {
250264
}
251265
}
252266
267+
async function deleteShow(show: Show): Promise<void> {
268+
const confirmed = await confirm(
269+
`Are you sure you want to delete ${show.name}? This will delete all data associated with this show and cannot be undone.`,
270+
{ title: 'Delete Show', okVariant: 'danger', okTitle: 'Delete' }
271+
);
272+
if (!confirmed) return;
273+
274+
isDeleting.value = true;
275+
deletingId.value = show.id;
276+
try {
277+
const params = new URLSearchParams({ id: String(show.id) });
278+
const response = await fetch(`${makeURL('/api/v1/show')}?${params}`, { method: 'DELETE' });
279+
if (response.ok) {
280+
await systemStore.getAvailableShows();
281+
toast.success('Deleted show!');
282+
} else {
283+
log.error('Unable to delete show');
284+
toast.error('Unable to delete show');
285+
}
286+
} catch (err) {
287+
log.error('Error deleting show:', err);
288+
toast.error('Unable to delete show');
289+
} finally {
290+
isDeleting.value = false;
291+
deletingId.value = null;
292+
}
293+
}
294+
253295
onMounted(async () => {
254296
await Promise.all([systemStore.getAvailableShows(), showStore.getScriptModes()]);
255297
loaded.value = true;

client/src/vue_components/config/ConfigShows.vue

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,32 @@
1414
<b-button v-b-modal.show-config variant="success"> Setup New Show </b-button>
1515
</template>
1616
<template #cell(btn)="data">
17-
<b-button
18-
variant="primary"
19-
:disabled="
20-
isSubmittingLoad || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
21-
"
22-
@click="loadShow(data.item)"
23-
>
24-
{{
25-
(CURRENT_SHOW != null && CURRENT_SHOW.id !== data.item.id) || CURRENT_SHOW == null
26-
? 'Load Show'
27-
: 'Loaded'
28-
}}
29-
</b-button>
17+
<b-button-group>
18+
<b-button
19+
variant="primary"
20+
:disabled="
21+
isSubmittingLoad || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
22+
"
23+
@click="loadShow(data.item)"
24+
>
25+
{{
26+
(CURRENT_SHOW != null && CURRENT_SHOW.id !== data.item.id) ||
27+
CURRENT_SHOW == null
28+
? 'Load Show'
29+
: 'Loaded'
30+
}}
31+
</b-button>
32+
<b-button
33+
variant="danger"
34+
:disabled="
35+
isDeleting || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
36+
"
37+
@click.stop.prevent="deleteShow(data.item)"
38+
>
39+
<b-spinner v-if="isDeleting && deletingId === data.item.id" small />
40+
Delete
41+
</b-button>
42+
</b-button-group>
3043
</template>
3144
</b-table>
3245
<b-pagination
@@ -157,6 +170,8 @@ export default defineComponent({
157170
currentPage: 1,
158171
isSubmittingLoad: false,
159172
isSubmittingShow: false,
173+
isDeleting: false,
174+
deletingId: null as number | null,
160175
formState: {
161176
name: null as string | null,
162177
start: null as string | null,
@@ -278,6 +293,42 @@ export default defineComponent({
278293
(this as any).$v.$reset();
279294
});
280295
},
296+
async deleteShow(item) {
297+
if (this.CURRENT_SHOW != null && this.CURRENT_SHOW.id === item.id) {
298+
this.$toast.error('Unable to delete currently loaded show');
299+
return;
300+
}
301+
302+
const msg = `Are you sure you want to delete ${item.name}? This will delete all data associated with this show and cannot be undone.`;
303+
const action = await this.$bvModal.msgBoxConfirm(msg, {});
304+
if (action !== true) {
305+
return;
306+
}
307+
308+
this.isDeleting = true;
309+
this.deletingId = item.id;
310+
try {
311+
const searchParams = new URLSearchParams({
312+
id: item.id,
313+
});
314+
const response = await fetch(`${makeURL('/api/v1/show')}?${searchParams}`, {
315+
method: 'DELETE',
316+
});
317+
if (response.ok) {
318+
await this.GET_AVAILABLE_SHOWS();
319+
this.$toast.success('Deleted show!');
320+
} else {
321+
this.$toast.error('Unable to delete show');
322+
log.error('Unable to delete show');
323+
}
324+
} catch (error) {
325+
this.$toast.error('Unable to delete show');
326+
log.error('Error deleting show:', error);
327+
} finally {
328+
this.isDeleting = false;
329+
this.deletingId = null;
330+
}
331+
},
281332
...mapActions(['GET_AVAILABLE_SHOWS', 'GET_SCRIPT_MODES']),
282333
},
283334
});

server/controllers/api/show/shows.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@
44
from sqlalchemy import select
55
from tornado import escape
66

7-
from controllers.api.constants import ERROR_SHOW_NOT_FOUND
7+
from controllers.api.constants import (
8+
ERROR_ID_MISSING,
9+
ERROR_INVALID_ID,
10+
ERROR_SHOW_NOT_FOUND,
11+
)
812
from digi_server.logger import get_logger
913
from models.script import Script, ScriptRevision
1014
from models.show import Show, ShowScriptType
1115
from rbac.role import Role
1216
from schemas.schemas import ShowSchema
1317
from utils.web.base_controller import BaseAPIController
1418
from utils.web.route import ApiRoute, ApiVersion
15-
from utils.web.web_decorators import api_authenticated, require_admin, requires_show
19+
from utils.web.web_decorators import (
20+
api_authenticated,
21+
no_live_session,
22+
require_admin,
23+
requires_show,
24+
)
1625

1726

1827
@ApiRoute("show", ApiVersion.V1)
@@ -118,7 +127,9 @@ async def post(self):
118127

119128
session.commit()
120129

121-
should_load = bool(self.get_query_argument("load", default="False"))
130+
should_load = (
131+
self.get_query_argument("load", default="false").lower() == "true"
132+
)
122133
if should_load:
123134
await self.application.digi_settings.set("current_show", show.id)
124135

@@ -220,6 +231,52 @@ async def patch(self):
220231
self.set_status(404)
221232
await self.finish({"message": ERROR_SHOW_NOT_FOUND})
222233

234+
@requires_show
235+
@no_live_session
236+
async def delete(self):
237+
"""
238+
Deletes a show. This is a destructive action and will delete all associated data including scripts, script revisions, acts, cues, etc. Use with caution.
239+
"""
240+
show_id_str = self.get_argument("id", None)
241+
if not show_id_str:
242+
self.set_status(400)
243+
await self.finish({"message": ERROR_ID_MISSING})
244+
return
245+
246+
try:
247+
show_id = int(show_id_str)
248+
except ValueError:
249+
self.set_status(400)
250+
await self.finish({"message": ERROR_INVALID_ID})
251+
return
252+
253+
current_show = self.get_current_show()
254+
current_show_id = current_show["id"]
255+
if show_id == current_show_id:
256+
self.set_status(400)
257+
await self.finish({"message": "Cannot delete the currently loaded show"})
258+
return
259+
260+
with self.make_session() as session:
261+
show: Show = session.get(Show, show_id)
262+
if show:
263+
# Break circular FKs before cascade: shows.current_session_id → showsession,
264+
# and script.current_revision → script_revisions.
265+
show.current_session_id = None
266+
for script in show.scripts:
267+
script.current_revision = None
268+
session.flush()
269+
with session.no_autoflush:
270+
session.delete(show)
271+
session.commit()
272+
273+
self.set_status(200)
274+
await self.application.ws_send_to_all("NOOP", "GET_SHOW_DETAILS", {})
275+
await self.finish({"message": "Successfully deleted show"})
276+
else:
277+
self.set_status(404)
278+
await self.finish({"message": ERROR_SHOW_NOT_FOUND})
279+
223280

224281
@ApiRoute("show/script_modes", ApiVersion.V1)
225282
class ShowScriptModesController(BaseAPIController):

server/models/mics.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
if TYPE_CHECKING:
12-
from models.show import Character, Scene
12+
from models.show import Character, Scene, Show
1313

1414

1515
class Microphone(db.Model):
@@ -21,6 +21,7 @@ class Microphone(db.Model):
2121
name: Mapped[str | None] = mapped_column(String(100))
2222
description: Mapped[str | None] = mapped_column(String(500))
2323

24+
show: Mapped[Show] = relationship(back_populates="microphones")
2425
allocations: Mapped[List[MicrophoneAllocation]] = relationship(
2526
cascade="all, delete-orphan", back_populates="microphone"
2627
)

server/models/script.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ class Script(db.Model):
4545
)
4646

4747
revisions: Mapped[List[ScriptRevision]] = relationship(
48-
primaryjoin="ScriptRevision.script_id == Script.id", back_populates="script"
48+
primaryjoin="ScriptRevision.script_id == Script.id",
49+
back_populates="script",
50+
cascade="all, delete-orphan",
4951
)
50-
show: Mapped[Show] = relationship(foreign_keys=[show_id])
52+
show: Mapped[Show] = relationship(foreign_keys=[show_id], back_populates="scripts")
5153
stage_direction_styles: Mapped[List[StageDirectionStyle]] = relationship(
5254
cascade="all, delete-orphan", back_populates="script"
5355
)

server/models/session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ class ShowSession(db.Model):
6868
ForeignKey("showinterval.id", ondelete="SET NULL")
6969
)
7070

71-
show: Mapped[Show] = relationship(uselist=False, foreign_keys=[show_id])
71+
show: Mapped[Show] = relationship(
72+
uselist=False, foreign_keys=[show_id], back_populates="show_sessions"
73+
)
7274
revision: Mapped[ScriptRevision] = relationship(
7375
uselist=False, foreign_keys=[script_revision_id]
7476
)

0 commit comments

Comments
 (0)