@@ -149,8 +153,12 @@
Configuration Options
-
-
+
+
Default
@@ -176,41 +184,41 @@
-
+
This is a required field.
diff --git a/dist/preload_modules.py b/dist/preload_modules.py
index 1f46753e..9a6c95fd 100644
--- a/dist/preload_modules.py
+++ b/dist/preload_modules.py
@@ -24,10 +24,16 @@
print(f'Error preloading controllers.api: {e}')
try:
- importlib.import_module('controllers.api.auth')
- print('Preloaded controllers.api.auth')
+ importlib.import_module('controllers.api.constants')
+ print('Preloaded controllers.api.constants')
except Exception as e:
- print(f'Error preloading controllers.api.auth: {e}')
+ print(f'Error preloading controllers.api.constants: {e}')
+
+try:
+ importlib.import_module('controllers.api.health')
+ print('Preloaded controllers.api.health')
+except Exception as e:
+ print(f'Error preloading controllers.api.health: {e}')
try:
importlib.import_module('controllers.api.rbac')
@@ -42,40 +48,40 @@
print(f'Error preloading controllers.api.settings: {e}')
try:
- importlib.import_module('controllers.api.websocket')
- print('Preloaded controllers.api.websocket')
+ importlib.import_module('controllers.api.version')
+ print('Preloaded controllers.api.version')
except Exception as e:
- print(f'Error preloading controllers.api.websocket: {e}')
+ print(f'Error preloading controllers.api.version: {e}')
try:
- importlib.import_module('controllers.api.show')
- print('Preloaded controllers.api.show')
+ importlib.import_module('controllers.api.websocket')
+ print('Preloaded controllers.api.websocket')
except Exception as e:
- print(f'Error preloading controllers.api.show: {e}')
+ print(f'Error preloading controllers.api.websocket: {e}')
try:
- importlib.import_module('controllers.api.show.cast')
- print('Preloaded controllers.api.show.cast')
+ importlib.import_module('controllers.api.auth')
+ print('Preloaded controllers.api.auth')
except Exception as e:
- print(f'Error preloading controllers.api.show.cast: {e}')
+ print(f'Error preloading controllers.api.auth: {e}')
try:
- importlib.import_module('controllers.api.show.shows')
- print('Preloaded controllers.api.show.shows')
+ importlib.import_module('controllers.api.auth.token')
+ print('Preloaded controllers.api.auth.token')
except Exception as e:
- print(f'Error preloading controllers.api.show.shows: {e}')
+ print(f'Error preloading controllers.api.auth.token: {e}')
try:
- importlib.import_module('controllers.api.show.sessions')
- print('Preloaded controllers.api.show.sessions')
+ importlib.import_module('controllers.api.auth.user')
+ print('Preloaded controllers.api.auth.user')
except Exception as e:
- print(f'Error preloading controllers.api.show.sessions: {e}')
+ print(f'Error preloading controllers.api.auth.user: {e}')
try:
- importlib.import_module('controllers.api.show.microphones')
- print('Preloaded controllers.api.show.microphones')
+ importlib.import_module('controllers.api.show')
+ print('Preloaded controllers.api.show')
except Exception as e:
- print(f'Error preloading controllers.api.show.microphones: {e}')
+ print(f'Error preloading controllers.api.show: {e}')
try:
importlib.import_module('controllers.api.show.acts')
@@ -84,10 +90,10 @@
print(f'Error preloading controllers.api.show.acts: {e}')
try:
- importlib.import_module('controllers.api.show.scenes')
- print('Preloaded controllers.api.show.scenes')
+ importlib.import_module('controllers.api.show.cast')
+ print('Preloaded controllers.api.show.cast')
except Exception as e:
- print(f'Error preloading controllers.api.show.scenes: {e}')
+ print(f'Error preloading controllers.api.show.cast: {e}')
try:
importlib.import_module('controllers.api.show.characters')
@@ -101,6 +107,24 @@
except Exception as e:
print(f'Error preloading controllers.api.show.cues: {e}')
+try:
+ importlib.import_module('controllers.api.show.microphones')
+ print('Preloaded controllers.api.show.microphones')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.microphones: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.scenes')
+ print('Preloaded controllers.api.show.scenes')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.scenes: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.shows')
+ print('Preloaded controllers.api.show.shows')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.shows: {e}')
+
try:
importlib.import_module('controllers.api.show.script')
print('Preloaded controllers.api.show.script')
@@ -108,16 +132,16 @@
print(f'Error preloading controllers.api.show.script: {e}')
try:
- importlib.import_module('controllers.api.show.script.config')
- print('Preloaded controllers.api.show.script.config')
+ importlib.import_module('controllers.api.show.script.compiled')
+ print('Preloaded controllers.api.show.script.compiled')
except Exception as e:
- print(f'Error preloading controllers.api.show.script.config: {e}')
+ print(f'Error preloading controllers.api.show.script.compiled: {e}')
try:
- importlib.import_module('controllers.api.show.script.stage_direction_styles')
- print('Preloaded controllers.api.show.script.stage_direction_styles')
+ importlib.import_module('controllers.api.show.script.config')
+ print('Preloaded controllers.api.show.script.config')
except Exception as e:
- print(f'Error preloading controllers.api.show.script.stage_direction_styles: {e}')
+ print(f'Error preloading controllers.api.show.script.config: {e}')
try:
importlib.import_module('controllers.api.show.script.revisions')
@@ -131,12 +155,84 @@
except Exception as e:
print(f'Error preloading controllers.api.show.script.script: {e}')
+try:
+ importlib.import_module('controllers.api.show.script.stage_direction_styles')
+ print('Preloaded controllers.api.show.script.stage_direction_styles')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.script.stage_direction_styles: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.session')
+ print('Preloaded controllers.api.show.session')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.session: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.session.assign_tags')
+ print('Preloaded controllers.api.show.session.assign_tags')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.session.assign_tags: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.session.sessions')
+ print('Preloaded controllers.api.show.session.sessions')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.session.sessions: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.session.tags')
+ print('Preloaded controllers.api.show.session.tags')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.session.tags: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage')
+ print('Preloaded controllers.api.show.stage')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage.crew')
+ print('Preloaded controllers.api.show.stage.crew')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage.crew: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage.crew_assignments')
+ print('Preloaded controllers.api.show.stage.crew_assignments')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage.crew_assignments: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage.helpers')
+ print('Preloaded controllers.api.show.stage.helpers')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage.helpers: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage.props')
+ print('Preloaded controllers.api.show.stage.props')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage.props: {e}')
+
+try:
+ importlib.import_module('controllers.api.show.stage.scenery')
+ print('Preloaded controllers.api.show.stage.scenery')
+except Exception as e:
+ print(f'Error preloading controllers.api.show.stage.scenery: {e}')
+
try:
importlib.import_module('controllers.api.user')
print('Preloaded controllers.api.user')
except Exception as e:
print(f'Error preloading controllers.api.user: {e}')
+try:
+ importlib.import_module('controllers.api.user.overrides')
+ print('Preloaded controllers.api.user.overrides')
+except Exception as e:
+ print(f'Error preloading controllers.api.user.overrides: {e}')
+
try:
importlib.import_module('controllers.api.user.settings')
print('Preloaded controllers.api.user.settings')
@@ -145,16 +241,16 @@
# Preload all model modules
try:
- importlib.import_module('models.user')
- print('Preloaded models.user')
+ importlib.import_module('models.cue')
+ print('Preloaded models.cue')
except Exception as e:
- print(f'Error preloading models.user: {e}')
+ print(f'Error preloading models.cue: {e}')
try:
- importlib.import_module('models.show')
- print('Preloaded models.show')
+ importlib.import_module('models.mics')
+ print('Preloaded models.mics')
except Exception as e:
- print(f'Error preloading models.show: {e}')
+ print(f'Error preloading models.mics: {e}')
try:
importlib.import_module('models.models')
@@ -163,16 +259,16 @@
print(f'Error preloading models.models: {e}')
try:
- importlib.import_module('models.session')
- print('Preloaded models.session')
+ importlib.import_module('models.script')
+ print('Preloaded models.script')
except Exception as e:
- print(f'Error preloading models.session: {e}')
+ print(f'Error preloading models.script: {e}')
try:
- importlib.import_module('models.mics')
- print('Preloaded models.mics')
+ importlib.import_module('models.session')
+ print('Preloaded models.session')
except Exception as e:
- print(f'Error preloading models.mics: {e}')
+ print(f'Error preloading models.session: {e}')
try:
importlib.import_module('models.settings')
@@ -181,15 +277,21 @@
print(f'Error preloading models.settings: {e}')
try:
- importlib.import_module('models.script')
- print('Preloaded models.script')
+ importlib.import_module('models.show')
+ print('Preloaded models.show')
except Exception as e:
- print(f'Error preloading models.script: {e}')
+ print(f'Error preloading models.show: {e}')
try:
- importlib.import_module('models.cue')
- print('Preloaded models.cue')
+ importlib.import_module('models.stage')
+ print('Preloaded models.stage')
except Exception as e:
- print(f'Error preloading models.cue: {e}')
+ print(f'Error preloading models.stage: {e}')
+
+try:
+ importlib.import_module('models.user')
+ print('Preloaded models.user')
+except Exception as e:
+ print(f'Error preloading models.user: {e}')
print('Module preloading complete.')
diff --git a/docs/images/config_show/stage_allocation_warning.png b/docs/images/config_show/stage_allocation_warning.png
new file mode 100644
index 00000000..b7fcc449
Binary files /dev/null and b/docs/images/config_show/stage_allocation_warning.png differ
diff --git a/docs/images/config_show/stage_crew_empty.png b/docs/images/config_show/stage_crew_empty.png
new file mode 100644
index 00000000..169bb3a1
Binary files /dev/null and b/docs/images/config_show/stage_crew_empty.png differ
diff --git a/docs/images/config_show/stage_crew_timeline.png b/docs/images/config_show/stage_crew_timeline.png
new file mode 100644
index 00000000..beab91b0
Binary files /dev/null and b/docs/images/config_show/stage_crew_timeline.png differ
diff --git a/docs/images/config_show/stage_crew_with_data.png b/docs/images/config_show/stage_crew_with_data.png
new file mode 100644
index 00000000..a6653992
Binary files /dev/null and b/docs/images/config_show/stage_crew_with_data.png differ
diff --git a/docs/images/config_show/stage_manager_crew_assignments.png b/docs/images/config_show/stage_manager_crew_assignments.png
new file mode 100644
index 00000000..700a4500
Binary files /dev/null and b/docs/images/config_show/stage_manager_crew_assignments.png differ
diff --git a/docs/images/config_show/stage_manager_empty.png b/docs/images/config_show/stage_manager_empty.png
new file mode 100644
index 00000000..3f7125d2
Binary files /dev/null and b/docs/images/config_show/stage_manager_empty.png differ
diff --git a/docs/images/config_show/stage_manager_with_allocations.png b/docs/images/config_show/stage_manager_with_allocations.png
new file mode 100644
index 00000000..fa3a7dcc
Binary files /dev/null and b/docs/images/config_show/stage_manager_with_allocations.png differ
diff --git a/docs/images/config_show/stage_props_empty.png b/docs/images/config_show/stage_props_empty.png
new file mode 100644
index 00000000..819aa464
Binary files /dev/null and b/docs/images/config_show/stage_props_empty.png differ
diff --git a/docs/images/config_show/stage_props_with_data.png b/docs/images/config_show/stage_props_with_data.png
new file mode 100644
index 00000000..08f7254f
Binary files /dev/null and b/docs/images/config_show/stage_props_with_data.png differ
diff --git a/docs/images/config_show/stage_scenery_empty.png b/docs/images/config_show/stage_scenery_empty.png
new file mode 100644
index 00000000..7533ea0c
Binary files /dev/null and b/docs/images/config_show/stage_scenery_empty.png differ
diff --git a/docs/images/config_show/stage_scenery_with_data.png b/docs/images/config_show/stage_scenery_with_data.png
new file mode 100644
index 00000000..04946c26
Binary files /dev/null and b/docs/images/config_show/stage_scenery_with_data.png differ
diff --git a/docs/images/config_show/stage_timeline.png b/docs/images/config_show/stage_timeline.png
new file mode 100644
index 00000000..347ecfe2
Binary files /dev/null and b/docs/images/config_show/stage_timeline.png differ
diff --git a/docs/images/config_show/stage_timeline_side_panel.png b/docs/images/config_show/stage_timeline_side_panel.png
new file mode 100644
index 00000000..d769e976
Binary files /dev/null and b/docs/images/config_show/stage_timeline_side_panel.png differ
diff --git a/docs/images/live_show/live_plan_modal_crew.png b/docs/images/live_show/live_plan_modal_crew.png
new file mode 100644
index 00000000..9142d3a9
Binary files /dev/null and b/docs/images/live_show/live_plan_modal_crew.png differ
diff --git a/docs/images/live_show/live_stage_manager_pane.png b/docs/images/live_show/live_stage_manager_pane.png
new file mode 100644
index 00000000..8c6cf988
Binary files /dev/null and b/docs/images/live_show/live_stage_manager_pane.png differ
diff --git a/docs/pages/live_show.md b/docs/pages/live_show.md
index a34c2792..73ade4ac 100644
--- a/docs/pages/live_show.md
+++ b/docs/pages/live_show.md
@@ -53,4 +53,32 @@ If the leader's client becomes disconnected, all other clients become "orphaned"
### Act Intervals
-If your show has intervals configured between acts, an interval screen will automatically appear between the acts during the live show. The interval will only display if there is script content in both acts surrounding the interval.
\ No newline at end of file
+If your show has intervals configured between acts, an interval screen will automatically appear between the acts during the live show. The interval will only display if there is script content in both acts surrounding the interval.
+
+### Stage Manager Pane
+
+During a live show, you can enable a Stage Manager pane that displays props and scenery allocations for each scene. This provides a quick reference for stage management during performances.
+
+To enable the Stage Manager pane:
+
+1. Click on the **Live Config** dropdown in the navigation bar
+2. Select **Enable Stage Manager**
+
+The Stage Manager pane will appear on the right side of the screen:
+
+
+
+#### Stage Manager Features
+
+- **Scene List**: Shows all scenes in the show as collapsible cards
+- **Current Scene**: The scene cards can be expanded to show allocated items
+- **Scenery Section**: Lists all scenery items allocated to each scene, grouped by type
+- **Props Section**: Lists all props allocated to each scene, grouped by type
+- **Plan Button**: Opens a planning modal showing which items are being set (brought on stage) and struck (removed) for the selected scene, with crew names listed beneath each assigned item
+- **Auto-scroll**: The current scene card is automatically expanded, and the pane scrolls to keep it visible as the show progresses
+
+
+
+Click on a scene header to expand or collapse its details. The Stage Manager pane provides a quick at-a-glance view of what items are needed for each scene, helping the stage management team track prop and scenery requirements throughout the performance.
+
+To disable the Stage Manager pane, click **Live Config** and select **Disable Stage Manager**.
\ No newline at end of file
diff --git a/docs/pages/show_config.md b/docs/pages/show_config.md
index 2b61dd4c..6d01cfc4 100644
--- a/docs/pages/show_config.md
+++ b/docs/pages/show_config.md
@@ -9,6 +9,7 @@ Heading over to the **Show Config** navigation option at the top of the page wil
The Show Config page is organized into several sections, each accessible from the left sidebar:
- **Show**: View and edit basic show information (name, dates, settings)
+- [Staging](./show_config/stage_management.md): Manage crew, props, and scenery for your production
- [Cast, Characters and Character Groups](./show_config/cast_and_characters.md): Manage performers and roles
- [Acts and Scenes](./show_config/acts_and_scenes.md): Structure your show's timeline
- **Script**: Create and edit the show script with revisions
@@ -27,6 +28,7 @@ The recommended workflow for configuring a new show is:
5. Move on to [Script Configuration](./script_config.md)
6. Configure [Cues](./cue_config.md)
7. Optionally set up [Microphones](./show_config/microphones.md)
+8. Optionally configure [Stage Management](./show_config/stage_management.md) (crew, props, scenery)
### Script Display Modes
diff --git a/docs/pages/show_config/stage_management.md b/docs/pages/show_config/stage_management.md
new file mode 100644
index 00000000..9988d86d
--- /dev/null
+++ b/docs/pages/show_config/stage_management.md
@@ -0,0 +1,219 @@
+## Configuring a Show
+
+### Stage Management
+
+Once Characters, Acts and Scenes have been configured, you can optionally configure stage management features including crew members, props, and scenery. This is done from the **Staging** tab in the **Show Config** page.
+
+The Staging section provides five tabs for managing different aspects of your production:
+
+- **Crew**: Manage crew member names
+- **Scenery**: Define scenery types and items
+- **Props**: Define prop types and items
+- **Stage Manager**: Allocate props and scenery to specific scenes
+- **Timeline**: Visualize allocations across the entire show
+
+#### Managing Crew Members
+
+The **Crew** tab allows you to maintain a list of crew members for your production:
+
+
+
+Click the **New Crew Member** button to add crew members. Each crew member has a first name and last name:
+
+
+
+You can use the **Edit** and **Delete** buttons to manage existing crew members.
+
+#### Managing Scenery
+
+The **Scenery** tab is divided into two sections: Scenery Types and Scenery List.
+
+
+
+**Scenery Types** allow you to categorize your scenery items (e.g., "Backdrop", "Furniture", "Set Pieces"). To create a scenery type:
+
+1. Click **New Scenery Type**
+2. Enter a name and optional description
+3. Click **OK**
+
+**Scenery List** contains the actual scenery items used in your production. To add a scenery item:
+
+1. Click **New Scenery Item**
+2. Select a scenery type from the dropdown
+3. Enter a name and optional description
+4. Click **OK**
+
+
+
+#### Managing Props
+
+The **Props** tab follows the same structure as Scenery, with Prop Types and a Props List:
+
+
+
+**Prop Types** allow you to categorize your props (e.g., "Hand Props", "Set Dressing", "Consumables"). To create a prop type:
+
+1. Click **New Prop Type**
+2. Enter a name and optional description
+3. Click **OK**
+
+**Props List** contains the actual prop items. To add a prop:
+
+1. Click **New Props Item**
+2. Select a prop type from the dropdown
+3. Enter a name and optional description
+4. Click **OK**
+
+
+
+#### Understanding Allocation Blocks
+
+When a prop or scenery item is allocated to consecutive scenes within the same act, those scenes form an **allocation block**. Blocks are the foundation for crew assignments — crew members are assigned to the boundaries of each block:
+
+- **SET boundary**: The first scene of the block, where the item needs to be brought on stage
+- **STRIKE boundary**: The last scene of the block, where the item needs to be removed from stage
+
+If a block contains only a single scene, both SET and STRIKE occur on that same scene.
+
+Blocks never span act boundaries. If a Kitchen Table is allocated to Act 1 Scenes 1-2 and Act 2 Scene 1, it forms two separate blocks: one for Act 1 (SET at Scene 1, STRIKE at Scene 2) and one for Act 2 (SET and STRIKE both at Scene 1).
+
+Understanding blocks helps you interpret the Stage Manager's SET and STRIKE cards and the Timeline's side panel, both of which organise crew assignments around block boundaries.
+
+#### Stage Manager - Scene Allocations
+
+The **Stage Manager** tab provides a scene-by-scene interface for allocating props and scenery to specific scenes:
+
+
+
+The interface shows:
+- **Scene navigation**: Use the **Prev Scene** and **Next Scene** buttons to move between scenes, or click **Go to Scene** to jump to a specific scene
+- **Current scene display**: Shows which act and scene you're currently viewing
+- **Scenery section**: Lists all scenery allocated to the current scene
+- **Props section**: Lists all props allocated to the current scene
+
+To allocate items to a scene:
+
+1. Navigate to the desired scene
+2. Click the **Add** dropdown button
+3. Select either **Scenery** or **Prop**
+4. Choose the item from the dropdown
+5. Click **OK**
+
+
+
+To remove an allocation, click the **Delete** button next to the item.
+
+**Note**: Each prop or scenery item can only be allocated to one scene at a time, reflecting the physical constraint that an item can only be in one place.
+
+##### Crew Assignment Warnings
+
+When adding or removing an allocation that changes the boundaries of an allocation block, any crew assignments on the affected boundaries will be removed. A warning dialog will appear listing the specific crew assignments that will be affected, giving you the opportunity to cancel or proceed.
+
+For example, if a chair is allocated to Scenes 1-3 with Alice assigned to SET (Scene 1), removing Scene 1's allocation shifts the SET boundary to Scene 2. The warning dialog will list "Chair - SET (Scene 1): Alice" as an assignment that will be removed. After proceeding, you can reassign crew to the new boundaries.
+
+
+
+##### Assigning Crew to Items
+
+Below the Allocations card, the Stage Manager displays **SET** and **STRIKE** collapsible cards when the current scene has block boundary items:
+
+- **SET** shows items that are new to the current scene (need to be brought on stage)
+- **STRIKE** shows items that are leaving after the current scene (need to be removed)
+
+Each card header displays the total number of items and an **unassigned count** badge if any items lack crew. Click the card header to expand it.
+
+
+
+Inside each card, every boundary item is shown with:
+- The item name and a **type badge** (scenery or prop)
+- Any currently assigned crew members, each with a **×** button to remove
+- A dropdown to add additional crew members
+
+To assign crew to an item:
+1. Expand the **SET** or **STRIKE** card
+2. Find the item you want to assign crew to
+3. Select a crew member from the dropdown
+4. The crew member appears immediately — no save button needed
+
+To remove a crew assignment, click the **×** button next to the crew member's name.
+
+#### Stage Timeline
+
+The **Timeline** tab provides a visual overview of all props and scenery allocations across the entire show:
+
+
+
+##### Timeline Features
+
+- **View Modes**: Switch between three different perspectives using the buttons at the top:
+ - **Combined**: Shows both props and scenery in a single view
+ - **Props**: Shows only prop allocations
+ - **Scenery**: Shows only scenery allocations
+
+- **Visual Layout**: The timeline uses color-coded bars to represent allocations:
+ - Each row represents a prop or scenery item
+ - Each column represents a scene in the show
+ - Acts are labeled at the top for easy reference
+ - Colored bars show where each item is allocated
+
+- **Export**: Click the download button to export the timeline as a PNG image for documentation or planning purposes
+
+##### Using the Timeline
+
+1. Select your preferred view mode using the buttons at the top
+2. Scroll horizontally to see all scenes in large shows
+3. Use the timeline to identify:
+ - Which scenes have the most items
+ - Which items are used in which scenes
+ - Potential conflicts or busy changeover points
+
+##### Assigning Crew Using the Timeline
+
+Click any allocation bar on the timeline to open a **side panel** showing the block details and crew assignment controls:
+
+
+
+The side panel displays:
+- The **item name** at the top, with a **×** button to close the panel
+- The **block scene range** (e.g., "Act 1: Scene 1 - Act 1: Scene 2")
+- A **SET** section showing the SET boundary scene and assigned crew
+- A **STRIKE** section showing the STRIKE boundary scene and assigned crew
+- A **Conflicts** section at the bottom if the assigned crew have scheduling conflicts with other items in the same scene
+
+Each section has a dropdown to add crew members and **×** buttons to remove existing assignments. Hovering over a bar highlights it with an outline; clicking selects it and opens the panel. Click a different bar to switch, or click **×** to close.
+
+##### Crew Timeline
+
+The **Timeline** tab includes a **Crew** sub-tab (navigate to **Timeline** → **Crew**) that displays a crew-centric visual grid showing all SET and STRIKE assignments across the show:
+
+
+
+- **Rows** represent crew members (only those with at least one assignment are shown)
+- **Columns** represent scenes, grouped by act
+- **Bars** are color-coded by the prop or scenery item, with **▲** for SET and **▼** for STRIKE
+- When a crew member has multiple assignments in the same scene, bars stack vertically
+
+###### Conflict Indicators
+
+The timeline highlights potential scheduling problems based on **distinct items** — SET and STRIKE of the same item in a scene is the normal lifecycle and is not treated as a conflict:
+
+- **Red border** (hard conflict): A crew member is assigned to **two or more different items** in the same scene (e.g., SET Chair + SET Table)
+- **Orange dashed border** (soft conflict): A crew member has assignments in adjacent scenes within the same act involving **different items**, which may leave insufficient changeover time (e.g., SET Chair in Scene 1 + SET Table in Scene 2). If both scenes involve exactly the same items, no soft conflict is raised.
+
+Act boundaries are not treated as soft conflicts, since intermissions provide natural gaps.
+
+Click the **Export** button to save the crew timeline as a PNG image.
+
+#### Recommended Workflow
+
+The Stage Manager, Timeline, and Crew Timeline tabs work together to support a three-phase crew assignment workflow:
+
+1. **Planning**: Use the Stage Manager's SET/STRIKE cards or the Timeline's side panel to assign crew members to block boundaries. The Stage Manager is best for working scene-by-scene, while the Timeline side panel is useful for seeing the full block context at a glance.
+2. **Review**: Switch to the Crew Timeline to verify workload balance across crew members and check for conflicts (red or orange borders). Export the crew timeline for offline review or printing.
+3. **Live Show**: During the performance, the Plan modal in the live show view displays crew names beneath each item, giving the stage management team a quick reference without leaving the live page.
+
+#### Plan Modal (Live Show View)
+
+During a live show, the Stage Manager pane's **Plan** button opens a modal showing what items are being set and struck for a given scene. When crew members have been assigned to SET or STRIKE operations, their names appear in italics beneath each item in the Plan modal.
+
+This allows stage crew to quickly see who is responsible for each item during scene changes without navigating away from the live show view. Items with no crew assigned show no additional text.
\ No newline at end of file
diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md
index 4bee4bba..7d734696 100644
--- a/docs/pages/user_config.md
+++ b/docs/pages/user_config.md
@@ -4,6 +4,25 @@ The **System Config** section, accessible from the top navigation bar, provides

+### System Tab
+
+The **System** tab provides an overview of the current system state:
+
+- **Current Show**: Displays the currently loaded show name, with buttons to load an existing show or set up a new one.
+- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session.
+- **Version**: Displays the current DigiScript version and checks for available updates.
+
+#### Version Checker
+
+The version checker automatically checks for new DigiScript releases when the server starts, and periodically (every hour) thereafter. The version status shows:
+
+- **Current version**: The version of DigiScript currently running
+- **Status badge**: Indicates whether you're up to date (green), an update is available (yellow), or the check failed (red)
+- **Latest version**: When an update is available, shows the newest version number with a link to the release notes
+- **Last checked**: Timestamp of when the version was last checked
+
+Click the **Check Now** button to manually trigger a version check against the GitHub releases.
+
### System Settings
The **Settings** tab allows you to configure system-wide settings that apply across all shows:
diff --git a/documentation/development.md b/documentation/development.md
index ddcca263..b1b317d3 100644
--- a/documentation/development.md
+++ b/documentation/development.md
@@ -31,6 +31,8 @@ DigiScript consists of three main components:
- **`client/`** - Vue.js 2 frontend (builds to `server/static/` for web, or `client/dist-electron/` for Electron)
- **`electron/`** - Electron desktop application wrapper
+Refer to [DeepWiki](https://deepwiki.com/dreamteamprod/DigiScript) for detailed documentation on the architecture and design.
+
## Building the Web Client
```shell
diff --git a/electron/package-lock.json b/electron/package-lock.json
index c8e53e08..02ff4e4f 100644
--- a/electron/package-lock.json
+++ b/electron/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "digiscript-electron",
- "version": "0.24.2",
+ "version": "0.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "digiscript-electron",
- "version": "0.24.2",
+ "version": "0.25.0",
"license": "GPL-3.0",
"dependencies": {
"bonjour-service": "^1.3.0",
@@ -19,11 +19,11 @@
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1",
"@eslint/js": "^9.39.2",
- "electron": "^40.0.0",
+ "electron": "^40.6.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
- "globals": "^17.1.0",
+ "globals": "^17.3.0",
"prettier": "^3.8.1"
},
"engines": {
@@ -1169,7 +1169,6 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@@ -1801,7 +1800,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1877,7 +1875,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2119,7 +2116,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2869,9 +2865,9 @@
"license": "MIT"
},
"node_modules/electron": {
- "version": "40.0.0",
- "resolved": "https://registry.npmjs.org/electron/-/electron-40.0.0.tgz",
- "integrity": "sha512-UyBy5yJ0/wm4gNugCtNPjvddjAknMTuXR2aCHioXicH7aKRKGDBPp4xqTEi/doVcB3R+MN3wfU9o8d/9pwgK2A==",
+ "version": "40.6.1",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
+ "integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3478,6 +3474,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -3595,7 +3616,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3656,7 +3676,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4452,9 +4471,9 @@
}
},
"node_modules/globals": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.1.0.tgz",
- "integrity": "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw==",
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
+ "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6207,7 +6226,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7626,7 +7644,6 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
diff --git a/electron/package.json b/electron/package.json
index 8c3a8319..468664e7 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "digiscript-electron",
- "version": "0.24.2",
+ "version": "0.25.0",
"description": "DigiScript Electron Desktop Application",
"author": "DreamTeamProd",
"license": "GPL-3.0",
@@ -29,7 +29,7 @@
"bonjour-service": "^1.3.0"
},
"devDependencies": {
- "electron": "^40.0.0",
+ "electron": "^40.6.1",
"@electron-forge/cli": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1",
@@ -39,7 +39,7 @@
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
- "globals": "^17.1.0",
+ "globals": "^17.3.0",
"prettier": "^3.8.1"
},
"config": {
diff --git a/server/alembic_config/versions/625ac1e96e88_add_unique_constraints_to_allocation_.py b/server/alembic_config/versions/625ac1e96e88_add_unique_constraints_to_allocation_.py
new file mode 100644
index 00000000..e9bb6c6c
--- /dev/null
+++ b/server/alembic_config/versions/625ac1e96e88_add_unique_constraints_to_allocation_.py
@@ -0,0 +1,42 @@
+"""add unique constraints to allocation tables
+
+Revision ID: 625ac1e96e88
+Revises: 9849eb6d381a
+Create Date: 2026-01-16 00:31:42.000334
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "625ac1e96e88"
+down_revision: Union[str, None] = "9849eb6d381a"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("props_allocation", schema=None) as batch_op:
+ batch_op.create_unique_constraint("uq_props_scene", ["props_id", "scene_id"])
+
+ with op.batch_alter_table("scenery_allocation", schema=None) as batch_op:
+ batch_op.create_unique_constraint(
+ "uq_scenery_scene", ["scenery_id", "scene_id"]
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("scenery_allocation", schema=None) as batch_op:
+ batch_op.drop_constraint("uq_scenery_scene", type_="unique")
+
+ with op.batch_alter_table("props_allocation", schema=None) as batch_op:
+ batch_op.drop_constraint("uq_props_scene", type_="unique")
+
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/9849eb6d381a_add_prop_and_scenery_types.py b/server/alembic_config/versions/9849eb6d381a_add_prop_and_scenery_types.py
new file mode 100644
index 00000000..22f8f34c
--- /dev/null
+++ b/server/alembic_config/versions/9849eb6d381a_add_prop_and_scenery_types.py
@@ -0,0 +1,93 @@
+"""Add prop and scenery types
+
+Revision ID: 9849eb6d381a
+Revises: fa27b233d26c
+Create Date: 2026-01-14 01:01:31.586812
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "9849eb6d381a"
+down_revision: Union[str, None] = "fa27b233d26c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "prop_type",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("show_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["show_id"], ["shows.id"], name=op.f("fk_prop_type_show_id_shows")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_prop_type")),
+ )
+ op.create_table(
+ "scenery_type",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("show_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["show_id"], ["shows.id"], name=op.f("fk_scenery_type_show_id_shows")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_scenery_type")),
+ )
+ with op.batch_alter_table("crew", schema=None) as batch_op:
+ batch_op.alter_column("first_name", existing_type=sa.String(), nullable=False)
+
+ with op.batch_alter_table("props", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("prop_type_id", sa.Integer(), nullable=False))
+ batch_op.alter_column("name", existing_type=sa.String(), nullable=False)
+ batch_op.create_foreign_key(
+ batch_op.f("fk_props_prop_type_id_prop_type"),
+ "prop_type",
+ ["prop_type_id"],
+ ["id"],
+ )
+
+ with op.batch_alter_table("scenery", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("scenery_type_id", sa.Integer(), nullable=False))
+ batch_op.alter_column("name", existing_type=sa.String(), nullable=False)
+ batch_op.create_foreign_key(
+ batch_op.f("fk_scenery_scenery_type_id_scenery_type"),
+ "scenery_type",
+ ["scenery_type_id"],
+ ["id"],
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("scenery", schema=None) as batch_op:
+ batch_op.drop_constraint(
+ batch_op.f("fk_scenery_scenery_type_id_scenery_type"), type_="foreignkey"
+ )
+ batch_op.alter_column("name", existing_type=sa.String(), nullable=True)
+ batch_op.drop_column("scenery_type_id")
+
+ with op.batch_alter_table("props", schema=None) as batch_op:
+ batch_op.drop_constraint(
+ batch_op.f("fk_props_prop_type_id_prop_type"), type_="foreignkey"
+ )
+ batch_op.alter_column("name", existing_type=sa.String(), nullable=True)
+ batch_op.drop_column("prop_type_id")
+
+ with op.batch_alter_table("crew", schema=None) as batch_op:
+ batch_op.alter_column("first_name", existing_type=sa.String(), nullable=True)
+
+ op.drop_table("scenery_type")
+ op.drop_table("prop_type")
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/c1db8c1f4e37_add_console_log_level_to_user_settings.py b/server/alembic_config/versions/c1db8c1f4e37_add_console_log_level_to_user_settings.py
new file mode 100644
index 00000000..b79a73cc
--- /dev/null
+++ b/server/alembic_config/versions/c1db8c1f4e37_add_console_log_level_to_user_settings.py
@@ -0,0 +1,47 @@
+"""Add console_log_level to user_settings
+
+Revision ID: c1db8c1f4e37
+Revises: fbb1b6bd8707
+Create Date: 2026-02-20 22:51:22.081714
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c1db8c1f4e37"
+down_revision: Union[str, None] = "fbb1b6bd8707"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "console_log_level",
+ sa.String(),
+ nullable=False,
+ server_default="WARN",
+ )
+ )
+ batch_op.create_check_constraint(
+ "ck_user_settings_console_log_level",
+ "console_log_level IN ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'SILENT')",
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.drop_constraint("ck_user_settings_console_log_level", type_="check")
+ batch_op.drop_column("console_log_level")
+
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/fa27b233d26c_add_crew_props_and_scenery_tables_and_.py b/server/alembic_config/versions/fa27b233d26c_add_crew_props_and_scenery_tables_and_.py
new file mode 100644
index 00000000..920b08ba
--- /dev/null
+++ b/server/alembic_config/versions/fa27b233d26c_add_crew_props_and_scenery_tables_and_.py
@@ -0,0 +1,105 @@
+"""Add crew, props and scenery tables and allocations
+
+Revision ID: fa27b233d26c
+Revises: 01fb1d6c6b08
+Create Date: 2026-01-14 00:38:40.210710
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "fa27b233d26c"
+down_revision: Union[str, None] = "01fb1d6c6b08"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "crew",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("show_id", sa.Integer(), nullable=False),
+ sa.Column("first_name", sa.String(), nullable=True),
+ sa.Column("last_name", sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["show_id"], ["shows.id"], name=op.f("fk_crew_show_id_shows")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_crew")),
+ )
+ op.create_table(
+ "props",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("show_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["show_id"], ["shows.id"], name=op.f("fk_props_show_id_shows")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_props")),
+ )
+ op.create_table(
+ "scenery",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("show_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["show_id"], ["shows.id"], name=op.f("fk_scenery_show_id_shows")
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_scenery")),
+ )
+ op.create_table(
+ "props_allocation",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("props_id", sa.Integer(), nullable=False),
+ sa.Column("scene_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["props_id"],
+ ["props.id"],
+ name=op.f("fk_props_allocation_props_id_props"),
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["scene_id"],
+ ["scene.id"],
+ name=op.f("fk_props_allocation_scene_id_scene"),
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_props_allocation")),
+ )
+ op.create_table(
+ "scenery_allocation",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("scenery_id", sa.Integer(), nullable=False),
+ sa.Column("scene_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["scene_id"],
+ ["scene.id"],
+ name=op.f("fk_scenery_allocation_scene_id_scene"),
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["scenery_id"],
+ ["scenery.id"],
+ name=op.f("fk_scenery_allocation_scenery_id_scenery"),
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_scenery_allocation")),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("scenery_allocation")
+ op.drop_table("props_allocation")
+ op.drop_table("scenery")
+ op.drop_table("props")
+ op.drop_table("crew")
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/fbb1b6bd8707_add_crewassignment_model.py b/server/alembic_config/versions/fbb1b6bd8707_add_crewassignment_model.py
new file mode 100644
index 00000000..1630b86f
--- /dev/null
+++ b/server/alembic_config/versions/fbb1b6bd8707_add_crewassignment_model.py
@@ -0,0 +1,82 @@
+"""Add CrewAssignment model
+
+Revision ID: fbb1b6bd8707
+Revises: 625ac1e96e88
+Create Date: 2026-02-04 02:47:35.857906
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "fbb1b6bd8707"
+down_revision: Union[str, None] = "625ac1e96e88"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "crew_assignment",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("crew_id", sa.Integer(), nullable=False),
+ sa.Column("scene_id", sa.Integer(), nullable=False),
+ sa.Column("assignment_type", sa.String(), nullable=False),
+ sa.Column("prop_id", sa.Integer(), nullable=True),
+ sa.Column("scenery_id", sa.Integer(), nullable=True),
+ sa.CheckConstraint(
+ "(prop_id IS NOT NULL AND scenery_id IS NULL) OR (prop_id IS NULL AND scenery_id IS NOT NULL)",
+ name=op.f("ck_crew_assignment_exactly_one_item_type"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["crew_id"],
+ ["crew.id"],
+ name=op.f("fk_crew_assignment_crew_id_crew"),
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["prop_id"],
+ ["props.id"],
+ name=op.f("fk_crew_assignment_prop_id_props"),
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["scene_id"],
+ ["scene.id"],
+ name=op.f("fk_crew_assignment_scene_id_scene"),
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["scenery_id"],
+ ["scenery.id"],
+ name=op.f("fk_crew_assignment_scenery_id_scenery"),
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_crew_assignment")),
+ sa.UniqueConstraint(
+ "crew_id",
+ "scene_id",
+ "assignment_type",
+ "prop_id",
+ name="uq_crew_prop_assignment",
+ ),
+ sa.UniqueConstraint(
+ "crew_id",
+ "scene_id",
+ "assignment_type",
+ "scenery_id",
+ name="uq_crew_scenery_assignment",
+ ),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("crew_assignment")
+ # ### end Alembic commands ###
diff --git a/server/controllers/api/auth/user.py b/server/controllers/api/auth/user.py
index 4af56f50..d7248d23 100644
--- a/server/controllers/api/auth/user.py
+++ b/server/controllers/api/auth/user.py
@@ -14,63 +14,74 @@
allow_when_password_required,
api_authenticated,
no_live_session,
+ redact_data_paths,
require_admin,
- requires_show,
)
@ApiRoute("auth/create", ApiVersion.V1)
class UserCreateController(BaseAPIController):
+ @redact_data_paths(paths=["/password", "/confirmPassword"])
async def post(self):
- data = escape.json_decode(self.request.body)
+ with self.make_session() as session:
+ # If there are no users, allow creation without authentication, otherwise require admin.
+ has_any_users = session.scalars(select(User)).first() is not None
+ if has_any_users:
+ self.requires_admin()
- username = data.get("username", "")
- if not username:
- self.set_status(400)
- await self.finish({"message": "Username missing"})
- return
+ data = escape.json_decode(self.request.body)
- password = data.get("password", "")
- if not password:
- self.set_status(400)
- await self.finish({"message": "Password missing"})
- return
+ username = data.get("username", "")
+ if not username:
+ self.set_status(400)
+ await self.finish({"message": "Username missing"})
+ return
- # Validate password strength
- is_valid, error_msg = PasswordService.validate_password_strength(password)
- if not is_valid:
- self.set_status(400)
- await self.finish({"message": error_msg})
- return
+ password = data.get("password", "")
+ if not password:
+ self.set_status(400)
+ await self.finish({"message": "Password missing"})
+ return
- is_admin = data.get("is_admin", False)
+ is_admin = data.get("is_admin", False)
+ if not has_any_users and not is_admin:
+ self.set_status(400)
+ await self.finish({"message": "First user must be an admin"})
+ return
- with self.make_session() as session:
- conflict_user = session.scalars(
- select(User).where(User.username == username)
- ).first()
- if conflict_user:
+ # Validate password strength
+ is_valid, error_msg = PasswordService.validate_password_strength(password)
+ if not is_valid:
self.set_status(400)
- await self.finish({"message": "Username already taken"})
+ await self.finish({"message": error_msg})
return
- hashed_password = await PasswordService.hash_password(password)
+ async with NamedLockRegistry.acquire(f"UserLock::{username}"):
+ conflict_user = session.scalars(
+ select(User).where(User.username == username)
+ ).first()
+ if conflict_user:
+ self.set_status(400)
+ await self.finish({"message": "Username already taken"})
+ return
+
+ hashed_password = await PasswordService.hash_password(password)
- session.add(
- User(
- username=username,
- password=hashed_password,
- is_admin=is_admin,
+ session.add(
+ User(
+ username=username,
+ password=hashed_password,
+ is_admin=is_admin,
+ )
)
- )
- session.commit()
+ session.commit()
- if is_admin:
- await self.application.digi_settings.set("has_admin_user", True)
+ if is_admin:
+ await self.application.digi_settings.set("has_admin_user", True)
- self.set_status(200)
- await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
- await self.finish({"message": "Successfully created user"})
+ self.set_status(200)
+ await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
+ await self.finish({"message": "Successfully created user"})
@ApiRoute("auth/delete", ApiVersion.V1)
@@ -88,14 +99,24 @@ async def post(self):
with self.make_session() as session:
user_to_delete: User = session.get(User, int(user_to_delete))
+ all_admins = session.scalars(
+ select(User).where(User.is_admin.is_(True))
+ ).all()
if not user_to_delete:
self.set_status(400)
await self.finish({"message": "Could not find user to delete"})
return
- if user_to_delete.is_admin:
+ if user_to_delete.id == self.current_user["id"]:
+ self.set_status(400)
+ await self.finish(
+ {"message": "Cannot delete currently authenticated user"}
+ )
+ return
+
+ if user_to_delete.is_admin and len(all_admins) <= 1:
self.set_status(400)
- await self.finish({"message": "Cannot delete admin user"})
+ await self.finish({"message": "Cannot delete the only admin user"})
return
async with NamedLockRegistry.acquire(
@@ -120,6 +141,7 @@ async def post(self):
@ApiRoute("auth/login", ApiVersion.V1)
class LoginHandler(BaseAPIController):
+ @redact_data_paths(paths=["/password"])
async def post(self):
data = escape.json_decode(self.request.body)
@@ -233,7 +255,6 @@ async def post(self):
class UsersHandler(BaseAPIController):
@api_authenticated
@require_admin
- @requires_show
def get(self):
user_schema = UserSchema()
with self.make_session() as session:
@@ -256,6 +277,7 @@ class PasswordChangeController(BaseAPIController):
@api_authenticated
@allow_when_password_required
+ @redact_data_paths(paths=["/old_password", "/new_password"])
async def patch(self):
"""
Change authenticated user's password.
diff --git a/server/controllers/api/constants.py b/server/controllers/api/constants.py
new file mode 100644
index 00000000..b487758e
--- /dev/null
+++ b/server/controllers/api/constants.py
@@ -0,0 +1,113 @@
+"""
+Error message constants for API controllers.
+
+This module centralizes error messages to reduce code duplication across controllers.
+All error messages follow the existing API contract patterns.
+"""
+
+# =============================================================================
+# HTTP 404 Not Found Errors
+# =============================================================================
+
+# Core entities
+ERROR_SHOW_NOT_FOUND = "404 show not found"
+ERROR_ACT_NOT_FOUND = "404 act not found"
+ERROR_SCENE_NOT_FOUND = "404 scene not found"
+
+# People
+ERROR_CAST_MEMBER_NOT_FOUND = "404 cast member not found"
+ERROR_CHARACTER_NOT_FOUND = "404 character not found"
+ERROR_CHARACTER_GROUP_NOT_FOUND = "404 character group not found"
+ERROR_CREW_NOT_FOUND = "404 crew member not found"
+
+# Script
+ERROR_SCRIPT_NOT_FOUND = "404 script not found"
+ERROR_SCRIPT_REVISION_NOT_FOUND = "404 script revision not found"
+ERROR_STAGE_DIRECTION_STYLE_NOT_FOUND = "404 stage direction style not found"
+
+# Cues
+ERROR_CUE_NOT_FOUND = "404 cue not found"
+ERROR_CUE_TYPE_NOT_FOUND = "404 cue type not found"
+
+# Microphones
+ERROR_MICROPHONE_NOT_FOUND = "404 microphone not found"
+
+# Tags
+ERROR_TAG_NOT_FOUND = "404 tag not found"
+
+# Stage: Props
+ERROR_PROP_NOT_FOUND = "404 prop not found"
+ERROR_PROPS_NOT_FOUND = "404 props not found"
+ERROR_PROP_TYPE_NOT_FOUND = "404 prop type not found"
+
+# Stage: Scenery
+ERROR_SCENERY_NOT_FOUND = "404 scenery not found"
+ERROR_SCENERY_TYPE_NOT_FOUND = "404 scenery type not found"
+
+# Allocations
+ERROR_ALLOCATION_NOT_FOUND = "404 allocation not found"
+
+# Crew assignments
+ERROR_CREW_ASSIGNMENT_NOT_FOUND = "404 crew assignment not found"
+
+
+# =============================================================================
+# HTTP 400 Validation Errors - Missing Required Fields
+# =============================================================================
+
+# Generic
+ERROR_ID_MISSING = "ID missing"
+ERROR_NAME_MISSING = "Name missing"
+ERROR_INVALID_ID = "Invalid ID"
+
+# People
+ERROR_FIRST_NAME_MISSING = "First name missing"
+ERROR_LAST_NAME_MISSING = "Last name missing"
+
+# Appearance
+ERROR_COLOUR_MISSING = "Colour missing"
+ERROR_DESCRIPTION_MISSING = "Description missing"
+
+# Acts/Scenes
+ERROR_ACT_ID_MISSING = "Act ID missing"
+ERROR_SCENE_ID_MISSING = "Scene ID missing"
+
+# Cues
+ERROR_PREFIX_MISSING = "Prefix missing"
+ERROR_CUE_TYPE_MISSING = "Cue Type missing"
+ERROR_CUE_ID_MISSING = "Cue ID missing"
+ERROR_IDENTIFIER_MISSING = "Identifier missing"
+ERROR_LINE_ID_MISSING = "Line ID missing"
+
+# Stage direction styles
+ERROR_TEXT_FORMAT_INVALID = "Text format missing or invalid"
+ERROR_TEXT_COLOUR_MISSING = "Text colour missing"
+ERROR_BACKGROUND_COLOUR_MISSING = "Background colour missing"
+
+# Tags
+ERROR_TAG_NAME_MISSING = "Tag name missing"
+
+# Props/Scenery
+ERROR_PROP_TYPE_ID_MISSING = "Prop type ID missing"
+ERROR_SCENERY_TYPE_ID_MISSING = "Scenery type ID missing"
+
+# Allocations
+ERROR_PROPS_ID_MISSING = "props_id missing"
+ERROR_SCENERY_ID_MISSING = "scenery_id missing"
+
+# Crew assignments
+ERROR_CREW_ID_MISSING = "crew_id missing"
+ERROR_ASSIGNMENT_TYPE_MISSING = "assignment_type missing"
+ERROR_ASSIGNMENT_TYPE_INVALID = "assignment_type must be 'set' or 'strike'"
+ERROR_ITEM_ID_MISSING = "Either prop_id or scenery_id must be provided"
+ERROR_ITEM_ID_BOTH = "Only one of prop_id or scenery_id can be provided"
+ERROR_INVALID_BOUNDARY = "Scene is not a valid boundary for this assignment type"
+ERROR_CREW_ASSIGNMENT_EXISTS = "Crew assignment already exists"
+
+
+# =============================================================================
+# HTTP 400 Conflict/Business Rule Errors
+# =============================================================================
+
+ERROR_NAME_ALREADY_TAKEN = "Name already taken"
+ERROR_TAG_NAME_EXISTS = "Tag name already exists (case-insensitive)"
diff --git a/server/controllers/api/logging.py b/server/controllers/api/logging.py
new file mode 100644
index 00000000..75856952
--- /dev/null
+++ b/server/controllers/api/logging.py
@@ -0,0 +1,109 @@
+from typing import Any, Dict, List
+
+from tornado import escape, httputil
+
+from digi_server.app_server import DigiScriptServer
+from digi_server.logger import get_logger, map_client_level
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+
+
+class ClientLoggingBase(BaseAPIController):
+ def __init__(
+ self,
+ application: DigiScriptServer,
+ request: httputil.HTTPServerRequest,
+ **kwargs: Any,
+ ):
+ super().__init__(application, request, **kwargs)
+ self.client_logger = get_logger("Client")
+
+ def process_logs(self, entries: List[Dict]):
+ # Build request-level context added to every entry's extra
+ request_extra = {
+ "remote_ip": self.request.remote_ip,
+ "agent": self.request.headers.get("User-Agent", "Unknown"),
+ }
+ if self.current_user:
+ request_extra["user_id"] = self.current_user.get("id")
+ request_extra["username"] = self.current_user.get("username")
+
+ for entry in entries:
+ level = entry.get("level", "INFO").upper()
+ message = entry.get("message", "")
+ extra = {**entry.get("extra", {}), **request_extra}
+
+ log_level = map_client_level(level)
+
+ log_msg = f"[Client] {message}"
+ if extra:
+ log_msg += f" | Extra: {extra}"
+
+ # Pass structured fields via extra so LogBufferHandler can read them.
+ # Tornado's LogFormatter ignores unknown extra fields, so file output
+ # is unaffected.
+ self.client_logger.log(
+ log_level,
+ log_msg,
+ extra={
+ "user_id": request_extra.get("user_id"),
+ "username": request_extra.get("username"),
+ "remote_ip": request_extra.get("remote_ip"),
+ },
+ )
+
+ self.set_status(200)
+ self.write({"status": "OK"})
+
+
+@ApiRoute("logs/batch", ApiVersion.V1, ignore_logging=True)
+class ClientLoggingBatchController(ClientLoggingBase):
+ async def post(self):
+ client_log_enabled = await self.application.digi_settings.get(
+ "client_log_enabled"
+ )
+ if not client_log_enabled:
+ self.set_status(403)
+ self.write({"message": "Client logging is disabled"})
+ return
+
+ try:
+ data = escape.json_decode(self.request.body)
+ except ValueError:
+ self.set_status(400)
+ self.write({"message": "Invalid JSON"})
+ return
+
+ if not isinstance(data, dict):
+ self.set_status(400)
+ self.write({"message": "Expected JSON object with 'batch' key"})
+ return
+
+ batch = data.get("batch")
+ if not isinstance(batch, list):
+ self.set_status(400)
+ self.write({"message": "Expected 'batch' to be a list of log entries"})
+ return
+
+ self.process_logs(batch)
+
+
+@ApiRoute("logs", ApiVersion.V1, ignore_logging=True)
+class ClientLoggingController(ClientLoggingBase):
+ async def post(self):
+ client_log_enabled = await self.application.digi_settings.get(
+ "client_log_enabled"
+ )
+ if not client_log_enabled:
+ self.set_status(403)
+ self.write({"message": "Client logging is disabled"})
+ return
+
+ try:
+ data = escape.json_decode(self.request.body)
+ except ValueError:
+ self.set_status(400)
+ self.write({"message": "Invalid JSON"})
+ return
+
+ self.process_logs([data])
diff --git a/server/controllers/api/logs_viewer.py b/server/controllers/api/logs_viewer.py
new file mode 100644
index 00000000..dcd63e0a
--- /dev/null
+++ b/server/controllers/api/logs_viewer.py
@@ -0,0 +1,241 @@
+"""Log viewer API endpoints.
+
+Provides two admin-only endpoints backed by the in-memory log buffers
+populated by :class:`~digi_server.log_buffer.LogBufferHandler`:
+
+* ``GET /api/v1/logs/view`` — paginated one-shot snapshot
+* ``GET /api/v1/logs/stream`` — Server-Sent Events (SSE) live stream with
+ backfill of existing entries
+"""
+
+import asyncio
+import json
+import logging
+
+from utils.log_buffer import get_client_buffer, get_server_buffer
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import require_admin
+
+
+# Map level name → minimum level_no for "greater-than-or-equal" filtering.
+# WARN is accepted as an alias for WARNING (mirrors loglevel npm behaviour).
+_LEVEL_ALIASES = {
+ "WARN": logging.WARNING,
+ "WARNING": logging.WARNING,
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "ERROR": logging.ERROR,
+ "CRITICAL": logging.CRITICAL,
+ "TRACE": 5, # Custom level registered in main.py
+}
+
+_MAX_LIMIT = 1000
+
+# Sentinel placed on the SSE queue when the client disconnects, so the
+# awaiting coroutine can exit cleanly without waiting for the next timeout.
+_STREAM_CLOSED = object()
+
+
+def _parse_source(raw: str) -> str:
+ """Normalise the ``source`` query parameter.
+
+ :param raw: Raw value from the query string.
+ :returns: ``"client"`` or ``"server"``.
+ """
+ return "client" if raw.lower() == "client" else "server"
+
+
+def _filter_entries(
+ entries: list,
+ level_name: str,
+ search: str,
+ username_filter: str,
+ source: str,
+) -> list:
+ """Apply all active filters to *entries* and return the matching subset.
+
+ :param entries: List of entry dicts to filter.
+ :param level_name: Uppercase level name (e.g. ``"ERROR"``); empty means
+ no level filter.
+ :param search: Lowercase search string; empty means no search filter.
+ :param username_filter: Lowercase username substring; only applied when
+ *source* is ``"client"``; empty means no filter.
+ :param source: ``"server"`` or ``"client"``.
+ :returns: Filtered list of entry dicts.
+ """
+ if level_name:
+ min_level_no = _LEVEL_ALIASES.get(level_name)
+ if min_level_no is not None:
+ entries = [e for e in entries if e["level_no"] >= min_level_no]
+
+ if search:
+ entries = [e for e in entries if search in e["message"].lower()]
+
+ if username_filter and source == "client":
+ entries = [
+ e
+ for e in entries
+ if e.get("username") and username_filter in e["username"].lower()
+ ]
+
+ return entries
+
+
+@ApiRoute("logs/view", ApiVersion.V1, ignore_logging=True)
+class LogViewerController(BaseAPIController):
+ """Return a filtered snapshot of the in-memory log buffer.
+
+ Query parameters
+ ----------------
+ source : str
+ ``"server"`` (default) or ``"client"``.
+ level : str
+ Minimum level name. Empty string (default) means all levels.
+ Accepts ``TRACE``, ``DEBUG``, ``INFO``, ``WARN``/``WARNING``,
+ ``ERROR``, ``CRITICAL``.
+ search : str
+ Case-insensitive substring match on the ``message`` field.
+ username : str
+ (Client source only) case-insensitive substring match on the
+ ``username`` field.
+ limit : int
+ Maximum number of entries to return (capped at 1000, default 500).
+ offset : int
+ Number of entries to skip before returning (default 0).
+ """
+
+ @require_admin
+ async def get(self):
+ """Handle ``GET /api/v1/logs/view``.
+
+ :raises tornado.web.HTTPError: 401 if not authenticated,
+ 403 if not admin.
+ """
+ source = _parse_source(self.get_argument("source", "server"))
+ level_name = self.get_argument("level", "").upper()
+ search = self.get_argument("search", "").lower()
+ username_filter = self.get_argument("username", "").lower()
+
+ try:
+ limit = min(int(self.get_argument("limit", "500")), _MAX_LIMIT)
+ except ValueError:
+ limit = 500
+
+ try:
+ offset = max(int(self.get_argument("offset", "0")), 0)
+ except ValueError:
+ offset = 0
+
+ if source == "client":
+ entries = get_client_buffer().get_entries()
+ else:
+ entries = get_server_buffer().get_entries()
+
+ entries = _filter_entries(entries, level_name, search, username_filter, source)
+
+ total = len(entries)
+ page = entries[offset : offset + limit]
+
+ self.write(
+ {
+ "entries": page,
+ "total": total,
+ "returned": len(page),
+ "source": source,
+ }
+ )
+
+
+@ApiRoute("logs/stream", ApiVersion.V1, ignore_logging=True)
+class LogStreamController(BaseAPIController):
+ """Stream log entries to the client using Server-Sent Events (SSE).
+
+ On connection the handler first sends all existing (backfill) entries from
+ the buffer that match the active filters, then emits new entries in
+ real-time as they arrive. A ``: keepalive`` comment is written every 20 s
+ to prevent proxies and browsers from closing an idle connection.
+
+ Query parameters
+ ----------------
+ Same as :class:`LogViewerController` except ``limit`` and ``offset`` which
+ are not applicable to a live stream.
+
+ SSE event format
+ ----------------
+ Each event is a single ``data:`` line containing a JSON-encoded entry dict,
+ followed by a blank line::
+
+ data: {"ts": "...", "level": "INFO", ...}
+
+ """
+
+ def on_connection_close(self):
+ """Called by Tornado when the client closes the connection.
+
+ Sets a flag and pushes the :data:`_STREAM_CLOSED` sentinel onto the
+ queue so the suspended ``get()`` coroutine wakes up and exits.
+ """
+ self._sse_closed = True
+ if hasattr(self, "_sse_queue"):
+ self._sse_queue.put_nowait(_STREAM_CLOSED)
+
+ @require_admin
+ async def get(self):
+ """Handle ``GET /api/v1/logs/stream``.
+
+ :raises tornado.web.HTTPError: 401 if not authenticated,
+ 403 if not admin.
+ """
+ source = _parse_source(self.get_argument("source", "server"))
+ level_name = self.get_argument("level", "").upper()
+ search = self.get_argument("search", "").lower()
+ username_filter = self.get_argument("username", "").lower()
+
+ self._sse_closed = False
+
+ self.set_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.set_header("Cache-Control", "no-cache")
+ # Instruct nginx / other reverse proxies not to buffer this response.
+ self.set_header("X-Accel-Buffering", "no")
+
+ buffer = get_client_buffer() if source == "client" else get_server_buffer()
+
+ # Send backfill — all existing entries that pass the current filters.
+ for entry in _filter_entries(
+ buffer.get_entries(), level_name, search, username_filter, source
+ ):
+ self.write(f"data: {json.dumps(entry)}\n\n")
+ await self.flush()
+
+ # Subscribe to future entries.
+ queue: asyncio.Queue = asyncio.Queue()
+ self._sse_queue = queue
+
+ unsubscribe = buffer.subscribe(queue.put_nowait)
+
+ try:
+ while True:
+ try:
+ entry = await asyncio.wait_for(queue.get(), timeout=20.0)
+ except asyncio.TimeoutError:
+ # Send a keepalive comment so proxies don't time out.
+ if self._sse_closed:
+ break
+ self.write(": keepalive\n\n")
+ await self.flush()
+ continue
+
+ if entry is _STREAM_CLOSED:
+ break
+
+ matched = _filter_entries(
+ [entry], level_name, search, username_filter, source
+ )
+ if matched:
+ self.write(f"data: {json.dumps(matched[0])}\n\n")
+ await self.flush()
+ except Exception: # noqa: BLE001
+ pass
+ finally:
+ unsubscribe()
diff --git a/server/controllers/api/settings.py b/server/controllers/api/settings.py
index 4a0fefc3..4a203eeb 100644
--- a/server/controllers/api/settings.py
+++ b/server/controllers/api/settings.py
@@ -41,6 +41,13 @@ async def patch(self):
self.write({"message": "Settings updated"})
+@ApiRoute("settings/categories", ApiVersion.V1)
+class SettingsCategoriesController(BaseAPIController):
+ @allow_when_password_required
+ async def get(self):
+ await self.finish({"categories": self.application.digi_settings.categories})
+
+
@ApiRoute("settings/raw", ApiVersion.V1)
class RawSettingsController(BaseAPIController):
async def get(self):
diff --git a/server/controllers/api/show/acts.py b/server/controllers/api/show/acts.py
index b3033e8d..99a0568e 100644
--- a/server/controllers/api/show/acts.py
+++ b/server/controllers/api/show/acts.py
@@ -3,6 +3,16 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_ACT_ID_MISSING,
+ ERROR_ACT_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_SCENE_ID_MISSING,
+ ERROR_SCENE_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.show import Act, Scene, Show
from rbac.role import Role
from schemas.schemas import ActSchema
@@ -30,7 +40,7 @@ def get(self):
self.finish({"acts": acts})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -47,7 +57,7 @@ async def post(self):
name: str = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
interval_after: bool = data.get("interval_after", None)
@@ -80,7 +90,7 @@ async def post(self):
await self.application.ws_send_to_all("NOOP", "GET_ACT_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -97,7 +107,7 @@ async def patch(self):
act_id = data.get("id", None)
if not act_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: Act = session.get(Act, act_id)
@@ -105,7 +115,7 @@ async def patch(self):
name = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
entry.name = name
@@ -159,11 +169,11 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_ACT_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 act not found"})
+ await self.finish({"message": ERROR_ACT_NOT_FOUND})
return
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -179,14 +189,14 @@ async def delete(self):
act_id_str = self.get_argument("id", None)
if not act_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
act_id = int(act_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: Act = session.get(Act, act_id)
@@ -210,10 +220,10 @@ async def delete(self):
await self.application.ws_send_to_all("NOOP", "GET_ACT_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 act not found"})
+ await self.finish({"message": ERROR_ACT_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/act/first_scene", ApiVersion.V1)
@@ -233,12 +243,12 @@ async def post(self):
act_id: int = data.get("act_id", None)
if not act_id:
self.set_status(400)
- await self.finish({"message": "Act ID missing"})
+ await self.finish({"message": ERROR_ACT_ID_MISSING})
return
if "scene_id" not in data:
self.set_status(400)
- await self.finish({"message": "Scene ID missing"})
+ await self.finish({"message": ERROR_SCENE_ID_MISSING})
return
scene_id: int = data.get("scene_id", None)
@@ -246,7 +256,7 @@ async def post(self):
scene: Scene = session.get(Scene, scene_id)
if not scene:
self.set_status(404)
- await self.finish({"message": "404 scene not found"})
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
return
if scene.previous_scene_id:
self.set_status(400)
@@ -273,4 +283,4 @@ async def post(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/cast.py b/server/controllers/api/show/cast.py
index 7b6454c4..85165e78 100644
--- a/server/controllers/api/show/cast.py
+++ b/server/controllers/api/show/cast.py
@@ -3,6 +3,14 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_CAST_MEMBER_NOT_FOUND,
+ ERROR_FIRST_NAME_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_LAST_NAME_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision
from models.show import Cast, Character, Show
from rbac.role import Role
@@ -28,7 +36,7 @@ def get(self):
self.finish({"cast": cast})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -45,13 +53,13 @@ async def post(self):
first_name = data.get("firstName", None)
if not first_name:
self.set_status(400)
- await self.finish({"message": "First name missing"})
+ await self.finish({"message": ERROR_FIRST_NAME_MISSING})
return
last_name = data.get("lastName", None)
if not last_name:
self.set_status(400)
- await self.finish({"message": "Last name missing"})
+ await self.finish({"message": ERROR_LAST_NAME_MISSING})
return
new_cast = Cast(
@@ -70,7 +78,7 @@ async def post(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -87,7 +95,7 @@ async def patch(self):
cast_id = data.get("id", None)
if not cast_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: Cast = session.get(Cast, cast_id)
@@ -95,14 +103,14 @@ async def patch(self):
first_name = data.get("firstName", None)
if not first_name:
self.set_status(400)
- await self.finish({"message": "First name missing"})
+ await self.finish({"message": ERROR_FIRST_NAME_MISSING})
return
entry.first_name = first_name
last_name = data.get("lastName", None)
if not last_name:
self.set_status(400)
- await self.finish({"message": "Last name missing"})
+ await self.finish({"message": ERROR_LAST_NAME_MISSING})
return
entry.last_name = last_name
@@ -116,11 +124,11 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 cast member not found"})
+ await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND})
return
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -136,14 +144,14 @@ async def delete(self):
cast_id_str = self.get_argument("id", None)
if not cast_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
cast_id = int(cast_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry = session.get(Cast, cast_id)
@@ -159,10 +167,10 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 cast member not found"})
+ await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/cast/stats", ApiVersion.V1)
@@ -214,4 +222,4 @@ async def get(self):
await self.finish({"line_counts": line_counts})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/show/characters.py
index 4b020b86..67258218 100644
--- a/server/controllers/api/show/characters.py
+++ b/server/controllers/api/show/characters.py
@@ -3,6 +3,15 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_CAST_MEMBER_NOT_FOUND,
+ ERROR_CHARACTER_GROUP_NOT_FOUND,
+ ERROR_CHARACTER_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision
from models.show import Cast, Character, CharacterGroup, Show
from rbac.role import Role
@@ -28,7 +37,7 @@ def get(self):
self.finish({"characters": characters})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -45,7 +54,7 @@ async def post(self):
name = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
description = data.get("description", None)
@@ -54,7 +63,7 @@ async def post(self):
cast_member = session.get(Cast, played_by)
if not cast_member:
self.set_status(404)
- await self.finish({"message": "404 cast member found"})
+ await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND})
return
new_character = Character(
@@ -77,7 +86,7 @@ async def post(self):
await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -94,7 +103,7 @@ async def patch(self):
character_id = data.get("id", None)
if not character_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: Character = session.get(Character, character_id)
@@ -102,7 +111,7 @@ async def patch(self):
name = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
entry.name = name
@@ -114,7 +123,7 @@ async def patch(self):
cast_member = session.get(Cast, played_by)
if not cast_member:
self.set_status(404)
- await self.finish({"message": "404 cast member found"})
+ await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND})
return
entry.played_by = played_by
@@ -128,11 +137,11 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 character not found"})
+ await self.finish({"message": ERROR_CHARACTER_NOT_FOUND})
return
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -148,14 +157,14 @@ async def delete(self):
character_id_str = self.get_argument("id", None)
if not character_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
character_id = int(character_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: Character = session.get(Character, character_id)
@@ -171,10 +180,10 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 character not found"})
+ await self.finish({"message": ERROR_CHARACTER_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/character/stats", ApiVersion.V1)
@@ -223,7 +232,7 @@ async def get(self):
await self.finish({"line_counts": line_counts})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/character/group", ApiVersion.V1)
@@ -244,7 +253,7 @@ def get(self):
self.finish({"character_groups": character_groups})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -261,7 +270,7 @@ async def post(self):
name = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
description = data.get("description", None)
@@ -297,7 +306,7 @@ async def post(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -313,14 +322,14 @@ async def delete(self):
character_group_id_str = self.get_argument("id", None)
if not character_group_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
character_group_id = int(character_group_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: CharacterGroup = session.get(CharacterGroup, character_group_id)
@@ -338,10 +347,10 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 character not found"})
+ await self.finish({"message": ERROR_CHARACTER_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -358,7 +367,7 @@ async def patch(self):
character_group_id = data.get("id", None)
if not character_group_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: CharacterGroup = session.get(CharacterGroup, character_group_id)
@@ -366,7 +375,7 @@ async def patch(self):
name = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
entry.name = name
@@ -397,8 +406,8 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 character group not found"})
+ await self.finish({"message": ERROR_CHARACTER_GROUP_NOT_FOUND})
return
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/cues.py b/server/controllers/api/show/cues.py
index 76ff3283..accf6003 100644
--- a/server/controllers/api/show/cues.py
+++ b/server/controllers/api/show/cues.py
@@ -5,6 +5,19 @@
from sqlalchemy import func, select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_COLOUR_MISSING,
+ ERROR_CUE_ID_MISSING,
+ ERROR_CUE_NOT_FOUND,
+ ERROR_CUE_TYPE_MISSING,
+ ERROR_CUE_TYPE_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_IDENTIFIER_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_LINE_ID_MISSING,
+ ERROR_PREFIX_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.cue import Cue, CueAssociation, CueType
from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision
from models.show import Show
@@ -31,7 +44,7 @@ def get(self):
self.finish({"cue_types": cue_types})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -48,7 +61,7 @@ async def post(self):
prefix: str = data.get("prefix", None)
if not prefix:
self.set_status(400)
- await self.finish({"message": "Prefix missing"})
+ await self.finish({"message": ERROR_PREFIX_MISSING})
return
description: str = data.get("description", None)
@@ -56,7 +69,7 @@ async def post(self):
colour: str = data.get("colour", None)
if not colour:
self.set_status(400)
- await self.finish({"message": "Colour missing"})
+ await self.finish({"message": ERROR_COLOUR_MISSING})
return
new_cuetype = CueType(
@@ -76,7 +89,7 @@ async def post(self):
await self.application.ws_send_to_all("NOOP", "GET_CUE_TYPES", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -93,19 +106,19 @@ async def patch(self):
cue_type_id = data.get("id", None)
if not cue_type_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
cue_type: CueType = session.get(CueType, cue_type_id)
if not cue_type:
self.set_status(404)
- await self.finish({"message": "404 cue type not found"})
+ await self.finish({"message": ERROR_CUE_TYPE_NOT_FOUND})
return
prefix: str = data.get("prefix", None)
if not prefix:
self.set_status(400)
- await self.finish({"message": "Prefix missing"})
+ await self.finish({"message": ERROR_PREFIX_MISSING})
return
description: str = data.get("description", None)
@@ -113,7 +126,7 @@ async def patch(self):
colour: str = data.get("colour", None)
if not colour:
self.set_status(400)
- await self.finish({"message": "Colour missing"})
+ await self.finish({"message": ERROR_COLOUR_MISSING})
return
cue_type.prefix = prefix
@@ -127,7 +140,7 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_CUE_TYPES", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -143,14 +156,14 @@ async def delete(self):
cue_type_id_str = self.get_argument("id", None)
if not cue_type_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
cue_type_id = int(cue_type_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: CueType = session.get(CueType, cue_type_id)
@@ -164,10 +177,10 @@ async def delete(self):
await self.application.ws_send_to_all("NOOP", "GET_CUE_TYPES", {})
else:
self.set_status(404)
- await self.finish({"message": "404 cue type not found"})
+ await self.finish({"message": ERROR_CUE_TYPE_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/cues", ApiVersion.V1)
@@ -208,7 +221,7 @@ def get(self):
self.finish({"cues": cues})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
async def post(self):
@@ -238,7 +251,7 @@ async def post(self):
cue_type_id: int = data.get("cueType", None)
if not cue_type_id:
self.set_status(400)
- await self.finish({"message": "Cue Type missing"})
+ await self.finish({"message": ERROR_CUE_TYPE_MISSING})
return
cue_type = session.get(CueType, cue_type_id)
@@ -253,13 +266,13 @@ async def post(self):
ident: str = data.get("ident", None)
if not ident:
self.set_status(400)
- await self.finish({"message": "Identifier missing"})
+ await self.finish({"message": ERROR_IDENTIFIER_MISSING})
return
line_id: int = data.get("lineId", None)
if not line_id:
self.set_status(400)
- await self.finish({"message": "Line ID missing"})
+ await self.finish({"message": ERROR_LINE_ID_MISSING})
return
line: ScriptLine = session.get(ScriptLine, line_id)
@@ -290,7 +303,7 @@ async def post(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -321,13 +334,13 @@ async def patch(self):
cue_id: int = data.get("cueId")
if not cue_id:
self.set_status(400)
- await self.finish({"message": "Cue ID missing"})
+ await self.finish({"message": ERROR_CUE_ID_MISSING})
return
cue_type_id: int = data.get("cueType", None)
if not cue_type_id:
self.set_status(400)
- await self.finish({"message": "Cue Type missing"})
+ await self.finish({"message": ERROR_CUE_TYPE_MISSING})
return
cue_type = session.get(CueType, cue_type_id)
@@ -342,19 +355,19 @@ async def patch(self):
ident: str = data.get("ident", None)
if not ident:
self.set_status(400)
- await self.finish({"message": "Identifier missing"})
+ await self.finish({"message": ERROR_IDENTIFIER_MISSING})
return
line_id: int = data.get("lineId", None)
if not line_id:
self.set_status(400)
- await self.finish({"message": "Line ID missing"})
+ await self.finish({"message": ERROR_LINE_ID_MISSING})
return
cue: Cue = session.get(Cue, cue_id)
if not cue:
self.set_status(404)
- await self.finish({"message": "404 cue not found"})
+ await self.finish({"message": ERROR_CUE_NOT_FOUND})
return
current_association: CueAssociation = session.get(
@@ -394,7 +407,7 @@ async def patch(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -423,7 +436,7 @@ async def delete(self):
cue_id_str = self.get_argument("cueId", None)
if not cue_id_str:
self.set_status(400)
- await self.finish({"message": "Cue ID missing"})
+ await self.finish({"message": ERROR_CUE_ID_MISSING})
return
try:
@@ -440,7 +453,7 @@ async def delete(self):
line_id_str = self.get_argument("lineId", None)
if not line_id_str:
self.set_status(400)
- await self.finish({"message": "Line ID missing"})
+ await self.finish({"message": ERROR_LINE_ID_MISSING})
return
try:
@@ -470,7 +483,7 @@ async def delete(self):
return
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/cues/stats", ApiVersion.V1)
@@ -512,7 +525,7 @@ async def get(self):
await self.finish({"cue_counts": cue_counts})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/cues/search", ApiVersion.V1)
@@ -547,7 +560,7 @@ async def get(self):
show: Show = session.get(Show, show_id)
if not show:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
script: Script = session.scalars(
diff --git a/server/controllers/api/show/microphones.py b/server/controllers/api/show/microphones.py
index 6c8557a8..5b07429e 100644
--- a/server/controllers/api/show/microphones.py
+++ b/server/controllers/api/show/microphones.py
@@ -4,6 +4,16 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_CHARACTER_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_MICROPHONE_NOT_FOUND,
+ ERROR_NAME_ALREADY_TAKEN,
+ ERROR_NAME_MISSING,
+ ERROR_SCENE_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.mics import Microphone, MicrophoneAllocation
from models.script import Script, ScriptRevision
from models.show import Act, Character, Scene, Show
@@ -38,7 +48,7 @@ def get(self):
self.finish({"microphones": mics})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -55,7 +65,7 @@ async def post(self):
name: str = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
other_named = session.scalars(
@@ -65,7 +75,7 @@ async def post(self):
).first()
if other_named:
self.set_status(400)
- await self.finish({"message": "Name already taken"})
+ await self.finish({"message": ERROR_NAME_ALREADY_TAKEN})
return
description: str = data.get("description", None)
@@ -87,7 +97,7 @@ async def post(self):
await self.application.ws_send_to_all("NOOP", "GET_MICROPHONE_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -104,19 +114,19 @@ async def patch(self):
microphone_id = data.get("id", None)
if not microphone_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
microphone: Microphone = session.get(Microphone, microphone_id)
if not microphone:
self.set_status(404)
- await self.finish({"message": "404 microphone not found"})
+ await self.finish({"message": ERROR_MICROPHONE_NOT_FOUND})
return
name: str = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
other_named = session.scalars(
@@ -128,7 +138,7 @@ async def patch(self):
).first()
if other_named:
self.set_status(400)
- await self.finish({"message": "Name already taken"})
+ await self.finish({"message": ERROR_NAME_ALREADY_TAKEN})
return
description: str = data.get("description", None)
@@ -143,7 +153,7 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_MICROPHONE_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -159,14 +169,14 @@ async def delete(self):
microphone_id_str = self.get_argument("id", None)
if not microphone_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
microphone_id = int(microphone_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: Microphone = session.get(Microphone, microphone_id)
@@ -182,10 +192,10 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 microphone not found"})
+ await self.finish({"message": ERROR_MICROPHONE_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 microphone not found"})
+ await self.finish({"message": ERROR_MICROPHONE_NOT_FOUND})
@ApiRoute("show/microphones/allocations", ApiVersion.V1)
@@ -213,7 +223,7 @@ def get(self):
self.finish({"allocations": allocations})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -231,14 +241,14 @@ async def patch(self):
mic = session.get(Microphone, microphone_id)
if not mic:
self.set_status(404)
- await self.finish({"message": "404 microphone not found"})
+ await self.finish({"message": ERROR_MICROPHONE_NOT_FOUND})
return
for scene_id in data[microphone_id]:
scene = session.get(Scene, scene_id)
if not scene:
self.set_status(404)
- await self.finish({"message": "404 scene not found"})
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
return
existing_allocation: MicrophoneAllocation = session.scalars(
@@ -254,7 +264,7 @@ async def patch(self):
if not character:
self.set_status(404)
await self.finish(
- {"message": "404 character not found"}
+ {"message": ERROR_CHARACTER_NOT_FOUND}
)
return
@@ -275,7 +285,7 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_MIC_ALLOCATIONS", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/microphones/suggest", ApiVersion.V1)
@@ -488,9 +498,11 @@ async def post(self):
]
# Sort by scene position (chronological order)
character_scenes.sort(
- key=lambda x: scene_metadata[x[0]].position
- if x[0] in scene_metadata
- else 0
+ key=lambda x: (
+ scene_metadata[x[0]].position
+ if x[0] in scene_metadata
+ else 0
+ )
)
# Assign mic for each scene
@@ -579,4 +591,4 @@ async def post(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/scenes.py b/server/controllers/api/show/scenes.py
index cf392ef7..2892354f 100644
--- a/server/controllers/api/show/scenes.py
+++ b/server/controllers/api/show/scenes.py
@@ -3,6 +3,14 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_ACT_ID_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_SCENE_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
from models.show import Scene, Show
from rbac.role import Role
from schemas.schemas import SceneSchema
@@ -30,7 +38,7 @@ def get(self):
self.finish({"scenes": scenes})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -47,13 +55,13 @@ async def post(self):
act_id: int = data.get("act_id", None)
if not act_id:
self.set_status(400)
- await self.finish({"message": "Act ID missing"})
+ await self.finish({"message": ERROR_ACT_ID_MISSING})
return
name: str = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
previous_scene_id = data.get("previous_scene_id", None)
@@ -97,7 +105,7 @@ async def post(self):
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -113,14 +121,14 @@ async def delete(self):
scene_id_str = self.get_argument("id", None)
if not scene_id_str:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
try:
scene_id = int(scene_id_str)
except ValueError:
self.set_status(400)
- await self.finish({"message": "Invalid ID"})
+ await self.finish({"message": ERROR_INVALID_ID})
return
entry: Scene = session.get(Scene, scene_id)
@@ -144,10 +152,10 @@ async def delete(self):
await self.application.ws_send_to_all("NOOP", "GET_SCENE_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 scene not found"})
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -164,7 +172,7 @@ async def patch(self):
scene_id = data.get("scene_id", None)
if not scene_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: Scene = session.get(Scene, scene_id)
@@ -172,13 +180,13 @@ async def patch(self):
act_id: int = data.get("act_id", None)
if not act_id:
self.set_status(400)
- await self.finish({"message": "Act ID missing"})
+ await self.finish({"message": ERROR_ACT_ID_MISSING})
return
name: str = data.get("name", None)
if not name:
self.set_status(400)
- await self.finish({"message": "Name missing"})
+ await self.finish({"message": ERROR_NAME_MISSING})
return
previous_scene_id = data.get("previous_scene_id", None)
@@ -235,7 +243,7 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_SCENE_LIST", {})
else:
self.set_status(404)
- await self.finish({"message": "404 scene not found"})
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/script/compiled.py b/server/controllers/api/show/script/compiled.py
index 56e9ba3f..a9248e1f 100644
--- a/server/controllers/api/show/script/compiled.py
+++ b/server/controllers/api/show/script/compiled.py
@@ -4,6 +4,10 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_SCRIPT_REVISION_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
from digi_server.logger import get_logger
from models.script import CompiledScript, Script, ScriptRevision
from models.show import Show
@@ -26,7 +30,7 @@ def get(self):
show = session.get(Show, show_id)
if not show:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
stmt = (
@@ -53,7 +57,7 @@ async def post(self):
show = session.get(Show, show_id)
if not show:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
self.requires_role(show, Role.WRITE)
@@ -67,7 +71,7 @@ async def post(self):
revision = session.get(ScriptRevision, revision_id)
if not revision or revision.script.show_id != show_id:
self.set_status(404)
- await self.finish({"message": "404 script revision not found"})
+ await self.finish({"message": ERROR_SCRIPT_REVISION_NOT_FOUND})
return
await CompiledScript.compile_script(self.application, revision.id)
@@ -83,7 +87,7 @@ async def delete(self):
show = session.get(Show, show_id)
if not show:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
self.requires_role(show, Role.WRITE)
@@ -101,7 +105,7 @@ async def delete(self):
revision = session.get(ScriptRevision, revision_id)
if not revision or revision.script.show_id != show_id:
self.set_status(404)
- await self.finish({"message": "404 script revision not found"})
+ await self.finish({"message": ERROR_SCRIPT_REVISION_NOT_FOUND})
return
compiled_script: Optional[CompiledScript] = session.scalars(
diff --git a/server/controllers/api/show/script/revisions.py b/server/controllers/api/show/script/revisions.py
index b6a4c6f4..639944fc 100644
--- a/server/controllers/api/show/script/revisions.py
+++ b/server/controllers/api/show/script/revisions.py
@@ -5,6 +5,12 @@
from tornado import escape
from tornado.web import MissingArgumentError
+from controllers.api.constants import (
+ ERROR_DESCRIPTION_MISSING,
+ ERROR_SCRIPT_NOT_FOUND,
+ ERROR_SCRIPT_REVISION_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
from digi_server.logger import get_logger
from models.cue import CueAssociation
from models.script import (
@@ -48,10 +54,10 @@ def get(self):
)
else:
self.set_status(404)
- self.finish({"message": "404 script not found"})
+ self.finish({"message": ERROR_SCRIPT_NOT_FOUND})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -69,7 +75,7 @@ async def post(self):
).first()
if not script:
self.set_status(404)
- await self.finish({"message": "404 script not found"})
+ await self.finish({"message": ERROR_SCRIPT_NOT_FOUND})
return
self.requires_role(script, Role.WRITE)
@@ -80,7 +86,7 @@ async def post(self):
if not parent_rev_id:
self.set_status(404)
- await self.finish({"message": "404 script revision not found"})
+ await self.finish({"message": ERROR_SCRIPT_REVISION_NOT_FOUND})
return
# Get set_as_current flag (defaults to True for backward compatibility)
@@ -110,7 +116,7 @@ async def post(self):
description: str = data.get("description", None)
if not description:
self.set_status(400)
- await self.finish({"message": "Description missing"})
+ await self.finish({"message": ERROR_DESCRIPTION_MISSING})
return
now_time = datetime.now(UTC)
@@ -169,7 +175,7 @@ async def post(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -185,7 +191,7 @@ async def delete(self):
).first()
if not script:
self.set_status(404)
- await self.finish({"message": "404 script not found"})
+ await self.finish({"message": ERROR_SCRIPT_NOT_FOUND})
return
self.requires_role(script, Role.WRITE)
@@ -265,7 +271,7 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/script/revisions/current", ApiVersion.V1)
@@ -291,10 +297,10 @@ def get(self):
)
else:
self.set_status(404)
- self.finish({"message": "404 script not found"})
+ self.finish({"message": ERROR_SCRIPT_NOT_FOUND})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -313,7 +319,7 @@ async def post(self):
).first()
if not script:
self.set_status(404)
- await self.finish({"message": "404 script not found"})
+ await self.finish({"message": ERROR_SCRIPT_NOT_FOUND})
return
new_rev_id: int = data.get("new_rev_id", None)
@@ -358,4 +364,4 @@ async def post(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/script/script.py b/server/controllers/api/show/script/script.py
index 4016e1b8..5dc074ac 100644
--- a/server/controllers/api/show/script/script.py
+++ b/server/controllers/api/show/script/script.py
@@ -6,6 +6,7 @@
from tornado import escape
from tornado.ioloop import IOLoop
+from controllers.api.constants import ERROR_SHOW_NOT_FOUND
from models.cue import CueAssociation
from models.script import (
CompiledScript,
@@ -98,7 +99,7 @@ def get(self):
self.finish({"lines": lines, "page": page})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@staticmethod
@@ -270,7 +271,7 @@ async def post(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@staticmethod
@@ -671,7 +672,7 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@@ -707,7 +708,7 @@ def get(self):
self.finish(compiled_script)
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@@ -743,7 +744,7 @@ def get(self):
self.finish({"cuts": line_cuts})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@requires_show
@@ -845,5 +846,5 @@ def get(self):
self.finish({"max_page": max_page})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
diff --git a/server/controllers/api/show/script/stage_direction_styles.py b/server/controllers/api/show/script/stage_direction_styles.py
index c3398fb4..0d9f87f0 100644
--- a/server/controllers/api/show/script/stage_direction_styles.py
+++ b/server/controllers/api/show/script/stage_direction_styles.py
@@ -1,6 +1,15 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_BACKGROUND_COLOUR_MISSING,
+ ERROR_DESCRIPTION_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+ ERROR_STAGE_DIRECTION_STYLE_NOT_FOUND,
+ ERROR_TEXT_COLOUR_MISSING,
+ ERROR_TEXT_FORMAT_INVALID,
+)
from models.script import Script, StageDirectionStyle
from models.show import Show
from rbac.role import Role
@@ -10,6 +19,40 @@
from utils.web.web_decorators import no_live_session, requires_show
+VALID_TEXT_FORMATS = ("default", "upper", "lower")
+
+
+def validate_style_fields(data):
+ """
+ Validate stage direction style fields from request data.
+
+ :param data: Request data dictionary
+ :returns: Tuple of (error_message, validated_fields) - error_message is None if valid
+ """
+ text_format = data.get("textFormat", None)
+ if not text_format or text_format not in VALID_TEXT_FORMATS:
+ return ERROR_TEXT_FORMAT_INVALID, None
+
+ text_colour = data.get("textColour", None)
+ if not text_colour:
+ return ERROR_TEXT_COLOUR_MISSING, None
+
+ enable_background_colour = data.get("enableBackgroundColour", False)
+ background_colour = data.get("backgroundColour", None)
+ if enable_background_colour and not background_colour:
+ return ERROR_BACKGROUND_COLOUR_MISSING, None
+
+ return None, {
+ "bold": data.get("bold", False),
+ "italic": data.get("italic", False),
+ "underline": data.get("underline", False),
+ "text_format": text_format,
+ "text_colour": text_colour,
+ "enable_background_colour": enable_background_colour,
+ "background_colour": background_colour,
+ }
+
+
@ApiRoute("/show/script/stage_direction_styles", ApiVersion.V1)
class StageDirectionStylesController(BaseAPIController):
@requires_show
@@ -33,7 +76,7 @@ def get(self):
self.finish({"styles": stage_direction_styles})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
@requires_show
@@ -54,44 +97,19 @@ async def post(self):
description: str = data.get("description", None)
if not description:
self.set_status(400)
- await self.finish({"message": "Description missing"})
- return
-
- bold: bool = data.get("bold", False)
- italic: bool = data.get("italic", False)
- underline: bool = data.get("underline", False)
-
- text_format: str = data.get("textFormat", None)
- if not text_format or text_format not in ["default", "upper", "lower"]:
- self.set_status(400)
- await self.finish({"message": "Text format missing or invalid"})
+ await self.finish({"message": ERROR_DESCRIPTION_MISSING})
return
- text_colour: str = data.get("textColour", None)
- if not text_colour:
+ error, fields = validate_style_fields(data)
+ if error:
self.set_status(400)
- await self.finish({"message": "Text colour missing"})
- return
-
- enable_background_colour: bool = data.get(
- "enableBackgroundColour", False
- )
- background_colour: str = data.get("backgroundColour", None)
- if enable_background_colour and not background_colour:
- self.set_status(400)
- await self.finish({"message": "Background colour missing"})
+ await self.finish({"message": error})
return
new_style = StageDirectionStyle(
script_id=script.id,
description=description,
- bold=bold,
- italic=italic,
- underline=underline,
- text_format=text_format,
- text_colour=text_colour,
- enable_background_colour=enable_background_colour,
- background_colour=background_colour,
+ **fields,
)
session.add(new_style)
session.commit()
@@ -109,7 +127,7 @@ async def post(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -129,56 +147,32 @@ async def patch(self):
style_id = data.get("id", None)
if not style_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
style: StageDirectionStyle = session.get(StageDirectionStyle, style_id)
if not style:
self.set_status(404)
await self.finish(
- {"message": "404 stage direction style not found"}
+ {"message": ERROR_STAGE_DIRECTION_STYLE_NOT_FOUND}
)
return
description: str = data.get("description", None)
if not description:
self.set_status(400)
- await self.finish({"message": "Description missing"})
- return
-
- bold: bool = data.get("bold", False)
- italic: bool = data.get("italic", False)
- underline: bool = data.get("underline", False)
-
- text_format: str = data.get("textFormat", None)
- if not text_format or text_format not in ["default", "upper", "lower"]:
- self.set_status(400)
- await self.finish({"message": "Text format missing or invalid"})
- return
-
- text_colour: str = data.get("textColour", None)
- if not text_colour:
- self.set_status(400)
- await self.finish({"message": "Text colour missing"})
+ await self.finish({"message": ERROR_DESCRIPTION_MISSING})
return
- enable_background_colour: bool = data.get(
- "enableBackgroundColour", False
- )
- background_colour: str = data.get("backgroundColour", None)
- if enable_background_colour and not background_colour:
+ error, fields = validate_style_fields(data)
+ if error:
self.set_status(400)
- await self.finish({"message": "Background colour missing"})
+ await self.finish({"message": error})
return
style.description = description
- style.bold = bold
- style.italic = italic
- style.underline = underline
- style.text_format = text_format
- style.text_colour = text_colour
- style.enable_background_colour = enable_background_colour
- style.background_colour = background_colour
+ for key, value in fields.items():
+ setattr(style, key, value)
session.commit()
self.set_status(200)
@@ -191,7 +185,7 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -211,7 +205,7 @@ async def delete(self):
style_id = data.get("id", None)
if not style_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
entry: StageDirectionStyle = session.get(StageDirectionStyle, style_id)
@@ -230,8 +224,8 @@ async def delete(self):
else:
self.set_status(404)
await self.finish(
- {"message": "404 stage direction style not found"}
+ {"message": ERROR_STAGE_DIRECTION_STYLE_NOT_FOUND}
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/session/assign_tags.py b/server/controllers/api/show/session/assign_tags.py
index ee8770fc..a9655881 100644
--- a/server/controllers/api/show/session/assign_tags.py
+++ b/server/controllers/api/show/session/assign_tags.py
@@ -1,6 +1,7 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import ERROR_SHOW_NOT_FOUND
from models.session import SessionTag, ShowSession
from models.show import Show
from rbac.role import Role
@@ -34,7 +35,7 @@ async def patch(self):
show = session.get(Show, show_id)
if not show:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
return
self.requires_role(show, Role.WRITE)
diff --git a/server/controllers/api/show/session/sessions.py b/server/controllers/api/show/session/sessions.py
index 5d1afa91..6eafa4db 100644
--- a/server/controllers/api/show/session/sessions.py
+++ b/server/controllers/api/show/session/sessions.py
@@ -4,6 +4,7 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import ERROR_SHOW_NOT_FOUND
from models.script import Script
from models.session import Interval, Session, ShowSession
from models.show import Show
@@ -54,7 +55,7 @@ def get(self):
)
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/sessions/start", ApiVersion.V1)
@@ -121,7 +122,7 @@ async def post(self):
await self.application.ws_send_to_all("START_SHOW", "NOOP", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/sessions/stop", ApiVersion.V1)
@@ -155,4 +156,4 @@ async def post(self):
await self.application.ws_send_to_all("STOP_SHOW", "NOOP", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/session/tags.py b/server/controllers/api/show/session/tags.py
index 788e53a1..1e4672b5 100644
--- a/server/controllers/api/show/session/tags.py
+++ b/server/controllers/api/show/session/tags.py
@@ -1,6 +1,14 @@
from sqlalchemy import func, select
from tornado import escape
+from controllers.api.constants import (
+ ERROR_COLOUR_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+ ERROR_TAG_NAME_EXISTS,
+ ERROR_TAG_NAME_MISSING,
+ ERROR_TAG_NOT_FOUND,
+)
from models.session import SessionTag
from models.show import Show
from rbac.role import Role
@@ -30,7 +38,7 @@ def get(self):
self.finish({"tags": tags})
else:
self.set_status(404)
- self.finish({"message": "404 show not found"})
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -49,14 +57,14 @@ async def post(self):
tag = data.get("tag", None)
if not tag:
self.set_status(400)
- await self.finish({"message": "Tag name missing"})
+ await self.finish({"message": ERROR_TAG_NAME_MISSING})
return
# Validate colour
colour = data.get("colour", None)
if not colour:
self.set_status(400)
- await self.finish({"message": "Colour missing"})
+ await self.finish({"message": ERROR_COLOUR_MISSING})
return
# Check case-insensitive uniqueness
@@ -68,9 +76,7 @@ async def post(self):
).first()
if existing_tag:
self.set_status(400)
- await self.finish(
- {"message": "Tag name already exists (case-insensitive)"}
- )
+ await self.finish({"message": ERROR_TAG_NAME_EXISTS})
return
# Create new tag
@@ -86,7 +92,7 @@ async def post(self):
await self.application.ws_send_to_all("NOOP", "GET_SESSION_TAGS", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -105,28 +111,28 @@ async def patch(self):
tag_id = data.get("id", None)
if not tag_id:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
# Fetch existing tag
tag_obj = session.get(SessionTag, tag_id)
if not tag_obj:
self.set_status(404)
- await self.finish({"message": "404 tag not found"})
+ await self.finish({"message": ERROR_TAG_NOT_FOUND})
return
# Validate tag name
tag = data.get("tag", None)
if not tag:
self.set_status(400)
- await self.finish({"message": "Tag name missing"})
+ await self.finish({"message": ERROR_TAG_NAME_MISSING})
return
# Validate colour
colour = data.get("colour", None)
if not colour:
self.set_status(400)
- await self.finish({"message": "Colour missing"})
+ await self.finish({"message": ERROR_COLOUR_MISSING})
return
# Check case-insensitive uniqueness (excluding self)
@@ -139,9 +145,7 @@ async def patch(self):
).first()
if other_tag:
self.set_status(400)
- await self.finish(
- {"message": "Tag name already exists (case-insensitive)"}
- )
+ await self.finish({"message": ERROR_TAG_NAME_EXISTS})
return
# Update tag
@@ -158,7 +162,7 @@ async def patch(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
@no_live_session
@@ -177,7 +181,7 @@ async def delete(self):
tag_id: int = int(self.get_query_argument("id"))
except Exception:
self.set_status(400)
- await self.finish({"message": "ID missing"})
+ await self.finish({"message": ERROR_ID_MISSING})
return
# Fetch tag and delete
@@ -197,7 +201,7 @@ async def delete(self):
)
else:
self.set_status(404)
- await self.finish({"message": "404 tag not found"})
+ await self.finish({"message": ERROR_TAG_NOT_FOUND})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/shows.py b/server/controllers/api/show/shows.py
index ebd2a029..4eb91997 100644
--- a/server/controllers/api/show/shows.py
+++ b/server/controllers/api/show/shows.py
@@ -4,6 +4,7 @@
from sqlalchemy import select
from tornado import escape
+from controllers.api.constants import ERROR_SHOW_NOT_FOUND
from digi_server.logger import get_logger
from models.script import Script, ScriptRevision
from models.show import Show, ShowScriptType
@@ -143,7 +144,7 @@ def get(self):
self.write(show)
else:
self.set_status(404)
- self.write({"message": "404 show not found"})
+ self.write({"message": ERROR_SHOW_NOT_FOUND})
@requires_show
async def patch(self):
@@ -217,7 +218,7 @@ async def patch(self):
await self.application.ws_send_to_all("NOOP", "GET_SHOW_DETAILS", {})
else:
self.set_status(404)
- await self.finish({"message": "404 show not found"})
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
@ApiRoute("show/script_modes", ApiVersion.V1)
diff --git a/server/controllers/api/show/stage/__init__.py b/server/controllers/api/show/stage/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/controllers/api/show/stage/crew.py b/server/controllers/api/show/stage/crew.py
new file mode 100644
index 00000000..22ef9059
--- /dev/null
+++ b/server/controllers/api/show/stage/crew.py
@@ -0,0 +1,171 @@
+from tornado import escape
+
+from controllers.api.constants import (
+ ERROR_CAST_MEMBER_NOT_FOUND,
+ ERROR_CREW_NOT_FOUND,
+ ERROR_FIRST_NAME_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_LAST_NAME_MISSING,
+ ERROR_SHOW_NOT_FOUND,
+)
+from models.show import Show
+from models.stage import Crew
+from rbac.role import Role
+from schemas.schemas import CrewSchema
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import no_live_session, requires_show
+
+
+@ApiRoute("show/stage/crew", ApiVersion.V1)
+class CrewController(BaseAPIController):
+ @requires_show
+ def get(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ crew_schema = CrewSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ crew = [crew_schema.dump(c) for c in show.crew_list]
+ self.set_status(200)
+ self.finish({"crew": crew})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ first_name = data.get("firstName", None)
+ if not first_name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_FIRST_NAME_MISSING})
+ return
+
+ last_name = data.get("lastName", None)
+ if not last_name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_LAST_NAME_MISSING})
+ return
+
+ new_crew = Crew(
+ show_id=show.id, first_name=first_name, last_name=last_name
+ )
+ session.add(new_crew)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {"id": new_crew.id, "message": "Successfully added crew member"}
+ )
+
+ await self.application.ws_send_to_all(
+ "GET_CREW_LIST", "GET_CREW_LIST", {}
+ )
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ crew_id = data.get("id", None)
+ if not crew_id:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ entry: Crew = session.get(Crew, crew_id)
+ if entry:
+ first_name = data.get("firstName", None)
+ if not first_name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_FIRST_NAME_MISSING})
+ return
+ entry.first_name = first_name
+
+ last_name = data.get("lastName", None)
+ if not last_name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_LAST_NAME_MISSING})
+ return
+ entry.last_name = last_name
+
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully updated crew member"})
+
+ await self.application.ws_send_to_all(
+ "GET_CREW_LIST", "GET_CREW_LIST", {}
+ )
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND})
+ return
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+
+ crew_id_str = self.get_argument("id", None)
+ if not crew_id_str:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ crew_id = int(crew_id_str)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ entry = session.get(Crew, crew_id)
+ if entry:
+ session.delete(entry)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully deleted crew member"})
+
+ await self.application.ws_send_to_all(
+ "GET_CREW_LIST", "GET_CREW_LIST", {}
+ )
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_NOT_FOUND})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
diff --git a/server/controllers/api/show/stage/crew_assignments.py b/server/controllers/api/show/stage/crew_assignments.py
new file mode 100644
index 00000000..c6d83af8
--- /dev/null
+++ b/server/controllers/api/show/stage/crew_assignments.py
@@ -0,0 +1,474 @@
+"""
+API controller for crew assignments to props/scenery items.
+
+Crew assignments track which crew members are responsible for setting (bringing on stage)
+or striking (removing from stage) props and scenery items at specific scene boundaries.
+"""
+
+from sqlalchemy import select
+from tornado import escape
+
+from controllers.api.constants import (
+ ERROR_ASSIGNMENT_TYPE_INVALID,
+ ERROR_ASSIGNMENT_TYPE_MISSING,
+ ERROR_CREW_ASSIGNMENT_EXISTS,
+ ERROR_CREW_ASSIGNMENT_NOT_FOUND,
+ ERROR_CREW_ID_MISSING,
+ ERROR_CREW_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_BOUNDARY,
+ ERROR_INVALID_ID,
+ ERROR_ITEM_ID_BOTH,
+ ERROR_ITEM_ID_MISSING,
+ ERROR_PROP_NOT_FOUND,
+ ERROR_SCENE_ID_MISSING,
+ ERROR_SCENE_NOT_FOUND,
+ ERROR_SCENERY_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
+from models.show import Scene, Show
+from models.stage import Crew, CrewAssignment, Props, Scenery
+from rbac.role import Role
+from schemas.schemas import CrewAssignmentSchema
+from utils.show.block_computation import is_valid_boundary
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import no_live_session, requires_show
+
+
+@ApiRoute("show/stage/crew/assignments", ApiVersion.V1)
+class CrewAssignmentController(BaseAPIController):
+ """Controller for crew assignment CRUD operations."""
+
+ @requires_show
+ def get(self):
+ """Get all crew assignments for the current show."""
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ schema = CrewAssignmentSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ # Get all assignments for crew members in this show
+ assignments = session.scalars(
+ select(CrewAssignment).join(Crew).where(Crew.show_id == show_id)
+ ).all()
+
+ result = [schema.dump(a) for a in assignments]
+ self.set_status(200)
+ self.finish({"assignments": result})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ """
+ Create a new crew assignment.
+
+ Required body fields:
+ - crew_id: ID of the crew member
+ - scene_id: ID of the scene (must be a valid block boundary)
+ - assignment_type: 'set' or 'strike'
+ - prop_id OR scenery_id: ID of the item (exactly one required)
+ """
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ # Validate crew_id
+ crew_id = data.get("crew_id")
+ if crew_id is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_CREW_ID_MISSING})
+ return
+
+ try:
+ crew_id = int(crew_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ crew = session.get(Crew, crew_id)
+ if not crew or crew.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_NOT_FOUND})
+ return
+
+ # Validate scene_id
+ scene_id = data.get("scene_id")
+ if scene_id is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_SCENE_ID_MISSING})
+ return
+
+ try:
+ scene_id = int(scene_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ scene = session.get(Scene, scene_id)
+ if not scene or scene.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
+ return
+
+ # Validate assignment_type
+ assignment_type = data.get("assignment_type")
+ if not assignment_type:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ASSIGNMENT_TYPE_MISSING})
+ return
+
+ if assignment_type not in ("set", "strike"):
+ self.set_status(400)
+ await self.finish({"message": ERROR_ASSIGNMENT_TYPE_INVALID})
+ return
+
+ # Validate prop_id/scenery_id (exactly one must be provided)
+ prop_id = data.get("prop_id")
+ scenery_id = data.get("scenery_id")
+
+ if prop_id is None and scenery_id is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ITEM_ID_MISSING})
+ return
+
+ if prop_id is not None and scenery_id is not None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ITEM_ID_BOTH})
+ return
+
+ # Validate the item exists and belongs to the show
+ if prop_id is not None:
+ try:
+ prop_id = int(prop_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ prop = session.get(Props, prop_id)
+ if not prop or prop.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_PROP_NOT_FOUND})
+ return
+ scenery_id = None
+ else:
+ try:
+ scenery_id = int(scenery_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ scenery = session.get(Scenery, scenery_id)
+ if not scenery or scenery.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENERY_NOT_FOUND})
+ return
+ prop_id = None
+
+ # Validate that the scene is a valid boundary for this assignment
+ if not is_valid_boundary(
+ session, scene_id, assignment_type, prop_id, scenery_id, show
+ ):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_BOUNDARY})
+ return
+
+ # Check for duplicate assignment
+ existing_query = select(CrewAssignment).where(
+ CrewAssignment.crew_id == crew_id,
+ CrewAssignment.scene_id == scene_id,
+ CrewAssignment.assignment_type == assignment_type,
+ )
+ if prop_id is not None:
+ existing_query = existing_query.where(CrewAssignment.prop_id == prop_id)
+ else:
+ existing_query = existing_query.where(
+ CrewAssignment.scenery_id == scenery_id
+ )
+
+ existing = session.scalars(existing_query).first()
+ if existing:
+ self.set_status(400)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_EXISTS})
+ return
+
+ # Create the assignment
+ assignment = CrewAssignment(
+ crew_id=crew_id,
+ scene_id=scene_id,
+ assignment_type=assignment_type,
+ prop_id=prop_id,
+ scenery_id=scenery_id,
+ )
+ session.add(assignment)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {"id": assignment.id, "message": "Successfully created crew assignment"}
+ )
+
+ await self.application.ws_send_to_all("NOOP", "GET_CREW_ASSIGNMENTS", {})
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ """
+ Update an existing crew assignment.
+
+ Required body fields:
+ - id: ID of the assignment to update
+
+ Optional body fields (provide any to update):
+ - crew_id: New crew member ID
+ - scene_id: New scene ID (must be valid boundary for item/type)
+ - assignment_type: New assignment type ('set' or 'strike')
+ - prop_id: New prop ID (clears scenery_id)
+ - scenery_id: New scenery ID (clears prop_id)
+ """
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ # Get the assignment to update
+ assignment_id = data.get("id")
+ if assignment_id is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ assignment_id = int(assignment_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ assignment = session.get(CrewAssignment, assignment_id)
+ if not assignment:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_NOT_FOUND})
+ return
+
+ # Verify the assignment belongs to this show
+ crew = session.get(Crew, assignment.crew_id)
+ if not crew or crew.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_NOT_FOUND})
+ return
+
+ # Collect the new values (use existing if not provided)
+ new_crew_id = assignment.crew_id
+ new_scene_id = assignment.scene_id
+ new_assignment_type = assignment.assignment_type
+ new_prop_id = assignment.prop_id
+ new_scenery_id = assignment.scenery_id
+
+ # Update crew_id if provided
+ if "crew_id" in data:
+ try:
+ new_crew_id = int(data["crew_id"])
+ except (ValueError, TypeError):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ new_crew = session.get(Crew, new_crew_id)
+ if not new_crew or new_crew.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_NOT_FOUND})
+ return
+
+ # Update scene_id if provided
+ if "scene_id" in data:
+ try:
+ new_scene_id = int(data["scene_id"])
+ except (ValueError, TypeError):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ new_scene = session.get(Scene, new_scene_id)
+ if not new_scene or new_scene.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENE_NOT_FOUND})
+ return
+
+ # Update assignment_type if provided
+ if "assignment_type" in data:
+ new_assignment_type = data["assignment_type"]
+ if new_assignment_type not in ("set", "strike"):
+ self.set_status(400)
+ await self.finish({"message": ERROR_ASSIGNMENT_TYPE_INVALID})
+ return
+
+ # Update prop_id/scenery_id if provided (XOR logic)
+ if "prop_id" in data or "scenery_id" in data:
+ new_prop_id = data.get("prop_id")
+ new_scenery_id = data.get("scenery_id")
+
+ if new_prop_id is None and new_scenery_id is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ITEM_ID_MISSING})
+ return
+
+ if new_prop_id is not None and new_scenery_id is not None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ITEM_ID_BOTH})
+ return
+
+ if new_prop_id is not None:
+ try:
+ new_prop_id = int(new_prop_id)
+ except (ValueError, TypeError):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ prop = session.get(Props, new_prop_id)
+ if not prop or prop.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_PROP_NOT_FOUND})
+ return
+ new_scenery_id = None
+ else:
+ try:
+ new_scenery_id = int(new_scenery_id)
+ except (ValueError, TypeError):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ scenery = session.get(Scenery, new_scenery_id)
+ if not scenery or scenery.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENERY_NOT_FOUND})
+ return
+ new_prop_id = None
+
+ # Validate the new combination is a valid boundary
+ if not is_valid_boundary(
+ session,
+ new_scene_id,
+ new_assignment_type,
+ new_prop_id,
+ new_scenery_id,
+ show,
+ ):
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_BOUNDARY})
+ return
+
+ # Check for duplicate assignment (excluding self)
+ existing_query = select(CrewAssignment).where(
+ CrewAssignment.id != assignment_id,
+ CrewAssignment.crew_id == new_crew_id,
+ CrewAssignment.scene_id == new_scene_id,
+ CrewAssignment.assignment_type == new_assignment_type,
+ )
+ if new_prop_id is not None:
+ existing_query = existing_query.where(
+ CrewAssignment.prop_id == new_prop_id
+ )
+ else:
+ existing_query = existing_query.where(
+ CrewAssignment.scenery_id == new_scenery_id
+ )
+
+ existing = session.scalars(existing_query).first()
+ if existing:
+ self.set_status(400)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_EXISTS})
+ return
+
+ # Apply updates
+ assignment.crew_id = new_crew_id
+ assignment.scene_id = new_scene_id
+ assignment.assignment_type = new_assignment_type
+ assignment.prop_id = new_prop_id
+ assignment.scenery_id = new_scenery_id
+
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully updated crew assignment"})
+
+ await self.application.ws_send_to_all("NOOP", "GET_CREW_ASSIGNMENTS", {})
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ """Delete a crew assignment by ID (query parameter)."""
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ self.requires_role(show, Role.WRITE)
+
+ assignment_id_str = self.get_argument("id", None)
+ if not assignment_id_str:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ assignment_id = int(assignment_id_str)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ assignment = session.get(CrewAssignment, assignment_id)
+ if not assignment:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_NOT_FOUND})
+ return
+
+ # Verify the assignment belongs to a crew member in this show
+ crew = session.get(Crew, assignment.crew_id)
+ if not crew or crew.show_id != show_id:
+ self.set_status(404)
+ await self.finish({"message": ERROR_CREW_ASSIGNMENT_NOT_FOUND})
+ return
+
+ session.delete(assignment)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully deleted crew assignment"})
+
+ await self.application.ws_send_to_all("NOOP", "GET_CREW_ASSIGNMENTS", {})
diff --git a/server/controllers/api/show/stage/helpers.py b/server/controllers/api/show/stage/helpers.py
new file mode 100644
index 00000000..8bb2b6be
--- /dev/null
+++ b/server/controllers/api/show/stage/helpers.py
@@ -0,0 +1,448 @@
+"""
+Shared helper functions for stage item controllers (props, scenery).
+
+These helpers reduce code duplication by extracting common validation
+and CRUD patterns used across Props and Scenery controllers.
+"""
+
+from sqlalchemy import select
+from tornado import escape
+
+from controllers.api.constants import (
+ ERROR_ALLOCATION_NOT_FOUND,
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_SCENE_ID_MISSING,
+ ERROR_SCENE_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
+from models.show import Scene, Show
+from models.stage import Props, Scenery
+from rbac.role import Role
+from utils.show.block_computation import (
+ delete_orphaned_assignments_for_prop,
+ delete_orphaned_assignments_for_scenery,
+)
+
+
+async def handle_type_post(controller, type_model, ws_action, success_message):
+ """
+ Handle POST request for creating stage item types (PropType/SceneryType).
+
+ :param controller: The controller instance
+ :param type_model: SQLAlchemy model class for the type
+ :param ws_action: WebSocket action to send on success
+ :param success_message: Success message to return
+ """
+ current_show = controller.get_current_show()
+ show_id = current_show["id"]
+
+ with controller.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+ controller.requires_role(show, Role.WRITE)
+ data = escape.json_decode(controller.request.body)
+
+ name = data.get("name", None)
+ if not name:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_NAME_MISSING})
+ return
+
+ description = data.get("description", "")
+
+ new_type = type_model(show_id=show.id, name=name, description=description)
+ session.add(new_type)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"id": new_type.id, "message": success_message})
+
+ await controller.application.ws_send_to_all("NOOP", ws_action, {})
+
+
+async def handle_type_patch(
+ controller, type_model, ws_action, success_message, not_found_message
+):
+ """
+ Handle PATCH request for updating stage item types.
+
+ :param controller: The controller instance
+ :param type_model: SQLAlchemy model class for the type
+ :param ws_action: WebSocket action to send on success
+ :param success_message: Success message to return
+ :param not_found_message: Not found error message
+ """
+ current_show = controller.get_current_show()
+ show_id = current_show["id"]
+
+ with controller.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+ controller.requires_role(show, Role.WRITE)
+ data = escape.json_decode(controller.request.body)
+
+ type_id = data.get("id", None)
+ if not type_id:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_ID_MISSING})
+ return
+
+ entry = session.get(type_model, type_id)
+ if not entry:
+ controller.set_status(404)
+ await controller.finish({"message": not_found_message})
+ return
+
+ name = data.get("name", None)
+ description = data.get("description", "")
+ if not name:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_NAME_MISSING})
+ return
+
+ entry.name = name
+ entry.description = description
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"message": success_message})
+
+ await controller.application.ws_send_to_all("NOOP", ws_action, {})
+
+
+async def handle_type_delete(
+ controller,
+ type_model,
+ ws_actions,
+ success_message,
+ not_found_message,
+):
+ """
+ Handle DELETE request for removing stage item types.
+
+ :param controller: The controller instance
+ :param type_model: SQLAlchemy model class for the type
+ :param ws_actions: List of WebSocket actions to send on success
+ :param success_message: Success message to return
+ :param not_found_message: Not found error message
+ """
+ current_show = controller.get_current_show()
+ show_id = current_show["id"]
+
+ with controller.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+ controller.requires_role(show, Role.WRITE)
+
+ type_id_str = controller.get_argument("id", None)
+ if not type_id_str:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ type_id = int(type_id_str)
+ except ValueError:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_INVALID_ID})
+ return
+
+ entry = session.get(type_model, type_id)
+ if not entry:
+ controller.set_status(404)
+ await controller.finish({"message": not_found_message})
+ return
+
+ session.delete(entry)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"message": success_message})
+
+ for ws_action in ws_actions:
+ await controller.application.ws_send_to_all("NOOP", ws_action, {})
+
+
+async def validate_type_id(
+ controller, data, type_model, type_id_key, error_missing, show
+):
+ """
+ Validate a type ID from request data.
+
+ :param controller: The controller instance
+ :param data: Request data dictionary
+ :param type_model: SQLAlchemy model class for the type (PropType/SceneryType)
+ :param type_id_key: Key to look up in data (e.g., 'prop_type_id')
+ :param error_missing: Error message constant for missing type ID
+ :param show: The current show object
+ :returns: Tuple of (type_instance, error_occurred). If error_occurred is True,
+ the response has already been sent.
+ """
+ type_id = data.get(type_id_key, None)
+ if not type_id:
+ controller.set_status(400)
+ await controller.finish({"message": error_missing})
+ return None, True
+
+ try:
+ type_id = int(type_id)
+ except ValueError:
+ controller.set_status(400)
+ await controller.finish({"message": f"Invalid {type_id_key}"})
+ return None, True
+
+ with controller.make_session() as session:
+ type_instance = session.get(type_model, type_id)
+ if not type_instance:
+ controller.set_status(404)
+ await controller.finish({"message": f"{type_model.__name__} not found"})
+ return None, True
+
+ if type_instance.show_id != show.id:
+ controller.set_status(400)
+ await controller.finish(
+ {"message": f"Invalid {type_model.__name__.lower()} for show"}
+ )
+ return None, True
+
+ return type_instance, False
+
+
+async def handle_allocation_post(
+ controller,
+ item_model,
+ item_id_key,
+ allocation_model,
+ allocation_item_fk,
+ ws_action,
+ error_item_id_missing,
+ error_item_not_found,
+ allocation_exists_message,
+):
+ """
+ Handle POST request for creating allocations (props or scenery to scenes).
+
+ :param controller: The controller instance
+ :param item_model: SQLAlchemy model class for the item (Props/Scenery)
+ :param item_id_key: Key in request data (e.g., 'props_id', 'scenery_id')
+ :param allocation_model: SQLAlchemy model class for allocation
+ :param allocation_item_fk: Name of FK column on allocation model (e.g., 'props_id')
+ :param ws_action: WebSocket action to send on success
+ :param error_item_id_missing: Error constant for missing item ID
+ :param error_item_not_found: Error constant for item not found
+ :param allocation_exists_message: Message for duplicate allocation error
+ """
+ current_show = controller.get_current_show()
+ show_id = current_show["id"]
+
+ with controller.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ controller.requires_role(show, Role.WRITE)
+ data = escape.json_decode(controller.request.body)
+
+ # Validate item_id
+ item_id = data.get(item_id_key, None)
+ if item_id is None:
+ controller.set_status(400)
+ await controller.finish({"message": error_item_id_missing})
+ return
+
+ try:
+ item_id = int(item_id)
+ except ValueError:
+ controller.set_status(400)
+ await controller.finish({"message": f"Invalid {item_id_key}"})
+ return
+
+ item = session.get(item_model, item_id)
+ if not item:
+ controller.set_status(404)
+ await controller.finish({"message": error_item_not_found})
+ return
+
+ if item.show_id != show_id:
+ controller.set_status(404)
+ await controller.finish({"message": error_item_not_found})
+ return
+
+ # Validate scene_id
+ scene_id = data.get("scene_id", None)
+ if scene_id is None:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_SCENE_ID_MISSING})
+ return
+
+ try:
+ scene_id = int(scene_id)
+ except ValueError:
+ controller.set_status(400)
+ await controller.finish({"message": "Invalid scene_id"})
+ return
+
+ scene: Scene = session.get(Scene, scene_id)
+ if not scene:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SCENE_NOT_FOUND})
+ return
+
+ if scene.show_id != show_id:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SCENE_NOT_FOUND})
+ return
+
+ # Check for duplicate allocation
+ item_fk_attr = getattr(allocation_model, allocation_item_fk)
+ existing = session.scalars(
+ select(allocation_model).where(
+ item_fk_attr == item_id,
+ allocation_model.scene_id == scene_id,
+ )
+ ).first()
+ if existing:
+ controller.set_status(400)
+ await controller.finish({"message": allocation_exists_message})
+ return
+
+ # Create allocation using keyword arguments
+ new_allocation = allocation_model(
+ **{allocation_item_fk: item_id, "scene_id": scene_id}
+ )
+ session.add(new_allocation)
+ # Flush to persist the allocation within the current transaction,
+ # making it available for block computation without committing yet
+ session.flush()
+
+ # Refresh the item to get updated relationships for orphan detection
+ session.refresh(item)
+
+ # Delete any crew assignments that are now orphaned due to block boundary changes
+ deleted_assignment_ids = []
+ if isinstance(item, Props):
+ deleted_assignment_ids = delete_orphaned_assignments_for_prop(
+ session, item, show
+ )
+ elif isinstance(item, Scenery):
+ deleted_assignment_ids = delete_orphaned_assignments_for_scenery(
+ session, item, show
+ )
+
+ # Commit the entire operation atomically (allocation + orphan deletions)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish(
+ {"id": new_allocation.id, "message": "Successfully added allocation"}
+ )
+
+ await controller.application.ws_send_to_all("NOOP", ws_action, {})
+
+ # Also notify about crew assignment changes if any were deleted
+ if deleted_assignment_ids:
+ await controller.application.ws_send_to_all(
+ "NOOP", "GET_CREW_ASSIGNMENTS", {}
+ )
+
+
+async def handle_allocation_delete(
+ controller,
+ item_model,
+ allocation_model,
+ allocation_item_fk,
+ ws_action,
+):
+ """
+ Handle DELETE request for removing allocations.
+
+ :param controller: The controller instance
+ :param item_model: SQLAlchemy model class for the item (Props/Scenery)
+ :param allocation_model: SQLAlchemy model class for allocation
+ :param allocation_item_fk: Name of FK column on allocation model
+ :param ws_action: WebSocket action to send on success
+ """
+ current_show = controller.get_current_show()
+ show_id = current_show["id"]
+
+ with controller.make_session() as session:
+ show = session.get(Show, show_id)
+ if not show:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_SHOW_NOT_FOUND})
+ return
+
+ controller.requires_role(show, Role.WRITE)
+
+ allocation_id_str = controller.get_argument("id", None)
+ if not allocation_id_str:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ allocation_id = int(allocation_id_str)
+ except ValueError:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_INVALID_ID})
+ return
+
+ allocation = session.get(allocation_model, allocation_id)
+ if not allocation:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_ALLOCATION_NOT_FOUND})
+ return
+
+ # Verify the allocation belongs to an item in this show
+ item_id = getattr(allocation, allocation_item_fk)
+ item = session.get(item_model, item_id)
+ if not item or item.show_id != show_id:
+ controller.set_status(404)
+ await controller.finish({"message": ERROR_ALLOCATION_NOT_FOUND})
+ return
+
+ session.delete(allocation)
+ # Flush to persist the deletion within the current transaction,
+ # making it available for block computation without committing yet
+ session.flush()
+
+ # Refresh the item to get updated relationships for orphan detection
+ session.refresh(item)
+
+ # Delete any crew assignments that are now orphaned due to block boundary changes
+ deleted_assignment_ids = []
+ if isinstance(item, Props):
+ deleted_assignment_ids = delete_orphaned_assignments_for_prop(
+ session, item, show
+ )
+ elif isinstance(item, Scenery):
+ deleted_assignment_ids = delete_orphaned_assignments_for_scenery(
+ session, item, show
+ )
+
+ # Commit the entire operation atomically (allocation deletion + orphan deletions)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"message": "Successfully deleted allocation"})
+
+ await controller.application.ws_send_to_all("NOOP", ws_action, {})
+
+ # Also notify about crew assignment changes if any were deleted
+ if deleted_assignment_ids:
+ await controller.application.ws_send_to_all(
+ "NOOP", "GET_CREW_ASSIGNMENTS", {}
+ )
diff --git a/server/controllers/api/show/stage/props.py b/server/controllers/api/show/stage/props.py
new file mode 100644
index 00000000..67d3332d
--- /dev/null
+++ b/server/controllers/api/show/stage/props.py
@@ -0,0 +1,319 @@
+from sqlalchemy import select
+from tornado import escape
+
+from controllers.api.constants import (
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_PROP_NOT_FOUND,
+ ERROR_PROP_TYPE_ID_MISSING,
+ ERROR_PROP_TYPE_NOT_FOUND,
+ ERROR_PROPS_ID_MISSING,
+ ERROR_PROPS_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
+from controllers.api.show.stage.helpers import (
+ handle_allocation_delete,
+ handle_allocation_post,
+ handle_type_delete,
+ handle_type_patch,
+ handle_type_post,
+)
+from models.show import Show
+from models.stage import Props, PropsAllocation, PropType
+from rbac.role import Role
+from schemas.schemas import PropsAllocationSchema, PropsSchema, PropTypeSchema
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import no_live_session, requires_show
+
+
+@ApiRoute("show/stage/props/types", ApiVersion.V1)
+class PropsTypesController(BaseAPIController):
+ @requires_show
+ def get(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ prop_type_schema = PropTypeSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ prop_types = [prop_type_schema.dump(c) for c in show.prop_types]
+ self.set_status(200)
+ self.finish({"prop_types": prop_types})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ await handle_type_post(
+ self,
+ type_model=PropType,
+ ws_action="GET_PROP_TYPES",
+ success_message="Successfully added prop type",
+ )
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ await handle_type_patch(
+ self,
+ type_model=PropType,
+ ws_action="GET_PROP_TYPES",
+ success_message="Successfully updated prop type",
+ not_found_message=ERROR_PROP_TYPE_NOT_FOUND,
+ )
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ await handle_type_delete(
+ self,
+ type_model=PropType,
+ ws_actions=["GET_PROP_TYPES", "GET_PROPS_LIST"],
+ success_message="Successfully deleted prop type",
+ not_found_message=ERROR_PROP_TYPE_NOT_FOUND,
+ )
+
+
+@ApiRoute("show/stage/props", ApiVersion.V1)
+class PropsController(BaseAPIController):
+ @requires_show
+ def get(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ props_schema = PropsSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ props = [props_schema.dump(c) for c in show.props_list]
+ self.set_status(200)
+ self.finish({"props": props})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ name = data.get("name", None)
+ if not name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_NAME_MISSING})
+ return
+
+ prop_type_id = data.get("prop_type_id", None)
+ if not prop_type_id:
+ self.set_status(400)
+ await self.finish({"message": ERROR_PROP_TYPE_ID_MISSING})
+ return
+ try:
+ prop_type_id = int(prop_type_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": "Invalid prop type ID"})
+ return
+ prop_type: PropType = session.get(PropType, prop_type_id)
+ if not prop_type:
+ self.set_status(404)
+ await self.finish({"message": "Prop type not found"})
+ return
+ if prop_type.show_id != show.id:
+ self.set_status(400)
+ await self.finish({"message": "Invalid prop type for show"})
+ return
+
+ description = data.get("description", "")
+
+ new_props = Props(
+ show_id=show.id,
+ name=name,
+ description=description,
+ prop_type_id=prop_type.id,
+ )
+ session.add(new_props)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {"id": new_props.id, "message": "Successfully added props"}
+ )
+
+ await self.application.ws_send_to_all("NOOP", "GET_PROPS_LIST", {})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ props = data.get("id", None)
+ if not props:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ entry: Props = session.get(Props, props)
+ if entry:
+ name = data.get("name", None)
+ if not name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_NAME_MISSING})
+ return
+ entry.name = name
+
+ prop_type_id = data.get("prop_type_id", None)
+ if not prop_type_id:
+ self.set_status(400)
+ await self.finish({"message": ERROR_PROP_TYPE_ID_MISSING})
+ return
+ try:
+ prop_type_id = int(prop_type_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": "Invalid prop type ID"})
+ return
+ prop_type: PropType = session.get(PropType, prop_type_id)
+ if not prop_type:
+ self.set_status(404)
+ await self.finish({"message": "Prop type not found"})
+ return
+ if prop_type.show_id != show.id:
+ self.set_status(400)
+ await self.finish({"message": "Invalid prop type for show"})
+ return
+ entry.prop_type_id = prop_type.id
+
+ description = data.get("description", "")
+ entry.description = description
+
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully updated props"})
+
+ await self.application.ws_send_to_all("NOOP", "GET_PROPS_LIST", {})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_PROP_NOT_FOUND})
+ return
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+
+ props_id_str = self.get_argument("id", None)
+ if not props_id_str:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ props_id = int(props_id_str)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ entry = session.get(Props, props_id)
+ if entry:
+ session.delete(entry)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully deleted props"})
+
+ await self.application.ws_send_to_all("NOOP", "GET_PROPS_LIST", {})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_PROPS_NOT_FOUND})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+
+@ApiRoute("show/stage/props/allocations", ApiVersion.V1)
+class PropsAllocationController(BaseAPIController):
+ """Controller for managing props allocations to scenes."""
+
+ @requires_show
+ def get(self):
+ """Get all props allocations for the current show."""
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ allocation_schema = PropsAllocationSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ allocations = session.scalars(
+ select(PropsAllocation)
+ .join(Props, PropsAllocation.props_id == Props.id)
+ .where(Props.show_id == show_id)
+ ).all()
+ allocations = [allocation_schema.dump(a) for a in allocations]
+ self.set_status(200)
+ self.finish({"allocations": allocations})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ """Create a new props allocation."""
+ await handle_allocation_post(
+ self,
+ item_model=Props,
+ item_id_key="props_id",
+ allocation_model=PropsAllocation,
+ allocation_item_fk="props_id",
+ ws_action="GET_PROPS_ALLOCATIONS",
+ error_item_id_missing=ERROR_PROPS_ID_MISSING,
+ error_item_not_found=ERROR_PROP_NOT_FOUND,
+ allocation_exists_message="Allocation already exists for this prop and scene",
+ )
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ """Delete a props allocation by ID (query parameter)."""
+ await handle_allocation_delete(
+ self,
+ item_model=Props,
+ allocation_model=PropsAllocation,
+ allocation_item_fk="props_id",
+ ws_action="GET_PROPS_ALLOCATIONS",
+ )
diff --git a/server/controllers/api/show/stage/scenery.py b/server/controllers/api/show/stage/scenery.py
new file mode 100644
index 00000000..aa673e5e
--- /dev/null
+++ b/server/controllers/api/show/stage/scenery.py
@@ -0,0 +1,326 @@
+from sqlalchemy import select
+from tornado import escape
+
+from controllers.api.constants import (
+ ERROR_ID_MISSING,
+ ERROR_INVALID_ID,
+ ERROR_NAME_MISSING,
+ ERROR_SCENERY_ID_MISSING,
+ ERROR_SCENERY_NOT_FOUND,
+ ERROR_SCENERY_TYPE_ID_MISSING,
+ ERROR_SCENERY_TYPE_NOT_FOUND,
+ ERROR_SHOW_NOT_FOUND,
+)
+from controllers.api.show.stage.helpers import (
+ handle_allocation_delete,
+ handle_allocation_post,
+ handle_type_delete,
+ handle_type_patch,
+ handle_type_post,
+)
+from models.show import Show
+from models.stage import Scenery, SceneryAllocation, SceneryType
+from rbac.role import Role
+from schemas.schemas import SceneryAllocationSchema, ScenerySchema, SceneryTypeSchema
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import no_live_session, requires_show
+
+
+@ApiRoute("show/stage/scenery/types", ApiVersion.V1)
+class SceneryTypesController(BaseAPIController):
+ @requires_show
+ def get(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ scenery_type_schema = SceneryTypeSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ scenery_types = [
+ scenery_type_schema.dump(c) for c in show.scenery_types
+ ]
+ self.set_status(200)
+ self.finish({"scenery_types": scenery_types})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ await handle_type_post(
+ self,
+ type_model=SceneryType,
+ ws_action="GET_SCENERY_TYPES",
+ success_message="Successfully added scenery type",
+ )
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ await handle_type_patch(
+ self,
+ type_model=SceneryType,
+ ws_action="GET_SCENERY_TYPES",
+ success_message="Successfully updated scenery type",
+ not_found_message=ERROR_SCENERY_TYPE_NOT_FOUND,
+ )
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ await handle_type_delete(
+ self,
+ type_model=SceneryType,
+ ws_actions=["GET_SCENERY_TYPES", "GET_SCENERY_LIST"],
+ success_message="Successfully deleted scenery type",
+ not_found_message=ERROR_SCENERY_TYPE_NOT_FOUND,
+ )
+
+
+@ApiRoute("show/stage/scenery", ApiVersion.V1)
+class SceneryController(BaseAPIController):
+ @requires_show
+ def get(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ scenery_schema = ScenerySchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ scenery = [scenery_schema.dump(c) for c in show.scenery_list]
+ self.set_status(200)
+ self.finish({"scenery": scenery})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ name = data.get("name", None)
+ if not name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_NAME_MISSING})
+ return
+
+ scenery_type_id = data.get("scenery_type_id", None)
+ if not scenery_type_id:
+ self.set_status(400)
+ await self.finish({"message": ERROR_SCENERY_TYPE_ID_MISSING})
+ return
+ try:
+ scenery_type_id = int(scenery_type_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": "Invalid scenery type ID"})
+ return
+ scenery_type: SceneryType = session.get(SceneryType, scenery_type_id)
+ if not scenery_type:
+ self.set_status(404)
+ await self.finish({"message": "Scenery type not found"})
+ return
+ if scenery_type.show_id != show.id:
+ self.set_status(400)
+ await self.finish({"message": "Invalid scenery type for show"})
+ return
+
+ description = data.get("description", "")
+
+ new_scenery = Scenery(
+ show_id=show.id,
+ name=name,
+ description=description,
+ scenery_type_id=scenery_type.id,
+ )
+ session.add(new_scenery)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {"id": new_scenery.id, "message": "Successfully added scenery"}
+ )
+
+ await self.application.ws_send_to_all("NOOP", "GET_SCENERY_LIST", {})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def patch(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+ data = escape.json_decode(self.request.body)
+
+ scenery = data.get("id", None)
+ if not scenery:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ entry: Scenery = session.get(Scenery, scenery)
+ if entry:
+ name = data.get("name", None)
+ if not name:
+ self.set_status(400)
+ await self.finish({"message": ERROR_NAME_MISSING})
+ return
+ entry.name = name
+
+ scenery_type_id = data.get("scenery_type_id", None)
+ if not scenery_type_id:
+ self.set_status(400)
+ await self.finish({"message": ERROR_SCENERY_TYPE_ID_MISSING})
+ return
+ try:
+ scenery_type_id = int(scenery_type_id)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": "Invalid scenery type ID"})
+ return
+ scenery_type: SceneryType = session.get(
+ SceneryType, scenery_type_id
+ )
+ if not scenery_type:
+ self.set_status(404)
+ await self.finish({"message": "Scenery type not found"})
+ return
+ if scenery_type.show_id != show.id:
+ self.set_status(400)
+ await self.finish({"message": "Invalid scenery type for show"})
+ return
+ entry.scenery_type_id = scenery_type.id
+
+ description = data.get("description", "")
+ entry.description = description
+
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully updated scenery"})
+
+ await self.application.ws_send_to_all(
+ "NOOP", "GET_SCENERY_LIST", {}
+ )
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENERY_NOT_FOUND})
+ return
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ self.requires_role(show, Role.WRITE)
+
+ scenery_id_str = self.get_argument("id", None)
+ if not scenery_id_str:
+ self.set_status(400)
+ await self.finish({"message": ERROR_ID_MISSING})
+ return
+
+ try:
+ scenery_id = int(scenery_id_str)
+ except ValueError:
+ self.set_status(400)
+ await self.finish({"message": ERROR_INVALID_ID})
+ return
+
+ entry = session.get(Scenery, scenery_id)
+ if entry:
+ session.delete(entry)
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "Successfully deleted scenery"})
+
+ await self.application.ws_send_to_all(
+ "NOOP", "GET_SCENERY_LIST", {}
+ )
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SCENERY_NOT_FOUND})
+ else:
+ self.set_status(404)
+ await self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+
+@ApiRoute("show/stage/scenery/allocations", ApiVersion.V1)
+class SceneryAllocationController(BaseAPIController):
+ """Controller for managing scenery allocations to scenes."""
+
+ @requires_show
+ def get(self):
+ """Get all scenery allocations for the current show."""
+ current_show = self.get_current_show()
+ show_id = current_show["id"]
+ allocation_schema = SceneryAllocationSchema()
+
+ with self.make_session() as session:
+ show = session.get(Show, show_id)
+ if show:
+ allocations = session.scalars(
+ select(SceneryAllocation)
+ .join(Scenery, SceneryAllocation.scenery_id == Scenery.id)
+ .where(Scenery.show_id == show_id)
+ ).all()
+ allocations = [allocation_schema.dump(a) for a in allocations]
+ self.set_status(200)
+ self.finish({"allocations": allocations})
+ else:
+ self.set_status(404)
+ self.finish({"message": ERROR_SHOW_NOT_FOUND})
+
+ @requires_show
+ @no_live_session
+ async def post(self):
+ """Create a new scenery allocation."""
+ await handle_allocation_post(
+ self,
+ item_model=Scenery,
+ item_id_key="scenery_id",
+ allocation_model=SceneryAllocation,
+ allocation_item_fk="scenery_id",
+ ws_action="GET_SCENERY_ALLOCATIONS",
+ error_item_id_missing=ERROR_SCENERY_ID_MISSING,
+ error_item_not_found=ERROR_SCENERY_NOT_FOUND,
+ allocation_exists_message="Allocation already exists for this scenery and scene",
+ )
+
+ @requires_show
+ @no_live_session
+ async def delete(self):
+ """Delete a scenery allocation by ID (query parameter)."""
+ await handle_allocation_delete(
+ self,
+ item_model=Scenery,
+ allocation_model=SceneryAllocation,
+ allocation_item_fk="scenery_id",
+ ws_action="GET_SCENERY_ALLOCATIONS",
+ )
diff --git a/server/controllers/api/user/overrides.py b/server/controllers/api/user/overrides.py
index 28735d46..802b3257 100644
--- a/server/controllers/api/user/overrides.py
+++ b/server/controllers/api/user/overrides.py
@@ -2,6 +2,13 @@
from tornado import escape
+from controllers.api.constants import (
+ ERROR_BACKGROUND_COLOUR_MISSING,
+ ERROR_COLOUR_MISSING,
+ ERROR_ID_MISSING,
+ ERROR_TEXT_COLOUR_MISSING,
+ ERROR_TEXT_FORMAT_INVALID,
+)
from models.cue import CueType
from models.script import StageDirectionStyle
from models.user import UserOverrides
@@ -10,6 +17,96 @@
from utils.web.web_decorators import api_authenticated
+VALID_TEXT_FORMATS = ("default", "upper", "lower")
+
+
+async def handle_override_patch(
+ controller, ws_action, success_message, not_found_message
+):
+ """
+ Handle PATCH request for user override controllers.
+
+ :param controller: The controller instance handling the request
+ :param ws_action: WebSocket action to send on success
+ :param success_message: Success message to return
+ :param not_found_message: Not found error message
+ """
+ data = escape.json_decode(controller.request.body)
+ settings_id = data.get("id", None)
+ if not settings_id:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_ID_MISSING})
+ return
+
+ with controller.make_session() as session:
+ entry: UserOverrides = session.get(UserOverrides, settings_id)
+ if entry:
+ if entry.user_id != controller.current_user["id"]:
+ controller.set_status(403)
+ await controller.finish()
+ return
+
+ merge_settings = data.copy()
+ del merge_settings["id"]
+ entry.update_settings(merge_settings)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"message": success_message})
+
+ await controller.application.ws_send_to_user(
+ controller.current_user["id"],
+ "NOOP",
+ ws_action,
+ {},
+ )
+ else:
+ controller.set_status(404)
+ await controller.finish({"message": not_found_message})
+
+
+async def handle_override_delete(
+ controller, ws_action, success_message, not_found_message
+):
+ """
+ Handle DELETE request for user override controllers.
+
+ :param controller: The controller instance handling the request
+ :param ws_action: WebSocket action to send on success
+ :param success_message: Success message to return
+ :param not_found_message: Not found error message
+ """
+ settings_id = controller.get_argument("id", None)
+ if not settings_id:
+ controller.set_status(400)
+ await controller.finish({"message": ERROR_ID_MISSING})
+ return
+
+ with controller.make_session() as session:
+ entry: UserOverrides = session.get(UserOverrides, int(settings_id))
+ if entry:
+ if entry.user_id != controller.current_user["id"]:
+ controller.set_status(403)
+ await controller.finish()
+ return
+
+ session.delete(entry)
+ session.commit()
+
+ controller.set_status(200)
+ await controller.finish({"message": success_message})
+
+ await controller.application.ws_send_to_user(
+ controller.current_user["id"],
+ "NOOP",
+ ws_action,
+ {},
+ )
+ else:
+ controller.set_status(404)
+ await controller.finish({"message": not_found_message})
+
+
@ApiRoute("user/settings/stage_direction_overrides", ApiVersion.V1)
class StageDirectionOverridesController(BaseAPIController):
@api_authenticated
@@ -37,27 +134,23 @@ async def post(self):
await self.finish({"message": "Style ID missing"})
return
- bold: bool = data.get("bold", False)
- italic: bool = data.get("italic", False)
- underline: bool = data.get("underline", False)
-
text_format: str = data.get("textFormat", None)
- if not text_format or text_format not in ["default", "upper", "lower"]:
+ if not text_format or text_format not in VALID_TEXT_FORMATS:
self.set_status(400)
- await self.finish({"message": "Text format missing or invalid"})
+ await self.finish({"message": ERROR_TEXT_FORMAT_INVALID})
return
text_colour: str = data.get("textColour", None)
if not text_colour:
self.set_status(400)
- await self.finish({"message": "Text colour missing"})
+ await self.finish({"message": ERROR_TEXT_COLOUR_MISSING})
return
enable_background_colour: bool = data.get("enableBackgroundColour", False)
background_colour: str = data.get("backgroundColour", None)
if enable_background_colour and not background_colour:
self.set_status(400)
- await self.finish({"message": "Background colour missing"})
+ await self.finish({"message": ERROR_BACKGROUND_COLOUR_MISSING})
return
with self.make_session() as session:
@@ -72,9 +165,9 @@ async def post(self):
settings_type="stage_direction_styles",
settings_data={
"id": style_id,
- "bold": bold,
- "italic": italic,
- "underline": underline,
+ "bold": data.get("bold", False),
+ "italic": data.get("italic", False),
+ "underline": data.get("underline", False),
"text_format": text_format,
"text_colour": text_colour,
"enable_background_colour": enable_background_colour,
@@ -101,76 +194,21 @@ async def post(self):
@api_authenticated
async def patch(self):
- data = escape.json_decode(self.request.body)
- settings_id = data.get("id", None)
- if not settings_id:
- self.set_status(400)
- await self.finish({"message": "ID missing"})
- return
-
- with self.make_session() as session:
- entry: UserOverrides = session.get(UserOverrides, settings_id)
- if entry:
- if entry.user_id != self.current_user["id"]:
- self.set_status(403)
- await self.finish()
-
- merge_settings = data.copy()
- del merge_settings["id"]
- entry.update_settings(merge_settings)
- session.commit()
-
- self.set_status(200)
- await self.finish(
- {"message": "Successfully edited stage direction style override"}
- )
-
- await self.application.ws_send_to_user(
- self.current_user["id"],
- "NOOP",
- "GET_STAGE_DIRECTION_STYLE_OVERRIDES",
- {},
- )
- else:
- self.set_status(404)
- await self.finish(
- {"message": "Stage direction style override not found"}
- )
+ await handle_override_patch(
+ self,
+ "GET_STAGE_DIRECTION_STYLE_OVERRIDES",
+ "Successfully edited stage direction style override",
+ "Stage direction style override not found",
+ )
@api_authenticated
async def delete(self):
- settings_id = self.get_argument("id", None)
- if not settings_id:
- self.set_status(400)
- await self.finish({"message": "ID missing"})
- return
-
- with self.make_session() as session:
- entry: UserOverrides = session.get(UserOverrides, int(settings_id))
- if entry:
- if entry.user_id != self.current_user["id"]:
- self.set_status(403)
- await self.finish()
-
- session.delete(entry)
- session.commit()
-
- self.set_status(200)
- await self.finish(
- {"message": "Successfully deleted stage direction style override"}
- )
-
- await self.application.ws_send_to_user(
- self.current_user["id"],
- "NOOP",
- "GET_STAGE_DIRECTION_STYLE_OVERRIDES",
- {},
- )
- else:
- self.set_status(404)
- await self.finish(
- {"message": "Stage direction style override not found"}
- )
+ await handle_override_delete(
+ self,
+ "GET_STAGE_DIRECTION_STYLE_OVERRIDES",
+ "Successfully deleted stage direction style override",
+ "Stage direction style override not found",
+ )
@ApiRoute("user/settings/cue_colour_overrides", ApiVersion.V1)
@@ -203,7 +241,7 @@ async def post(self):
colour: str = data.get("colour", None)
if not colour:
self.set_status(400)
- await self.finish({"message": "Colour missing"})
+ await self.finish({"message": ERROR_COLOUR_MISSING})
return
with self.make_session() as session:
@@ -241,69 +279,18 @@ async def post(self):
@api_authenticated
async def patch(self):
- data = escape.json_decode(self.request.body)
- settings_id = data.get("id", None)
- if not settings_id:
- self.set_status(400)
- await self.finish({"message": "ID missing"})
- return
-
- with self.make_session() as session:
- entry: UserOverrides = session.get(UserOverrides, settings_id)
- if entry:
- if entry.user_id != self.current_user["id"]:
- self.set_status(403)
- await self.finish()
-
- merge_settings = data.copy()
- del merge_settings["id"]
- entry.update_settings(merge_settings)
- session.commit()
-
- self.set_status(200)
- await self.finish(
- {"message": "Successfully edited cue colour override"}
- )
-
- await self.application.ws_send_to_user(
- self.current_user["id"],
- "NOOP",
- "GET_CUE_COLOUR_OVERRIDES",
- {},
- )
- else:
- self.set_status(404)
- await self.finish({"message": "Cue colour override not found"})
+ await handle_override_patch(
+ self,
+ "GET_CUE_COLOUR_OVERRIDES",
+ "Successfully edited cue colour override",
+ "Cue colour override not found",
+ )
@api_authenticated
async def delete(self):
- settings_id = self.get_argument("id", None)
- if not settings_id:
- self.set_status(400)
- await self.finish({"message": "ID missing"})
- return
-
- with self.make_session() as session:
- entry: UserOverrides = session.get(UserOverrides, int(settings_id))
- if entry:
- if entry.user_id != self.current_user["id"]:
- self.set_status(403)
- await self.finish()
-
- session.delete(entry)
- session.commit()
-
- self.set_status(200)
- await self.finish(
- {"message": "Successfully deleted cue colour override"}
- )
-
- await self.application.ws_send_to_user(
- self.current_user["id"],
- "NOOP",
- "GET_CUE_COLOUR_OVERRIDES",
- {},
- )
- else:
- self.set_status(404)
- await self.finish({"message": "Cue colour override not found"})
+ await handle_override_delete(
+ self,
+ "GET_CUE_COLOUR_OVERRIDES",
+ "Successfully deleted cue colour override",
+ "Cue colour override not found",
+ )
diff --git a/server/controllers/api/version.py b/server/controllers/api/version.py
new file mode 100644
index 00000000..7d4a0364
--- /dev/null
+++ b/server/controllers/api/version.py
@@ -0,0 +1,60 @@
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import api_authenticated, require_admin
+
+
+@ApiRoute("version/status", ApiVersion.V1)
+class VersionStatusController(BaseAPIController):
+ @api_authenticated
+ async def get(self):
+ """
+ Get the current version status.
+
+ Returns information about the running version, latest available version,
+ whether an update is available, and when the last check occurred.
+
+ :returns: JSON response with version status.
+ """
+ version_checker = self.application.version_checker
+ if not version_checker:
+ await self.finish(
+ {
+ "error": "Version checker not initialized",
+ "current_version": None,
+ "latest_version": None,
+ "update_available": False,
+ "release_url": None,
+ "last_checked": None,
+ "check_error": "Service not available",
+ }
+ )
+ return
+
+ await self.finish(version_checker.status.as_json())
+
+
+@ApiRoute("version/check", ApiVersion.V1)
+class VersionCheckController(BaseAPIController):
+ @api_authenticated
+ @require_admin
+ async def post(self):
+ """
+ Trigger a manual version check.
+
+ Forces an immediate check against the GitHub API, updating the
+ cached version status.
+
+ :returns: JSON response with updated version status.
+ """
+ version_checker = self.application.version_checker
+ if not version_checker:
+ self.set_status(503)
+ await self.finish(
+ {
+ "error": "Version checker not initialized",
+ }
+ )
+ return
+
+ status = await version_checker.check_for_updates()
+ await self.finish(status.as_json())
diff --git a/server/controllers/api/websocket.py b/server/controllers/api/websocket.py
index 2588bc34..521b49ff 100644
--- a/server/controllers/api/websocket.py
+++ b/server/controllers/api/websocket.py
@@ -6,7 +6,7 @@
from utils.web.route import ApiRoute, ApiVersion
-@ApiRoute("ws/sessions", ApiVersion.V1)
+@ApiRoute("ws/sessions", ApiVersion.V1, ignore_logging=True)
class WebsocketSessionsController(BaseAPIController):
def get(self):
session_scheme = SessionSchema()
diff --git a/server/controllers/ws_controller.py b/server/controllers/ws_controller.py
index bfa38e41..d202f753 100644
--- a/server/controllers/ws_controller.py
+++ b/server/controllers/ws_controller.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import json
from typing import TYPE_CHECKING, Any, Awaitable, Dict, Optional, Union
diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py
index 901e1673..6e6a9c54 100644
--- a/server/digi_server/app_server.py
+++ b/server/digi_server/app_server.py
@@ -17,7 +17,15 @@
from controllers import controllers
from controllers.ws_controller import WebSocketController
-from digi_server.logger import configure_db_logging, configure_file_logging, get_logger
+from digi_server.logger import (
+ configure_client_buffer,
+ configure_client_logging,
+ configure_db_logging,
+ configure_file_logging,
+ configure_log_level,
+ configure_server_buffer,
+ get_logger,
+)
from digi_server.settings import Settings
from models import models
from models.cue import CueType
@@ -32,6 +40,7 @@
from utils.exceptions import DatabaseTypeException, DatabaseUpgradeRequired
from utils.mdns_service import MDNSAdvertiser
from utils.module_discovery import get_resource_path, is_frozen
+from utils.version_checker import VersionChecker
from utils.web.jwt_service import JWTService
from utils.web.route import Route
@@ -49,6 +58,9 @@ def __init__(
self.digi_settings: Settings = Settings(self, settings_path)
self.app_log_handler = None
self.db_file_handler = None
+ self.client_file_handler = None
+ self.server_buffer = None
+ self.client_buffer = None
# Controller imports (needed to trigger the decorator)
controllers.import_all_controllers()
@@ -60,6 +72,7 @@ def __init__(
self._db: DigiSQLAlchemy = models.db
self.jwt_service: JWTService = None
self.mdns_advertiser: Optional[MDNSAdvertiser] = None
+ self.version_checker: Optional[VersionChecker] = None
db_path: str = self.digi_settings.settings.get("db_path").get_value()
if db_path.startswith("sqlite://"):
@@ -351,6 +364,7 @@ def _check_migrations(self):
async def configure(self):
await self._configure_logging()
await self.start_mdns_advertising()
+ await self.start_version_checker()
async def _configure_logging(self):
get_logger().info("Reconfiguring logging!")
@@ -359,23 +373,47 @@ async def _configure_logging(self):
log_path = await self.digi_settings.get("log_path")
file_size = await self.digi_settings.get("max_log_mb")
backups = await self.digi_settings.get("log_backups")
+ log_level = await self.digi_settings.get("log_level")
if log_path:
self.app_log_handler = configure_file_logging(
- log_path, file_size, backups, self.app_log_handler
+ log_path=log_path,
+ max_size_mb=file_size,
+ log_backups=backups,
+ handler=self.app_log_handler,
)
+ configure_log_level(log_level)
+
+ # Client logging
+ client_log_path = await self.digi_settings.get("client_log_path")
+ client_file_size = await self.digi_settings.get("client_max_log_mb")
+ client_backups = await self.digi_settings.get("client_log_backups")
+ client_log_level = await self.digi_settings.get("client_log_level")
+ self.client_file_handler = configure_client_logging(
+ log_path=client_log_path,
+ max_size_mb=client_file_size,
+ log_backups=client_backups,
+ handler=self.client_file_handler,
+ log_level=client_log_level,
+ )
+
+ # In-memory log buffers (for the log viewer UI)
+ buffer_maxlen = await self.digi_settings.get("log_buffer_size")
+ self.server_buffer = configure_server_buffer(buffer_maxlen)
+ self.client_buffer = configure_client_buffer(buffer_maxlen)
# Database logging
use_db_logging = await self.digi_settings.get("db_log_enabled")
- if use_db_logging:
- db_log_path = await self.digi_settings.get("db_log_path")
- db_file_size = await self.digi_settings.get("db_max_log_mb")
- db_backups = await self.digi_settings.get("db_log_backups")
- self.db_file_handler = configure_db_logging(
- log_path=db_log_path,
- max_size_mb=db_file_size,
- log_backups=db_backups,
- handler=self.db_file_handler,
- )
+ db_log_path = await self.digi_settings.get("db_log_path")
+ db_file_size = await self.digi_settings.get("db_max_log_mb")
+ db_backups = await self.digi_settings.get("db_log_backups")
+ self.db_file_handler = configure_db_logging(
+ log_path=db_log_path,
+ max_size_mb=db_file_size,
+ log_backups=db_backups,
+ handler=self.db_file_handler,
+ log_level=log_level,
+ enable_db_logging=use_db_logging,
+ )
def _configure_rbac(self):
self._db.register_delete_hook(self.rbac.rbac_db.check_object_deletion)
@@ -528,3 +566,16 @@ async def _toggle_mdns_advertising(self) -> None:
else:
# Stop advertising
await self.stop_mdns_advertising()
+
+ async def start_version_checker(self) -> None:
+ """Start the version checker service."""
+ if not self.version_checker:
+ self.version_checker = VersionChecker(application=self)
+
+ await self.version_checker.start()
+
+ async def stop_version_checker(self) -> None:
+ """Stop the version checker service if it's running."""
+ if self.version_checker:
+ await self.version_checker.stop()
+ self.version_checker = None
diff --git a/server/digi_server/logger.py b/server/digi_server/logger.py
index b9aed4bc..3c076c5c 100644
--- a/server/digi_server/logger.py
+++ b/server/digi_server/logger.py
@@ -4,8 +4,20 @@
from tornado.log import LogFormatter
+from utils.log_buffer import (
+ LogBufferHandler,
+ get_client_buffer,
+ get_server_buffer,
+)
+
logger = logging.getLogger("DigiScript")
+ALL_LOGGERS = [
+ logging.getLogger("tornado.access"),
+ logging.getLogger("tornado.application"),
+ logging.getLogger("tornado.general"),
+ logger,
+]
def get_logger(name: Optional[str] = None):
@@ -14,26 +26,40 @@ def get_logger(name: Optional[str] = None):
return logger
-def configure_file_logging(log_path, max_size_mb=100, log_backups=5, handler=None):
+def configure_log_level(log_level=logging.DEBUG):
+ for _logger in ALL_LOGGERS:
+ logger.info(f"Setting log level to {log_level} for logger: {_logger.name}")
+ _logger.setLevel(log_level)
+
+
+def configure_file_logging(
+ log_path,
+ max_size_mb=100,
+ log_backups=5,
+ handler=None,
+):
size_bytes = max_size_mb * 1024 * 1024
- app_logger = get_logger()
if handler:
- app_logger.removeHandler(handler)
+ for _logger in ALL_LOGGERS:
+ _logger.removeHandler(handler)
file_handler = RotatingFileHandler(
log_path, maxBytes=size_bytes, backupCount=log_backups
)
file_handler.setFormatter(LogFormatter(color=False))
- app_logger.addHandler(file_handler)
- logging.getLogger("tornado.access").addHandler(file_handler)
- logging.getLogger("tornado.application").addHandler(file_handler)
- logging.getLogger("tornado.general").addHandler(file_handler)
+ for _logger in ALL_LOGGERS:
+ _logger.addHandler(file_handler)
return file_handler
def configure_db_logging(
- log_level=logging.DEBUG, log_path=None, max_size_mb=100, log_backups=5, handler=None
+ log_level=logging.DEBUG,
+ log_path=None,
+ max_size_mb=100,
+ log_backups=5,
+ handler=None,
+ enable_db_logging=False,
):
size_bytes = max_size_mb * 1024 * 1024
db_logger = logging.getLogger("sqlalchemy.engine")
@@ -41,6 +67,11 @@ def configure_db_logging(
if handler:
db_logger.removeHandler(handler)
+ if not enable_db_logging:
+ logger.info(f"Disabling logger: {db_logger.name}")
+ return None
+
+ logger.info(f"Setting log level to {log_level} for logger: {db_logger.name}")
db_logger.setLevel(log_level)
file_handler = None
if log_path:
@@ -53,6 +84,54 @@ def configure_db_logging(
return file_handler
+CLIENT_LEVEL_MAP = {
+ "TRACE": 5, # Registered via add_logging_level("TRACE", logging.DEBUG - 5) in main.py
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "WARN": logging.WARNING, # loglevel npm uses WARN; Python uses WARNING
+ "ERROR": logging.ERROR,
+ "SILENT": logging.CRITICAL + 1, # No Python equivalent; suppress all
+}
+
+
+def map_client_level(level_name: str) -> int:
+ """Map a loglevel npm level name to a Python logging integer.
+
+ :param level_name: Level name from the loglevel npm package (e.g. TRACE, WARN).
+ :returns: The corresponding Python logging integer level.
+ """
+ return CLIENT_LEVEL_MAP.get(level_name.upper(), logging.INFO)
+
+
+def configure_client_logging(
+ log_path,
+ max_size_mb=100,
+ log_backups=5,
+ handler=None,
+ log_level=logging.DEBUG,
+):
+ size_bytes = max_size_mb * 1024 * 1024
+ client_logger = get_logger("Client")
+
+ if handler:
+ client_logger.removeHandler(handler)
+
+ if isinstance(log_level, str):
+ log_level = map_client_level(log_level)
+
+ client_logger.setLevel(log_level)
+ file_handler = None
+ if log_path:
+ file_handler = RotatingFileHandler(
+ log_path, maxBytes=size_bytes, backupCount=log_backups
+ )
+ file_handler.setFormatter(LogFormatter(color=False))
+ client_logger.addHandler(file_handler)
+ # Prevent propagation to avoid polluting the server console
+ client_logger.propagate = False
+ return file_handler
+
+
def add_logging_level(level_name, level_num, method_name=None):
if not method_name:
method_name = level_name.lower()
@@ -75,3 +154,43 @@ def log_to_root(message, *args, **kwargs):
setattr(logging, level_name, level_num)
setattr(logging.getLoggerClass(), method_name, log_for_level)
setattr(logging, method_name, log_to_root)
+
+
+def configure_server_buffer(maxlen: int) -> LogBufferHandler:
+ """Attach (or resize) the server log buffer to all server loggers.
+
+ Idempotent: if the handler is already attached, only the buffer size is
+ updated.
+
+ :param maxlen: Maximum number of entries to keep in the buffer.
+ :returns: The :class:`LogBufferHandler` singleton for server logs.
+ """
+ handler = get_server_buffer()
+ handler.resize(maxlen)
+ for _logger in ALL_LOGGERS:
+ if handler not in _logger.handlers:
+ _logger.addHandler(handler)
+ return handler
+
+
+def configure_client_buffer(maxlen: int) -> LogBufferHandler:
+ """Attach (or resize) the client log buffer to the DigiScript.Client logger.
+
+ Idempotent: if the handler is already attached, only the buffer size is
+ updated.
+
+ :param maxlen: Maximum number of entries to keep in the buffer.
+ :returns: The :class:`LogBufferHandler` singleton for client logs.
+ """
+ handler = get_client_buffer()
+ handler.resize(maxlen)
+ client_logger = get_logger("Client")
+ if handler not in client_logger.handlers:
+ client_logger.addHandler(handler)
+ return handler
+
+
+def get_level_names_by_order():
+ levels = logging.getLevelNamesMapping()
+ sorted_levels = sorted(levels.items(), key=lambda x: x[1])
+ return [name for name, _ in sorted_levels]
diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py
index 960adde6..8d1274f8 100644
--- a/server/digi_server/settings.py
+++ b/server/digi_server/settings.py
@@ -4,11 +4,11 @@
import os
import tomllib
from pathlib import Path
-from typing import TYPE_CHECKING, Dict
+from typing import TYPE_CHECKING, Dict, List, Optional
from tornado.locks import Lock
-from digi_server.logger import get_logger
+from digi_server.logger import get_level_names_by_order, get_logger
from utils.file_watcher import IOLoopFileWatcher
@@ -17,12 +17,6 @@
def _get_version() -> str:
- """
- Read version from pyproject.toml.
-
- Returns:
- str: Version string from pyproject.toml, or '0.0.0' if not found
- """
try:
# Get path to pyproject.toml (one directory up from digi_server)
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
@@ -37,18 +31,12 @@ def _get_version() -> str:
def get_version() -> str:
- """
- Get the current application version.
-
- Public wrapper for _get_version() that can be imported by other modules.
-
- Returns:
- str: Version string from pyproject.toml, or '0.0.0' if not found
- """
return _get_version()
class SettingsObject:
+ ALLOWED_TYPES = [str, bool, int]
+
def __init__(
self,
key,
@@ -60,13 +48,38 @@ def __init__(
display_name: str = "",
help_text: str = "",
hide_from_ui: bool = False,
+ choice_options: Optional[list] = None,
):
- if val_type not in [str, bool, int]:
+ if val_type not in self.ALLOWED_TYPES:
raise RuntimeError(
f"Invalid type {val_type} for {key}. Allowed options are: "
- f"[str, int, bool]"
+ f"{[t.__name__ for t in self.ALLOWED_TYPES]}."
+ )
+
+ if default is None and not nullable:
+ raise RuntimeError(
+ f"Default value for {key} cannot be None if setting is not nullable."
+ )
+
+ if default is not None and not isinstance(default, val_type):
+ raise RuntimeError(
+ f"Default value {default} for {key} is not of type {val_type.__name__}."
)
+ if choice_options is not None:
+ if len(choice_options) == 0:
+ raise RuntimeError(f"Choice options for {key} cannot be an empty list.")
+
+ if any(not isinstance(option, val_type) for option in choice_options):
+ raise RuntimeError(
+ f"All choice options for {key} must be of type {val_type.__name__}."
+ )
+
+ if default not in choice_options:
+ raise RuntimeError(
+ f"Default value for {key} must be one of the choice options."
+ )
+
self.key = key
self.val_type = val_type
self.value = None
@@ -78,6 +91,7 @@ def __init__(
self.display_name = display_name
self.help_text = help_text
self.hide_from_ui = hide_from_ui
+ self.choice_options = choice_options
def set_to_default(self):
self.value = self.default
@@ -95,6 +109,12 @@ def set_value(self, value, spawn_callbacks=True):
f"type {self.val_type}"
)
+ if self.choice_options is not None and value not in self.choice_options:
+ raise ValueError(
+ f"Value for {self.key} must be one of the following options: "
+ f"{self.choice_options}"
+ )
+
changed = False
if value != self.value:
changed = True
@@ -120,6 +140,8 @@ def as_json(self):
"display_name": self.display_name,
"help_text": self.help_text,
"hide_from_ui": self.hide_from_ui,
+ "choice_options": self.choice_options,
+ "_nullable": self._nullable,
}
@@ -145,8 +167,17 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
)
os.makedirs(os.path.dirname(self.settings_path))
+ self.categories: Dict[str : List[str]] = {"General": []}
self.settings: Dict[str, SettingsObject] = {}
+ self.init_settings()
+ self._load(spawn_callbacks=False)
+ self._file_watcher = IOLoopFileWatcher(
+ self.settings_path, self.auto_reload_changes, 100
+ )
+ self._file_watcher.add_error_callback(self.file_deleted)
+ self._file_watcher.watch()
+ def init_settings(self):
db_default = f"sqlite:///{os.path.join(os.path.dirname(__file__), '../conf/digiscript.sqlite')}"
self.define(
"has_admin_user",
@@ -175,6 +206,16 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
hide_from_ui=True,
)
self.define("debug_mode", bool, False, True, display_name="Enable Debug Mode")
+ self.define(
+ "log_level",
+ str,
+ "DEBUG",
+ True,
+ self._application.regen_logging,
+ display_name="Log Level",
+ choice_options=get_level_names_by_order(),
+ category="Logging",
+ )
self.define(
"log_path",
str,
@@ -182,6 +223,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Application Log Path",
+ category="Logging",
)
self.define(
"max_log_mb",
@@ -190,6 +232,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Max Log Size (MB)",
+ category="Logging",
)
self.define(
"log_backups",
@@ -198,6 +241,16 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Log Backups",
+ category="Logging",
+ )
+ self.define(
+ "log_redaction",
+ bool,
+ False,
+ True,
+ display_name="Enable Log Redaction",
+ help_text="When enabled, potentially sensitive information will be redacted from logs.",
+ category="Logging",
)
self.define(
"db_log_enabled",
@@ -206,6 +259,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Enable Database Log",
+ category="DB Logging",
)
self.define(
"db_log_path",
@@ -214,6 +268,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Database Log Path",
+ category="DB Logging",
)
self.define(
"db_max_log_mb",
@@ -222,6 +277,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Max Database Log Size (MB)",
+ category="DB Logging",
)
self.define(
"db_log_backups",
@@ -230,6 +286,7 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
True,
self._application.regen_logging,
display_name="Database Log Backups",
+ category="DB Logging",
)
self.define(
"compiled_script_path",
@@ -248,14 +305,67 @@ def __init__(self, application: DigiScriptServer, settings_path=None):
display_name="Enable Network Discovery (mDNS)",
help_text="Advertise this server on the local network for automatic discovery by desktop clients.",
)
-
- self._load(spawn_callbacks=False)
-
- self._file_watcher = IOLoopFileWatcher(
- self.settings_path, self.auto_reload_changes, 100
+ self.define(
+ "client_log_enabled",
+ bool,
+ True,
+ True,
+ display_name="Enable Client Log Forwarding",
+ help_text="When enabled, client browsers will forward their logs to the server.",
+ category="Client Logging",
+ )
+ self.define(
+ "client_log_level",
+ str,
+ "INFO",
+ True,
+ self._application.regen_logging,
+ display_name="Client Log Level",
+ help_text="Minimum log level that clients will forward to the server.",
+ choice_options=["TRACE", "DEBUG", "INFO", "WARN", "ERROR"],
+ category="Client Logging",
+ )
+ self.define(
+ "client_log_path",
+ str,
+ os.path.join(self._base_path, "digiscript_client.log"),
+ True,
+ self._application.regen_logging,
+ display_name="Client Log Path",
+ help_text="Path to the log file for client-side log messages.",
+ category="Client Logging",
+ )
+ self.define(
+ "client_max_log_mb",
+ int,
+ 100,
+ True,
+ self._application.regen_logging,
+ display_name="Max Client Log Size (MB)",
+ help_text="Maximum size in MB of the client log file before it is rotated.",
+ category="Client Logging",
+ )
+ self.define(
+ "client_log_backups",
+ int,
+ 5,
+ True,
+ self._application.regen_logging,
+ display_name="Client Log Backups",
+ help_text="Number of rotated client log file backups to retain.",
+ category="Client Logging",
+ )
+ self.define(
+ "log_buffer_size",
+ int,
+ 2000,
+ True,
+ self._application.regen_logging,
+ display_name="Log Buffer Size",
+ help_text="Number of recent log entries to keep in memory for the log viewer. "
+ "Larger values use more memory. Changes take effect after restart.",
+ category="Client Logging",
)
- self._file_watcher.add_error_callback(self.file_deleted)
- self._file_watcher.watch()
def define(
self,
@@ -268,7 +378,12 @@ def define(
display_name: str = "",
help_text: str = "",
hide_from_ui: bool = False,
+ choice_options: Optional[list] = None,
+ category: str = "General",
):
+ if key in self.settings:
+ raise KeyError(f"Setting {key} is already defined")
+
self.settings[key] = SettingsObject(
key,
val_type,
@@ -279,7 +394,12 @@ def define(
display_name,
help_text,
hide_from_ui,
+ choice_options,
)
+ if category not in self.categories:
+ self.categories[category] = [key]
+ else:
+ self.categories[category].append(key)
def file_deleted(self):
get_logger().info("Settings file deleted; recreating from in memory settings")
diff --git a/server/main.py b/server/main.py
index 3da787f7..80e8994d 100755
--- a/server/main.py
+++ b/server/main.py
@@ -81,6 +81,8 @@ def patched_define(
display_name="",
help_text="",
hide_from_ui=False,
+ choice_options=None,
+ category="General",
):
# For database path, adjust to a writable location if needed
if key == "db_path" and default and isinstance(default, str):
@@ -106,6 +108,8 @@ def patched_define(
display_name,
help_text,
hide_from_ui,
+ choice_options,
+ category,
)
# Apply the patch
diff --git a/server/models/cue.py b/server/models/cue.py
index b2658ece..ced32185 100644
--- a/server/models/cue.py
+++ b/server/models/cue.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from typing import List
from sqlalchemy import ForeignKey, String, func, select
@@ -20,7 +22,7 @@ class CueType(db.Model, DeleteMixin):
description: Mapped[str | None] = mapped_column(String(100))
colour: Mapped[str | None] = mapped_column(String(16))
- cues: Mapped[List["Cue"]] = relationship(
+ cues: Mapped[List[Cue]] = relationship(
back_populates="cue_type", cascade="all, delete-orphan"
)
@@ -38,10 +40,10 @@ class Cue(db.Model):
cue_type_id: Mapped[int | None] = mapped_column(ForeignKey("cuetypes.id"))
ident: Mapped[str | None] = mapped_column(String(50))
- cue_type: Mapped["CueType"] = relationship(
+ cue_type: Mapped[CueType] = relationship(
foreign_keys=[cue_type_id], back_populates="cues"
)
- revision_associations: Mapped[List["CueAssociation"]] = relationship(
+ revision_associations: Mapped[List[CueAssociation]] = relationship(
cascade="all, delete-orphan", back_populates="cue"
)
@@ -59,13 +61,13 @@ class CueAssociation(db.Model, DeleteMixin):
ForeignKey("cue.id"), primary_key=True, index=True
)
- revision: Mapped["ScriptRevision"] = relationship(
+ revision: Mapped[ScriptRevision] = relationship(
foreign_keys=[revision_id], back_populates="cue_associations"
)
- line: Mapped["ScriptLine"] = relationship(
+ line: Mapped[ScriptLine] = relationship(
foreign_keys=[line_id], back_populates="cue_associations", viewonly=True
)
- cue: Mapped["Cue"] = relationship(
+ cue: Mapped[Cue] = relationship(
foreign_keys=[cue_id], back_populates="revision_associations"
)
diff --git a/server/models/mics.py b/server/models/mics.py
index b9a65511..2fce6f03 100644
--- a/server/models/mics.py
+++ b/server/models/mics.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from typing import TYPE_CHECKING, List
from sqlalchemy import ForeignKey, String
@@ -19,7 +21,7 @@ class Microphone(db.Model):
name: Mapped[str | None] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(String(500))
- allocations: Mapped[List["MicrophoneAllocation"]] = relationship(
+ allocations: Mapped[List[MicrophoneAllocation]] = relationship(
cascade="all, delete-orphan", back_populates="microphone"
)
@@ -33,6 +35,6 @@ class MicrophoneAllocation(db.Model):
ForeignKey("character.id"), primary_key=True
)
- microphone: Mapped["Microphone"] = relationship(back_populates="allocations")
- scene: Mapped["Scene"] = relationship(back_populates="mic_allocations")
- character: Mapped["Character"] = relationship(back_populates="mic_allocations")
+ microphone: Mapped[Microphone] = relationship(back_populates="allocations")
+ scene: Mapped[Scene] = relationship(back_populates="mic_allocations")
+ character: Mapped[Character] = relationship(back_populates="mic_allocations")
diff --git a/server/models/script.py b/server/models/script.py
index d1f3a97d..414b4aaf 100644
--- a/server/models/script.py
+++ b/server/models/script.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import enum
import gzip
@@ -41,11 +43,11 @@ class Script(db.Model):
ForeignKey("script_revisions.id")
)
- revisions: Mapped[List["ScriptRevision"]] = relationship(
+ revisions: Mapped[List[ScriptRevision]] = relationship(
primaryjoin="ScriptRevision.script_id == Script.id", back_populates="script"
)
- show: Mapped["Show"] = relationship(foreign_keys=[show_id])
- stage_direction_styles: Mapped[List["StageDirectionStyle"]] = relationship(
+ show: Mapped[Show] = relationship(foreign_keys=[show_id])
+ stage_direction_styles: Mapped[List[StageDirectionStyle]] = relationship(
cascade="all, delete-orphan", back_populates="script"
)
@@ -64,19 +66,19 @@ class ScriptRevision(db.Model):
ForeignKey("script_revisions.id", ondelete="SET NULL")
)
- previous_revision: Mapped["ScriptRevision"] = relationship(
+ previous_revision: Mapped[ScriptRevision] = relationship(
foreign_keys=[previous_revision_id]
)
- script: Mapped["Script"] = relationship(
+ script: Mapped[Script] = relationship(
foreign_keys=[script_id], back_populates="revisions"
)
- line_associations: Mapped[List["ScriptLineRevisionAssociation"]] = relationship(
+ line_associations: Mapped[List[ScriptLineRevisionAssociation]] = relationship(
cascade="all, delete", back_populates="revision"
)
- line_part_cuts: Mapped[List["ScriptCuts"]] = relationship(
+ line_part_cuts: Mapped[List[ScriptCuts]] = relationship(
cascade="all, delete-orphan", back_populates="revision"
)
- cue_associations: Mapped[List["CueAssociation"]] = relationship(
+ cue_associations: Mapped[List[CueAssociation]] = relationship(
cascade="all, delete-orphan", back_populates="revision"
)
@@ -130,17 +132,17 @@ class ScriptLine(db.Model):
ForeignKey("stage_direction_styles.id", ondelete="SET NULL")
)
- act: Mapped["Act"] = relationship(back_populates="lines")
- scene: Mapped["Scene"] = relationship(back_populates="lines")
- revision_associations: Mapped[List["ScriptLineRevisionAssociation"]] = relationship(
+ act: Mapped[Act] = relationship(back_populates="lines")
+ scene: Mapped[Scene] = relationship(back_populates="lines")
+ revision_associations: Mapped[List[ScriptLineRevisionAssociation]] = relationship(
foreign_keys="[ScriptLineRevisionAssociation.line_id]",
cascade="all, delete",
back_populates="line",
)
- cue_associations: Mapped[List["CueAssociation"]] = relationship(
+ cue_associations: Mapped[List[CueAssociation]] = relationship(
foreign_keys="[CueAssociation.line_id]", viewonly=True, back_populates="line"
)
- line_parts: Mapped[List["ScriptLinePart"]] = relationship(
+ line_parts: Mapped[List[ScriptLinePart]] = relationship(
cascade="all, delete-orphan", back_populates="line"
)
@@ -158,14 +160,14 @@ class ScriptLineRevisionAssociation(db.Model, DeleteMixin):
next_line_id: Mapped[int | None] = mapped_column(ForeignKey("script_lines.id"))
previous_line_id: Mapped[int | None] = mapped_column(ForeignKey("script_lines.id"))
- revision: Mapped["ScriptRevision"] = relationship(
+ revision: Mapped[ScriptRevision] = relationship(
foreign_keys=[revision_id], back_populates="line_associations"
)
- line: Mapped["ScriptLine"] = relationship(
+ line: Mapped[ScriptLine] = relationship(
foreign_keys=[line_id], back_populates="revision_associations"
)
- next_line: Mapped["ScriptLine"] = relationship(foreign_keys=[next_line_id])
- previous_line: Mapped["ScriptLine"] = relationship(foreign_keys=[previous_line_id])
+ next_line: Mapped[ScriptLine] = relationship(foreign_keys=[next_line_id])
+ previous_line: Mapped[ScriptLine] = relationship(foreign_keys=[previous_line_id])
def pre_delete(self, session):
pass
@@ -259,12 +261,12 @@ class ScriptLinePart(db.Model):
)
line_text: Mapped[str | None] = mapped_column(String)
- line: Mapped["ScriptLine"] = relationship(
+ line: Mapped[ScriptLine] = relationship(
foreign_keys=[line_id], back_populates="line_parts"
)
- character: Mapped["Character"] = relationship()
- character_group: Mapped["CharacterGroup"] = relationship()
- line_part_cuts: Mapped["ScriptCuts"] = relationship(
+ character: Mapped[Character] = relationship()
+ character_group: Mapped[CharacterGroup] = relationship()
+ line_part_cuts: Mapped[ScriptCuts] = relationship(
cascade="all, delete-orphan", back_populates="line_part"
)
@@ -279,10 +281,10 @@ class ScriptCuts(db.Model):
ForeignKey("script_revisions.id"), primary_key=True, index=True
)
- line_part: Mapped["ScriptLinePart"] = relationship(
+ line_part: Mapped[ScriptLinePart] = relationship(
foreign_keys=[line_part_id], back_populates="line_part_cuts"
)
- revision: Mapped["ScriptRevision"] = relationship(
+ revision: Mapped[ScriptRevision] = relationship(
foreign_keys=[revision_id], back_populates="line_part_cuts"
)
@@ -313,7 +315,7 @@ class StageDirectionStyle(db.Model, DeleteMixin):
enable_background_colour: Mapped[bool | None] = mapped_column(Boolean)
background_colour: Mapped[str | None] = mapped_column(String)
- script: Mapped["Script"] = relationship(
+ script: Mapped[Script] = relationship(
foreign_keys=[script_id], back_populates="stage_direction_styles"
)
@@ -344,10 +346,10 @@ class CompiledScript(db.Model):
)
data_path: Mapped[str | None] = mapped_column(String)
- script_revision: Mapped["ScriptRevision"] = relationship(foreign_keys=[revision_id])
+ script_revision: Mapped[ScriptRevision] = relationship(foreign_keys=[revision_id])
@classmethod
- async def compile_script(cls, application: "DigiScriptServer", revision_id):
+ async def compile_script(cls, application: DigiScriptServer, revision_id):
line_schema = get_registry().get_schema_by_model(ScriptLine)()
with application.get_db().sessionmaker() as session:
revision: ScriptRevision = session.get(ScriptRevision, revision_id)
diff --git a/server/models/session.py b/server/models/session.py
index cc72e09d..416eebe9 100644
--- a/server/models/session.py
+++ b/server/models/session.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
from functools import partial
from typing import TYPE_CHECKING
@@ -26,8 +28,8 @@ class Session(db.Model):
ForeignKey("user.id", ondelete="SET NULL"), index=True
)
- user: Mapped["User"] = relationship(back_populates="sessions")
- live_session: Mapped["ShowSession"] = relationship(
+ user: Mapped[User] = relationship(back_populates="sessions")
+ live_session: Mapped[ShowSession] = relationship(
foreign_keys="[ShowSession.client_internal_id]",
back_populates="client",
)
@@ -66,16 +68,16 @@ class ShowSession(db.Model):
ForeignKey("showinterval.id", ondelete="SET NULL")
)
- show: Mapped["Show"] = relationship(uselist=False, foreign_keys=[show_id])
- revision: Mapped["ScriptRevision"] = relationship(
+ show: Mapped[Show] = relationship(uselist=False, foreign_keys=[show_id])
+ revision: Mapped[ScriptRevision] = relationship(
uselist=False, foreign_keys=[script_revision_id]
)
- user: Mapped["User"] = relationship(uselist=False, foreign_keys=[user_id])
- client: Mapped["Session"] = relationship(
+ user: Mapped[User] = relationship(uselist=False, foreign_keys=[user_id])
+ client: Mapped[Session] = relationship(
foreign_keys=[client_internal_id],
back_populates="live_session",
)
- tags: Mapped[list["SessionTag"]] = relationship(
+ tags: Mapped[list[SessionTag]] = relationship(
secondary=session_tag_association_table, back_populates="sessions"
)
@@ -105,7 +107,7 @@ class SessionTag(db.Model):
tag: Mapped[str] = mapped_column(String(255))
colour: Mapped[str] = mapped_column(String(16))
- show: Mapped["Show"] = relationship(uselist=False, foreign_keys=[show_id])
- sessions: Mapped[list["ShowSession"]] = relationship(
+ show: Mapped[Show] = relationship(uselist=False, foreign_keys=[show_id])
+ sessions: Mapped[list[ShowSession]] = relationship(
secondary=session_tag_association_table, back_populates="tags"
)
diff --git a/server/models/show.py b/server/models/show.py
index 5c115844..3ee2e5ac 100644
--- a/server/models/show.py
+++ b/server/models/show.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import enum
from typing import TYPE_CHECKING, List
@@ -13,6 +15,16 @@
from models.mics import MicrophoneAllocation
from models.script import ScriptLine
from models.session import ShowSession
+ from models.stage import (
+ Crew,
+ CrewAssignment,
+ Props,
+ PropsAllocation,
+ PropType,
+ Scenery,
+ SceneryAllocation,
+ SceneryType,
+ )
class ShowScriptType(enum.IntEnum):
@@ -60,23 +72,40 @@ class Show(db.Model):
script_mode: Mapped[ShowScriptType] = mapped_column(ShowScriptTypeCol)
# Relationships
- first_act: Mapped["Act"] = relationship(foreign_keys=[first_act_id])
- current_session: Mapped["ShowSession"] = relationship(
+ first_act: Mapped[Act] = relationship(foreign_keys=[first_act_id])
+ current_session: Mapped[ShowSession] = relationship(
foreign_keys=[current_session_id]
)
-
- cast_list: Mapped[List["Cast"]] = relationship(cascade="all, delete-orphan")
- character_list: Mapped[List["Character"]] = relationship(
- cascade="all, delete-orphan"
+ cast_list: Mapped[List[Cast]] = relationship(cascade="all, delete-orphan")
+ crew_list: Mapped[List[Crew]] = relationship(
+ back_populates="show",
+ cascade="all, delete-orphan",
+ )
+ scenery_types: Mapped[List[SceneryType]] = relationship(
+ back_populates="show",
+ cascade="all, delete-orphan",
+ )
+ scenery_list: Mapped[List[Scenery]] = relationship(
+ back_populates="show",
+ cascade="all, delete-orphan",
+ )
+ prop_types: Mapped[List[PropType]] = relationship(
+ back_populates="show",
+ cascade="all, delete-orphan",
+ )
+ props_list: Mapped[List[Props]] = relationship(
+ back_populates="show",
+ cascade="all, delete-orphan",
)
- character_group_list: Mapped[List["CharacterGroup"]] = relationship(
+ character_list: Mapped[List[Character]] = relationship(cascade="all, delete-orphan")
+ character_group_list: Mapped[List[CharacterGroup]] = relationship(
cascade="all, delete-orphan"
)
- act_list: Mapped[List["Act"]] = relationship(
+ act_list: Mapped[List[Act]] = relationship(
primaryjoin="Show.id == Act.show_id", cascade="all, delete-orphan"
)
- scene_list: Mapped[List["Scene"]] = relationship(cascade="all, delete-orphan")
- cue_type_list: Mapped[List["CueType"]] = relationship(cascade="all, delete-orphan")
+ scene_list: Mapped[List[Scene]] = relationship(cascade="all, delete-orphan")
+ cue_type_list: Mapped[List[CueType]] = relationship(cascade="all, delete-orphan")
class Cast(db.Model):
@@ -88,9 +117,7 @@ class Cast(db.Model):
last_name: Mapped[str | None] = mapped_column()
# Relationships
- character_list: Mapped[List["Character"]] = relationship(
- back_populates="cast_member"
- )
+ character_list: Mapped[List[Character]] = relationship(back_populates="cast_member")
character_group_association_table = Table(
@@ -110,12 +137,12 @@ class Character(db.Model):
name: Mapped[str | None] = mapped_column()
description: Mapped[str | None] = mapped_column()
- cast_member: Mapped["Cast"] = relationship(back_populates="character_list")
- character_groups: Mapped[List["CharacterGroup"]] = relationship(
+ cast_member: Mapped[Cast] = relationship(back_populates="character_list")
+ character_groups: Mapped[List[CharacterGroup]] = relationship(
secondary=character_group_association_table,
back_populates="characters",
)
- mic_allocations: Mapped[List["MicrophoneAllocation"]] = relationship(
+ mic_allocations: Mapped[List[MicrophoneAllocation]] = relationship(
cascade="all, delete-orphan", back_populates="character"
)
@@ -129,7 +156,7 @@ class CharacterGroup(db.Model):
name: Mapped[str | None] = mapped_column()
description: Mapped[str | None] = mapped_column()
- characters: Mapped[List["Character"]] = relationship(
+ characters: Mapped[List[Character]] = relationship(
secondary=character_group_association_table,
back_populates="character_groups",
)
@@ -145,22 +172,22 @@ class Act(db.Model):
first_scene_id: Mapped[int | None] = mapped_column(ForeignKey("scene.id"))
previous_act_id: Mapped[int | None] = mapped_column(ForeignKey("act.id"))
- first_scene: Mapped["Scene"] = relationship(foreign_keys=[first_scene_id])
- previous_act: Mapped["Act"] = relationship(
+ first_scene: Mapped[Scene] = relationship(foreign_keys=[first_scene_id])
+ previous_act: Mapped[Act] = relationship(
remote_side="[Act.id]",
back_populates="next_act",
foreign_keys=[previous_act_id],
)
- next_act: Mapped["Act"] = relationship(
+ next_act: Mapped[Act] = relationship(
back_populates="previous_act",
foreign_keys="[Act.previous_act_id]",
)
- scene_list: Mapped[List["Scene"]] = relationship(
+ scene_list: Mapped[List[Scene]] = relationship(
back_populates="act",
cascade="all, delete-orphan",
foreign_keys="[Scene.act_id]",
)
- lines: Mapped[List["ScriptLine"]] = relationship(
+ lines: Mapped[List[ScriptLine]] = relationship(
back_populates="act", cascade="all, delete-orphan"
)
@@ -174,23 +201,35 @@ class Scene(db.Model):
name: Mapped[str | None] = mapped_column()
previous_scene_id: Mapped[int | None] = mapped_column(ForeignKey("scene.id"))
- act: Mapped["Act"] = relationship(
+ act: Mapped[Act] = relationship(
back_populates="scene_list",
foreign_keys=[act_id],
post_update=True,
)
- previous_scene: Mapped["Scene"] = relationship(
+ previous_scene: Mapped[Scene] = relationship(
remote_side="[Scene.id]",
back_populates="next_scene",
foreign_keys=[previous_scene_id],
)
- next_scene: Mapped["Scene"] = relationship(
+ next_scene: Mapped[Scene] = relationship(
back_populates="previous_scene",
foreign_keys="[Scene.previous_scene_id]",
)
- lines: Mapped[List["ScriptLine"]] = relationship(
+ lines: Mapped[List[ScriptLine]] = relationship(
back_populates="scene", cascade="all, delete-orphan"
)
- mic_allocations: Mapped[List["MicrophoneAllocation"]] = relationship(
+ mic_allocations: Mapped[List[MicrophoneAllocation]] = relationship(
cascade="all, delete-orphan", back_populates="scene"
)
+ scenery_allocations: Mapped[List["SceneryAllocation"]] = relationship(
+ back_populates="scene",
+ cascade="all, delete-orphan",
+ )
+ props_allocations: Mapped[List["PropsAllocation"]] = relationship(
+ back_populates="scene",
+ cascade="all, delete-orphan",
+ )
+ crew_assignments: Mapped[List["CrewAssignment"]] = relationship(
+ back_populates="scene",
+ cascade="all, delete-orphan",
+ )
diff --git a/server/models/stage.py b/server/models/stage.py
new file mode 100644
index 00000000..a5cb6ad2
--- /dev/null
+++ b/server/models/stage.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+from typing import List
+
+from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from models.models import db
+from models.show import Scene, Show
+
+
+class Crew(db.Model):
+ __tablename__ = "crew"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ first_name: Mapped[str] = mapped_column()
+ last_name: Mapped[str | None] = mapped_column()
+
+ show: Mapped[Show] = relationship(back_populates="crew_list")
+ crew_assignments: Mapped[List["CrewAssignment"]] = relationship(
+ back_populates="crew",
+ cascade="all, delete-orphan",
+ )
+
+
+class CrewAssignment(db.Model):
+ """
+ Assigns a crew member to SET or STRIKE an item (prop or scenery) in a specific scene.
+
+ The scene must be a block boundary for the item:
+ - SET assignments go on the first scene of a block
+ - STRIKE assignments go on the last scene of a block
+
+ Exactly one of prop_id or scenery_id must be set (enforced by CHECK constraint).
+ """
+
+ __tablename__ = "crew_assignment"
+ __table_args__ = (
+ # Exactly one of prop_id or scenery_id must be set
+ CheckConstraint(
+ "(prop_id IS NOT NULL AND scenery_id IS NULL) OR "
+ "(prop_id IS NULL AND scenery_id IS NOT NULL)",
+ name="exactly_one_item_type",
+ ),
+ # Prevent duplicate assignments for props
+ UniqueConstraint(
+ "crew_id",
+ "scene_id",
+ "assignment_type",
+ "prop_id",
+ name="uq_crew_prop_assignment",
+ ),
+ # Prevent duplicate assignments for scenery
+ UniqueConstraint(
+ "crew_id",
+ "scene_id",
+ "assignment_type",
+ "scenery_id",
+ name="uq_crew_scenery_assignment",
+ ),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ crew_id: Mapped[int] = mapped_column(ForeignKey("crew.id", ondelete="CASCADE"))
+ scene_id: Mapped[int] = mapped_column(ForeignKey("scene.id", ondelete="CASCADE"))
+ assignment_type: Mapped[str] = mapped_column() # 'set' or 'strike'
+
+ # Two nullable FKs - exactly one must be non-null
+ prop_id: Mapped[int | None] = mapped_column(
+ ForeignKey("props.id", ondelete="CASCADE")
+ )
+ scenery_id: Mapped[int | None] = mapped_column(
+ ForeignKey("scenery.id", ondelete="CASCADE")
+ )
+
+ # Relationships
+ crew: Mapped["Crew"] = relationship(back_populates="crew_assignments")
+ scene: Mapped[Scene] = relationship(back_populates="crew_assignments")
+ prop: Mapped["Props | None"] = relationship(back_populates="crew_assignments")
+ scenery: Mapped["Scenery | None"] = relationship(back_populates="crew_assignments")
+
+
+class SceneryAllocation(db.Model):
+ __tablename__ = "scenery_allocation"
+ __table_args__ = (
+ UniqueConstraint("scenery_id", "scene_id", name="uq_scenery_scene"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ scenery_id: Mapped[int] = mapped_column(
+ ForeignKey("scenery.id", ondelete="CASCADE")
+ )
+ scene_id: Mapped[int] = mapped_column(ForeignKey("scene.id", ondelete="CASCADE"))
+
+ scenery: Mapped[Scenery] = relationship(
+ back_populates="scene_allocations",
+ foreign_keys=[scenery_id],
+ )
+ scene: Mapped[Scene] = relationship(
+ back_populates="scenery_allocations",
+ foreign_keys=[scene_id],
+ )
+
+
+class PropsAllocation(db.Model):
+ __tablename__ = "props_allocation"
+ __table_args__ = (UniqueConstraint("props_id", "scene_id", name="uq_props_scene"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ props_id: Mapped[int] = mapped_column(ForeignKey("props.id", ondelete="CASCADE"))
+ scene_id: Mapped[int] = mapped_column(ForeignKey("scene.id", ondelete="CASCADE"))
+
+ prop: Mapped[Props] = relationship(
+ back_populates="scene_allocations",
+ foreign_keys=[props_id],
+ )
+ scene: Mapped[Scene] = relationship(
+ back_populates="props_allocations",
+ foreign_keys=[scene_id],
+ )
+
+
+class SceneryType(db.Model):
+ __tablename__ = "scenery_type"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ name: Mapped[str] = mapped_column()
+ description: Mapped[str | None] = mapped_column()
+
+ show: Mapped[Show] = relationship(back_populates="scenery_types")
+ scenery_items: Mapped[list[Scenery]] = relationship(
+ back_populates="scenery_type",
+ cascade="all, delete-orphan",
+ )
+
+
+class Scenery(db.Model):
+ __tablename__ = "scenery"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ scenery_type_id: Mapped[int] = mapped_column(ForeignKey("scenery_type.id"))
+ name: Mapped[str] = mapped_column()
+ description: Mapped[str | None] = mapped_column()
+
+ show: Mapped[Show] = relationship(back_populates="scenery_list")
+ scenery_type: Mapped[SceneryType] = relationship(back_populates="scenery_items")
+ scene_allocations: Mapped[List[SceneryAllocation]] = relationship(
+ back_populates="scenery",
+ cascade="all, delete-orphan",
+ )
+ crew_assignments: Mapped[List["CrewAssignment"]] = relationship(
+ back_populates="scenery",
+ cascade="all, delete-orphan",
+ )
+
+
+class PropType(db.Model):
+ __tablename__ = "prop_type"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ name: Mapped[str] = mapped_column()
+ description: Mapped[str | None] = mapped_column()
+
+ show: Mapped[Show] = relationship(back_populates="prop_types")
+ prop_items: Mapped[List[Props]] = relationship(
+ back_populates="prop_type",
+ cascade="all, delete-orphan",
+ )
+
+
+class Props(db.Model):
+ __tablename__ = "props"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ prop_type_id: Mapped[int] = mapped_column(ForeignKey("prop_type.id"))
+ name: Mapped[str] = mapped_column()
+ description: Mapped[str | None] = mapped_column()
+
+ show: Mapped[Show] = relationship(back_populates="props_list")
+ prop_type: Mapped[PropType] = relationship(back_populates="prop_items")
+ scene_allocations: Mapped[list[PropsAllocation]] = relationship(
+ back_populates="prop",
+ cascade="all, delete-orphan",
+ )
+ crew_assignments: Mapped[List["CrewAssignment"]] = relationship(
+ back_populates="prop",
+ cascade="all, delete-orphan",
+ )
diff --git a/server/models/user.py b/server/models/user.py
index 5075859e..258655c9 100644
--- a/server/models/user.py
+++ b/server/models/user.py
@@ -1,10 +1,12 @@
+from __future__ import annotations
+
import datetime
import enum
import json
from functools import partial
from typing import TYPE_CHECKING, List, Union
-from sqlalchemy import ForeignKey, Integer, Text, TypeDecorator, select
+from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text, TypeDecorator, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.models import db
@@ -71,7 +73,7 @@ class User(db.Model):
requires_password_change: Mapped[bool] = mapped_column(default=False)
token_version: Mapped[int] = mapped_column(default=0)
- sessions: Mapped[List["Session"]] = relationship(back_populates="user")
+ sessions: Mapped[List[Session]] = relationship(back_populates="user")
class UserSettings(db.Model):
@@ -84,6 +86,14 @@ class UserSettings(db.Model):
script_text_alignment: Mapped[TextAlignment] = mapped_column(
TextAlignmentCol, default=TextAlignment.CENTER
)
+ console_log_level: Mapped[str] = mapped_column(default="WARN")
+
+ __table_args__ = (
+ CheckConstraint(
+ "console_log_level IN ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'SILENT')",
+ name="ck_user_settings_console_log_level",
+ ),
+ )
# Hidden Properties (None user editable, marked with _)
# Make sure to also mark these as hidden in the Schema for this in schemas/schemas.py
diff --git a/server/pyproject.toml b/server/pyproject.toml
index d99b93e2..6f579273 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "digiscript-server"
-version = "0.24.2"
+version = "0.25.0"
description = "DigiScript server - Digital script management for theatrical shows"
readme = "../README.md"
requires-python = ">=3.13"
diff --git a/server/rbac/rbac.py b/server/rbac/rbac.py
index a3fa8b9c..c5074782 100644
--- a/server/rbac/rbac.py
+++ b/server/rbac/rbac.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from typing import TYPE_CHECKING, List, Optional
from models.models import db
@@ -12,7 +14,7 @@
class RBACController:
- def __init__(self, app: "DigiScriptServer"):
+ def __init__(self, app: DigiScriptServer):
self.app = app
self._rbac_db = RBACDatabase(app.get_db(), app)
self._display_fields = {}
diff --git a/server/rbac/rbac_db.py b/server/rbac/rbac_db.py
index 4fa413cf..19151ab4 100644
--- a/server/rbac/rbac_db.py
+++ b/server/rbac/rbac_db.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import functools
from collections import defaultdict
from copy import deepcopy
@@ -85,7 +87,7 @@ def _get_mapping_columns(
class RBACDatabase:
- def __init__(self, _db: DigiSQLAlchemy, app: "DigiScriptServer"):
+ def __init__(self, _db: DigiSQLAlchemy, app: DigiScriptServer):
self._db: DigiSQLAlchemy = _db
self._app = app
self._mappings = {}
diff --git a/server/registry/schema.py b/server/registry/schema.py
index 4e5f7f53..9e744e6f 100644
--- a/server/registry/schema.py
+++ b/server/registry/schema.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from typing import TYPE_CHECKING, Optional
from marshmallow_sqlalchemy import SQLAlchemySchema
@@ -19,7 +21,7 @@ def set(self, key, value):
def get_schema_by_model(self, key) -> Optional[SQLAlchemySchema]:
return self._backward_registry.get(key, None)
- def get_model_by_schema(self, key) -> "db.Model":
+ def get_model_by_schema(self, key) -> db.Model:
return self._forward_registry.get(key, None)
diff --git a/server/requirements.txt b/server/requirements.txt
index 1d8a2d0b..51709168 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -6,9 +6,10 @@ marshmallow-sqlalchemy>=1.4.0
tornado-prometheus==0.1.2
bcrypt==4.3.0
anytree==2.13.0
-alembic==1.18.1
+alembic==1.18.4
marshmallow<5
-pyjwt[crypto]==2.10.1
+pyjwt[crypto]==2.11.0
setuptools==80.10.2
xkcdpass==1.30.0
-zeroconf==0.148.0
\ No newline at end of file
+zeroconf==0.148.0
+python-jsonpath==2.0.2
\ No newline at end of file
diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py
index 84b4a383..9feacdb7 100644
--- a/server/schemas/schemas.py
+++ b/server/schemas/schemas.py
@@ -14,6 +14,16 @@
)
from models.session import Interval, Session, SessionTag, ShowSession
from models.show import Act, Cast, Character, CharacterGroup, Scene, Show
+from models.stage import (
+ Crew,
+ CrewAssignment,
+ Props,
+ PropsAllocation,
+ PropType,
+ Scenery,
+ SceneryAllocation,
+ SceneryType,
+)
from models.user import User, UserSettings
from registry.schema import get_registry
@@ -72,6 +82,70 @@ class Meta:
)
+@schema
+class CrewSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = Crew
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class SceneryTypeSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = SceneryType
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class ScenerySchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = Scenery
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class PropsSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = Props
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class PropTypeSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = PropType
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class PropsAllocationSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = PropsAllocation
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class SceneryAllocationSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = SceneryAllocation
+ load_instance = True
+ include_fk = True
+
+
+@schema
+class CrewAssignmentSchema(SQLAlchemyAutoSchema):
+ class Meta:
+ model = CrewAssignment
+ load_instance = True
+ include_fk = True
+
+
@schema
class CharacterSchema(SQLAlchemyAutoSchema):
class Meta:
@@ -153,7 +227,7 @@ class Meta:
include_fk = True
line_parts = Nested(
- lambda: ScriptLinePartSchema(),
+ lambda: ScriptLinePartSchema(), # noqa: PLW0108 — forward reference required
many=True,
)
diff --git a/server/test/controllers/api/show/stage/__init__.py b/server/test/controllers/api/show/stage/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/show/stage/test_crew.py b/server/test/controllers/api/show/stage/test_crew.py
new file mode 100644
index 00000000..49550867
--- /dev/null
+++ b/server/test/controllers/api/show/stage/test_crew.py
@@ -0,0 +1,288 @@
+import tornado.escape
+
+from models.show import Show, ShowScriptType
+from models.stage import Crew
+from models.user import User
+from test.conftest import DigiScriptTestCase
+
+
+class TestCrewController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/crew endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # GET tests
+
+ def test_get_crew_empty(self):
+ """Test GET with no crew returns empty list."""
+ response = self.fetch("/api/v1/show/stage/crew")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("crew", response_body)
+ self.assertEqual([], response_body["crew"])
+
+ def test_get_crew_returns_all(self):
+ """Test GET returns all crew members for the show."""
+ with self._app.get_db().sessionmaker() as session:
+ crew_member = Crew(show_id=self.show_id, first_name="John", last_name="Doe")
+ session.add(crew_member)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/crew")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["crew"]))
+ self.assertEqual("John", response_body["crew"][0]["first_name"])
+ self.assertEqual("Doe", response_body["crew"][0]["last_name"])
+
+ def test_get_crew_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/crew")
+ self.assertEqual(400, response.code)
+
+ # POST tests
+
+ def test_create_crew_success(self):
+ """Test POST creates a new crew member."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="POST",
+ body=tornado.escape.json_encode({"firstName": "Jane", "lastName": "Smith"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify crew member was created
+ with self._app.get_db().sessionmaker() as session:
+ crew = session.get(Crew, response_body["id"])
+ self.assertIsNotNone(crew)
+ self.assertEqual("Jane", crew.first_name)
+ self.assertEqual("Smith", crew.last_name)
+
+ def test_create_crew_missing_first_name(self):
+ """Test POST returns 400 when firstName is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="POST",
+ body=tornado.escape.json_encode({"lastName": "Smith"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("First name missing", response_body["message"])
+
+ def test_create_crew_missing_last_name(self):
+ """Test POST returns 400 when lastName is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="POST",
+ body=tornado.escape.json_encode({"firstName": "Jane"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Last name missing", response_body["message"])
+
+ def test_create_crew_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="POST",
+ body=tornado.escape.json_encode({"firstName": "Jane", "lastName": "Smith"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # PATCH tests
+
+ def test_update_crew_success(self):
+ """Test PATCH updates an existing crew member."""
+ # Create a crew member first
+ with self._app.get_db().sessionmaker() as session:
+ crew_member = Crew(show_id=self.show_id, first_name="John", last_name="Doe")
+ session.add(crew_member)
+ session.flush()
+ crew_id = crew_member.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": crew_id, "firstName": "Jane", "lastName": "Smith"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ crew = session.get(Crew, crew_id)
+ self.assertEqual("Jane", crew.first_name)
+ self.assertEqual("Smith", crew.last_name)
+
+ def test_update_crew_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode({"firstName": "Jane", "lastName": "Smith"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_update_crew_not_found(self):
+ """Test PATCH returns 404 for non-existent crew member."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": 99999, "firstName": "Jane", "lastName": "Smith"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_update_crew_missing_first_name(self):
+ """Test PATCH returns 400 when firstName is missing."""
+ # Create a crew member first
+ with self._app.get_db().sessionmaker() as session:
+ crew_member = Crew(show_id=self.show_id, first_name="John", last_name="Doe")
+ session.add(crew_member)
+ session.flush()
+ crew_id = crew_member.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": crew_id, "lastName": "Smith"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("First name missing", response_body["message"])
+
+ def test_update_crew_missing_last_name(self):
+ """Test PATCH returns 400 when lastName is missing."""
+ # Create a crew member first
+ with self._app.get_db().sessionmaker() as session:
+ crew_member = Crew(show_id=self.show_id, first_name="John", last_name="Doe")
+ session.add(crew_member)
+ session.flush()
+ crew_id = crew_member.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": crew_id, "firstName": "Jane"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Last name missing", response_body["message"])
+
+ def test_update_crew_no_show(self):
+ """Test PATCH returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": 1, "firstName": "Jane", "lastName": "Smith"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # DELETE tests
+
+ def test_delete_crew_success(self):
+ """Test DELETE removes a crew member."""
+ # Create a crew member first
+ with self._app.get_db().sessionmaker() as session:
+ crew_member = Crew(show_id=self.show_id, first_name="John", last_name="Doe")
+ session.add(crew_member)
+ session.flush()
+ crew_id = crew_member.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/crew?id={crew_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ crew = session.get(Crew, crew_id)
+ self.assertIsNone(crew)
+
+ def test_delete_crew_missing_id(self):
+ """Test DELETE returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_crew_not_found(self):
+ """Test DELETE returns 404 for non-existent crew member."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_crew_invalid_id(self):
+ """Test DELETE returns 400 when id is not a valid integer."""
+ response = self.fetch(
+ "/api/v1/show/stage/crew?id=not-a-number",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_crew_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/crew?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
diff --git a/server/test/controllers/api/show/stage/test_crew_assignments.py b/server/test/controllers/api/show/stage/test_crew_assignments.py
new file mode 100644
index 00000000..fc90bb81
--- /dev/null
+++ b/server/test/controllers/api/show/stage/test_crew_assignments.py
@@ -0,0 +1,837 @@
+"""Unit tests for crew assignments API controller."""
+
+import tornado.escape
+from tornado.httpclient import HTTPRequest
+from tornado.testing import gen_test
+
+from models.stage import (
+ Crew,
+ CrewAssignment,
+ Props,
+ PropsAllocation,
+ Scenery,
+ SceneryAllocation,
+)
+from test.conftest import DigiScriptTestCase
+from test.helpers.stage_fixtures import (
+ create_act_with_scenes,
+ create_admin_user,
+ create_crew,
+ create_prop,
+ create_scenery,
+ create_show,
+)
+
+
+class TestCrewAssignmentController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/crew/assignments endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ _, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 3,
+ link_to_show=True,
+ )
+ self.scene1_id, self.scene2_id, self.scene3_id = scene_ids
+
+ self.crew_id = create_crew(session, self.show_id)
+ self.prop_type_id, self.prop_id = create_prop(session, self.show_id)
+ self.scenery_type_id, self.scenery_id = create_scenery(
+ session, self.show_id
+ )
+
+ # Allocate prop to scenes 1 and 2 (forms one block)
+ # SET boundary: scene 1, STRIKE boundary: scene 2
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene1_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene2_id
+ )
+ session.add_all([allocation1, allocation2])
+
+ # Allocate scenery to scene 3 only (single-scene block)
+ allocation3 = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene3_id
+ )
+ session.add(allocation3)
+
+ self.user_id = create_admin_user(session)
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # =========================================================================
+ # GET Tests
+ # =========================================================================
+
+ def test_get_assignments_empty(self):
+ """Test GET with no assignments returns empty list."""
+ response = self.fetch("/api/v1/show/stage/crew/assignments")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("assignments", response_body)
+ self.assertEqual([], response_body["assignments"])
+
+ def test_get_assignments_returns_all(self):
+ """Test GET returns all assignments for the show."""
+ # Create an assignment
+ with self._app.get_db().sessionmaker() as session:
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/crew/assignments")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["assignments"]))
+ self.assertEqual(self.crew_id, response_body["assignments"][0]["crew_id"])
+ self.assertEqual(self.scene1_id, response_body["assignments"][0]["scene_id"])
+ self.assertEqual("set", response_body["assignments"][0]["assignment_type"])
+ self.assertEqual(self.prop_id, response_body["assignments"][0]["prop_id"])
+
+ def test_get_assignments_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/crew/assignments")
+ self.assertEqual(400, response.code)
+
+ # =========================================================================
+ # POST Tests - Basic Creation
+ # =========================================================================
+
+ @gen_test
+ async def test_create_assignment_for_prop(self):
+ """Test POST creates a new crew assignment for a prop."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify assignment was created
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, response_body["id"])
+ self.assertIsNotNone(assignment)
+ self.assertEqual(self.crew_id, assignment.crew_id)
+ self.assertEqual(self.scene1_id, assignment.scene_id)
+ self.assertEqual("set", assignment.assignment_type)
+ self.assertEqual(self.prop_id, assignment.prop_id)
+ self.assertIsNone(assignment.scenery_id)
+
+ @gen_test
+ async def test_create_assignment_for_scenery(self):
+ """Test POST creates a new crew assignment for scenery."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene3_id,
+ "assignment_type": "set",
+ "scenery_id": self.scenery_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+
+ # Verify assignment was created
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, response_body["id"])
+ self.assertIsNotNone(assignment)
+ self.assertIsNone(assignment.prop_id)
+ self.assertEqual(self.scenery_id, assignment.scenery_id)
+
+ @gen_test
+ async def test_create_assignment_strike(self):
+ """Test POST creates a strike assignment."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene2_id, # Valid STRIKE boundary
+ "assignment_type": "strike",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # =========================================================================
+ # POST Tests - Validation Errors
+ # =========================================================================
+
+ @gen_test
+ async def test_create_assignment_missing_crew_id(self):
+ """Test POST returns 400 when crew_id is missing."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("crew_id missing", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_missing_scene_id(self):
+ """Test POST returns 400 when scene_id is missing."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scene ID missing", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_missing_assignment_type(self):
+ """Test POST returns 400 when assignment_type is missing."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("assignment_type missing", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_invalid_assignment_type(self):
+ """Test POST returns 400 for invalid assignment_type."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "invalid",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("'set' or 'strike'", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_missing_item_id(self):
+ """Test POST returns 400 when neither prop_id nor scenery_id is provided."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Either prop_id or scenery_id", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_both_item_ids(self):
+ """Test POST returns 400 when both prop_id and scenery_id are provided."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ "scenery_id": self.scenery_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Only one of prop_id or scenery_id", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_invalid_boundary(self):
+ """Test POST returns 400 for scene that is not a valid boundary."""
+ # Scene 2 is valid STRIKE but not valid SET for the prop
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene2_id, # Not a valid SET boundary
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("not a valid boundary", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_unallocated_scene(self):
+ """Test POST returns 400 for scene where item is not allocated."""
+ # Scene 3 has no prop allocation
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene3_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("not a valid boundary", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_crew_not_found(self):
+ """Test POST returns 404 for non-existent crew member."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": 99999,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("crew member not found", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_prop_not_found(self):
+ """Test POST returns 404 for non-existent prop."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": 99999,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("prop not found", response_body["message"])
+
+ @gen_test
+ async def test_create_assignment_duplicate(self):
+ """Test POST returns 400 for duplicate assignment."""
+ # Create first assignment
+ with self._app.get_db().sessionmaker() as session:
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.commit()
+
+ # Try to create duplicate
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "crew_id": self.crew_id,
+ "scene_id": self.scene1_id,
+ "assignment_type": "set",
+ "prop_id": self.prop_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("already exists", response_body["message"])
+
+ # =========================================================================
+ # PATCH Tests
+ # =========================================================================
+
+ @gen_test
+ async def test_update_assignment_crew_id(self):
+ """Test PATCH can update crew_id."""
+ # Create a second crew member
+ with self._app.get_db().sessionmaker() as session:
+ crew2 = Crew(show_id=self.show_id, first_name="Jane", last_name="Smith")
+ session.add(crew2)
+ session.flush()
+ crew2_id = crew2.id
+
+ # Create assignment
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ assignment_id = assignment.id
+ session.commit()
+
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": assignment_id, "crew_id": crew2_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, assignment_id)
+ self.assertEqual(crew2_id, assignment.crew_id)
+
+ @gen_test
+ async def test_update_assignment_assignment_type(self):
+ """Test PATCH can update assignment_type if new scene is valid boundary."""
+ # Create assignment for SET at scene 1
+ with self._app.get_db().sessionmaker() as session:
+ # Create single-scene allocation for scene 3 so it's valid for both
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene3_id)
+ session.add(allocation)
+ session.flush()
+
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene3_id, # Single-scene block - valid for both
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ assignment_id = assignment.id
+ session.commit()
+
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": assignment_id, "assignment_type": "strike"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, assignment_id)
+ self.assertEqual("strike", assignment.assignment_type)
+
+ @gen_test
+ async def test_update_assignment_invalid_new_boundary(self):
+ """Test PATCH returns 400 when new combination is invalid boundary."""
+ # Create assignment for SET at scene 1
+ with self._app.get_db().sessionmaker() as session:
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ assignment_id = assignment.id
+ session.commit()
+
+ # Try to change to STRIKE at scene 1 (not valid - scene 2 is STRIKE boundary)
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": assignment_id, "assignment_type": "strike"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("not a valid boundary", response_body["message"])
+
+ @gen_test
+ async def test_update_assignment_not_found(self):
+ """Test PATCH returns 404 for non-existent assignment."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": 99999, "crew_id": self.crew_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(404, response.code)
+
+ @gen_test
+ async def test_update_assignment_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="PATCH",
+ body=tornado.escape.json_encode({"crew_id": self.crew_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ # =========================================================================
+ # DELETE Tests
+ # =========================================================================
+
+ @gen_test
+ async def test_delete_assignment_success(self):
+ """Test DELETE removes an assignment."""
+ with self._app.get_db().sessionmaker() as session:
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ assignment_id = assignment.id
+ session.commit()
+
+ request = HTTPRequest(
+ self.get_url(f"/api/v1/show/stage/crew/assignments?id={assignment_id}"),
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, assignment_id)
+ self.assertIsNone(assignment)
+
+ @gen_test
+ async def test_delete_assignment_not_found(self):
+ """Test DELETE returns 404 for non-existent assignment."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments?id=99999"),
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(404, response.code)
+
+ @gen_test
+ async def test_delete_assignment_missing_id(self):
+ """Test DELETE returns 400 when ID is missing."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments"),
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ @gen_test
+ async def test_delete_assignment_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/crew/assignments?id=invalid"),
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request, raise_error=False)
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+
+class TestCrewAssignmentCascadeDelete(DigiScriptTestCase):
+ """Test suite for CASCADE delete behavior on crew assignments."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ _, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 1,
+ link_to_show=True,
+ )
+ self.scene_id = scene_ids[0]
+
+ self.crew_id = create_crew(session, self.show_id)
+ _, self.prop_id = create_prop(session, self.show_id)
+
+ # Allocate prop to scene
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene_id)
+ session.add(allocation)
+
+ # Create assignment
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene_id,
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ self.assignment_id = assignment.id
+
+ self.user_id = create_admin_user(session)
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ def test_cascade_delete_on_crew_delete(self):
+ """Test assignment is deleted when crew member is deleted."""
+ # Delete the crew member
+ with self._app.get_db().sessionmaker() as session:
+ crew = session.get(Crew, self.crew_id)
+ session.delete(crew)
+ session.commit()
+
+ # Verify assignment was cascade deleted
+ assignment = session.get(CrewAssignment, self.assignment_id)
+ self.assertIsNone(assignment)
+
+ def test_cascade_delete_on_prop_delete(self):
+ """Test assignment is deleted when prop is deleted."""
+ # Delete the prop
+ with self._app.get_db().sessionmaker() as session:
+ prop = session.get(Props, self.prop_id)
+ session.delete(prop)
+ session.commit()
+
+ # Verify assignment was cascade deleted
+ assignment = session.get(CrewAssignment, self.assignment_id)
+ self.assertIsNone(assignment)
+
+ def test_cascade_delete_on_scenery_delete(self):
+ """Test assignment is deleted when scenery is deleted."""
+ # Create a scenery item and assignment
+ with self._app.get_db().sessionmaker() as session:
+ _, scenery_id = create_scenery(session, self.show_id, name="Wall")
+
+ # Allocate scenery to scene
+ allocation = SceneryAllocation(
+ scenery_id=scenery_id, scene_id=self.scene_id
+ )
+ session.add(allocation)
+
+ # Create assignment for scenery
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene_id,
+ assignment_type="set",
+ scenery_id=scenery_id,
+ )
+ session.add(assignment)
+ session.flush()
+ assignment_id = assignment.id
+ session.commit()
+
+ # Delete the scenery
+ with self._app.get_db().sessionmaker() as session:
+ scenery = session.get(Scenery, scenery_id)
+ session.delete(scenery)
+ session.commit()
+
+ # Verify assignment was cascade deleted
+ assignment = session.get(CrewAssignment, assignment_id)
+ self.assertIsNone(assignment)
+
+
+class TestOrphanDeletionOnAllocationChange(DigiScriptTestCase):
+ """Test suite for orphan deletion when allocations change."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ _, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 3,
+ link_to_show=True,
+ )
+ self.scene1_id, self.scene2_id, self.scene3_id = scene_ids
+
+ self.crew_id = create_crew(session, self.show_id)
+ _, self.prop_id = create_prop(session, self.show_id)
+
+ # Allocate prop to scenes 1 and 2 (block: SET at 1, STRIKE at 2)
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene1_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene2_id
+ )
+ session.add_all([allocation1, allocation2])
+ session.flush()
+ self.allocation2_id = allocation2.id
+
+ # Create crew assignment at STRIKE boundary (scene 2)
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene2_id,
+ assignment_type="strike",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.flush()
+ self.assignment_id = assignment.id
+
+ self.user_id = create_admin_user(session)
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ @gen_test
+ async def test_orphan_deleted_on_allocation_delete(self):
+ """Test crew assignment is deleted when allocation removal invalidates boundary."""
+ # Delete allocation at scene 2 via API
+ # This should make scene 1 the new STRIKE boundary
+ # and orphan the assignment at scene 2
+ request = HTTPRequest(
+ self.get_url(
+ f"/api/v1/show/stage/props/allocations?id={self.allocation2_id}"
+ ),
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # Verify the crew assignment was orphan-deleted
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, self.assignment_id)
+ self.assertIsNone(assignment)
+
+ @gen_test
+ async def test_orphan_deleted_on_allocation_create(self):
+ """Test crew assignment is deleted when new allocation changes boundaries."""
+ # First, set up a scenario where adding allocation creates orphan
+ # Current: prop allocated to scenes 1,2 (SET:1, STRIKE:2)
+ # Add allocation to scene 3 (new block: scenes 1,2,3, SET:1, STRIKE:3)
+ # This makes scene 2 no longer a STRIKE boundary
+
+ request = HTTPRequest(
+ self.get_url("/api/v1/show/stage/props/allocations"),
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": self.prop_id, "scene_id": self.scene3_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ response = await self.http_client.fetch(request)
+ self.assertEqual(200, response.code)
+
+ # Verify the crew assignment at scene 2 STRIKE was orphan-deleted
+ with self._app.get_db().sessionmaker() as session:
+ assignment = session.get(CrewAssignment, self.assignment_id)
+ self.assertIsNone(assignment)
diff --git a/server/test/controllers/api/show/stage/test_props.py b/server/test/controllers/api/show/stage/test_props.py
new file mode 100644
index 00000000..57a56675
--- /dev/null
+++ b/server/test/controllers/api/show/stage/test_props.py
@@ -0,0 +1,1058 @@
+import tornado.escape
+
+from models.show import Act, Scene, Show, ShowScriptType
+from models.stage import Props, PropsAllocation, PropType
+from models.user import User
+from test.conftest import DigiScriptTestCase
+
+
+class TestPropsAllocationController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/props/allocations endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create an act and scene
+ act = Act(show_id=show.id, name="Act 1", interval_after=False)
+ session.add(act)
+ session.flush()
+ self.act_id = act.id
+
+ scene = Scene(
+ show_id=show.id,
+ act_id=act.id,
+ name="Scene 1",
+ previous_scene_id=None,
+ )
+ session.add(scene)
+ session.flush()
+ self.scene_id = scene.id
+
+ # Create a prop type and prop
+ prop_type = PropType(show_id=show.id, name="Hand Props", description="")
+ session.add(prop_type)
+ session.flush()
+ self.prop_type_id = prop_type.id
+
+ prop = Props(
+ show_id=show.id,
+ prop_type_id=prop_type.id,
+ name="Sword",
+ description="Test prop",
+ )
+ session.add(prop)
+ session.flush()
+ self.prop_id = prop.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ def test_get_allocations_empty(self):
+ """Test GET with no allocations returns empty list."""
+ response = self.fetch("/api/v1/show/stage/props/allocations")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("allocations", response_body)
+ self.assertEqual([], response_body["allocations"])
+
+ def test_get_allocations_returns_all(self):
+ """Test GET returns all allocations for the show."""
+ # Create an allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene_id)
+ session.add(allocation)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/props/allocations")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["allocations"]))
+ self.assertEqual(self.prop_id, response_body["allocations"][0]["props_id"])
+ self.assertEqual(self.scene_id, response_body["allocations"][0]["scene_id"])
+
+ def test_get_allocations_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/props/allocations")
+ self.assertEqual(400, response.code)
+
+ def test_create_allocation_success(self):
+ """Test POST creates a new allocation."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": self.prop_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify allocation was created
+ with self._app.get_db().sessionmaker() as session:
+ allocation = session.get(PropsAllocation, response_body["id"])
+ self.assertIsNotNone(allocation)
+ self.assertEqual(self.prop_id, allocation.props_id)
+ self.assertEqual(self.scene_id, allocation.scene_id)
+
+ def test_create_allocation_duplicate(self):
+ """Test POST returns 400 for duplicate allocation."""
+ # Create initial allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene_id)
+ session.add(allocation)
+ session.commit()
+
+ # Try to create duplicate
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": self.prop_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("already exists", response_body["message"])
+
+ def test_create_allocation_invalid_props_id(self):
+ """Test POST returns 404 for non-existent prop."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": 99999, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_invalid_scene_id(self):
+ """Test POST returns 404 for non-existent scene."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": self.prop_id, "scene_id": 99999}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_prop_wrong_show(self):
+ """Test POST returns 404 for prop from different show."""
+ # Create another show with a prop
+ with self._app.get_db().sessionmaker() as session:
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_prop_type = PropType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_prop_type)
+ session.flush()
+
+ other_prop = Props(
+ show_id=other_show.id,
+ prop_type_id=other_prop_type.id,
+ name="Other Prop",
+ description="",
+ )
+ session.add(other_prop)
+ session.flush()
+ other_prop_id = other_prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": other_prop_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_missing_props_id(self):
+ """Test POST returns 400 when props_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode({"scene_id": self.scene_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("props_id missing", response_body["message"])
+
+ def test_create_allocation_missing_scene_id(self):
+ """Test POST returns 400 when scene_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode({"props_id": self.prop_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scene ID missing", response_body["message"])
+
+ def test_create_allocation_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"props_id": self.prop_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ def test_delete_allocation_success(self):
+ """Test DELETE removes an allocation."""
+ # Create an allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene_id)
+ session.add(allocation)
+ session.flush()
+ allocation_id = allocation.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/props/allocations?id={allocation_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify allocation was deleted
+ with self._app.get_db().sessionmaker() as session:
+ allocation = session.get(PropsAllocation, allocation_id)
+ self.assertIsNone(allocation)
+
+ def test_delete_allocation_not_found(self):
+ """Test DELETE returns 404 for non-existent allocation."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_allocation_missing_id(self):
+ """Test DELETE returns 400 when ID is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_allocation_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_allocation_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props/allocations?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+
+class TestPropTypesController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/props/types endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # GET tests
+
+ def test_get_prop_types_empty(self):
+ """Test GET with no prop types returns empty list."""
+ response = self.fetch("/api/v1/show/stage/props/types")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("prop_types", response_body)
+ self.assertEqual([], response_body["prop_types"])
+
+ def test_get_prop_types_returns_all(self):
+ """Test GET returns all prop types for the show."""
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = PropType(
+ show_id=self.show_id, name="Hand Props", description="Small items"
+ )
+ session.add(prop_type)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/props/types")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["prop_types"]))
+ self.assertEqual("Hand Props", response_body["prop_types"][0]["name"])
+
+ def test_get_prop_types_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/props/types")
+ self.assertEqual(400, response.code)
+
+ # POST tests
+
+ def test_create_prop_type_success(self):
+ """Test POST creates a new prop type."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Furniture"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify prop type was created
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = session.get(PropType, response_body["id"])
+ self.assertIsNotNone(prop_type)
+ self.assertEqual("Furniture", prop_type.name)
+
+ def test_create_prop_type_with_description(self):
+ """Test POST creates a prop type with description."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Furniture", "description": "Tables and chairs"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+
+ # Verify description was saved
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = session.get(PropType, response_body["id"])
+ self.assertEqual("Tables and chairs", prop_type.description)
+
+ def test_create_prop_type_missing_name(self):
+ """Test POST returns 400 when name is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="POST",
+ body=tornado.escape.json_encode({"description": "Some description"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_create_prop_type_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Furniture"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # PATCH tests
+
+ def test_update_prop_type_success(self):
+ """Test PATCH updates an existing prop type."""
+ # Create a prop type first
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = PropType(
+ show_id=self.show_id, name="Hand Props", description=""
+ )
+ session.add(prop_type)
+ session.flush()
+ prop_type_id = prop_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": prop_type_id, "name": "Stage Props", "description": "Updated"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = session.get(PropType, prop_type_id)
+ self.assertEqual("Stage Props", prop_type.name)
+ self.assertEqual("Updated", prop_type.description)
+
+ def test_update_prop_type_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"name": "Stage Props"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_update_prop_type_not_found(self):
+ """Test PATCH returns 404 for non-existent prop type."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": 99999, "name": "Stage Props"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_update_prop_type_missing_name(self):
+ """Test PATCH returns 400 when name is missing."""
+ # Create a prop type first
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = PropType(
+ show_id=self.show_id, name="Hand Props", description=""
+ )
+ session.add(prop_type)
+ session.flush()
+ prop_type_id = prop_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": prop_type_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_update_prop_type_no_show(self):
+ """Test PATCH returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": 1, "name": "Stage Props"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # DELETE tests
+
+ def test_delete_prop_type_success(self):
+ """Test DELETE removes a prop type."""
+ # Create a prop type first
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = PropType(
+ show_id=self.show_id, name="Hand Props", description=""
+ )
+ session.add(prop_type)
+ session.flush()
+ prop_type_id = prop_type.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/props/types?id={prop_type_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ prop_type = session.get(PropType, prop_type_id)
+ self.assertIsNone(prop_type)
+
+ def test_delete_prop_type_missing_id(self):
+ """Test DELETE returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_prop_type_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_prop_type_not_found(self):
+ """Test DELETE returns 404 for non-existent prop type."""
+ response = self.fetch(
+ "/api/v1/show/stage/props/types?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_prop_type_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props/types?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+
+class TestPropsController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/props endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create a prop type
+ prop_type = PropType(show_id=show.id, name="Hand Props", description="")
+ session.add(prop_type)
+ session.flush()
+ self.prop_type_id = prop_type.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # GET tests
+
+ def test_get_props_empty(self):
+ """Test GET with no props returns empty list."""
+ response = self.fetch("/api/v1/show/stage/props")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("props", response_body)
+ self.assertEqual([], response_body["props"])
+
+ def test_get_props_returns_all(self):
+ """Test GET returns all props for the show."""
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="A prop sword",
+ )
+ session.add(prop)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/props")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["props"]))
+ self.assertEqual("Sword", response_body["props"][0]["name"])
+
+ def test_get_props_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/props")
+ self.assertEqual(400, response.code)
+
+ # POST tests
+
+ def test_create_props_success(self):
+ """Test POST creates a new prop."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Sword", "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify prop was created
+ with self._app.get_db().sessionmaker() as session:
+ prop = session.get(Props, response_body["id"])
+ self.assertIsNotNone(prop)
+ self.assertEqual("Sword", prop.name)
+ self.assertEqual(self.prop_type_id, prop.prop_type_id)
+
+ def test_create_props_with_description(self):
+ """Test POST creates a prop with description."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "name": "Sword",
+ "prop_type_id": self.prop_type_id,
+ "description": "A medieval sword",
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+
+ # Verify description was saved
+ with self._app.get_db().sessionmaker() as session:
+ prop = session.get(Props, response_body["id"])
+ self.assertEqual("A medieval sword", prop.description)
+
+ def test_create_props_missing_name(self):
+ """Test POST returns 400 when name is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode({"prop_type_id": self.prop_type_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_create_props_missing_prop_type_id(self):
+ """Test POST returns 400 when prop_type_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Sword"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Prop type ID missing", response_body["message"])
+
+ def test_create_props_invalid_prop_type_id(self):
+ """Test POST returns 400 for non-integer prop_type_id."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Sword", "prop_type_id": "invalid"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid prop type ID", response_body["message"])
+
+ def test_create_props_prop_type_not_found(self):
+ """Test POST returns 404 for non-existent prop type."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Sword", "prop_type_id": 99999}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Prop type not found", response_body["message"])
+
+ def test_create_props_prop_type_wrong_show(self):
+ """Test POST returns 400 for prop type from different show."""
+ # Create another show with a prop type
+ with self._app.get_db().sessionmaker() as session:
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_prop_type = PropType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_prop_type)
+ session.flush()
+ other_prop_type_id = other_prop_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Sword", "prop_type_id": other_prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid prop type for show", response_body["message"])
+
+ def test_create_props_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Sword", "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # PATCH tests
+
+ def test_update_props_success(self):
+ """Test PATCH updates an existing prop."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {
+ "id": prop_id,
+ "name": "Shield",
+ "prop_type_id": self.prop_type_id,
+ "description": "Updated",
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ prop = session.get(Props, prop_id)
+ self.assertEqual("Shield", prop.name)
+ self.assertEqual("Updated", prop.description)
+
+ def test_update_props_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"name": "Shield", "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_update_props_not_found(self):
+ """Test PATCH returns 404 for non-existent prop."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": 99999, "name": "Shield", "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_update_props_missing_name(self):
+ """Test PATCH returns 400 when name is missing."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": prop_id, "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_update_props_missing_prop_type_id(self):
+ """Test PATCH returns 400 when prop_type_id is missing."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": prop_id, "name": "Shield"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Prop type ID missing", response_body["message"])
+
+ def test_update_props_invalid_prop_type_id(self):
+ """Test PATCH returns 400 for non-integer prop_type_id."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": prop_id, "name": "Shield", "prop_type_id": "invalid"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid prop type ID", response_body["message"])
+
+ def test_update_props_prop_type_not_found(self):
+ """Test PATCH returns 404 for non-existent prop type."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": prop_id, "name": "Shield", "prop_type_id": 99999}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Prop type not found", response_body["message"])
+
+ def test_update_props_prop_type_wrong_show(self):
+ """Test PATCH returns 400 for prop type from different show."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+
+ # Create another show with a prop type
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_prop_type = PropType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_prop_type)
+ session.flush()
+ other_prop_type_id = other_prop_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": prop_id, "name": "Shield", "prop_type_id": other_prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid prop type for show", response_body["message"])
+
+ def test_update_props_no_show(self):
+ """Test PATCH returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": 1, "name": "Shield", "prop_type_id": self.prop_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # DELETE tests
+
+ def test_delete_props_success(self):
+ """Test DELETE removes a prop."""
+ # Create a prop first
+ with self._app.get_db().sessionmaker() as session:
+ prop = Props(
+ show_id=self.show_id,
+ prop_type_id=self.prop_type_id,
+ name="Sword",
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ prop_id = prop.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/props?id={prop_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ prop = session.get(Props, prop_id)
+ self.assertIsNone(prop)
+
+ def test_delete_props_missing_id(self):
+ """Test DELETE returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/props",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_props_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/props?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_props_not_found(self):
+ """Test DELETE returns 404 for non-existent prop."""
+ response = self.fetch(
+ "/api/v1/show/stage/props?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_props_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/props?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
diff --git a/server/test/controllers/api/show/stage/test_scenery.py b/server/test/controllers/api/show/stage/test_scenery.py
new file mode 100644
index 00000000..1d08354d
--- /dev/null
+++ b/server/test/controllers/api/show/stage/test_scenery.py
@@ -0,0 +1,1078 @@
+import tornado.escape
+
+from models.show import Act, Scene, Show, ShowScriptType
+from models.stage import Scenery, SceneryAllocation, SceneryType
+from models.user import User
+from test.conftest import DigiScriptTestCase
+
+
+class TestSceneryAllocationController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/scenery/allocations endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create an act and scene
+ act = Act(show_id=show.id, name="Act 1", interval_after=False)
+ session.add(act)
+ session.flush()
+ self.act_id = act.id
+
+ scene = Scene(
+ show_id=show.id,
+ act_id=act.id,
+ name="Scene 1",
+ previous_scene_id=None,
+ )
+ session.add(scene)
+ session.flush()
+ self.scene_id = scene.id
+
+ # Create a scenery type and scenery
+ scenery_type = SceneryType(
+ show_id=show.id, name="Backdrops", description=""
+ )
+ session.add(scenery_type)
+ session.flush()
+ self.scenery_type_id = scenery_type.id
+
+ scenery = Scenery(
+ show_id=show.id,
+ scenery_type_id=scenery_type.id,
+ name="Forest Backdrop",
+ description="Test scenery",
+ )
+ session.add(scenery)
+ session.flush()
+ self.scenery_id = scenery.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ def test_get_allocations_empty(self):
+ """Test GET with no allocations returns empty list."""
+ response = self.fetch("/api/v1/show/stage/scenery/allocations")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("allocations", response_body)
+ self.assertEqual([], response_body["allocations"])
+
+ def test_get_allocations_returns_all(self):
+ """Test GET returns all allocations for the show."""
+ # Create an allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene_id
+ )
+ session.add(allocation)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/scenery/allocations")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["allocations"]))
+ self.assertEqual(self.scenery_id, response_body["allocations"][0]["scenery_id"])
+ self.assertEqual(self.scene_id, response_body["allocations"][0]["scene_id"])
+
+ def test_get_allocations_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/scenery/allocations")
+ self.assertEqual(400, response.code)
+
+ def test_create_allocation_success(self):
+ """Test POST creates a new allocation."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": self.scenery_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify allocation was created
+ with self._app.get_db().sessionmaker() as session:
+ allocation = session.get(SceneryAllocation, response_body["id"])
+ self.assertIsNotNone(allocation)
+ self.assertEqual(self.scenery_id, allocation.scenery_id)
+ self.assertEqual(self.scene_id, allocation.scene_id)
+
+ def test_create_allocation_duplicate(self):
+ """Test POST returns 400 for duplicate allocation."""
+ # Create initial allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene_id
+ )
+ session.add(allocation)
+ session.commit()
+
+ # Try to create duplicate
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": self.scenery_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("already exists", response_body["message"])
+
+ def test_create_allocation_invalid_scenery_id(self):
+ """Test POST returns 404 for non-existent scenery."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": 99999, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_invalid_scene_id(self):
+ """Test POST returns 404 for non-existent scene."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": self.scenery_id, "scene_id": 99999}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_scenery_wrong_show(self):
+ """Test POST returns 404 for scenery from different show."""
+ # Create another show with scenery
+ with self._app.get_db().sessionmaker() as session:
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_scenery_type = SceneryType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_scenery_type)
+ session.flush()
+
+ other_scenery = Scenery(
+ show_id=other_show.id,
+ scenery_type_id=other_scenery_type.id,
+ name="Other Scenery",
+ description="",
+ )
+ session.add(other_scenery)
+ session.flush()
+ other_scenery_id = other_scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": other_scenery_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_create_allocation_missing_scenery_id(self):
+ """Test POST returns 400 when scenery_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode({"scene_id": self.scene_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("scenery_id missing", response_body["message"])
+
+ def test_create_allocation_missing_scene_id(self):
+ """Test POST returns 400 when scene_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode({"scenery_id": self.scenery_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scene ID missing", response_body["message"])
+
+ def test_create_allocation_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"scenery_id": self.scenery_id, "scene_id": self.scene_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ def test_delete_allocation_success(self):
+ """Test DELETE removes an allocation."""
+ # Create an allocation
+ with self._app.get_db().sessionmaker() as session:
+ allocation = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene_id
+ )
+ session.add(allocation)
+ session.flush()
+ allocation_id = allocation.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/scenery/allocations?id={allocation_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify allocation was deleted
+ with self._app.get_db().sessionmaker() as session:
+ allocation = session.get(SceneryAllocation, allocation_id)
+ self.assertIsNone(allocation)
+
+ def test_delete_allocation_not_found(self):
+ """Test DELETE returns 404 for non-existent allocation."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_allocation_missing_id(self):
+ """Test DELETE returns 400 when ID is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_allocation_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_allocation_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/allocations?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+
+class TestSceneryTypesController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/scenery/types endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # GET tests
+
+ def test_get_scenery_types_empty(self):
+ """Test GET with no scenery types returns empty list."""
+ response = self.fetch("/api/v1/show/stage/scenery/types")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("scenery_types", response_body)
+ self.assertEqual([], response_body["scenery_types"])
+
+ def test_get_scenery_types_returns_all(self):
+ """Test GET returns all scenery types for the show."""
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = SceneryType(
+ show_id=self.show_id, name="Backdrops", description="Background pieces"
+ )
+ session.add(scenery_type)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/scenery/types")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["scenery_types"]))
+ self.assertEqual("Backdrops", response_body["scenery_types"][0]["name"])
+
+ def test_get_scenery_types_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/scenery/types")
+ self.assertEqual(400, response.code)
+
+ # POST tests
+
+ def test_create_scenery_type_success(self):
+ """Test POST creates a new scenery type."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Platforms"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify scenery type was created
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = session.get(SceneryType, response_body["id"])
+ self.assertIsNotNone(scenery_type)
+ self.assertEqual("Platforms", scenery_type.name)
+
+ def test_create_scenery_type_with_description(self):
+ """Test POST creates a scenery type with description."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Platforms", "description": "Elevated surfaces"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+
+ # Verify description was saved
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = session.get(SceneryType, response_body["id"])
+ self.assertEqual("Elevated surfaces", scenery_type.description)
+
+ def test_create_scenery_type_missing_name(self):
+ """Test POST returns 400 when name is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="POST",
+ body=tornado.escape.json_encode({"description": "Some description"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_create_scenery_type_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Platforms"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # PATCH tests
+
+ def test_update_scenery_type_success(self):
+ """Test PATCH updates an existing scenery type."""
+ # Create a scenery type first
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = SceneryType(
+ show_id=self.show_id, name="Backdrops", description=""
+ )
+ session.add(scenery_type)
+ session.flush()
+ scenery_type_id = scenery_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": scenery_type_id, "name": "Flats", "description": "Updated"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = session.get(SceneryType, scenery_type_id)
+ self.assertEqual("Flats", scenery_type.name)
+ self.assertEqual("Updated", scenery_type.description)
+
+ def test_update_scenery_type_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"name": "Flats"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_update_scenery_type_not_found(self):
+ """Test PATCH returns 404 for non-existent scenery type."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": 99999, "name": "Flats"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_update_scenery_type_missing_name(self):
+ """Test PATCH returns 400 when name is missing."""
+ # Create a scenery type first
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = SceneryType(
+ show_id=self.show_id, name="Backdrops", description=""
+ )
+ session.add(scenery_type)
+ session.flush()
+ scenery_type_id = scenery_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": scenery_type_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_update_scenery_type_no_show(self):
+ """Test PATCH returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": 1, "name": "Flats"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # DELETE tests
+
+ def test_delete_scenery_type_success(self):
+ """Test DELETE removes a scenery type."""
+ # Create a scenery type first
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = SceneryType(
+ show_id=self.show_id, name="Backdrops", description=""
+ )
+ session.add(scenery_type)
+ session.flush()
+ scenery_type_id = scenery_type.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/scenery/types?id={scenery_type_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ scenery_type = session.get(SceneryType, scenery_type_id)
+ self.assertIsNone(scenery_type)
+
+ def test_delete_scenery_type_missing_id(self):
+ """Test DELETE returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_scenery_type_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_scenery_type_not_found(self):
+ """Test DELETE returns 404 for non-existent scenery type."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_scenery_type_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery/types?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+
+class TestSceneryController(DigiScriptTestCase):
+ """Test suite for /api/v1/show/stage/scenery endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ # Create a scenery type
+ scenery_type = SceneryType(
+ show_id=show.id, name="Backdrops", description=""
+ )
+ session.add(scenery_type)
+ session.flush()
+ self.scenery_type_id = scenery_type.id
+
+ # Create admin user for RBAC
+ admin = User(username="admin", is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ self.user_id = admin.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.token = self._app.jwt_service.create_access_token(
+ data={"user_id": self.user_id}
+ )
+
+ # GET tests
+
+ def test_get_scenery_empty(self):
+ """Test GET with no scenery returns empty list."""
+ response = self.fetch("/api/v1/show/stage/scenery")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("scenery", response_body)
+ self.assertEqual([], response_body["scenery"])
+
+ def test_get_scenery_returns_all(self):
+ """Test GET returns all scenery for the show."""
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Forest Backdrop",
+ description="A woodland scene",
+ )
+ session.add(scenery)
+ session.commit()
+
+ response = self.fetch("/api/v1/show/stage/scenery")
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertEqual(1, len(response_body["scenery"]))
+ self.assertEqual("Forest Backdrop", response_body["scenery"][0]["name"])
+
+ def test_get_scenery_no_show(self):
+ """Test GET returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch("/api/v1/show/stage/scenery")
+ self.assertEqual(400, response.code)
+
+ # POST tests
+
+ def test_create_scenery_success(self):
+ """Test POST creates a new scenery."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Castle Wall", "scenery_type_id": self.scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("id", response_body)
+ self.assertIn("message", response_body)
+
+ # Verify scenery was created
+ with self._app.get_db().sessionmaker() as session:
+ scenery = session.get(Scenery, response_body["id"])
+ self.assertIsNotNone(scenery)
+ self.assertEqual("Castle Wall", scenery.name)
+ self.assertEqual(self.scenery_type_id, scenery.scenery_type_id)
+
+ def test_create_scenery_with_description(self):
+ """Test POST creates a scenery with description."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {
+ "name": "Castle Wall",
+ "scenery_type_id": self.scenery_type_id,
+ "description": "Stone castle wall",
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+
+ # Verify description was saved
+ with self._app.get_db().sessionmaker() as session:
+ scenery = session.get(Scenery, response_body["id"])
+ self.assertEqual("Stone castle wall", scenery.description)
+
+ def test_create_scenery_missing_name(self):
+ """Test POST returns 400 when name is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode({"scenery_type_id": self.scenery_type_id}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_create_scenery_missing_scenery_type_id(self):
+ """Test POST returns 400 when scenery_type_id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode({"name": "Castle Wall"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scenery type ID missing", response_body["message"])
+
+ def test_create_scenery_invalid_scenery_type_id(self):
+ """Test POST returns 400 for non-integer scenery_type_id."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Castle Wall", "scenery_type_id": "invalid"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid scenery type ID", response_body["message"])
+
+ def test_create_scenery_scenery_type_not_found(self):
+ """Test POST returns 404 for non-existent scenery type."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Castle Wall", "scenery_type_id": 99999}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scenery type not found", response_body["message"])
+
+ def test_create_scenery_scenery_type_wrong_show(self):
+ """Test POST returns 400 for scenery type from different show."""
+ # Create another show with a scenery type
+ with self._app.get_db().sessionmaker() as session:
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_scenery_type = SceneryType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_scenery_type)
+ session.flush()
+ other_scenery_type_id = other_scenery_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Castle Wall", "scenery_type_id": other_scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid scenery type for show", response_body["message"])
+
+ def test_create_scenery_no_show(self):
+ """Test POST returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"name": "Castle Wall", "scenery_type_id": self.scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # PATCH tests
+
+ def test_update_scenery_success(self):
+ """Test PATCH updates an existing scenery."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {
+ "id": scenery_id,
+ "name": "Stone Wall",
+ "scenery_type_id": self.scenery_type_id,
+ "description": "Updated",
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify update
+ with self._app.get_db().sessionmaker() as session:
+ scenery = session.get(Scenery, scenery_id)
+ self.assertEqual("Stone Wall", scenery.name)
+ self.assertEqual("Updated", scenery.description)
+
+ def test_update_scenery_missing_id(self):
+ """Test PATCH returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"name": "Stone Wall", "scenery_type_id": self.scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_update_scenery_not_found(self):
+ """Test PATCH returns 404 for non-existent scenery."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {
+ "id": 99999,
+ "name": "Stone Wall",
+ "scenery_type_id": self.scenery_type_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_update_scenery_missing_name(self):
+ """Test PATCH returns 400 when name is missing."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": scenery_id, "scenery_type_id": self.scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Name missing", response_body["message"])
+
+ def test_update_scenery_missing_scenery_type_id(self):
+ """Test PATCH returns 400 when scenery_type_id is missing."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode({"id": scenery_id, "name": "Stone Wall"}),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scenery type ID missing", response_body["message"])
+
+ def test_update_scenery_invalid_scenery_type_id(self):
+ """Test PATCH returns 400 for non-integer scenery_type_id."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": scenery_id, "name": "Stone Wall", "scenery_type_id": "invalid"}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid scenery type ID", response_body["message"])
+
+ def test_update_scenery_scenery_type_not_found(self):
+ """Test PATCH returns 404 for non-existent scenery type."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": scenery_id, "name": "Stone Wall", "scenery_type_id": 99999}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Scenery type not found", response_body["message"])
+
+ def test_update_scenery_scenery_type_wrong_show(self):
+ """Test PATCH returns 400 for scenery type from different show."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+
+ # Create another show with a scenery type
+ other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL)
+ session.add(other_show)
+ session.flush()
+
+ other_scenery_type = SceneryType(
+ show_id=other_show.id, name="Other Type", description=""
+ )
+ session.add(other_scenery_type)
+ session.flush()
+ other_scenery_type_id = other_scenery_type.id
+ session.commit()
+
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {
+ "id": scenery_id,
+ "name": "Stone Wall",
+ "scenery_type_id": other_scenery_type_id,
+ }
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid scenery type for show", response_body["message"])
+
+ def test_update_scenery_no_show(self):
+ """Test PATCH returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="PATCH",
+ body=tornado.escape.json_encode(
+ {"id": 1, "name": "Stone Wall", "scenery_type_id": self.scenery_type_id}
+ ),
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+
+ # DELETE tests
+
+ def test_delete_scenery_success(self):
+ """Test DELETE removes a scenery."""
+ # Create a scenery first
+ with self._app.get_db().sessionmaker() as session:
+ scenery = Scenery(
+ show_id=self.show_id,
+ scenery_type_id=self.scenery_type_id,
+ name="Castle Wall",
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ scenery_id = scenery.id
+ session.commit()
+
+ response = self.fetch(
+ f"/api/v1/show/stage/scenery?id={scenery_id}",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify deletion
+ with self._app.get_db().sessionmaker() as session:
+ scenery = session.get(Scenery, scenery_id)
+ self.assertIsNone(scenery)
+
+ def test_delete_scenery_missing_id(self):
+ """Test DELETE returns 400 when id is missing."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("ID missing", response_body["message"])
+
+ def test_delete_scenery_invalid_id(self):
+ """Test DELETE returns 400 for non-integer ID."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery?id=invalid",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
+ response_body = tornado.escape.json_decode(response.body)
+ self.assertIn("Invalid ID", response_body["message"])
+
+ def test_delete_scenery_not_found(self):
+ """Test DELETE returns 404 for non-existent scenery."""
+ response = self.fetch(
+ "/api/v1/show/stage/scenery?id=99999",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(404, response.code)
+
+ def test_delete_scenery_no_show(self):
+ """Test DELETE returns 400 when no show is loaded."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ response = self.fetch(
+ "/api/v1/show/stage/scenery?id=1",
+ method="DELETE",
+ headers={"Authorization": f"Bearer {self.token}"},
+ )
+ self.assertEqual(400, response.code)
diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/test_auth.py
index 825cba51..af589061 100644
--- a/server/test/controllers/api/test_auth.py
+++ b/server/test/controllers/api/test_auth.py
@@ -1,7 +1,6 @@
from sqlalchemy import select
from tornado import escape
-from models.show import Show, ShowScriptType
from models.user import User
from test.conftest import DigiScriptTestCase
@@ -51,6 +50,21 @@ def test_create_admin(self):
self.assertTrue("message" in response_body)
self.assertEqual("Successfully created user", response_body["message"])
+ def test_create_first_user_must_be_admin(self):
+ """Test that the first user created in the system must be an admin."""
+ response = self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "firstuser", "password": "password", "is_admin": False}
+ ),
+ )
+ response_body = escape.json_decode(response.body)
+
+ self.assertEqual(400, response.code)
+ self.assertTrue("message" in response_body)
+ self.assertEqual("First user must be an admin", response_body["message"])
+
def test_create_user_duplicate_username(self):
"""Test POST /api/v1/auth/create with duplicate username.
@@ -60,11 +74,28 @@ def test_create_user_duplicate_username(self):
When a user with the same username already exists, the query should return
that user and the endpoint should return a 400 error.
"""
- # Create an existing user directly in the database
- with self._app.get_db().sessionmaker() as session:
- existing_user = User(username="duplicate_test", password="hashed_pw")
- session.add(existing_user)
- session.commit()
+ # Create initial admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "duplicate_test",
+ "password": "adminpass",
+ "is_admin": True,
+ }
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "duplicate_test", "password": "adminpass"}
+ ),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
# Try to create a user with the same username
response = self.fetch(
@@ -77,6 +108,7 @@ def test_create_user_duplicate_username(self):
"is_admin": False,
}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
response_body = escape.json_decode(response.body)
@@ -407,16 +439,6 @@ def test_delete_user(self):
),
)
- # Create a test show (required by @requires_show decorator)
- with self._app.get_db().sessionmaker() as session:
- show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
- session.add(show)
- session.flush()
- show_id = show.id
- session.commit()
-
- self._app.digi_settings.settings["current_show"].set_value(show_id)
-
# Login as admin to get token
response = self.fetch(
"/api/v1/auth/login",
@@ -433,6 +455,7 @@ def test_delete_user(self):
body=escape.json_encode(
{"username": "userToDelete", "password": "password", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Get the user ID
@@ -479,16 +502,6 @@ def test_get_users(self):
),
)
- # Create a test show (required by @requires_show decorator)
- with self._app.get_db().sessionmaker() as session:
- show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
- session.add(show)
- session.flush()
- show_id = show.id
- session.commit()
-
- self._app.digi_settings.settings["current_show"].set_value(show_id)
-
# Login as admin
response = self.fetch(
"/api/v1/auth/login",
@@ -505,6 +518,7 @@ def test_get_users(self):
body=escape.json_encode(
{"username": "user1", "password": "password", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
self.fetch(
"/api/v1/auth/create",
@@ -512,6 +526,7 @@ def test_get_users(self):
body=escape.json_encode(
{"username": "user2", "password": "password", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Get all users
@@ -535,6 +550,23 @@ def test_get_users(self):
def test_change_password_success(self):
"""Test successful password change with valid old password"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create and login a user
self.fetch(
"/api/v1/auth/create",
@@ -542,6 +574,7 @@ def test_change_password_success(self):
body=escape.json_encode(
{"username": "testuser", "password": "oldpass123", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
response = self.fetch(
@@ -593,6 +626,23 @@ def test_change_password_success(self):
def test_change_password_incorrect_old_password(self):
"""Test password change fails with incorrect old password"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create and login a user
self.fetch(
"/api/v1/auth/create",
@@ -600,6 +650,7 @@ def test_change_password_incorrect_old_password(self):
body=escape.json_encode(
{"username": "testuser", "password": "oldpass123", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
response = self.fetch(
@@ -625,6 +676,23 @@ def test_change_password_incorrect_old_password(self):
def test_change_password_missing_new_password(self):
"""Test password change fails when new password is missing"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create and login a user
self.fetch(
"/api/v1/auth/create",
@@ -632,6 +700,7 @@ def test_change_password_missing_new_password(self):
body=escape.json_encode(
{"username": "testuser", "password": "oldpass123", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
response = self.fetch(
@@ -655,6 +724,23 @@ def test_change_password_missing_new_password(self):
def test_change_password_weak_password(self):
"""Test password change fails with weak new password"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create and login a user
self.fetch(
"/api/v1/auth/create",
@@ -662,6 +748,7 @@ def test_change_password_weak_password(self):
body=escape.json_encode(
{"username": "testuser", "password": "oldpass123", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
response = self.fetch(
@@ -699,6 +786,23 @@ def test_change_password_requires_authentication(self):
def test_change_password_with_requires_password_change_flag(self):
"""Test password change works without old password when requires_password_change=True"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create user via API to ensure password is properly hashed
self.fetch(
"/api/v1/auth/create",
@@ -710,6 +814,7 @@ def test_change_password_with_requires_password_change_flag(self):
"is_admin": False,
}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Set requires_password_change flag
@@ -748,6 +853,23 @@ def test_change_password_with_requires_password_change_flag(self):
def test_password_enforcement_blocks_regular_endpoints(self):
"""Test that requires_password_change blocks access to regular endpoints"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create user with requires_password_change=True
self.fetch(
"/api/v1/auth/create",
@@ -759,6 +881,7 @@ def test_password_enforcement_blocks_regular_endpoints(self):
"is_admin": False,
}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Log in to get JWT
@@ -790,6 +913,23 @@ def test_password_enforcement_blocks_regular_endpoints(self):
def test_password_enforcement_allows_change_password_endpoint(self):
"""Test that requires_password_change allows access to change-password"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create user with requires_password_change=True
self.fetch(
"/api/v1/auth/create",
@@ -801,6 +941,7 @@ def test_password_enforcement_allows_change_password_endpoint(self):
"is_admin": False,
}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Log in to get JWT
@@ -833,6 +974,23 @@ def test_password_enforcement_allows_change_password_endpoint(self):
def test_password_enforcement_allows_logout_endpoint(self):
"""Test that requires_password_change allows access to logout"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create user with requires_password_change=True
self.fetch(
"/api/v1/auth/create",
@@ -844,6 +1002,7 @@ def test_password_enforcement_allows_logout_endpoint(self):
"is_admin": False,
}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Log in to get JWT
@@ -900,6 +1059,7 @@ def test_admin_reset_password_success(self):
body=escape.json_encode(
{"username": "regularuser", "password": "userpass", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Get user ID
@@ -907,6 +1067,8 @@ def test_admin_reset_password_success(self):
user = session.scalars(
select(User).where(User.username == "regularuser")
).first()
+ if not user:
+ self.fail("User 'regularuser' not found in database")
user_id = user.id
# Admin resets user password
@@ -979,6 +1141,23 @@ def test_admin_reset_password_cannot_reset_own(self):
def test_admin_reset_password_requires_admin(self):
"""Test password reset requires admin privileges"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
# Create regular user
self.fetch(
"/api/v1/auth/create",
@@ -986,6 +1165,7 @@ def test_admin_reset_password_requires_admin(self):
body=escape.json_encode(
{"username": "regularuser", "password": "userpass", "is_admin": False}
),
+ headers={"Authorization": f"Bearer {admin_token}"},
)
# Login as regular user
diff --git a/server/test/controllers/api/test_logging.py b/server/test/controllers/api/test_logging.py
new file mode 100644
index 00000000..905ed3ed
--- /dev/null
+++ b/server/test/controllers/api/test_logging.py
@@ -0,0 +1,72 @@
+import json
+
+from tornado import escape
+from tornado.testing import gen_test
+
+from test.conftest import DigiScriptTestCase
+
+
+class TestLoggingController(DigiScriptTestCase):
+ @gen_test
+ async def test_logging_endpoint_success(self):
+ """Test that the logging endpoint successfully receives logs."""
+ payload = {
+ "level": "INFO",
+ "message": "Test message from client",
+ "extra": {"source": "unit-test"},
+ }
+ response = await self.http_client.fetch(
+ self.get_url("/api/v1/logs"),
+ method="POST",
+ body=json.dumps(payload),
+ headers={"Content-Type": "application/json"},
+ )
+ self.assertEqual(200, response.code)
+ data = escape.json_decode(response.body)
+ self.assertEqual("OK", data["status"])
+
+ @gen_test
+ async def test_logging_endpoint_disabled(self):
+ """Test that the logging endpoint returns 403 when disabled in settings."""
+ # Disable client logging in settings
+ await self._app.digi_settings.set("client_log_enabled", False)
+
+ payload = {"level": "INFO", "message": "Test message from client"}
+ response = await self.http_client.fetch(
+ self.get_url("/api/v1/logs"),
+ method="POST",
+ body=json.dumps(payload),
+ headers={"Content-Type": "application/json"},
+ raise_error=False,
+ )
+ self.assertEqual(403, response.code)
+ data = escape.json_decode(response.body)
+ self.assertEqual("Client logging is disabled", data["message"])
+
+ # Re-enable for other tests
+ await self._app.digi_settings.set("client_log_enabled", True)
+
+ @gen_test
+ async def test_logging_endpoint_invalid_json(self):
+ """Test that the logging endpoint returns 400 for invalid JSON."""
+ response = await self.http_client.fetch(
+ self.get_url("/api/v1/logs"),
+ method="POST",
+ body="invalid json",
+ headers={"Content-Type": "application/json"},
+ raise_error=False,
+ )
+ self.assertEqual(400, response.code)
+
+ @gen_test
+ async def test_logging_endpoint_different_levels(self):
+ """Test that the logging endpoint accepts various log levels."""
+ for level in ["DEBUG", "INFO", "WARN", "ERROR"]:
+ payload = {"level": level, "message": f"Test level {level}"}
+ response = await self.http_client.fetch(
+ self.get_url("/api/v1/logs"),
+ method="POST",
+ body=json.dumps(payload),
+ headers={"Content-Type": "application/json"},
+ )
+ self.assertEqual(200, response.code)
diff --git a/server/test/controllers/api/test_logs_viewer.py b/server/test/controllers/api/test_logs_viewer.py
new file mode 100644
index 00000000..05586ffb
--- /dev/null
+++ b/server/test/controllers/api/test_logs_viewer.py
@@ -0,0 +1,396 @@
+"""Integration tests for GET /api/v1/logs/view."""
+
+import logging
+
+from tornado import escape
+
+from test.conftest import DigiScriptTestCase
+from utils.log_buffer import get_client_buffer, get_server_buffer
+
+
+def _make_record(msg, level=logging.INFO, name="test", **extra_attrs):
+ record = logging.LogRecord(
+ name=name,
+ level=level,
+ pathname="fake.py",
+ lineno=1,
+ msg=msg,
+ args=(),
+ exc_info=None,
+ )
+ for k, v in extra_attrs.items():
+ setattr(record, k, v)
+ return record
+
+
+class TestLogViewerController(DigiScriptTestCase):
+ """Tests for the log viewer endpoint."""
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_and_login_admin(self, username="admin", password="adminpass"):
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": username, "password": password, "is_admin": True}
+ ),
+ )
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": username, "password": password}),
+ )
+ return escape.json_decode(resp.body)["access_token"]
+
+ def _create_and_login_user(self, admin_token, username="user", password="userpass"):
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": username, "password": password, "is_admin": False}
+ ),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": username, "password": password}),
+ )
+ return escape.json_decode(resp.body)["access_token"]
+
+ def _inject_server_entry(self, msg, level=logging.INFO):
+ get_server_buffer().emit(_make_record(msg, level=level))
+
+ def _inject_client_entry(self, msg, level=logging.INFO, **extra):
+ get_client_buffer().emit(_make_record(msg, level=level, **extra))
+
+ def _fetch_view(self, token=None, **params):
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
+ url = f"/api/v1/logs/view?{qs}" if qs else "/api/v1/logs/view"
+ headers = {}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return self.fetch(url, method="GET", headers=headers, raise_error=False)
+
+ # ------------------------------------------------------------------
+ # Authentication / authorisation
+ # ------------------------------------------------------------------
+
+ def test_unauthenticated_returns_401(self):
+ resp = self._fetch_view()
+ self.assertEqual(401, resp.code)
+
+ def test_non_admin_returns_401(self):
+ admin_token = self._create_and_login_admin()
+ user_token = self._create_and_login_user(admin_token)
+ resp = self._fetch_view(token=user_token)
+ self.assertEqual(401, resp.code)
+
+ def test_admin_returns_200(self):
+ token = self._create_and_login_admin()
+ resp = self._fetch_view(token=token)
+ self.assertEqual(200, resp.code)
+
+ def test_admin_response_structure(self):
+ """Response must contain entries, total, returned, source."""
+ token = self._create_and_login_admin()
+ resp = self._fetch_view(token=token)
+ body = escape.json_decode(resp.body)
+ for key in ("entries", "total", "returned", "source"):
+ self.assertIn(key, body, f"Missing key: {key}")
+
+ # ------------------------------------------------------------------
+ # Source selection
+ # ------------------------------------------------------------------
+
+ def test_source_defaults_to_server(self):
+ token = self._create_and_login_admin()
+ resp = self._fetch_view(token=token)
+ body = escape.json_decode(resp.body)
+ self.assertEqual("server", body["source"])
+
+ def test_source_server_explicit(self):
+ token = self._create_and_login_admin()
+ resp = self._fetch_view(token=token, source="server")
+ body = escape.json_decode(resp.body)
+ self.assertEqual("server", body["source"])
+
+ def test_source_client(self):
+ token = self._create_and_login_admin()
+ resp = self._fetch_view(token=token, source="client")
+ body = escape.json_decode(resp.body)
+ self.assertEqual("client", body["source"])
+
+ def test_server_and_client_buffers_are_independent(self):
+ """Entries injected into server buffer must not appear in client view."""
+ self._inject_server_entry("server_only_msg")
+ token = self._create_and_login_admin()
+
+ server_resp = escape.json_decode(
+ self._fetch_view(token=token, source="server").body
+ )
+ client_resp = escape.json_decode(
+ self._fetch_view(token=token, source="client").body
+ )
+
+ server_msgs = [e["message"] for e in server_resp["entries"]]
+ client_msgs = [e["message"] for e in client_resp["entries"]]
+
+ self.assertIn("server_only_msg", server_msgs)
+ self.assertNotIn("server_only_msg", client_msgs)
+
+ # ------------------------------------------------------------------
+ # Level filter
+ # ------------------------------------------------------------------
+
+ def test_level_filter_excludes_lower_levels(self):
+ """Requesting ERROR+ should hide INFO entries."""
+ self._inject_server_entry("info_entry", level=logging.INFO)
+ self._inject_server_entry("error_entry", level=logging.ERROR)
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(token=token, source="server", level="ERROR").body
+ )
+ messages = [e["message"] for e in resp["entries"]]
+ self.assertIn("error_entry", messages)
+ self.assertNotIn("info_entry", messages)
+
+ def test_level_filter_empty_returns_all(self):
+ """Empty level param returns entries at all levels."""
+ self._inject_server_entry("debug_entry", level=logging.DEBUG)
+ self._inject_server_entry("critical_entry", level=logging.CRITICAL)
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(token=token, source="server", level="").body
+ )
+ messages = [e["message"] for e in resp["entries"]]
+ self.assertIn("debug_entry", messages)
+ self.assertIn("critical_entry", messages)
+
+ def test_warn_alias(self):
+ """level=WARN should behave identically to level=WARNING."""
+ self._inject_server_entry("warn_entry", level=logging.WARNING)
+ self._inject_server_entry("debug_entry_warn_alias", level=logging.DEBUG)
+ token = self._create_and_login_admin()
+
+ warn_resp = escape.json_decode(
+ self._fetch_view(token=token, source="server", level="WARN").body
+ )
+ warning_resp = escape.json_decode(
+ self._fetch_view(token=token, source="server", level="WARNING").body
+ )
+
+ warn_msgs = [e["message"] for e in warn_resp["entries"]]
+ warning_msgs = [e["message"] for e in warning_resp["entries"]]
+
+ self.assertIn("warn_entry", warn_msgs)
+ self.assertNotIn("debug_entry_warn_alias", warn_msgs)
+ self.assertEqual(set(warn_msgs), set(warning_msgs))
+
+ # ------------------------------------------------------------------
+ # Search filter
+ # ------------------------------------------------------------------
+
+ def test_search_filter(self):
+ self._inject_server_entry("unique_search_term_xyz found here")
+ self._inject_server_entry("unrelated log entry")
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(
+ token=token, source="server", search="unique_search_term_xyz"
+ ).body
+ )
+ messages = [e["message"] for e in resp["entries"]]
+ self.assertTrue(any("unique_search_term_xyz" in m for m in messages))
+ self.assertFalse(any("unrelated log entry" in m for m in messages))
+
+ def test_search_filter_case_insensitive(self):
+ self._inject_server_entry("CaseSensitiveTest message")
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(
+ token=token, source="server", search="casesensitivetest"
+ ).body
+ )
+ messages = [e["message"] for e in resp["entries"]]
+ self.assertTrue(any("CaseSensitiveTest" in m for m in messages))
+
+ # ------------------------------------------------------------------
+ # Username filter (client source only)
+ # ------------------------------------------------------------------
+
+ def test_username_filter_client_source(self):
+ self._inject_client_entry("alice log", username="alice")
+ self._inject_client_entry("bob log", username="bob")
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(token=token, source="client", username="alice").body
+ )
+ messages = [e["message"] for e in resp["entries"]]
+ self.assertIn("alice log", messages)
+ self.assertNotIn("bob log", messages)
+
+ def test_username_filter_ignored_for_server_source(self):
+ """username param should have no effect on the server source."""
+ self._inject_server_entry("server_entry_for_username_test")
+ token = self._create_and_login_admin()
+
+ resp_no_filter = escape.json_decode(
+ self._fetch_view(token=token, source="server").body
+ )
+ resp_with_filter = escape.json_decode(
+ self._fetch_view(token=token, source="server", username="alice").body
+ )
+ self.assertEqual(resp_no_filter["total"], resp_with_filter["total"])
+
+ # ------------------------------------------------------------------
+ # Pagination
+ # ------------------------------------------------------------------
+
+ def test_limit(self):
+ for i in range(20):
+ self._inject_server_entry(f"limit_test_entry {i}")
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(token=token, source="server", limit=5).body
+ )
+ self.assertLessEqual(len(resp["entries"]), 5)
+ self.assertEqual(len(resp["entries"]), resp["returned"])
+
+ def test_offset(self):
+ """offset should skip entries."""
+ # Inject 10 entries that are easy to identify
+ for i in range(10):
+ self._inject_server_entry(f"offset_test_msg {i}")
+ token = self._create_and_login_admin()
+
+ resp_all = escape.json_decode(
+ self._fetch_view(
+ token=token, source="server", search="offset_test_msg", limit=10
+ ).body
+ )
+ resp_offset = escape.json_decode(
+ self._fetch_view(
+ token=token,
+ source="server",
+ search="offset_test_msg",
+ limit=10,
+ offset=5,
+ ).body
+ )
+ # total should be the same regardless of offset
+ self.assertEqual(resp_all["total"], resp_offset["total"])
+ # offset result should have 5 fewer entries
+ self.assertEqual(resp_all["returned"] - 5, resp_offset["returned"])
+
+ def test_limit_capped_at_1000(self):
+ """limit > 1000 should be silently capped, not error."""
+ token = self._create_and_login_admin()
+ # Just verify a large limit doesn't cause an error
+ self.assertEqual(
+ 200,
+ self.fetch(
+ "/api/v1/logs/view?limit=9999",
+ method="GET",
+ headers={"Authorization": f"Bearer {token}"},
+ raise_error=False,
+ ).code,
+ )
+
+ # ------------------------------------------------------------------
+ # total vs returned
+ # ------------------------------------------------------------------
+
+ def test_total_reflects_filtered_count(self):
+ """total should count all matching entries, not just the page."""
+ for i in range(15):
+ self._inject_server_entry(f"total_test_entry {i}")
+ token = self._create_and_login_admin()
+
+ resp = escape.json_decode(
+ self._fetch_view(
+ token=token, source="server", search="total_test_entry", limit=5
+ ).body
+ )
+ self.assertEqual(15, resp["total"])
+ self.assertEqual(5, resp["returned"])
+
+ # ------------------------------------------------------------------
+ # SSE stream endpoint — auth and header checks
+ # ------------------------------------------------------------------
+ # Full streaming content tests require async tooling; these tests verify
+ # the auth layer before any streaming begins.
+
+ def _fetch_stream_headers(self, token=None, **params):
+ """Perform a GET /api/v1/logs/stream with a very short request timeout.
+
+ The stream never sends a final Content-Length, so self.fetch() would
+ hang indefinitely without a timeout. We use request_timeout=0.5 s;
+ the server returns an HTTP 401/403 immediately for auth failures, but
+ for a valid admin request the timeout fires after headers are received
+ and some initial data may have been sent.
+ """
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
+ url = f"/api/v1/logs/stream?{qs}" if qs else "/api/v1/logs/stream"
+ headers = {}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return self.fetch(
+ url,
+ method="GET",
+ headers=headers,
+ raise_error=False,
+ request_timeout=0.5,
+ )
+
+ def test_stream_unauthenticated_returns_401(self):
+ resp = self._fetch_stream_headers()
+ self.assertEqual(401, resp.code)
+
+ def test_stream_non_admin_returns_401(self):
+ admin_token = self._create_and_login_admin()
+ user_token = self._create_and_login_user(admin_token)
+ resp = self._fetch_stream_headers(token=user_token)
+ self.assertEqual(401, resp.code)
+
+ def test_stream_backfill_contains_existing_entries(self):
+ """Entries injected before connection must appear in the first chunk.
+
+ SSE connections never send a terminal response, so self.fetch() raises
+ HTTPTimeoutError when the request_timeout expires. We use
+ streaming_callback to collect chunks as they arrive and inspect the
+ accumulated body in the except handler.
+ """
+ self._inject_server_entry("sse_backfill_unique_xyz")
+ token = self._create_and_login_admin()
+
+ received_chunks = []
+
+ try:
+ self.fetch(
+ "/api/v1/logs/stream?source=server",
+ method="GET",
+ headers={"Authorization": f"Bearer {token}"},
+ raise_error=False,
+ request_timeout=1.0,
+ streaming_callback=received_chunks.append,
+ )
+ except Exception:
+ # HTTPTimeoutError is expected — the stream stays open indefinitely.
+ pass
+
+ self.assertTrue(len(received_chunks) > 0, "No data received from SSE stream")
+ full_body = b"".join(received_chunks).decode()
+ self.assertIn("sse_backfill_unique_xyz", full_body)
+ # Verify SSE wire format: events must begin with "data: "
+ data_lines = [ln for ln in full_body.splitlines() if ln.startswith("data: ")]
+ self.assertTrue(len(data_lines) > 0)
diff --git a/server/test/digi_server/test_log_buffer.py b/server/test/digi_server/test_log_buffer.py
new file mode 100644
index 00000000..2bebd732
--- /dev/null
+++ b/server/test/digi_server/test_log_buffer.py
@@ -0,0 +1,238 @@
+import logging
+import unittest
+from datetime import datetime
+
+from utils.log_buffer import (
+ LogBufferHandler,
+ get_client_buffer,
+ get_server_buffer,
+)
+
+
+def _make_record(msg="hello", level=logging.INFO, name="test", **extra_attrs):
+ """Helper: create a :class:`logging.LogRecord` with optional extra attrs."""
+ record = logging.LogRecord(
+ name=name,
+ level=level,
+ pathname="test_file.py",
+ lineno=42,
+ msg=msg,
+ args=(),
+ exc_info=None,
+ )
+ for key, value in extra_attrs.items():
+ setattr(record, key, value)
+ return record
+
+
+class TestLogBufferHandler(unittest.TestCase):
+ def setUp(self):
+ self.handler = LogBufferHandler(maxlen=100)
+
+ # ------------------------------------------------------------------
+ # Basic emit / retrieval
+ # ------------------------------------------------------------------
+
+ def test_captures_entry(self):
+ """An emitted record should appear in get_entries()."""
+ self.handler.emit(_make_record("test message"))
+ entries = self.handler.get_entries()
+ self.assertEqual(1, len(entries))
+ self.assertEqual("test message", entries[0]["message"])
+
+ def test_entry_schema(self):
+ """Every required key must be present in an emitted entry."""
+ self.handler.emit(
+ _make_record("schema check", level=logging.WARNING, name="DigiScript")
+ )
+ entry = self.handler.get_entries()[0]
+ for key in (
+ "ts",
+ "level",
+ "level_no",
+ "logger",
+ "message",
+ "filename",
+ "lineno",
+ ):
+ self.assertIn(key, entry, f"Missing key: {key}")
+ self.assertEqual("WARNING", entry["level"])
+ self.assertEqual(logging.WARNING, entry["level_no"])
+ self.assertEqual("DigiScript", entry["logger"])
+ self.assertEqual("schema check", entry["message"])
+
+ def test_empty_buffer_returns_empty_list(self):
+ self.assertEqual([], self.handler.get_entries())
+
+ # ------------------------------------------------------------------
+ # Circular buffer (maxlen eviction)
+ # ------------------------------------------------------------------
+
+ def test_maxlen_eviction(self):
+ """When the buffer is full, the oldest entry is dropped."""
+ handler = LogBufferHandler(maxlen=5)
+ for i in range(6):
+ handler.emit(_make_record(f"msg {i}"))
+ entries = handler.get_entries()
+ self.assertEqual(5, len(entries))
+ # Oldest entry (msg 0) must have been evicted
+ messages = [e["message"] for e in entries]
+ self.assertNotIn("msg 0", messages)
+ self.assertIn("msg 5", messages)
+
+ def test_maxlen_not_exceeded(self):
+ handler = LogBufferHandler(maxlen=10)
+ for i in range(10):
+ handler.emit(_make_record(f"msg {i}"))
+ self.assertEqual(10, len(handler.get_entries()))
+
+ # ------------------------------------------------------------------
+ # Resize
+ # ------------------------------------------------------------------
+
+ def test_resize_shrink_keeps_newest(self):
+ """Shrinking retains the newest entries."""
+ handler = LogBufferHandler(maxlen=100)
+ for i in range(100):
+ handler.emit(_make_record(f"msg {i}"))
+ handler.resize(50)
+ entries = handler.get_entries()
+ self.assertEqual(50, len(entries))
+ # Newest entry must still be present
+ self.assertEqual("msg 99", entries[-1]["message"])
+
+ def test_resize_grow_retains_all(self):
+ """Growing preserves all existing entries."""
+ handler = LogBufferHandler(maxlen=10)
+ for i in range(10):
+ handler.emit(_make_record(f"msg {i}"))
+ handler.resize(200)
+ self.assertEqual(10, len(handler.get_entries()))
+
+ def test_resize_same_size(self):
+ """Resizing to the current size should be a no-op for content."""
+ handler = LogBufferHandler(maxlen=5)
+ for i in range(5):
+ handler.emit(_make_record(f"msg {i}"))
+ handler.resize(5)
+ self.assertEqual(5, len(handler.get_entries()))
+
+ # ------------------------------------------------------------------
+ # Snapshot isolation
+ # ------------------------------------------------------------------
+
+ def test_get_entries_snapshot(self):
+ """The returned list is independent of subsequent emits."""
+ self.handler.emit(_make_record("first"))
+ snapshot = self.handler.get_entries()
+ self.handler.emit(_make_record("second"))
+ # Snapshot should still only contain the first entry
+ self.assertEqual(1, len(snapshot))
+
+ # ------------------------------------------------------------------
+ # Client extra fields
+ # ------------------------------------------------------------------
+
+ def test_client_extra_fields_present(self):
+ """Extra attrs user_id / username / remote_ip are stored in the entry."""
+ record = _make_record(
+ "client log",
+ user_id=7,
+ username="alice",
+ remote_ip="192.168.1.1",
+ )
+ self.handler.emit(record)
+ entry = self.handler.get_entries()[0]
+ self.assertEqual(7, entry["user_id"])
+ self.assertEqual("alice", entry["username"])
+ self.assertEqual("192.168.1.1", entry["remote_ip"])
+
+ def test_missing_extra_fields_are_none(self):
+ """Records without client extra fields should store None, not raise."""
+ self.handler.emit(_make_record("plain record"))
+ entry = self.handler.get_entries()[0]
+ self.assertIsNone(entry["user_id"])
+ self.assertIsNone(entry["username"])
+ self.assertIsNone(entry["remote_ip"])
+
+ # ------------------------------------------------------------------
+ # Singletons
+ # ------------------------------------------------------------------
+
+ def test_get_server_buffer_singleton(self):
+ """get_server_buffer() returns the same instance on repeated calls."""
+ a = get_server_buffer()
+ b = get_server_buffer()
+ self.assertIs(a, b)
+
+ def test_get_client_buffer_singleton(self):
+ """get_client_buffer() returns the same instance on repeated calls."""
+ a = get_client_buffer()
+ b = get_client_buffer()
+ self.assertIs(a, b)
+
+ def test_server_and_client_buffers_are_distinct(self):
+ """Server and client buffers must be separate objects."""
+ self.assertIsNot(get_server_buffer(), get_client_buffer())
+
+ # ------------------------------------------------------------------
+ # Pub/sub (subscribe / unsubscribe)
+ # ------------------------------------------------------------------
+
+ def test_subscribe_called_on_emit(self):
+ """A registered callback is invoked when a record is emitted."""
+ received = []
+ self.handler.subscribe(received.append)
+ self.handler.emit(_make_record("sub test"))
+ self.assertEqual(1, len(received))
+ self.assertEqual("sub test", received[0]["message"])
+
+ def test_subscribe_receives_correct_entry(self):
+ """The callback receives the same dict that appears in get_entries()."""
+ received = []
+ self.handler.subscribe(received.append)
+ self.handler.emit(_make_record("match test", level=logging.ERROR))
+ self.assertEqual(received[0], self.handler.get_entries()[-1])
+
+ def test_unsubscribe_stops_callbacks(self):
+ """Calling the returned unsubscribe callable stops future callbacks."""
+ received = []
+ unsubscribe = self.handler.subscribe(received.append)
+ self.handler.emit(_make_record("before"))
+ unsubscribe()
+ self.handler.emit(_make_record("after"))
+ # Only the first emit should have reached the callback.
+ self.assertEqual(1, len(received))
+ self.assertEqual("before", received[0]["message"])
+
+ def test_multiple_subscribers_all_notified(self):
+ """All registered callbacks receive each emitted entry."""
+ received_a, received_b = [], []
+ self.handler.subscribe(received_a.append)
+ self.handler.subscribe(received_b.append)
+ self.handler.emit(_make_record("multi"))
+ self.assertEqual(1, len(received_a))
+ self.assertEqual(1, len(received_b))
+
+ def test_subscriber_exception_does_not_break_emit(self):
+ """A callback that raises must not prevent the entry being buffered."""
+
+ def bad_callback(_entry):
+ raise RuntimeError("boom")
+
+ self.handler.subscribe(bad_callback)
+ # Should not raise — the exception is caught internally.
+ self.handler.emit(_make_record("resilient"))
+ self.assertEqual(1, len(self.handler.get_entries()))
+
+ # ------------------------------------------------------------------
+ # Timestamp format
+ # ------------------------------------------------------------------
+
+ def test_timestamp_is_iso8601(self):
+ """The ts field should be parseable as an ISO-8601 UTC timestamp."""
+ self.handler.emit(_make_record("ts test"))
+ ts = self.handler.get_entries()[0]["ts"]
+ # Should not raise
+ parsed = datetime.fromisoformat(ts)
+ self.assertIsNotNone(parsed)
diff --git a/server/test/digi_server/test_logger.py b/server/test/digi_server/test_logger.py
new file mode 100644
index 00000000..617be374
--- /dev/null
+++ b/server/test/digi_server/test_logger.py
@@ -0,0 +1,40 @@
+import logging
+import unittest
+
+from digi_server.logger import CLIENT_LEVEL_MAP, map_client_level
+
+
+class TestMapClientLevel(unittest.TestCase):
+ def test_trace_maps_to_level_5(self):
+ self.assertEqual(5, map_client_level("TRACE"))
+
+ def test_debug_maps_to_logging_debug(self):
+ self.assertEqual(logging.DEBUG, map_client_level("DEBUG"))
+
+ def test_info_maps_to_logging_info(self):
+ self.assertEqual(logging.INFO, map_client_level("INFO"))
+
+ def test_warn_maps_to_logging_warning(self):
+ """loglevel npm uses WARN; Python uses WARNING (30)."""
+ self.assertEqual(logging.WARNING, map_client_level("WARN"))
+
+ def test_error_maps_to_logging_error(self):
+ self.assertEqual(logging.ERROR, map_client_level("ERROR"))
+
+ def test_silent_maps_above_critical(self):
+ """SILENT has no Python equivalent; should suppress all output."""
+ self.assertEqual(logging.CRITICAL + 1, map_client_level("SILENT"))
+
+ def test_unknown_level_falls_back_to_info(self):
+ self.assertEqual(logging.INFO, map_client_level("UNKNOWN"))
+ self.assertEqual(logging.INFO, map_client_level(""))
+ self.assertEqual(logging.INFO, map_client_level("NOTSET"))
+
+ def test_case_insensitive(self):
+ self.assertEqual(logging.WARNING, map_client_level("warn"))
+ self.assertEqual(logging.INFO, map_client_level("info"))
+ self.assertEqual(5, map_client_level("trace"))
+
+ def test_client_level_map_contains_all_expected_keys(self):
+ expected = {"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "SILENT"}
+ self.assertEqual(expected, set(CLIENT_LEVEL_MAP.keys()))
diff --git a/server/test/helpers/__init__.py b/server/test/helpers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/helpers/stage_fixtures.py b/server/test/helpers/stage_fixtures.py
new file mode 100644
index 00000000..cd8a4e8a
--- /dev/null
+++ b/server/test/helpers/stage_fixtures.py
@@ -0,0 +1,160 @@
+"""Shared test fixture helpers for stage-related tests.
+
+These helpers reduce duplication across test setUp methods that need
+Show / Act / Scene / Prop / Scenery / Crew / User scaffolding.
+"""
+
+from models.show import Act, Scene, Show, ShowScriptType
+from models.stage import Crew, Props, PropType, Scenery, SceneryType
+from models.user import User
+
+
+def create_show(session, name="Test Show"):
+ """Create a show and return its ID.
+
+ :param session: SQLAlchemy session.
+ :param name: Show name.
+ :returns: The new show's ID.
+ :rtype: int
+ """
+ show = Show(name=name, script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ return show.id
+
+
+def create_act_with_scenes(
+ session,
+ show_id,
+ act_name,
+ num_scenes,
+ *,
+ interval_after=False,
+ previous_act_id=None,
+ link_to_show=False,
+):
+ """Create an act with a linked-list of scenes.
+
+ Scenes are named "Scene 1", "Scene 2", etc. and wired via
+ ``previous_scene_id``. The act's ``first_scene`` is set automatically.
+
+ :param session: SQLAlchemy session.
+ :param show_id: Parent show ID.
+ :param act_name: Display name for the act.
+ :param num_scenes: Number of scenes to create.
+ :param interval_after: Whether the act has an interval after it.
+ :param previous_act_id: ID of the preceding act (for linked-list ordering).
+ :param link_to_show: If ``True``, sets ``show.first_act`` to this act.
+ :returns: Tuple of ``(act_id, [scene_id, ...])``.
+ :rtype: tuple[int, list[int]]
+ """
+ act = Act(
+ show_id=show_id,
+ name=act_name,
+ interval_after=interval_after,
+ previous_act_id=previous_act_id,
+ )
+ session.add(act)
+ session.flush()
+
+ scene_ids = []
+ previous_scene_id = None
+ for i in range(1, num_scenes + 1):
+ scene = Scene(
+ show_id=show_id,
+ act_id=act.id,
+ name=f"Scene {i}",
+ previous_scene_id=previous_scene_id,
+ )
+ session.add(scene)
+ session.flush()
+ if i == 1:
+ act.first_scene = scene
+ previous_scene_id = scene.id
+ scene_ids.append(scene.id)
+
+ if link_to_show:
+ show = session.get(Show, show_id)
+ show.first_act = act
+
+ return act.id, scene_ids
+
+
+def create_prop(session, show_id, name="Sword", type_name="Hand Props"):
+ """Create a prop type and prop, returning both IDs.
+
+ :param session: SQLAlchemy session.
+ :param show_id: Parent show ID.
+ :param name: Prop name.
+ :param type_name: Prop type name.
+ :returns: Tuple of ``(prop_type_id, prop_id)``.
+ :rtype: tuple[int, int]
+ """
+ prop_type = PropType(show_id=show_id, name=type_name, description="")
+ session.add(prop_type)
+ session.flush()
+
+ prop = Props(
+ show_id=show_id,
+ prop_type_id=prop_type.id,
+ name=name,
+ description="",
+ )
+ session.add(prop)
+ session.flush()
+ return prop_type.id, prop.id
+
+
+def create_scenery(session, show_id, name="Castle Wall", type_name="Backdrops"):
+ """Create a scenery type and scenery item, returning both IDs.
+
+ :param session: SQLAlchemy session.
+ :param show_id: Parent show ID.
+ :param name: Scenery name.
+ :param type_name: Scenery type name.
+ :returns: Tuple of ``(scenery_type_id, scenery_id)``.
+ :rtype: tuple[int, int]
+ """
+ scenery_type = SceneryType(show_id=show_id, name=type_name, description="")
+ session.add(scenery_type)
+ session.flush()
+
+ scenery = Scenery(
+ show_id=show_id,
+ scenery_type_id=scenery_type.id,
+ name=name,
+ description="",
+ )
+ session.add(scenery)
+ session.flush()
+ return scenery_type.id, scenery.id
+
+
+def create_crew(session, show_id, first_name="John", last_name="Doe"):
+ """Create a crew member and return the ID.
+
+ :param session: SQLAlchemy session.
+ :param show_id: Parent show ID.
+ :param first_name: First name.
+ :param last_name: Last name.
+ :returns: The new crew member's ID.
+ :rtype: int
+ """
+ crew = Crew(show_id=show_id, first_name=first_name, last_name=last_name)
+ session.add(crew)
+ session.flush()
+ return crew.id
+
+
+def create_admin_user(session, username="admin"):
+ """Create an admin user and return the ID.
+
+ :param session: SQLAlchemy session.
+ :param username: Username.
+ :returns: The new user's ID.
+ :rtype: int
+ """
+ admin = User(username=username, is_admin=True, password="test")
+ session.add(admin)
+ session.flush()
+ return admin.id
diff --git a/server/test/utils/show/test_block_computation.py b/server/test/utils/show/test_block_computation.py
new file mode 100644
index 00000000..823bcd2d
--- /dev/null
+++ b/server/test/utils/show/test_block_computation.py
@@ -0,0 +1,671 @@
+"""Unit tests for block computation utilities."""
+
+from models.show import Scene, Show
+from models.stage import (
+ CrewAssignment,
+ Props,
+ PropsAllocation,
+ Scenery,
+ SceneryAllocation,
+)
+from test.conftest import DigiScriptTestCase
+from test.helpers.stage_fixtures import (
+ create_act_with_scenes,
+ create_crew,
+ create_prop,
+ create_scenery,
+ create_show,
+)
+from utils.show.block_computation import (
+ Block,
+ compute_blocks_for_prop,
+ compute_blocks_for_scenery,
+ delete_orphaned_assignments_for_prop,
+ delete_orphaned_assignments_for_scenery,
+ find_orphaned_assignments_for_prop,
+ find_orphaned_assignments_for_scenery,
+ get_ordered_scenes_by_act,
+ is_valid_boundary,
+ is_valid_set_boundary,
+ is_valid_strike_boundary,
+)
+
+
+class TestGetOrderedScenesByAct(DigiScriptTestCase):
+ """Tests for get_ordered_scenes_by_act function."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ session.commit()
+
+ def test_empty_show(self):
+ """Test with show that has no acts or scenes."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = get_ordered_scenes_by_act(show)
+ self.assertEqual({}, result)
+
+ def test_single_act_single_scene(self):
+ """Test with a single act containing a single scene."""
+ with self._app.get_db().sessionmaker() as session:
+ act_id, scene_ids = create_act_with_scenes(
+ session, self.show_id, "Act 1", 1, link_to_show=True
+ )
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ result = get_ordered_scenes_by_act(show)
+
+ self.assertEqual(1, len(result))
+ self.assertIn(act_id, result)
+ self.assertEqual(1, len(result[act_id]))
+ self.assertEqual(scene_ids[0], result[act_id][0].id)
+
+ def test_single_act_multiple_scenes(self):
+ """Test with a single act containing multiple scenes in linked list order."""
+ with self._app.get_db().sessionmaker() as session:
+ act_id, scene_ids = create_act_with_scenes(
+ session, self.show_id, "Act 1", 3, link_to_show=True
+ )
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ result = get_ordered_scenes_by_act(show)
+
+ self.assertEqual(1, len(result))
+ self.assertEqual(3, len(result[act_id]))
+ self.assertEqual("Scene 1", result[act_id][0].name)
+ self.assertEqual("Scene 2", result[act_id][1].name)
+ self.assertEqual("Scene 3", result[act_id][2].name)
+
+ def test_multiple_acts(self):
+ """Test with multiple acts each containing scenes."""
+ with self._app.get_db().sessionmaker() as session:
+ act1_id, _ = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 2,
+ interval_after=True,
+ link_to_show=True,
+ )
+ act2_id, _ = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 2",
+ 1,
+ previous_act_id=act1_id,
+ )
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ result = get_ordered_scenes_by_act(show)
+
+ self.assertEqual(2, len(result))
+ self.assertEqual(2, len(result[act1_id]))
+ self.assertEqual(1, len(result[act2_id]))
+
+
+class TestComputeBlocksForProp(DigiScriptTestCase):
+ """Tests for compute_blocks_for_prop function."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ self.act1_id, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 4,
+ interval_after=True,
+ link_to_show=True,
+ )
+ self.scene1_id, self.scene2_id, self.scene3_id, self.scene4_id = scene_ids
+ _, self.prop_id = create_prop(session, self.show_id)
+ session.commit()
+
+ def test_no_allocations(self):
+ """Test prop with no allocations returns empty list."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+ self.assertEqual([], blocks)
+
+ def test_single_scene_allocation(self):
+ """Test single scene allocation creates one block."""
+ with self._app.get_db().sessionmaker() as session:
+ # Allocate prop to scene 2
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=self.scene2_id)
+ session.add(allocation)
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+
+ self.assertEqual(1, len(blocks))
+ self.assertEqual(self.act1_id, blocks[0].act_id)
+ self.assertEqual([self.scene2_id], blocks[0].scene_ids)
+ self.assertEqual(self.scene2_id, blocks[0].set_scene_id)
+ self.assertEqual(self.scene2_id, blocks[0].strike_scene_id)
+ self.assertTrue(blocks[0].is_single_scene)
+
+ def test_consecutive_scenes(self):
+ """Test consecutive scene allocations form one block."""
+ with self._app.get_db().sessionmaker() as session:
+ # Allocate prop to scenes 2 and 3
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene2_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene3_id
+ )
+ session.add_all([allocation1, allocation2])
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+
+ self.assertEqual(1, len(blocks))
+ self.assertEqual([self.scene2_id, self.scene3_id], blocks[0].scene_ids)
+ self.assertEqual(self.scene2_id, blocks[0].set_scene_id)
+ self.assertEqual(self.scene3_id, blocks[0].strike_scene_id)
+ self.assertFalse(blocks[0].is_single_scene)
+
+ def test_gap_creates_separate_blocks(self):
+ """Test gap between allocations creates separate blocks."""
+ with self._app.get_db().sessionmaker() as session:
+ # Allocate prop to scenes 1 and 3 (gap at scene 2)
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene1_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene3_id
+ )
+ session.add_all([allocation1, allocation2])
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+
+ self.assertEqual(2, len(blocks))
+
+ # First block: scene 1
+ self.assertEqual([self.scene1_id], blocks[0].scene_ids)
+ self.assertEqual(self.scene1_id, blocks[0].set_scene_id)
+ self.assertEqual(self.scene1_id, blocks[0].strike_scene_id)
+
+ # Second block: scene 3
+ self.assertEqual([self.scene3_id], blocks[1].scene_ids)
+ self.assertEqual(self.scene3_id, blocks[1].set_scene_id)
+ self.assertEqual(self.scene3_id, blocks[1].strike_scene_id)
+
+ def test_all_scenes_allocated(self):
+ """Test all scenes allocated forms one block."""
+ with self._app.get_db().sessionmaker() as session:
+ for scene_id in [
+ self.scene1_id,
+ self.scene2_id,
+ self.scene3_id,
+ self.scene4_id,
+ ]:
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=scene_id)
+ session.add(allocation)
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+
+ self.assertEqual(1, len(blocks))
+ self.assertEqual(
+ [self.scene1_id, self.scene2_id, self.scene3_id, self.scene4_id],
+ blocks[0].scene_ids,
+ )
+
+ def test_act_boundary_breaks_blocks(self):
+ """Test allocations in different acts create separate blocks."""
+ with self._app.get_db().sessionmaker() as session:
+ # Create Act 2 with a scene
+ act2_id, act2_scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 2",
+ 1,
+ previous_act_id=self.act1_id,
+ )
+
+ # Allocate prop to last scene of Act 1 and first scene of Act 2
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene4_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=act2_scene_ids[0]
+ )
+ session.add_all([allocation1, allocation2])
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ blocks = compute_blocks_for_prop(prop, show)
+
+ # Should be 2 blocks, one per act
+ self.assertEqual(2, len(blocks))
+ self.assertEqual(self.act1_id, blocks[0].act_id)
+ self.assertEqual(act2_id, blocks[1].act_id)
+
+
+class TestComputeBlocksForScenery(DigiScriptTestCase):
+ """Tests for compute_blocks_for_scenery function."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ self.act1_id, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 3,
+ link_to_show=True,
+ )
+ self.scene1_id, self.scene2_id, self.scene3_id = scene_ids
+ _, self.scenery_id = create_scenery(session, self.show_id)
+ session.commit()
+
+ def test_no_allocations(self):
+ """Test scenery with no allocations returns empty list."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ scenery = session.get(Scenery, self.scenery_id)
+ blocks = compute_blocks_for_scenery(scenery, show)
+ self.assertEqual([], blocks)
+
+ def test_consecutive_scenes(self):
+ """Test consecutive scene allocations for scenery form one block."""
+ with self._app.get_db().sessionmaker() as session:
+ allocation1 = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene1_id
+ )
+ allocation2 = SceneryAllocation(
+ scenery_id=self.scenery_id, scene_id=self.scene2_id
+ )
+ session.add_all([allocation1, allocation2])
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ scenery = session.get(Scenery, self.scenery_id)
+ blocks = compute_blocks_for_scenery(scenery, show)
+
+ self.assertEqual(1, len(blocks))
+ self.assertEqual([self.scene1_id, self.scene2_id], blocks[0].scene_ids)
+ self.assertEqual(self.scene1_id, blocks[0].set_scene_id)
+ self.assertEqual(self.scene2_id, blocks[0].strike_scene_id)
+
+
+class TestBoundaryValidation(DigiScriptTestCase):
+ """Tests for boundary validation functions."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ self.act_id, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 3,
+ link_to_show=True,
+ )
+ self.scene1_id, self.scene2_id, self.scene3_id = scene_ids
+ _, self.prop_id = create_prop(session, self.show_id)
+
+ # Allocate prop to scenes 1 and 2 (forms one block)
+ allocation1 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene1_id
+ )
+ allocation2 = PropsAllocation(
+ props_id=self.prop_id, scene_id=self.scene2_id
+ )
+ session.add_all([allocation1, allocation2])
+ session.commit()
+
+ def test_is_valid_set_boundary_first_scene(self):
+ """Test first scene of block is valid SET boundary."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_set_boundary(
+ session, self.scene1_id, self.prop_id, None, show
+ )
+ self.assertTrue(result)
+
+ def test_is_valid_set_boundary_middle_scene(self):
+ """Test middle scene of block is NOT valid SET boundary."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_set_boundary(
+ session, self.scene2_id, self.prop_id, None, show
+ )
+ self.assertFalse(result)
+
+ def test_is_valid_strike_boundary_last_scene(self):
+ """Test last scene of block is valid STRIKE boundary."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_strike_boundary(
+ session, self.scene2_id, self.prop_id, None, show
+ )
+ self.assertTrue(result)
+
+ def test_is_valid_strike_boundary_first_scene(self):
+ """Test first scene of multi-scene block is NOT valid STRIKE boundary."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_strike_boundary(
+ session, self.scene1_id, self.prop_id, None, show
+ )
+ self.assertFalse(result)
+
+ def test_is_valid_boundary_unallocated_scene(self):
+ """Test unallocated scene is not valid for any boundary type."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+
+ # Scene 3 is not allocated
+ set_result = is_valid_set_boundary(
+ session, self.scene3_id, self.prop_id, None, show
+ )
+ strike_result = is_valid_strike_boundary(
+ session, self.scene3_id, self.prop_id, None, show
+ )
+
+ self.assertFalse(set_result)
+ self.assertFalse(strike_result)
+
+ def test_is_valid_boundary_single_scene_block(self):
+ """Test single-scene block is valid for both SET and STRIKE."""
+ # Add a 4th scene and allocate to only scene 4 (gap at scene 3)
+ # This creates: Block 1 (scenes 1-2), gap at 3, Block 2 (scene 4 only)
+ with self._app.get_db().sessionmaker() as session:
+ # Add scene 4
+ scene4 = Scene(
+ show_id=self.show_id,
+ act_id=self.act_id,
+ name="Scene 4",
+ previous_scene_id=self.scene3_id,
+ )
+ session.add(scene4)
+ session.flush()
+ scene4_id = scene4.id
+
+ # Allocate prop to scene 4 (gap at scene 3 creates separate block)
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=scene4_id)
+ session.add(allocation)
+ session.commit()
+
+ # Use fresh session to ensure allocation is loaded
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+
+ # Verify the allocations exist in the fresh session
+ blocks = compute_blocks_for_prop(prop, show)
+ # Should have 2 blocks: scenes 1-2 and scene 4
+ self.assertEqual(2, len(blocks))
+
+ # Scene 4 should be both SET and STRIKE for its single-scene block
+ set_result = is_valid_set_boundary(
+ session, scene4_id, self.prop_id, None, show
+ )
+ strike_result = is_valid_strike_boundary(
+ session, scene4_id, self.prop_id, None, show
+ )
+
+ self.assertTrue(set_result)
+ self.assertTrue(strike_result)
+
+ def test_is_valid_boundary_generic_function(self):
+ """Test is_valid_boundary dispatches correctly based on assignment_type."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+
+ # Scene 1 is valid SET, not valid STRIKE
+ set_result = is_valid_boundary(
+ session, self.scene1_id, "set", self.prop_id, None, show
+ )
+ strike_result = is_valid_boundary(
+ session, self.scene1_id, "strike", self.prop_id, None, show
+ )
+
+ self.assertTrue(set_result)
+ self.assertFalse(strike_result)
+
+ # Scene 2 is valid STRIKE, not valid SET
+ set_result = is_valid_boundary(
+ session, self.scene2_id, "set", self.prop_id, None, show
+ )
+ strike_result = is_valid_boundary(
+ session, self.scene2_id, "strike", self.prop_id, None, show
+ )
+
+ self.assertFalse(set_result)
+ self.assertTrue(strike_result)
+
+ def test_is_valid_boundary_invalid_type(self):
+ """Test is_valid_boundary returns False for invalid assignment_type."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_boundary(
+ session, self.scene1_id, "invalid", self.prop_id, None, show
+ )
+ self.assertFalse(result)
+
+ def test_is_valid_boundary_nonexistent_prop(self):
+ """Test boundary check for non-existent prop returns False."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ result = is_valid_boundary(
+ session, self.scene1_id, "set", 99999, None, show
+ )
+ self.assertFalse(result)
+
+
+class TestOrphanDetection(DigiScriptTestCase):
+ """Tests for orphaned crew assignment detection and deletion."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ self.show_id = create_show(session)
+ self.act_id, scene_ids = create_act_with_scenes(
+ session,
+ self.show_id,
+ "Act 1",
+ 4,
+ link_to_show=True,
+ )
+ (
+ self.scene1_id,
+ self.scene2_id,
+ self.scene3_id,
+ self.scene4_id,
+ ) = scene_ids
+ self.crew_id = create_crew(session, self.show_id)
+ _, self.prop_id = create_prop(session, self.show_id)
+
+ # Initial allocation: scenes 1, 2, 3 (one block)
+ # SET boundary: scene 1, STRIKE boundary: scene 3
+ for scene_id in [self.scene1_id, self.scene2_id, self.scene3_id]:
+ allocation = PropsAllocation(props_id=self.prop_id, scene_id=scene_id)
+ session.add(allocation)
+
+ session.commit()
+
+ def test_find_orphaned_no_assignments(self):
+ """Test find_orphaned returns empty when no assignments exist."""
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ orphaned = find_orphaned_assignments_for_prop(session, prop, show)
+ self.assertEqual([], orphaned)
+
+ def test_find_orphaned_valid_assignments(self):
+ """Test find_orphaned returns empty for valid assignments."""
+ with self._app.get_db().sessionmaker() as session:
+ # Create valid assignments
+ set_assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene1_id, # Valid SET boundary
+ assignment_type="set",
+ prop_id=self.prop_id,
+ )
+ strike_assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene3_id, # Valid STRIKE boundary
+ assignment_type="strike",
+ prop_id=self.prop_id,
+ )
+ session.add_all([set_assignment, strike_assignment])
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ orphaned = find_orphaned_assignments_for_prop(session, prop, show)
+ self.assertEqual([], orphaned)
+
+ def test_find_orphaned_after_allocation_change(self):
+ """Test find_orphaned detects assignments that become invalid."""
+ with self._app.get_db().sessionmaker() as session:
+ # Create assignment at scene 3 STRIKE
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene3_id,
+ assignment_type="strike",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.commit()
+ assignment_id = assignment.id
+
+ # Now remove scene 3 allocation (making scene 2 the new STRIKE boundary)
+ scene3_allocation = (
+ session.query(PropsAllocation)
+ .filter_by(props_id=self.prop_id, scene_id=self.scene3_id)
+ .first()
+ )
+ session.delete(scene3_allocation)
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ session.refresh(prop) # Refresh to get updated allocations
+ orphaned = find_orphaned_assignments_for_prop(session, prop, show)
+
+ self.assertEqual(1, len(orphaned))
+ self.assertEqual(assignment_id, orphaned[0].id)
+
+ def test_delete_orphaned_assignments(self):
+ """Test delete_orphaned actually removes orphaned assignments."""
+ with self._app.get_db().sessionmaker() as session:
+ # Create assignment at scene 3 STRIKE
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene3_id,
+ assignment_type="strike",
+ prop_id=self.prop_id,
+ )
+ session.add(assignment)
+ session.commit()
+ assignment_id = assignment.id
+
+ # Remove scene 3 allocation
+ scene3_allocation = (
+ session.query(PropsAllocation)
+ .filter_by(props_id=self.prop_id, scene_id=self.scene3_id)
+ .first()
+ )
+ session.delete(scene3_allocation)
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ prop = session.get(Props, self.prop_id)
+ session.refresh(prop)
+
+ deleted_ids = delete_orphaned_assignments_for_prop(session, prop, show)
+ session.commit()
+
+ self.assertEqual([assignment_id], deleted_ids)
+
+ # Verify assignment is deleted
+ assignment = session.get(CrewAssignment, assignment_id)
+ self.assertIsNone(assignment)
+
+ def test_find_orphaned_for_scenery(self):
+ """Test orphan detection works for scenery items."""
+ with self._app.get_db().sessionmaker() as session:
+ _, scenery_id = create_scenery(session, self.show_id)
+
+ # Allocate to scenes 1 and 2
+ allocation1 = SceneryAllocation(
+ scenery_id=scenery_id, scene_id=self.scene1_id
+ )
+ allocation2 = SceneryAllocation(
+ scenery_id=scenery_id, scene_id=self.scene2_id
+ )
+ session.add_all([allocation1, allocation2])
+
+ # Create assignment at scene 2 STRIKE
+ assignment = CrewAssignment(
+ crew_id=self.crew_id,
+ scene_id=self.scene2_id,
+ assignment_type="strike",
+ scenery_id=scenery_id,
+ )
+ session.add(assignment)
+ session.commit()
+ assignment_id = assignment.id
+
+ # Remove scene 2 allocation (making scene 1 both SET and STRIKE)
+ scene2_allocation = (
+ session.query(SceneryAllocation)
+ .filter_by(scenery_id=scenery_id, scene_id=self.scene2_id)
+ .first()
+ )
+ session.delete(scene2_allocation)
+ session.commit()
+
+ show = session.get(Show, self.show_id)
+ scenery = session.get(Scenery, scenery_id)
+ session.refresh(scenery)
+
+ orphaned = find_orphaned_assignments_for_scenery(session, scenery, show)
+ self.assertEqual(1, len(orphaned))
+ self.assertEqual(assignment_id, orphaned[0].id)
+
+ # Test deletion
+ deleted_ids = delete_orphaned_assignments_for_scenery(
+ session, scenery, show
+ )
+ self.assertEqual([assignment_id], deleted_ids)
+
+
+class TestBlockDataclass(DigiScriptTestCase):
+ """Tests for Block dataclass."""
+
+ def test_is_single_scene_true(self):
+ """Test is_single_scene returns True for single-scene blocks."""
+ block = Block(act_id=1, scene_ids=[10], set_scene_id=10, strike_scene_id=10)
+ self.assertTrue(block.is_single_scene)
+
+ def test_is_single_scene_false(self):
+ """Test is_single_scene returns False for multi-scene blocks."""
+ block = Block(
+ act_id=1, scene_ids=[10, 11, 12], set_scene_id=10, strike_scene_id=12
+ )
+ self.assertFalse(block.is_single_scene)
diff --git a/server/test_requirements.txt b/server/test_requirements.txt
index 0f664215..4c4c4e4b 100644
--- a/server/test_requirements.txt
+++ b/server/test_requirements.txt
@@ -1,3 +1,3 @@
pytest<9.1
pytest-asyncio>=1.3.0
-ruff==0.14.14
\ No newline at end of file
+ruff==0.15.4
\ No newline at end of file
diff --git a/server/utils/log_buffer.py b/server/utils/log_buffer.py
new file mode 100644
index 00000000..3f238978
--- /dev/null
+++ b/server/utils/log_buffer.py
@@ -0,0 +1,133 @@
+"""In-memory circular log buffer.
+
+Provides a :class:`LogBufferHandler` that stores the last N log records as
+structured dicts in a :class:`collections.deque`. Two module-level singletons
+are maintained — one for server-side logs and one for client-side logs — so
+the log viewer endpoint can read from either without constructing new objects.
+
+Design rationale
+----------------
+* Tornado is single-threaded; ``emit()`` is always called from the IOLoop
+ thread, so no mutex is required.
+* ``deque(maxlen=N)`` provides O(1) append with automatic eviction of the
+ oldest entry when the buffer is full.
+* ``list(deque)`` is GIL-atomic in CPython, making snapshot reads safe even
+ if a background thread were to emit a record concurrently.
+"""
+
+import logging
+from collections import deque
+from datetime import datetime, timezone
+from typing import Optional
+
+
+class LogBufferHandler(logging.Handler):
+ """A logging handler that stores records in an in-memory circular buffer.
+
+ :param maxlen: Maximum number of entries to keep. When full, the oldest
+ entry is evicted automatically.
+ """
+
+ def __init__(self, maxlen: int = 2000):
+ super().__init__()
+ self._buffer: deque = deque(maxlen=maxlen)
+ self._subscribers: set = set()
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """Build a structured dict from *record* and append it to the buffer.
+
+ Extra attributes ``user_id``, ``username``, and ``remote_ip`` are read
+ via :func:`getattr` so they are optional; missing attributes become
+ ``None`` in the entry dict.
+
+ :param record: The log record to store.
+ """
+ try:
+ ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
+ timespec="milliseconds"
+ )
+ entry = {
+ "ts": ts,
+ "level": record.levelname,
+ "level_no": record.levelno,
+ "logger": record.name,
+ "message": record.getMessage(),
+ "filename": record.filename,
+ "lineno": record.lineno,
+ "user_id": getattr(record, "user_id", None),
+ "username": getattr(record, "username", None),
+ "remote_ip": getattr(record, "remote_ip", None),
+ }
+ self._buffer.append(entry)
+ for cb in list(self._subscribers):
+ try:
+ cb(entry)
+ except Exception: # noqa: BLE001
+ pass
+ except Exception: # noqa: BLE001
+ self.handleError(record)
+
+ def get_entries(self) -> list:
+ """Return a snapshot of all buffered entries as a plain list.
+
+ The returned list is independent of the internal deque; subsequent
+ ``emit()`` calls do not affect it.
+
+ :returns: List of entry dicts ordered oldest-first.
+ """
+ return list(self._buffer)
+
+ def subscribe(self, callback) -> "callable":
+ """Register *callback* to be called with each new entry dict.
+
+ The callback is invoked synchronously inside :meth:`emit`, on the
+ Tornado IOLoop thread, immediately after the entry is appended to the
+ buffer. Callbacks must be non-blocking.
+
+ :param callback: A callable that accepts one positional argument (the
+ entry dict).
+ :returns: An unsubscribe callable. Call it to deregister *callback*.
+ """
+ self._subscribers.add(callback)
+
+ def _unsubscribe():
+ self._subscribers.discard(callback)
+
+ return _unsubscribe
+
+ def resize(self, maxlen: int) -> None:
+ """Resize the buffer, preserving as many recent entries as possible.
+
+ When shrinking, the *newest* ``maxlen`` entries are kept.
+
+ :param maxlen: New maximum number of entries.
+ """
+ new_buf: deque = deque(self._buffer, maxlen=maxlen)
+ self._buffer = new_buf
+
+
+# Module-level singletons — one per log source.
+_server_buffer: Optional[LogBufferHandler] = None
+_client_buffer: Optional[LogBufferHandler] = None
+
+
+def get_server_buffer() -> LogBufferHandler:
+ """Return (creating if necessary) the server-side log buffer singleton.
+
+ :returns: The server :class:`LogBufferHandler` instance.
+ """
+ global _server_buffer # noqa: PLW0603
+ if _server_buffer is None:
+ _server_buffer = LogBufferHandler()
+ return _server_buffer
+
+
+def get_client_buffer() -> LogBufferHandler:
+ """Return (creating if necessary) the client-side log buffer singleton.
+
+ :returns: The client :class:`LogBufferHandler` instance.
+ """
+ global _client_buffer # noqa: PLW0603
+ if _client_buffer is None:
+ _client_buffer = LogBufferHandler()
+ return _client_buffer
diff --git a/server/utils/pkg_utils.py b/server/utils/pkg_utils.py
index c39937bc..3e4eaf13 100644
--- a/server/utils/pkg_utils.py
+++ b/server/utils/pkg_utils.py
@@ -27,6 +27,6 @@ def find_end_modules(path, prefix=None):
modules = find_modules(path, prefix)
end_modules = []
for module in modules:
- if not any(x for x in modules if x != module and x.startswith(module)):
+ if not any(x for x in modules if x != module and x.startswith(module + ".")):
end_modules.append(module)
return end_modules
diff --git a/server/utils/show/block_computation.py b/server/utils/show/block_computation.py
new file mode 100644
index 00000000..a51feb03
--- /dev/null
+++ b/server/utils/show/block_computation.py
@@ -0,0 +1,346 @@
+"""
+Utility functions for computing allocation blocks from scene-by-scene allocations.
+
+A "block" is a consecutive sequence of scenes where an item (prop or scenery) is allocated.
+Blocks are computed per-act and never span act boundaries.
+
+For each block:
+- The first scene is the SET boundary (where the item is brought on stage)
+- The last scene is the STRIKE boundary (where the item is removed)
+- Single-scene blocks have both SET and STRIKE on the same scene
+"""
+
+from dataclasses import dataclass
+from typing import List, Set
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from models.show import Scene, Show
+from models.stage import CrewAssignment, Props, Scenery
+
+
+@dataclass
+class Block:
+ """
+ Represents a consecutive allocation block for an item within an act.
+
+ :param act_id: ID of the act containing this block
+ :param scene_ids: List of scene IDs in this block, in order
+ :param set_scene_id: ID of the scene where the item is SET (first scene)
+ :param strike_scene_id: ID of the scene where the item is STRUCK (last scene)
+ """
+
+ act_id: int
+ scene_ids: List[int]
+ set_scene_id: int
+ strike_scene_id: int
+
+ @property
+ def is_single_scene(self) -> bool:
+ """Return True if this block contains only one scene."""
+ return len(self.scene_ids) == 1
+
+
+def get_ordered_scenes_by_act(show: Show) -> dict[int, List[Scene]]:
+ """
+ Get all scenes in a show, grouped by act and ordered within each act.
+
+ :param show: The show to get scenes for
+ :returns: Dictionary mapping act_id to list of scenes in order
+ """
+ result: dict[int, List[Scene]] = {}
+
+ # Traverse acts in order via linked list
+ act = show.first_act
+ while act:
+ scenes = []
+ scene = act.first_scene
+ while scene:
+ scenes.append(scene)
+ scene = scene.next_scene
+ if scenes:
+ result[act.id] = scenes
+ act = act.next_act
+
+ return result
+
+
+def compute_blocks_for_prop(prop: Props, show: Show) -> List[Block]:
+ """
+ Compute allocation blocks for a prop.
+
+ :param prop: The prop to compute blocks for
+ :param show: The show containing the prop
+ :returns: List of Block objects representing consecutive allocations
+ """
+ # Get all scene IDs where this prop is allocated
+ allocated_scene_ids: Set[int] = {alloc.scene_id for alloc in prop.scene_allocations}
+
+ return _compute_blocks(show, allocated_scene_ids)
+
+
+def compute_blocks_for_scenery(scenery: Scenery, show: Show) -> List[Block]:
+ """
+ Compute allocation blocks for a scenery item.
+
+ :param scenery: The scenery item to compute blocks for
+ :param show: The show containing the scenery
+ :returns: List of Block objects representing consecutive allocations
+ """
+ # Get all scene IDs where this scenery is allocated
+ allocated_scene_ids: Set[int] = {
+ alloc.scene_id for alloc in scenery.scene_allocations
+ }
+
+ return _compute_blocks(show, allocated_scene_ids)
+
+
+def _compute_blocks(show: Show, allocated_scene_ids: Set[int]) -> List[Block]:
+ """
+ Internal function to compute blocks from a set of allocated scene IDs.
+
+ :param show: The show to compute blocks for
+ :param allocated_scene_ids: Set of scene IDs where the item is allocated
+ :returns: List of Block objects
+ """
+ if not allocated_scene_ids:
+ return []
+
+ blocks: List[Block] = []
+ ordered_scenes_by_act = get_ordered_scenes_by_act(show)
+
+ for act_id, scenes in ordered_scenes_by_act.items():
+ current_block_scenes: List[int] = []
+
+ for scene in scenes:
+ if scene.id in allocated_scene_ids:
+ # Scene is allocated - add to current block
+ current_block_scenes.append(scene.id)
+ # Scene is not allocated - end current block if one exists
+ elif current_block_scenes:
+ blocks.append(
+ Block(
+ act_id=act_id,
+ scene_ids=current_block_scenes.copy(),
+ set_scene_id=current_block_scenes[0],
+ strike_scene_id=current_block_scenes[-1],
+ )
+ )
+ current_block_scenes = []
+
+ # Don't forget the last block in the act
+ if current_block_scenes:
+ blocks.append(
+ Block(
+ act_id=act_id,
+ scene_ids=current_block_scenes.copy(),
+ set_scene_id=current_block_scenes[0],
+ strike_scene_id=current_block_scenes[-1],
+ )
+ )
+
+ return blocks
+
+
+def is_valid_set_boundary(
+ session: Session,
+ scene_id: int,
+ prop_id: int | None,
+ scenery_id: int | None,
+ show: Show,
+) -> bool:
+ """
+ Check if a scene is a valid SET boundary for an item.
+
+ A scene is a valid SET boundary if it's the first scene of a block
+ (i.e., the item is allocated to this scene and either it's the first
+ scene in the act or the previous scene doesn't have the item allocated).
+
+ :param session: Database session
+ :param scene_id: Scene ID to check
+ :param prop_id: Prop ID (if checking a prop)
+ :param scenery_id: Scenery ID (if checking scenery)
+ :param show: The show
+ :returns: True if the scene is a valid SET boundary
+ """
+ if prop_id is not None:
+ prop = session.get(Props, prop_id)
+ if not prop:
+ return False
+ blocks = compute_blocks_for_prop(prop, show)
+ elif scenery_id is not None:
+ scenery = session.get(Scenery, scenery_id)
+ if not scenery:
+ return False
+ blocks = compute_blocks_for_scenery(scenery, show)
+ else:
+ return False
+
+ return any(block.set_scene_id == scene_id for block in blocks)
+
+
+def is_valid_strike_boundary(
+ session: Session,
+ scene_id: int,
+ prop_id: int | None,
+ scenery_id: int | None,
+ show: Show,
+) -> bool:
+ """
+ Check if a scene is a valid STRIKE boundary for an item.
+
+ A scene is a valid STRIKE boundary if it's the last scene of a block.
+
+ :param session: Database session
+ :param scene_id: Scene ID to check
+ :param prop_id: Prop ID (if checking a prop)
+ :param scenery_id: Scenery ID (if checking scenery)
+ :param show: The show
+ :returns: True if the scene is a valid STRIKE boundary
+ """
+ if prop_id is not None:
+ prop = session.get(Props, prop_id)
+ if not prop:
+ return False
+ blocks = compute_blocks_for_prop(prop, show)
+ elif scenery_id is not None:
+ scenery = session.get(Scenery, scenery_id)
+ if not scenery:
+ return False
+ blocks = compute_blocks_for_scenery(scenery, show)
+ else:
+ return False
+
+ return any(block.strike_scene_id == scene_id for block in blocks)
+
+
+def is_valid_boundary(
+ session: Session,
+ scene_id: int,
+ assignment_type: str,
+ prop_id: int | None,
+ scenery_id: int | None,
+ show: Show,
+) -> bool:
+ """
+ Check if a scene is a valid boundary for a crew assignment.
+
+ :param session: Database session
+ :param scene_id: Scene ID to check
+ :param assignment_type: 'set' or 'strike'
+ :param prop_id: Prop ID (if checking a prop)
+ :param scenery_id: Scenery ID (if checking scenery)
+ :param show: The show
+ :returns: True if the scene is a valid boundary for the assignment type
+ """
+ if assignment_type == "set":
+ return is_valid_set_boundary(session, scene_id, prop_id, scenery_id, show)
+ elif assignment_type == "strike":
+ return is_valid_strike_boundary(session, scene_id, prop_id, scenery_id, show)
+ else:
+ return False
+
+
+def find_orphaned_assignments_for_prop(
+ session: Session, prop: Props, show: Show
+) -> List[CrewAssignment]:
+ """
+ Find crew assignments for a prop that are no longer on valid block boundaries.
+
+ :param session: Database session
+ :param prop: The prop to check
+ :param show: The show
+ :returns: List of orphaned CrewAssignment objects
+ """
+ blocks = compute_blocks_for_prop(prop, show)
+ valid_set_scenes = {block.set_scene_id for block in blocks}
+ valid_strike_scenes = {block.strike_scene_id for block in blocks}
+
+ # Get all crew assignments for this prop
+ assignments = session.scalars(
+ select(CrewAssignment).where(CrewAssignment.prop_id == prop.id)
+ ).all()
+
+ orphaned = []
+ for assignment in assignments:
+ if assignment.assignment_type == "set":
+ if assignment.scene_id not in valid_set_scenes:
+ orphaned.append(assignment)
+ elif assignment.assignment_type == "strike":
+ if assignment.scene_id not in valid_strike_scenes:
+ orphaned.append(assignment)
+
+ return orphaned
+
+
+def find_orphaned_assignments_for_scenery(
+ session: Session, scenery: Scenery, show: Show
+) -> List[CrewAssignment]:
+ """
+ Find crew assignments for a scenery item that are no longer on valid block boundaries.
+
+ :param session: Database session
+ :param scenery: The scenery item to check
+ :param show: The show
+ :returns: List of orphaned CrewAssignment objects
+ """
+ blocks = compute_blocks_for_scenery(scenery, show)
+ valid_set_scenes = {block.set_scene_id for block in blocks}
+ valid_strike_scenes = {block.strike_scene_id for block in blocks}
+
+ # Get all crew assignments for this scenery
+ assignments = session.scalars(
+ select(CrewAssignment).where(CrewAssignment.scenery_id == scenery.id)
+ ).all()
+
+ orphaned = []
+ for assignment in assignments:
+ if assignment.assignment_type == "set":
+ if assignment.scene_id not in valid_set_scenes:
+ orphaned.append(assignment)
+ elif assignment.assignment_type == "strike":
+ if assignment.scene_id not in valid_strike_scenes:
+ orphaned.append(assignment)
+
+ return orphaned
+
+
+def delete_orphaned_assignments_for_prop(
+ session: Session, prop: Props, show: Show
+) -> List[int]:
+ """
+ Delete crew assignments for a prop that are no longer on valid block boundaries.
+
+ :param session: Database session
+ :param prop: The prop to check
+ :param show: The show
+ :returns: List of IDs of deleted assignments
+ """
+ orphaned = find_orphaned_assignments_for_prop(session, prop, show)
+ deleted_ids = [a.id for a in orphaned]
+
+ for assignment in orphaned:
+ session.delete(assignment)
+
+ return deleted_ids
+
+
+def delete_orphaned_assignments_for_scenery(
+ session: Session, scenery: Scenery, show: Show
+) -> List[int]:
+ """
+ Delete crew assignments for a scenery item that are no longer on valid block boundaries.
+
+ :param session: Database session
+ :param scenery: The scenery item to check
+ :param show: The show
+ :returns: List of IDs of deleted assignments
+ """
+ orphaned = find_orphaned_assignments_for_scenery(session, scenery, show)
+ deleted_ids = [a.id for a in orphaned]
+
+ for assignment in orphaned:
+ session.delete(assignment)
+
+ return deleted_ids
diff --git a/server/utils/version_checker.py b/server/utils/version_checker.py
new file mode 100644
index 00000000..cd330857
--- /dev/null
+++ b/server/utils/version_checker.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING, Optional
+
+from tornado.httpclient import AsyncHTTPClient, HTTPClientError
+from tornado.ioloop import PeriodicCallback
+
+from digi_server.logger import get_logger
+from digi_server.settings import get_version
+
+
+if TYPE_CHECKING:
+ from digi_server.app_server import DigiScriptServer
+
+
+class VersionStatus:
+ def __init__(
+ self,
+ current_version: str,
+ latest_version: Optional[str] = None,
+ update_available: bool = False,
+ release_url: Optional[str] = None,
+ last_checked: Optional[datetime] = None,
+ check_error: Optional[str] = None,
+ ):
+ self.current_version = current_version
+ self.latest_version = latest_version
+ self.update_available = update_available
+ self.release_url = release_url
+ self.last_checked = last_checked
+ self.check_error = check_error
+
+ def as_json(self) -> dict:
+ """
+ Serialize version status to JSON-compatible dictionary.
+
+ :returns: Dictionary with version status fields.
+ """
+ return {
+ "current_version": self.current_version,
+ "latest_version": self.latest_version,
+ "update_available": self.update_available,
+ "release_url": self.release_url,
+ "last_checked": (
+ self.last_checked.isoformat() if self.last_checked else None
+ ),
+ "check_error": self.check_error,
+ }
+
+
+class VersionChecker:
+ GITHUB_API_URL = (
+ "https://api.github.com/repos/dreamteamprod/DigiScript/releases/latest"
+ )
+ DEFAULT_CHECK_INTERVAL_MS = 60 * 60 * 1000
+
+ def __init__(
+ self,
+ application: "DigiScriptServer",
+ check_interval_ms: Optional[int] = None,
+ ):
+ """
+ Initialize the version checker.
+
+ :param application: The DigiScript server application instance.
+ :param check_interval_ms: Interval between checks in milliseconds.
+ Defaults to 1 hour.
+ """
+ self._application = application
+ self._check_interval_ms = check_interval_ms or self.DEFAULT_CHECK_INTERVAL_MS
+ self._logger = get_logger(name="version_checker")
+ self._http_client = AsyncHTTPClient()
+ self._periodic_callback: Optional[PeriodicCallback] = None
+
+ # Initialize status with current version
+ self._status = VersionStatus(current_version=get_version())
+
+ @property
+ def status(self) -> VersionStatus:
+ """Get the current version status."""
+ return self._status
+
+ async def start(self) -> None:
+ """
+ Start the version checker.
+
+ Performs an initial check and schedules periodic checks.
+ """
+ self._logger.info("Starting version checker service")
+
+ # Perform initial check
+ await self.check_for_updates()
+
+ # Schedule periodic checks
+ self._periodic_callback = PeriodicCallback(
+ self.check_for_updates,
+ self._check_interval_ms,
+ )
+ self._periodic_callback.start()
+
+ self._logger.info(
+ f"Version checker started (interval: {self._check_interval_ms // 1000}s)"
+ )
+
+ async def stop(self) -> None:
+ """Stop the version checker and cancel periodic checks."""
+ if self._periodic_callback:
+ self._periodic_callback.stop()
+ self._periodic_callback = None
+
+ if self._http_client:
+ self._http_client.close()
+ self._http_client = None
+
+ self._logger.info("Version checker stopped")
+
+ async def check_for_updates(self) -> VersionStatus:
+ """
+ Check GitHub for the latest release version.
+
+ :returns: Updated VersionStatus with check results.
+ """
+ self._logger.debug("Checking for updates...")
+
+ current_version = get_version()
+
+ try:
+ response = await self._http_client.fetch(
+ self.GITHUB_API_URL,
+ headers={
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": f"DigiScript/{current_version}",
+ },
+ request_timeout=10.0,
+ )
+
+ release_data = json.loads(response.body.decode("utf-8"))
+ latest_version = release_data.get("tag_name", "").lstrip("v")
+ release_url = release_data.get("html_url")
+
+ # Compare versions
+ update_available = self._is_newer_version(latest_version, current_version)
+
+ self._status = VersionStatus(
+ current_version=current_version,
+ latest_version=latest_version,
+ update_available=update_available,
+ release_url=release_url,
+ last_checked=datetime.now(timezone.utc),
+ check_error=None,
+ )
+
+ if update_available:
+ self._logger.info(
+ f"Update available: {current_version} -> {latest_version}"
+ )
+ else:
+ self._logger.debug(f"Running latest version: {current_version}")
+
+ except HTTPClientError as e:
+ error_msg = f"HTTP error checking for updates: {e.code}"
+ self._logger.warning(error_msg)
+ self._status = VersionStatus(
+ current_version=current_version,
+ latest_version=self._status.latest_version,
+ update_available=self._status.update_available,
+ release_url=self._status.release_url,
+ last_checked=datetime.now(timezone.utc),
+ check_error=error_msg,
+ )
+
+ except Exception as e:
+ error_msg = f"Unable to check for updates: {str(e)}"
+ self._logger.warning(error_msg)
+ self._status = VersionStatus(
+ current_version=current_version,
+ latest_version=self._status.latest_version,
+ update_available=self._status.update_available,
+ release_url=self._status.release_url,
+ last_checked=datetime.now(timezone.utc),
+ check_error=error_msg,
+ )
+
+ return self._status
+
+ def _is_newer_version(self, latest: str, current: str) -> bool:
+ """
+ Compare version strings to determine if an update is available.
+
+ Uses simple semantic version comparison (major.minor.patch).
+ Handles pre-release suffixes (e.g., "1.0.0-beta") by stripping them.
+
+ :param latest: The latest version string from GitHub.
+ :param current: The current running version string.
+ :returns: True if latest is newer than current.
+ """
+ try:
+ # Strip pre-release suffixes (everything after -)
+ latest_clean = latest.split("-", maxsplit=1)[0]
+ current_clean = current.split("-", maxsplit=1)[0]
+
+ latest_parts = [int(x) for x in latest_clean.split(".")]
+ current_parts = [int(x) for x in current_clean.split(".")]
+
+ # Pad shorter version with zeros
+ max_len = max(len(latest_parts), len(current_parts))
+ latest_parts.extend([0] * (max_len - len(latest_parts)))
+ current_parts.extend([0] * (max_len - len(current_parts)))
+
+ return latest_parts > current_parts
+ except (ValueError, AttributeError):
+ # If parsing fails, assume no update available
+ self._logger.warning(
+ f"Failed to parse versions: latest={latest}, current={current}"
+ )
+ return False
diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py
index 5aa3b422..3ecd84f7 100644
--- a/server/utils/web/base_controller.py
+++ b/server/utils/web/base_controller.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from copy import deepcopy
from typing import TYPE_CHECKING, Any, Awaitable, Optional
import bcrypt
@@ -121,6 +122,12 @@ async def prepare(
self.current_show = show_schema.dump(show)
return
+ def requires_admin(self):
+ if not self.current_user:
+ raise HTTPError(401, log_message="Not logged in")
+ if not self.current_user["is_admin"]:
+ raise HTTPError(403, log_message="Admin access required")
+
def requires_role(self, resource: db.Model, role: Role):
if not self.current_user:
raise HTTPError(401, log_message="Not logged in")
@@ -165,15 +172,33 @@ def _unimplemented_method(self, *args: str, **kwargs: str) -> None:
self.write({"message": "405 not allowed"})
def on_finish(self):
+ from utils.web.route import Route # noqa: PLC0415
+
+ if self.request.path in Route.ignored_logging_routes():
+ log_method = get_logger().trace
+ else:
+ log_method = get_logger().debug
+
if self.request.body:
+ method_name = self.request.method.lower()
+ handler_method = getattr(self, method_name, None)
+ redacted_data_paths = getattr(handler_method, "_redacted_data_paths", None)
try:
- get_logger().debug(
- f"{self.request.method} "
- f"{self.request.path} "
- f"{escape.json_decode(self.request.body)}"
- )
+ body = escape.json_decode(self.request.body)
except BaseException:
get_logger().debug(
f"{self.request.method} {self.request.path} {self.request.body}"
)
+ else:
+ if (
+ redacted_data_paths
+ and self.application.digi_settings.settings[
+ "log_redaction"
+ ].get_value()
+ ):
+ body = deepcopy(body)
+ redacted_data_paths.apply(body)
+
+ log_method(f"{self.request.method} {self.request.path} {body}")
+
super().on_finish()
diff --git a/server/utils/web/route.py b/server/utils/web/route.py
index 2bdb7bfd..464b455d 100644
--- a/server/utils/web/route.py
+++ b/server/utils/web/route.py
@@ -56,9 +56,15 @@ class ApiVersion(Enum):
class ApiRoute(Route):
- def __init__(self, route: str, api_version: ApiVersion, name: str = None):
+ def __init__(
+ self,
+ route: str,
+ api_version: ApiVersion,
+ name: str = None,
+ ignore_logging: bool = False,
+ ):
route = f"/api/v{api_version.value}/{route.removeprefix('/')}"
- super().__init__(route, name)
+ super().__init__(route, name, ignore_logging)
def __call__(self, controller):
if not issubclass(controller, (BaseAPIController, WebSocketHandler)):
diff --git a/server/utils/web/web_decorators.py b/server/utils/web/web_decorators.py
index bdd9d877..0ba3862d 100644
--- a/server/utils/web/web_decorators.py
+++ b/server/utils/web/web_decorators.py
@@ -1,6 +1,7 @@
import functools
-from typing import Awaitable, Callable, Optional
+from typing import Awaitable, Callable, List, Optional
+from jsonpath import JSONPatch
from tornado.web import HTTPError
from utils.web.base_controller import BaseController
@@ -77,3 +78,27 @@ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]:
# Mark the wrapper with an attribute so prepare() can detect it
wrapper._allow_when_password_required = True # type: ignore
return wrapper
+
+
+def redact_data_paths(
+ paths: List[str],
+) -> Callable[
+ [Callable[..., Optional[Awaitable[None]]]], Callable[..., Optional[Awaitable[None]]]
+]:
+ patch = None
+ if paths:
+ patch = JSONPatch()
+ for path in paths:
+ patch.replace(path, "<-- REDACTED -->")
+
+ def decorator(
+ method: Callable[..., Optional[Awaitable[None]]],
+ ) -> Callable[..., Optional[Awaitable[None]]]:
+ @functools.wraps(method)
+ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]:
+ return method(self, *args, **kwargs)
+
+ wrapper._redacted_data_paths = patch
+ return wrapper
+
+ return decorator