Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions client-v3/e2e/tests/03-system-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,48 @@ test('creates and loads a show via Save and Load', async () => {
await expect(page.locator('a:has-text("Show Config")')).toBeVisible({ timeout: 10_000 });
});

// ── Show deletion ─────────────────────────────────────────────────────────

test('creates a second show to use as a deletion target', async () => {
// Shows tab is still active after the page reload caused by loading the first show.
await expect(page.locator('button:has-text("Setup New Show")')).toBeVisible({ timeout: 10_000 });
await page.click('button:has-text("Setup New Show")');
await waitForModal(page, 'Setup New Show');

await page.fill('#show-name-input', 'E2E Show to Delete');
await page.fill('#show-start-input', '2025-02-01');
await page.fill('#show-end-input', '2025-04-30');
await page
.waitForSelector('#show-script-mode-input option:not([value=""])', { timeout: 5_000 })
.catch(() => {});
await page.selectOption('#show-script-mode-input', { index: 0 });

// Save only — do not load this show; the primary show must remain loaded for later specs.
await page.locator('.modal.show').getByRole('button', { name: 'Save', exact: true }).click();
await waitForModalClosed(page);
await expect(page.locator('td:has-text("E2E Show to Delete")')).toBeVisible();
});

test('delete button is disabled for the currently loaded show', async () => {
const loadedRow = page.locator('tr', {
has: page.locator('td:has-text("E2E Test Show")'),
});
await expect(loadedRow.locator('button:has-text("Delete")')).toBeDisabled();
});

test('can delete a show', async () => {
const targetRow = page.locator('tr', {
has: page.locator('td:has-text("E2E Show to Delete")'),
});
await targetRow.locator('button:has-text("Delete")').click();
await confirmDialog(page);
await expect(page.locator('td:has-text("E2E Show to Delete")')).not.toBeVisible({
timeout: 5_000,
});
// The primary show must still be present
await expect(page.locator('td:has-text("E2E Test Show")')).toBeVisible();
});

// ── Users tab ──────────────────────────────────────────────────────────────

