From fd6e9c77aca4acd3e747fadea6d47de19b1615a2 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Wed, 6 May 2026 23:55:50 +0100 Subject: [PATCH 1/4] Add cue type import from other shows (#991) * Add cue type import from other shows Mirrors the stage direction style import feature (PR #989): allows users to copy cue type definitions (prefix, description, colour) from any other show into the current show via a new Import Cue Type button on the Cue Types tab. - Backend: GET /api/v1/show/cues/types/import returns cue types from all other shows, grouped by show (shows with no cue types are excluded) - Frontend: import button + collapsible modal in ConfigCues.vue, with per-row loading state; GET_IMPORTABLE_CUE_TYPES Vuex action in show.js - Tests: 5 new backend tests in TestCueTypeImportController - Docs: import section added to cue_config.md Co-Authored-By: Claude Sonnet 4.6 * Fix chevron icon alignment in cue type import modal Use flexbox (d-flex justify-content-between) to push the collapse indicator to the right edge of the card header, matching the stage direction styles import layout. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client/src/store/modules/show.js | 10 ++ client/src/views/show/config/ConfigCues.vue | 108 +++++++++++++++++- docs/pages/cue_config.md | 6 +- server/controllers/api/show/cues.py | 35 ++++++ server/test/controllers/api/show/test_cues.py | 104 +++++++++++++++++ 5 files changed, 261 insertions(+), 2 deletions(-) diff --git a/client/src/store/modules/show.js b/client/src/store/modules/show.js index ab1a67aa..3d949d45 100644 --- a/client/src/store/modules/show.js +++ b/client/src/store/modules/show.js @@ -455,6 +455,16 @@ export default { Vue.$toast.error('Unable to edit cue type'); } }, + async GET_IMPORTABLE_CUE_TYPES() { + const response = await fetch(`${makeURL('/api/v1/show/cues/types/import')}`, { + method: 'GET', + }); + if (!response.ok) { + log.error('Unable to fetch importable cue types'); + throw new Error('Failed to fetch importable cue types'); + } + return response.json(); + }, async GET_SHOW_SESSION_DATA(context) { const response = await fetch(`${makeURL('/api/v1/show/sessions')}`); if (response.ok) { diff --git a/client/src/views/show/config/ConfigCues.vue b/client/src/views/show/config/ConfigCues.vue index 18952285..7e79952d 100644 --- a/client/src/views/show/config/ConfigCues.vue +++ b/client/src/views/show/config/ConfigCues.vue @@ -16,6 +16,14 @@ New Cue Type + + Import Cue Type + @@ -199,6 +253,16 @@ export default { submittingNewCueType: false, submittingEditCueType: false, deletingCueType: false, + importCueTypeGroups: [], + cueTypeGroupExpanded: {}, + isLoadingImport: false, + isImporting: {}, + importCueTypeFields: [ + { key: 'prefix', label: 'Prefix' }, + { key: 'description', label: 'Description' }, + { key: 'colour', label: 'Colour' }, + { key: 'action', label: '' }, + ], }; }, validations: { @@ -234,7 +298,13 @@ export default { await this.GET_CUE_TYPES(); }, methods: { - ...mapActions(['GET_CUE_TYPES', 'ADD_CUE_TYPE', 'DELETE_CUE_TYPE', 'UPDATE_CUE_TYPE']), + ...mapActions([ + 'GET_CUE_TYPES', + 'ADD_CUE_TYPE', + 'DELETE_CUE_TYPE', + 'UPDATE_CUE_TYPE', + 'GET_IMPORTABLE_CUE_TYPES', + ]), resetNewCueTypeForm() { this.newCueTypeForm = { prefix: '', @@ -334,6 +404,42 @@ export default { const { $dirty, $error } = this.$v.editCueTypeFormState[name]; return $dirty ? !$error : null; }, + async openImportModal() { + this.$bvModal.show('import-cue-type-modal'); + this.isLoadingImport = true; + try { + const data = await this.GET_IMPORTABLE_CUE_TYPES(); + this.importCueTypeGroups = data.cue_type_groups; + data.cue_type_groups.forEach((show) => { + this.$set(this.cueTypeGroupExpanded, show.id, true); + }); + } catch (e) { + log.error('Error loading importable cue types:', e); + } finally { + this.isLoadingImport = false; + } + }, + toggleImportShow(showId) { + this.$set(this.cueTypeGroupExpanded, showId, !this.cueTypeGroupExpanded[showId]); + }, + async importCueType(cueType) { + this.$set(this.isImporting, cueType.id, true); + try { + await this.ADD_CUE_TYPE({ + prefix: cueType.prefix, + description: cueType.description, + colour: cueType.colour, + }); + } finally { + this.$set(this.isImporting, cueType.id, false); + } + }, + resetImportState() { + this.importCueTypeGroups = []; + this.cueTypeGroupExpanded = {}; + this.isLoadingImport = false; + this.isImporting = {}; + }, }, }; diff --git a/docs/pages/cue_config.md b/docs/pages/cue_config.md index 47c0a513..0a393371 100644 --- a/docs/pages/cue_config.md +++ b/docs/pages/cue_config.md @@ -10,7 +10,7 @@ The **Cue Types** tab allows you to Add, Edit, and Delete different cue types. C #### Creating Cue Types -Click **Add** to create a new cue type. For each cue type, you'll need to specify: +Click **New Cue Type** to create a new cue type. For each cue type, you'll need to specify: - **Prefix**: A short identifier (e.g., "LX" for lighting, "SND" for sound) - **Description**: A full description of the cue type - **Color**: A color code to visually distinguish this cue type in the interface @@ -21,6 +21,10 @@ After adding cue types, they will appear in the cue types overview: The color you choose will be used throughout DigiScript to make different cue types instantly recognizable during configuration and live shows. +#### Importing Cue Types from Another Show + +If you have already configured cue types for another show, you can import them into the current show using the **Import Cue Type** button. This opens a panel listing all cue types from your other shows, grouped by show name. Click **Import** next to any cue type to add an independent copy to the current show — changes made after import do not affect the original show. + ### Adding Cues to the Script The **Cue Configuration** tab allows you to add cues to your script. This interface is similar to the script editing page in layout and function. When first accessed, you'll see your script without any cues: diff --git a/server/controllers/api/show/cues.py b/server/controllers/api/show/cues.py index accf6003..2dca6fb2 100644 --- a/server/controllers/api/show/cues.py +++ b/server/controllers/api/show/cues.py @@ -183,6 +183,41 @@ async def delete(self): await self.finish({"message": ERROR_SHOW_NOT_FOUND}) +@ApiRoute("show/cues/types/import", ApiVersion.V1) +class CueTypesImportController(BaseAPIController): + @requires_show + def get(self): + """ + Return all cue types from all other shows, grouped by show. + + :returns: JSON with a ``cue_type_groups`` key containing a list of show + objects, each with ``id``, ``name``, and ``cue_types`` fields. + """ + current_show_id = self.get_current_show()["id"] + schema = CueTypeSchema() + + with self.make_session() as session: + rows = session.execute( + select(Show, CueType) + .join(CueType, CueType.show_id == Show.id) + .where(Show.id != current_show_id) + .order_by(Show.id) + ).all() + + shows_map: dict = {} + for show, cue_type in rows: + if show.id not in shows_map: + shows_map[show.id] = { + "id": show.id, + "name": show.name, + "cue_types": [], + } + shows_map[show.id]["cue_types"].append(schema.dump(cue_type)) + + self.set_status(200) + self.finish({"cue_type_groups": list(shows_map.values())}) + + @ApiRoute("show/cues", ApiVersion.V1) class CueController(BaseAPIController): @requires_show diff --git a/server/test/controllers/api/show/test_cues.py b/server/test/controllers/api/show/test_cues.py index 0eee54de..be510b48 100644 --- a/server/test/controllers/api/show/test_cues.py +++ b/server/test/controllers/api/show/test_cues.py @@ -832,3 +832,107 @@ def test_search_location_data_accuracy(self): # Verify page number is valid (1-3 in our test data) self.assertGreaterEqual(location["page"], 1) self.assertLessEqual(location["page"], 3) + + +class TestCueTypeImportController(DigiScriptTestCase): + """Test suite for GET /api/v1/show/cues/types/import endpoint.""" + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + # Current show — its cue types must be excluded from import results + current_show = Show(name="Current Show", script_mode=ShowScriptType.FULL) + session.add(current_show) + session.flush() + self.current_show_id = current_show.id + + current_cue_type = CueType( + show_id=current_show.id, + prefix="LX", + description="Lighting", + colour="#ff0000", + ) + session.add(current_cue_type) + session.flush() + self.current_cue_type_id = current_cue_type.id + + # Other show — its cue types should appear in import results + other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL) + session.add(other_show) + session.flush() + self.other_show_id = other_show.id + + other_cue_type = CueType( + show_id=other_show.id, + prefix="SND", + description="Sound", + colour="#00ff00", + ) + session.add(other_cue_type) + session.flush() + self.other_cue_type_id = other_cue_type.id + + # Empty show — no cue types, must not appear in import results + empty_show = Show(name="Empty Show", script_mode=ShowScriptType.FULL) + session.add(empty_show) + session.flush() + self.empty_show_id = empty_show.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.current_show_id) + + def test_get_import_requires_show(self): + """Test that the endpoint returns 400 when no current show is set.""" + self._app.digi_settings.settings["current_show"].set_value(None) + response = self.fetch("/api/v1/show/cues/types/import") + self.assertEqual(400, response.code) + + def test_get_import_excludes_current_show(self): + """Test that the current show's cue types are not included in the response.""" + response = self.fetch("/api/v1/show/cues/types/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + group_ids = [g["id"] for g in body["cue_type_groups"]] + self.assertNotIn(self.current_show_id, group_ids) + + def test_get_import_includes_other_shows(self): + """Test that other shows with cue types are included with correct data.""" + response = self.fetch("/api/v1/show/cues/types/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + + other_group = next( + (g for g in body["cue_type_groups"] if g["id"] == self.other_show_id), None + ) + self.assertIsNotNone(other_group) + self.assertEqual("Other Show", other_group["name"]) + self.assertEqual(1, len(other_group["cue_types"])) + + cue_type = other_group["cue_types"][0] + self.assertEqual("SND", cue_type["prefix"]) + self.assertEqual("Sound", cue_type["description"]) + self.assertEqual("#00ff00", cue_type["colour"]) + + def test_get_import_skips_shows_with_no_cue_types(self): + """Test that shows with no cue types are not included in the response.""" + response = self.fetch("/api/v1/show/cues/types/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + group_ids = [g["id"] for g in body["cue_type_groups"]] + self.assertNotIn(self.empty_show_id, group_ids) + + def test_get_import_returns_correct_structure(self): + """Test that the response contains the expected top-level structure.""" + response = self.fetch("/api/v1/show/cues/types/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + + self.assertIn("cue_type_groups", body) + self.assertIsInstance(body["cue_type_groups"], list) + + for group in body["cue_type_groups"]: + self.assertIn("id", group) + self.assertIn("name", group) + self.assertIn("cue_types", group) + self.assertIsInstance(group["cue_types"], list) From 88d3d1baa9eb730d41a37ca11bc5ff586565ad2a Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Wed, 6 May 2026 23:56:58 +0100 Subject: [PATCH 2/4] Bump version to 0.27.1 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- server/pyproject.toml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index e4f4b2d6..d227e819 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.27.0", + "version": "0.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.27.0", + "version": "0.27.1", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index 617b0be6..a55cf15d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.27.0", + "version": "0.27.1", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, diff --git a/electron/package-lock.json b/electron/package-lock.json index 8c6f90b7..d23fe048 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.27.0", + "version": "0.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.27.0", + "version": "0.27.1", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.3.0", diff --git a/electron/package.json b/electron/package.json index 46d785e7..20e62c12 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.27.0", + "version": "0.27.1", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/pyproject.toml b/server/pyproject.toml index 69477a0a..4deeeb10 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.27.0" +version = "0.27.1" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" From 0a7d8f288c58ae2032a6bb84de1bbee1ea79a4ec Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 7 May 2026 00:13:59 +0100 Subject: [PATCH 3/4] Fix stage direction style DELETE reading ID from query param instead of body (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete handler was calling escape.json_decode(self.request.body) but DELETE requests carry no body — the frontend sends the ID as a query parameter (?id=N), matching all other DELETE endpoints in the codebase. Replaces body decode with self.get_argument("id", None) + int validation and adds four tests covering happy path, missing ID, invalid ID, and 404. Co-authored-by: Claude Sonnet 4.6 --- .../api/show/script/stage_direction_styles.py | 12 +++- .../script/test_stage_direction_styles.py | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/server/controllers/api/show/script/stage_direction_styles.py b/server/controllers/api/show/script/stage_direction_styles.py index 5bd35009..9f955795 100644 --- a/server/controllers/api/show/script/stage_direction_styles.py +++ b/server/controllers/api/show/script/stage_direction_styles.py @@ -5,6 +5,7 @@ ERROR_BACKGROUND_COLOUR_MISSING, ERROR_DESCRIPTION_MISSING, ERROR_ID_MISSING, + ERROR_INVALID_ID, ERROR_SHOW_NOT_FOUND, ERROR_STAGE_DIRECTION_STYLE_NOT_FOUND, ERROR_TEXT_COLOUR_MISSING, @@ -200,13 +201,18 @@ async def delete(self): select(Script).where(Script.show_id == show.id) ).first() self.requires_role(script, Role.WRITE) - data = escape.json_decode(self.request.body) - style_id = data.get("id", None) - if not style_id: + style_id_str = self.get_argument("id", None) + if not style_id_str: self.set_status(400) await self.finish({"message": ERROR_ID_MISSING}) return + try: + style_id = int(style_id_str) + except ValueError: + self.set_status(400) + await self.finish({"message": ERROR_INVALID_ID}) + return entry: StageDirectionStyle = session.get(StageDirectionStyle, style_id) if entry: diff --git a/server/test/controllers/api/show/script/test_stage_direction_styles.py b/server/test/controllers/api/show/script/test_stage_direction_styles.py index 261ff408..0f4a19b3 100644 --- a/server/test/controllers/api/show/script/test_stage_direction_styles.py +++ b/server/test/controllers/api/show/script/test_stage_direction_styles.py @@ -64,6 +64,66 @@ def test_get_stage_direction_styles(self): self.assertEqual(1, len(response_body["styles"])) self.assertEqual("Test Style", response_body["styles"][0]["description"]) + def _login_admin(self): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=tornado.escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=tornado.escape.json_encode( + {"username": "admin", "password": "adminpass"} + ), + ) + return tornado.escape.json_decode(resp.body)["access_token"] + + def test_delete_stage_direction_style(self): + """DELETE with valid id removes the style and returns 200.""" + token = self._login_admin() + response = self.fetch( + f"/api/v1/show/script/stage_direction_styles?id={self.style_id}", + method="DELETE", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + with self._app.get_db().sessionmaker() as session: + entry = session.get(StageDirectionStyle, self.style_id) + self.assertIsNone(entry) + + def test_delete_stage_direction_style_missing_id(self): + """DELETE without id param returns 400.""" + token = self._login_admin() + response = self.fetch( + "/api/v1/show/script/stage_direction_styles", + method="DELETE", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(400, response.code) + + def test_delete_stage_direction_style_invalid_id(self): + """DELETE with non-integer id returns 400.""" + token = self._login_admin() + response = self.fetch( + "/api/v1/show/script/stage_direction_styles?id=abc", + method="DELETE", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(400, response.code) + + def test_delete_stage_direction_style_not_found(self): + """DELETE with unknown id returns 404.""" + token = self._login_admin() + response = self.fetch( + "/api/v1/show/script/stage_direction_styles?id=99999", + method="DELETE", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(404, response.code) + def test_get_stage_direction_styles_empty(self): """Test GET returns empty list when no styles exist.""" # Create a show with a script but no styles From 266e3d85c6f3223c05db58f20c648a2afc77baac Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 7 May 2026 00:36:39 +0100 Subject: [PATCH 4/4] Add session tag type import from other shows (#994) * Add session tag type import from other shows Follows the same pattern as cue type (PR #991) and stage direction style (PR #989) imports. Adds GET /api/v1/show/session/tags/import endpoint and a corresponding import modal in SessionTagList.vue with real-time duplicate detection via the already-loaded SESSION_TAGS Vuex state. Co-Authored-By: Claude Sonnet 4.6 * Fix ruff formatting in test_tags.py Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client/src/store/modules/show.js | 10 ++ .../show/config/sessions/SessionTagList.vue | 118 +++++++++++++++++- docs/pages/show_config.md | 14 ++- server/controllers/api/show/session/tags.py | 35 ++++++ .../controllers/api/show/session/test_tags.py | 96 ++++++++++++++ 5 files changed, 271 insertions(+), 2 deletions(-) diff --git a/client/src/store/modules/show.js b/client/src/store/modules/show.js index 3d949d45..6db9517e 100644 --- a/client/src/store/modules/show.js +++ b/client/src/store/modules/show.js @@ -652,6 +652,16 @@ export default { Vue.$toast.error('Unable to delete session tag'); } }, + async GET_IMPORTABLE_SESSION_TAGS() { + const response = await fetch(`${makeURL('/api/v1/show/session/tags/import')}`, { + method: 'GET', + }); + if (!response.ok) { + log.error('Unable to fetch importable session tags'); + throw new Error('Failed to fetch importable session tags'); + } + return response.json(); + }, async UPDATE_SESSION_TAGS(context, { sessionId, tagIds }) { const response = await fetch(`${makeURL('/api/v1/show/sessions/assign-tags')}`, { method: 'PATCH', diff --git a/client/src/vue_components/show/config/sessions/SessionTagList.vue b/client/src/vue_components/show/config/sessions/SessionTagList.vue index 77861390..397b67cc 100644 --- a/client/src/vue_components/show/config/sessions/SessionTagList.vue +++ b/client/src/vue_components/show/config/sessions/SessionTagList.vue @@ -12,6 +12,14 @@ New Tag + + Import Tag + @@ -196,6 +261,14 @@ export default { isSubmittingNewTag: false, isSubmittingEditTag: false, isSubmittingDeleteTag: false, + importTagGroups: [], + tagGroupExpanded: {}, + isLoadingImport: false, + isImporting: {}, + importTagFields: [ + { key: 'tag', label: 'Tag' }, + { key: 'action', label: '' }, + ], }; }, validations: { @@ -222,9 +295,17 @@ export default { }, computed: { ...mapGetters(['SESSION_TAGS', 'SHOW_SESSIONS_LIST', 'IS_SHOW_EDITOR']), + existingTagNames() { + return new Set((this.SESSION_TAGS || []).map((t) => t.tag.toLowerCase())); + }, }, methods: { - ...mapActions(['ADD_SESSION_TAG', 'UPDATE_SESSION_TAG', 'DELETE_SESSION_TAG']), + ...mapActions([ + 'ADD_SESSION_TAG', + 'UPDATE_SESSION_TAG', + 'DELETE_SESSION_TAG', + 'GET_IMPORTABLE_SESSION_TAGS', + ]), contrastColor, getSessionCountForTag(tagId) { if (!this.SHOW_SESSIONS_LIST || !Array.isArray(this.SHOW_SESSIONS_LIST)) { @@ -316,6 +397,41 @@ export default { const { $dirty, $error } = this.$v.editTagForm[name]; return $dirty ? !$error : null; }, + async openImportModal() { + this.$bvModal.show('import-tag-modal'); + this.isLoadingImport = true; + try { + const data = await this.GET_IMPORTABLE_SESSION_TAGS(); + this.importTagGroups = data.tag_groups; + data.tag_groups.forEach((show) => { + this.$set(this.tagGroupExpanded, show.id, true); + }); + } catch (e) { + log.error('Error loading importable session tags:', e); + } finally { + this.isLoadingImport = false; + } + }, + toggleImportShow(showId) { + this.$set(this.tagGroupExpanded, showId, !this.tagGroupExpanded[showId]); + }, + tagAlreadyExists(tag) { + return this.existingTagNames.has(tag.tag.toLowerCase()); + }, + async importTag(tag) { + this.$set(this.isImporting, tag.id, true); + try { + await this.ADD_SESSION_TAG({ tag: tag.tag, colour: tag.colour }); + } finally { + this.$set(this.isImporting, tag.id, false); + } + }, + resetImportState() { + this.importTagGroups = []; + this.tagGroupExpanded = {}; + this.isLoadingImport = false; + this.isImporting = {}; + }, async deleteTag(data) { if (this.isSubmittingDeleteTag) { return; diff --git a/docs/pages/show_config.md b/docs/pages/show_config.md index 6d01cfc4..25949b59 100644 --- a/docs/pages/show_config.md +++ b/docs/pages/show_config.md @@ -15,7 +15,7 @@ The Show Config page is organized into several sections, each accessible from th - **Script**: Create and edit the show script with revisions - **Cues**: Configure cue types and assign cues to script lines (see [Cue Configuration](./cue_config.md)) - [Microphones](./show_config/microphones.md): Set up microphones and allocate them to characters -- **Sessions**: View the history of live show sessions +- **Sessions**: View the history of live show sessions and manage session tag types ### Configuration Workflow @@ -48,4 +48,16 @@ A streamlined single-column layout. This mode is ideal for: **Note**: The script mode is set during show creation and affects the entire show. It cannot be changed after the fact. +### Sessions + +The **Sessions** section shows a history of all live show sessions that have been run. The **Tags** tab lets you create and manage session tag types — coloured labels that can be applied to individual sessions to categorise them (e.g. "Tech", "Dress", "Opening Night"). + +#### Session Tags + +Each tag has a **name** and a **colour**. Tag names must be unique within a show (case-insensitive). Tags can be applied to sessions from the Sessions tab. + +#### Importing Session Tags from Another Show + +If you have already configured session tags for a different show, you can import them into the current show using the **Import Tag** button on the Tags tab. This opens a panel listing all session tags from your other shows, grouped by show name. Click **Import** next to any tag to add an independent copy to the current show. Tags that already exist in the current show (matched case-insensitively) are shown as disabled and cannot be imported again. + Once your show is fully configured, you're ready to [run a live show](./live_show.md)! \ No newline at end of file diff --git a/server/controllers/api/show/session/tags.py b/server/controllers/api/show/session/tags.py index 1e4672b5..e4698a9c 100644 --- a/server/controllers/api/show/session/tags.py +++ b/server/controllers/api/show/session/tags.py @@ -205,3 +205,38 @@ async def delete(self): else: self.set_status(404) await self.finish({"message": ERROR_SHOW_NOT_FOUND}) + + +@ApiRoute("show/session/tags/import", ApiVersion.V1) +class SessionTagImportController(BaseAPIController): + @requires_show + def get(self): + """ + Return all session tags from all other shows, grouped by show. + + :returns: JSON with a ``tag_groups`` key containing a list of show objects, + each with ``id``, ``name``, and ``tags`` fields. + """ + current_show_id = self.get_current_show()["id"] + schema = ShowSessionTagSchema() + + with self.make_session() as session: + rows = session.execute( + select(Show, SessionTag) + .join(SessionTag, SessionTag.show_id == Show.id) + .where(Show.id != current_show_id) + .order_by(Show.id) + ).all() + + shows_map: dict = {} + for show, tag in rows: + if show.id not in shows_map: + shows_map[show.id] = { + "id": show.id, + "name": show.name, + "tags": [], + } + shows_map[show.id]["tags"].append(schema.dump(tag)) + + self.set_status(200) + self.finish({"tag_groups": list(shows_map.values())}) diff --git a/server/test/controllers/api/show/session/test_tags.py b/server/test/controllers/api/show/session/test_tags.py index a14d97c6..2cdb709e 100644 --- a/server/test/controllers/api/show/session/test_tags.py +++ b/server/test/controllers/api/show/session/test_tags.py @@ -1,5 +1,6 @@ """Tests for SessionTag CRUD API controller.""" +import tornado.escape from sqlalchemy import insert, select from tornado import escape @@ -438,3 +439,98 @@ def test_delete_tag_cascade_associations(self): select(ShowSession).where(ShowSession.id == session_id) ).first() self.assertIsNotNone(show_session) + + +class TestSessionTagImportController(DigiScriptTestCase): + """Test suite for GET /api/v1/show/session/tags/import endpoint.""" + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + # Current show — its tags must be excluded from import results + current_show = Show(name="Current Show", script_mode=ShowScriptType.FULL) + session.add(current_show) + session.flush() + self.current_show_id = current_show.id + + current_tag = SessionTag( + show_id=current_show.id, tag="Tech", colour="#ff0000" + ) + session.add(current_tag) + session.flush() + self.current_tag_id = current_tag.id + + # Other show — its tags should appear in import results + other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL) + session.add(other_show) + session.flush() + self.other_show_id = other_show.id + + other_tag = SessionTag(show_id=other_show.id, tag="Dress", colour="#00ff00") + session.add(other_tag) + session.flush() + self.other_tag_id = other_tag.id + + # Empty show — no tags, must not appear in import results + empty_show = Show(name="Empty Show", script_mode=ShowScriptType.FULL) + session.add(empty_show) + session.flush() + self.empty_show_id = empty_show.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.current_show_id) + + def test_get_import_requires_show(self): + """Test that the endpoint returns 400 when no current show is set.""" + self._app.digi_settings.settings["current_show"].set_value(None) + response = self.fetch("/api/v1/show/session/tags/import") + self.assertEqual(400, response.code) + + def test_get_import_excludes_current_show(self): + """Test that the current show's tags are not included in the response.""" + response = self.fetch("/api/v1/show/session/tags/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + group_ids = [g["id"] for g in body["tag_groups"]] + self.assertNotIn(self.current_show_id, group_ids) + + def test_get_import_includes_other_shows(self): + """Test that other shows with tags are included with correct data.""" + response = self.fetch("/api/v1/show/session/tags/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + + other_group = next( + (g for g in body["tag_groups"] if g["id"] == self.other_show_id), None + ) + self.assertIsNotNone(other_group) + self.assertEqual("Other Show", other_group["name"]) + self.assertEqual(1, len(other_group["tags"])) + + tag = other_group["tags"][0] + self.assertEqual("Dress", tag["tag"]) + self.assertEqual("#00ff00", tag["colour"]) + + def test_get_import_skips_shows_with_no_tags(self): + """Test that shows with no tags are not included in the response.""" + response = self.fetch("/api/v1/show/session/tags/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + group_ids = [g["id"] for g in body["tag_groups"]] + self.assertNotIn(self.empty_show_id, group_ids) + + def test_get_import_returns_correct_structure(self): + """Test that the response contains the expected top-level structure.""" + response = self.fetch("/api/v1/show/session/tags/import") + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + + self.assertIn("tag_groups", body) + self.assertIsInstance(body["tag_groups"], list) + + for group in body["tag_groups"]: + self.assertIn("id", group) + self.assertIn("name", group) + self.assertIn("tags", group) + self.assertIsInstance(group["tags"], list)