test('switches to the Users tab', async () => {
Expand Down
64 changes: 53 additions & 11 deletions client-v3/src/components/config/ConfigShows.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,27 @@
<BButton variant="success" @click="newShowModal?.show()">Setup New Show</BButton>
</template>
<template #cell(btn)="data">
<BButton
variant="primary"
:disabled="
isSubmittingLoad || (currentShow != null && currentShow.id === data.item.id)
"
@click="loadShow(data.item)"
>
{{
currentShow != null && currentShow.id === data.item.id ? 'Loaded' : 'Load Show'
}}
</BButton>
<BButtonGroup>
<BButton
variant="primary"
:disabled="
isSubmittingLoad || (currentShow != null && currentShow.id === data.item.id)
"
@click="loadShow(data.item)"
>
{{
currentShow != null && currentShow.id === data.item.id ? 'Loaded' : 'Load Show'
}}
</BButton>
<BButton
variant="danger"
:disabled="isDeleting || (currentShow != null && currentShow.id === data.item.id)"
@click="deleteShow(data.item)"
>
<BSpinner v-if="isDeleting && deletingId === data.item.id" small />
Delete
</BButton>
</BButtonGroup>
</template>
</BTable>
<BPagination
Expand Down Expand Up @@ -118,18 +128,22 @@ import log from 'loglevel';
import { makeURL } from '@/js/utils';
import { useSystemStore } from '@/stores/system';
import { useShowStore } from '@/stores/show';
import { useConfirm } from '@/composables/useConfirm';
import { toast } from '@/js/toast';
import type { Show } from '@/types/api/show';

const systemStore = useSystemStore();
const showStore = useShowStore();
const { availableShows, currentShow } = storeToRefs(systemStore);
const { scriptModes } = storeToRefs(showStore);
const { confirm } = useConfirm();

const loaded = ref(false);
const newShowModal = ref<InstanceType<typeof BModal>>();
const isSubmittingLoad = ref(false);
const isSubmittingShow = ref(false);
const isDeleting = ref(false);
const deletingId = ref<number | null>(null);
const currentPage = ref(1);
const rowsPerPage = 15;

Expand Down Expand Up @@ -250,6 +264,34 @@ async function loadShow(show: Show): Promise<void> {
}
}

async function deleteShow(show: Show): Promise<void> {
const confirmed = await confirm(
`Are you sure you want to delete ${show.name}? This will delete all data associated with this show and cannot be undone.`,
{ title: 'Delete Show', okVariant: 'danger', okTitle: 'Delete' }
);
if (!confirmed) return;

isDeleting.value = true;
deletingId.value = show.id;
try {
const params = new URLSearchParams({ id: String(show.id) });
const response = await fetch(`${makeURL('/api/v1/show')}?${params}`, { method: 'DELETE' });
if (response.ok) {
await systemStore.getAvailableShows();
toast.success('Deleted show!');
} else {
log.error('Unable to delete show');
toast.error('Unable to delete show');
}
} catch (err) {
log.error('Error deleting show:', err);
toast.error('Unable to delete show');
} finally {
isDeleting.value = false;
deletingId.value = null;
}
}

onMounted(async () => {
await Promise.all([systemStore.getAvailableShows(), showStore.getScriptModes()]);
loaded.value = true;
Expand Down
77 changes: 64 additions & 13 deletions client/src/vue_components/config/ConfigShows.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,32 @@
<b-button v-b-modal.show-config variant="success"> Setup New Show </b-button>
</template>
<template #cell(btn)="data">
<b-button
variant="primary"
:disabled="
isSubmittingLoad || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
"
@click="loadShow(data.item)"
>
{{
(CURRENT_SHOW != null && CURRENT_SHOW.id !== data.item.id) || CURRENT_SHOW == null
? 'Load Show'
: 'Loaded'
}}
</b-button>
<b-button-group>
<b-button
variant="primary"
:disabled="
isSubmittingLoad || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
"
@click="loadShow(data.item)"
>
{{
(CURRENT_SHOW != null && CURRENT_SHOW.id !== data.item.id) ||
CURRENT_SHOW == null
? 'Load Show'
: 'Loaded'
}}
</b-button>
<b-button
variant="danger"
:disabled="
isDeleting || (CURRENT_SHOW != null && CURRENT_SHOW.id === data.item.id)
"
@click.stop.prevent="deleteShow(data.item)"
>
<b-spinner v-if="isDeleting && deletingId === data.item.id" small />
Delete
</b-button>
</b-button-group>
</template>
</b-table>
<b-pagination
Expand Down Expand Up @@ -157,6 +170,8 @@ export default defineComponent({
currentPage: 1,
isSubmittingLoad: false,
isSubmittingShow: false,
isDeleting: false,
deletingId: null as number | null,
formState: {
name: null as string | null,
start: null as string | null,
Expand Down Expand Up @@ -278,6 +293,42 @@ export default defineComponent({
(this as any).$v.$reset();
});
},
async deleteShow(item) {
if (this.CURRENT_SHOW != null && this.CURRENT_SHOW.id === item.id) {
this.$toast.error('Unable to delete currently loaded show');
return;
}

const msg = `Are you sure you want to delete ${item.name}? This will delete all data associated with this show and cannot be undone.`;
const action = await this.$bvModal.msgBoxConfirm(msg, {});
if (action !== true) {
return;
}

this.isDeleting = true;
this.deletingId = item.id;
try {
const searchParams = new URLSearchParams({
id: item.id,
});
const response = await fetch(`${makeURL('/api/v1/show')}?${searchParams}`, {
method: 'DELETE',
});
if (response.ok) {
await this.GET_AVAILABLE_SHOWS();
this.$toast.success('Deleted show!');
} else {
this.$toast.error('Unable to delete show');
log.error('Unable to delete show');
}
} catch (error) {
this.$toast.error('Unable to delete show');
log.error('Error deleting show:', error);
} finally {
this.isDeleting = false;
this.deletingId = null;
}
},
...mapActions(['GET_AVAILABLE_SHOWS', 'GET_SCRIPT_MODES']),
},
});
Expand Down
63 changes: 60 additions & 3 deletions server/controllers/api/show/shows.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@
from sqlalchemy import select
from tornado import escape

from controllers.api.constants import ERROR_SHOW_NOT_FOUND
from controllers.api.constants import (
ERROR_ID_MISSING,
ERROR_INVALID_ID,
ERROR_SHOW_NOT_FOUND,
)
from digi_server.logger import get_logger
from models.script import Script, ScriptRevision
from models.show import Show, ShowScriptType
from rbac.role import Role
from schemas.schemas import ShowSchema
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
from utils.web.web_decorators import api_authenticated, require_admin, requires_show
from utils.web.web_decorators import (
api_authenticated,
no_live_session,
require_admin,
requires_show,
)


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

session.commit()

should_load = bool(self.get_query_argument("load", default="False"))
should_load = (
self.get_query_argument("load", default="false").lower() == "true"
)
if should_load:
await self.application.digi_settings.set("current_show", show.id)

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

@requires_show
@no_live_session
async def delete(self):
"""
Deletes a show. This is a destructive action and will delete all associated data including scripts, script revisions, acts, cues, etc. Use with caution.
"""
show_id_str = self.get_argument("id", None)
if not show_id_str:
self.set_status(400)
await self.finish({"message": ERROR_ID_MISSING})
return

try:
show_id = int(show_id_str)
except ValueError:
self.set_status(400)
await self.finish({"message": ERROR_INVALID_ID})
return

current_show = self.get_current_show()
current_show_id = current_show["id"]
if show_id == current_show_id:
self.set_status(400)
await self.finish({"message": "Cannot delete the currently loaded show"})
return

with self.make_session() as session:
show: Show = session.get(Show, show_id)
if show:
# Break circular FKs before cascade: shows.current_session_id → showsession,
# and script.current_revision → script_revisions.
show.current_session_id = None
for script in show.scripts:
script.current_revision = None
session.flush()
with session.no_autoflush:
session.delete(show)
session.commit()

self.set_status(200)
await self.application.ws_send_to_all("NOOP", "GET_SHOW_DETAILS", {})
await self.finish({"message": "Successfully deleted show"})
else:
self.set_status(404)
await self.finish({"message": ERROR_SHOW_NOT_FOUND})


@ApiRoute("show/script_modes", ApiVersion.V1)
class ShowScriptModesController(BaseAPIController):
Expand Down
3 changes: 2 additions & 1 deletion server/models/mics.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


if TYPE_CHECKING:
from models.show import Character, Scene
from models.show import Character, Scene, Show


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

show: Mapped[Show] = relationship(back_populates="microphones")
allocations: Mapped[List[MicrophoneAllocation]] = relationship(
cascade="all, delete-orphan", back_populates="microphone"
)
Expand Down
6 changes: 4 additions & 2 deletions server/models/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
)

revisions: Mapped[List[ScriptRevision]] = relationship(
primaryjoin="ScriptRevision.script_id == Script.id", back_populates="script"
primaryjoin="ScriptRevision.script_id == Script.id",
back_populates="script",
cascade="all, delete-orphan",

Check failure on line 50 in server/models/script.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "all, delete-orphan" 6 times.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ5j3RIF6-HW161Ujf-2&open=AZ5j3RIF6-HW161Ujf-2&pullRequest=918
)
show: Mapped[Show] = relationship(foreign_keys=[show_id])
show: Mapped[Show] = relationship(foreign_keys=[show_id], back_populates="scripts")
stage_direction_styles: Mapped[List[StageDirectionStyle]] = relationship(
cascade="all, delete-orphan", back_populates="script"
)
Expand Down
4 changes: 3 additions & 1 deletion server/models/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ class ShowSession(db.Model):
ForeignKey("showinterval.id", ondelete="SET NULL")
)

show: Mapped[Show] = relationship(uselist=False, foreign_keys=[show_id])
show: Mapped[Show] = relationship(
uselist=False, foreign_keys=[show_id], back_populates="show_sessions"
)
revision: Mapped[ScriptRevision] = relationship(
uselist=False, foreign_keys=[script_revision_id]
)
Expand Down
Loading
Loading