diff --git a/client/src/js/blockOrphanUtils.js b/client/src/js/blockOrphanUtils.js new file mode 100644 index 00000000..bc018759 --- /dev/null +++ b/client/src/js/blockOrphanUtils.js @@ -0,0 +1,133 @@ +/** + * Block computation and orphan detection utilities for stage crew assignments. + * + * A "block" is a consecutive sequence of scenes (within an act) where an item + * is allocated. The first scene is the SET boundary; the last is the STRIKE + * boundary. These pure functions mirror the backend logic in + * server/utils/show/block_computation.py. + */ + +/** + * Compute allocation blocks from ordered scenes and a set of allocated scene IDs. + * + * Blocks break at act boundaries and allocation gaps. + * + * @param {Array<{id: number, act: number}>} orderedScenes - Scenes in display order + * @param {Set} allocatedSceneIds - Scene IDs where the item is allocated + * @returns {Array<{actId: number, sceneIds: number[], setSceneId: number, strikeSceneId: number}>} + */ +export function computeBlocks(orderedScenes, allocatedSceneIds) { + if ( + !orderedScenes || + orderedScenes.length === 0 || + !allocatedSceneIds || + allocatedSceneIds.size === 0 + ) { + return []; + } + + const blocks = []; + let currentBlockScenes = []; + let currentActId = null; + + for (const scene of orderedScenes) { + // Act boundary breaks the current block + if (currentActId !== null && scene.act !== currentActId) { + if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + currentBlockScenes = []; + } + } + currentActId = scene.act; + + if (allocatedSceneIds.has(scene.id)) { + currentBlockScenes.push(scene.id); + } else if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + currentBlockScenes = []; + } + } + + // Flush last block + if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + } + + return blocks; +} + +/** + * Find crew assignments that would become orphaned by adding or removing + * a scene allocation. + * + * @param {Object} params + * @param {Array<{id: number, act: number}>} params.orderedScenes - All scenes in order + * @param {Array<{scene_id: number}>} params.currentAllocations - Current allocations for the item + * @param {Array<{id: number, scene_id: number, assignment_type: string, crew_id: number}>} params.crewAssignments - Current crew assignments for the item + * @param {'add'|'remove'} params.changeType - Whether a scene allocation is being added or removed + * @param {number} params.changeSceneId - The scene ID being added/removed + * @returns {Array<{id: number, scene_id: number, assignment_type: string, crew_id: number}>} Orphaned assignments + */ +export function findOrphanedAssignments({ + orderedScenes, + currentAllocations, + crewAssignments, + changeType, + changeSceneId, +}) { + if (!crewAssignments || crewAssignments.length === 0) { + return []; + } + + // Build current allocated set + const currentSet = new Set(currentAllocations.map((a) => a.scene_id)); + + // Simulate the change + const newSet = new Set(currentSet); + if (changeType === 'add') { + newSet.add(changeSceneId); + } else if (changeType === 'remove') { + newSet.delete(changeSceneId); + } + + // Compute blocks before and after + const oldBlocks = computeBlocks(orderedScenes, currentSet); + const newBlocks = computeBlocks(orderedScenes, newSet); + + // Build valid boundary sets from new blocks + const validSetScenes = new Set(newBlocks.map((b) => b.setSceneId)); + const validStrikeScenes = new Set(newBlocks.map((b) => b.strikeSceneId)); + + // Also check which assignments were valid before — only flag ones that + // become invalid (were on a valid boundary before, but aren't after) + const oldValidSetScenes = new Set(oldBlocks.map((b) => b.setSceneId)); + const oldValidStrikeScenes = new Set(oldBlocks.map((b) => b.strikeSceneId)); + + return crewAssignments.filter((assignment) => { + if (assignment.assignment_type === 'set') { + const wasValid = oldValidSetScenes.has(assignment.scene_id); + const isValid = validSetScenes.has(assignment.scene_id); + return wasValid && !isValid; + } else if (assignment.assignment_type === 'strike') { + const wasValid = oldValidStrikeScenes.has(assignment.scene_id); + const isValid = validStrikeScenes.has(assignment.scene_id); + return wasValid && !isValid; + } + return false; + }); +} diff --git a/client/src/js/blockOrphanUtils.test.js b/client/src/js/blockOrphanUtils.test.js new file mode 100644 index 00000000..70479d95 --- /dev/null +++ b/client/src/js/blockOrphanUtils.test.js @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { computeBlocks, findOrphanedAssignments } from './blockOrphanUtils'; + +describe('blockOrphanUtils', () => { + describe('computeBlocks', () => { + it('returns empty array for empty inputs', () => { + expect(computeBlocks([], new Set())).toEqual([]); + expect(computeBlocks([], new Set([1]))).toEqual([]); + expect(computeBlocks(null, null)).toEqual([]); + }); + + it('returns single block for one allocated scene', () => { + const scenes = [{ id: 1, act: 1 }]; + const result = computeBlocks(scenes, new Set([1])); + expect(result).toEqual([{ actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 }]); + }); + + it('returns one block for consecutive scenes in one act', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 3])); + expect(result).toEqual([{ actId: 1, sceneIds: [1, 2, 3], setSceneId: 1, strikeSceneId: 3 }]); + }); + + it('splits into two blocks when there is a gap in the middle', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 4])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 }, + { actId: 1, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 }, + ]); + }); + + it('breaks blocks at act boundaries even for consecutive scenes', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 2 }, + { id: 4, act: 2 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 3, 4])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 }, + { actId: 2, sceneIds: [3, 4], setSceneId: 3, strikeSceneId: 4 }, + ]); + }); + + it('handles multiple acts with multiple blocks each', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 2 }, + { id: 5, act: 2 }, + { id: 6, act: 2 }, + ]; + // Allocated: 1, 3 (act 1 gap), 4, 6 (act 2 gap) + const result = computeBlocks(scenes, new Set([1, 3, 4, 6])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 }, + { actId: 1, sceneIds: [3], setSceneId: 3, strikeSceneId: 3 }, + { actId: 2, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 }, + { actId: 2, sceneIds: [6], setSceneId: 6, strikeSceneId: 6 }, + ]); + }); + + it('ignores scenes not in the allocated set', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([2])); + expect(result).toEqual([{ actId: 1, sceneIds: [2], setSceneId: 2, strikeSceneId: 2 }]); + }); + }); + + describe('findOrphanedAssignments', () => { + // Reusable test data + const orderedScenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 1 }, + ]; + + it('returns empty array when there are no crew assignments', () => { + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }], + crewAssignments: [], + changeType: 'remove', + changeSceneId: 1, + }); + expect(result).toEqual([]); + }); + + it('returns empty when boundary does not change', () => { + // Block is scenes 1-3, remove scene 2 (middle) → boundaries stay at 1 and 3 + // Actually removing middle splits block: [1] and [3], boundaries change! + // Use: remove scene 2 from [1,2,3] → [1] block (set=1,strike=1) + [3] (set=3,strike=3) + // Original: [1,2,3] → set=1, strike=3 + // So boundaries DO change for strike. Let's use a case where they don't change. + // Add scene 2 to [1,3] → no boundary change (set=1, strike=3 in both) + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 3 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'add', + changeSceneId: 2, + }); + expect(result).toEqual([]); + }); + + it('orphans SET assignments when block start is removed', () => { + // Block [1,2,3] → remove scene 1 → block becomes [2,3], SET moves to 2 + const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 1, + }); + expect(result).toEqual([setAssignment]); + }); + + it('orphans STRIKE assignments when block end is removed', () => { + // Block [1,2,3] → remove scene 3 → block becomes [1,2], STRIKE moves to 2 + const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 3, + }); + expect(result).toEqual([strikeAssignment]); + }); + + it('orphans both SET and STRIKE when a single-scene block is removed', () => { + // Block [2] only → remove scene 2 → block disappears + const setAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 2 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 2, + }); + expect(result).toContainEqual(setAssignment); + expect(result).toContainEqual(strikeAssignment); + expect(result).toHaveLength(2); + }); + + it('does not orphan when removing a middle scene (split keeps original boundaries)', () => { + // Block [1,2,3] → remove scene 2 → blocks [1] and [3] + // SET scene 1 stays valid (set of block [1]), STRIKE scene 3 stays valid (strike of block [3]) + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'remove', + changeSceneId: 2, + }); + expect(result).toEqual([]); + }); + + it('orphans old SET when adding a scene before block start', () => { + // Block [2,3] → add scene 1 → block becomes [1,2,3], SET moves to 1 + const oldSetAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [ + oldSetAssignment, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'add', + changeSceneId: 1, + }); + expect(result).toEqual([oldSetAssignment]); + }); + + it('orphans old STRIKE when adding a scene after block end', () => { + // Block [1,2] → add scene 3 → block becomes [1,2,3], STRIKE moves to 3 + const oldStrikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + oldStrikeAssignment, + ], + changeType: 'add', + changeSceneId: 3, + }); + expect(result).toEqual([oldStrikeAssignment]); + }); + + it('does not affect assignments in a different act', () => { + const multiActScenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 2 }, + { id: 4, act: 2 }, + ]; + // Act 1 block [1,2], Act 2 block [3,4] + // Remove scene 1 (act 1 boundary change) → Act 2 should be unaffected + const act1Set = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const act2Set = { id: 20, scene_id: 3, assignment_type: 'set', crew_id: 1 }; + const act2Strike = { id: 21, scene_id: 4, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes: multiActScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }, { scene_id: 4 }], + crewAssignments: [act1Set, act2Set, act2Strike], + changeType: 'remove', + changeSceneId: 1, + }); + // Only act1 SET is orphaned + expect(result).toEqual([act1Set]); + }); + }); +}); diff --git a/client/src/mixins/timelineMixin.js b/client/src/mixins/timelineMixin.js index fbe41e04..3a25a812 100644 --- a/client/src/mixins/timelineMixin.js +++ b/client/src/mixins/timelineMixin.js @@ -119,11 +119,19 @@ export default { const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id); if (hasAllocation) { - if (currentSegment) { - // Extend current segment + const sameAct = currentSegment + ? scene.act === this.scenes[currentSegment.startIndex].act + : true; + + if (currentSegment && sameAct) { + // Extend current segment within same act currentSegment.endIndex = sceneIndex; currentSegment.endScene = scene.name; } else { + // Close previous segment if act boundary crossed + if (currentSegment) { + segments.push(currentSegment); + } // Start new segment currentSegment = { startIndex: sceneIndex, diff --git a/client/src/store/modules/stage.js b/client/src/store/modules/stage.js index 78fa7be3..3b531ec6 100644 --- a/client/src/store/modules/stage.js +++ b/client/src/store/modules/stage.js @@ -6,6 +6,7 @@ import { makeURL } from '@/js/utils'; export default { state: { crewList: [], + crewAssignments: [], sceneryTypes: [], sceneryList: [], sceneryAllocations: [], @@ -17,6 +18,9 @@ export default { SET_CREW_LIST(state, crewList) { state.crewList = crewList; }, + SET_CREW_ASSIGNMENTS(state, assignments) { + state.crewAssignments = assignments; + }, SET_SCENERY_TYPES(state, sceneryTypes) { state.sceneryTypes = sceneryTypes; }, @@ -427,6 +431,56 @@ export default { Vue.$toast.error('Unable to delete scenery allocation'); } }, + async GET_CREW_ASSIGNMENTS(context) { + const response = await fetch(`${makeURL('/api/v1/show/stage/crew/assignments')}`); + if (response.ok) { + const data = await response.json(); + context.commit('SET_CREW_ASSIGNMENTS', data.assignments); + } else { + log.error('Unable to get crew assignments'); + } + }, + async ADD_CREW_ASSIGNMENT(context, assignment) { + const response = await fetch(`${makeURL('/api/v1/show/stage/crew/assignments')}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(assignment), + }); + if (response.ok) { + context.dispatch('GET_CREW_ASSIGNMENTS'); + Vue.$toast.success('Added crew assignment!'); + return { success: true }; + } else { + const errorData = await response.json().catch(() => ({})); + const errorMsg = errorData.message || 'Unable to add crew assignment'; + log.error(errorMsg); + Vue.$toast.error(errorMsg); + return { success: false, error: errorMsg }; + } + }, + async DELETE_CREW_ASSIGNMENT(context, assignmentId) { + const searchParams = new URLSearchParams({ id: assignmentId }); + const response = await fetch( + `${makeURL('/api/v1/show/stage/crew/assignments')}?${searchParams}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + if (response.ok) { + context.dispatch('GET_CREW_ASSIGNMENTS'); + Vue.$toast.success('Deleted crew assignment!'); + } else { + const errorData = await response.json().catch(() => ({})); + const errorMsg = errorData.message || 'Unable to delete crew assignment'; + log.error(errorMsg); + Vue.$toast.error(errorMsg); + } + }, }, getters: { CREW_LIST(state) { @@ -506,5 +560,56 @@ export default { SCENERY_ALLOCATIONS(state) { return state.sceneryAllocations; }, + CREW_ASSIGNMENTS(state) { + return state.crewAssignments; + }, + CREW_MEMBER_BY_ID: (state) => (crewId) => { + if (crewId == null) return null; + return state.crewList.find((c) => c.id === crewId) || null; + }, + CREW_ASSIGNMENTS_BY_PROP(state) { + const result = {}; + state.crewAssignments.forEach((assignment) => { + if (assignment.prop_id != null) { + if (!result[assignment.prop_id]) { + result[assignment.prop_id] = []; + } + result[assignment.prop_id].push(assignment); + } + }); + return result; + }, + CREW_ASSIGNMENTS_BY_SCENERY(state) { + const result = {}; + state.crewAssignments.forEach((assignment) => { + if (assignment.scenery_id != null) { + if (!result[assignment.scenery_id]) { + result[assignment.scenery_id] = []; + } + result[assignment.scenery_id].push(assignment); + } + }); + return result; + }, + CREW_ASSIGNMENTS_BY_CREW(state) { + const result = {}; + state.crewAssignments.forEach((assignment) => { + if (!result[assignment.crew_id]) { + result[assignment.crew_id] = []; + } + result[assignment.crew_id].push(assignment); + }); + return result; + }, + CREW_ASSIGNMENTS_BY_SCENE(state) { + const result = {}; + state.crewAssignments.forEach((assignment) => { + if (!result[assignment.scene_id]) { + result[assignment.scene_id] = []; + } + result[assignment.scene_id].push(assignment); + }); + return result; + }, }, }; diff --git a/client/src/views/show/config/ConfigStage.vue b/client/src/views/show/config/ConfigStage.vue index 734f7288..cef1fbbc 100644 --- a/client/src/views/show/config/ConfigStage.vue +++ b/client/src/views/show/config/ConfigStage.vue @@ -16,7 +16,14 @@ - + + + + + + + + @@ -30,6 +37,7 @@ import SceneryList from '@/vue_components/show/config/stage/SceneryList.vue'; import PropsList from '@/vue_components/show/config/stage/PropsList.vue'; import StageManager from '@/vue_components/show/config/stage/StageManager.vue'; import StageTimeline from '@/vue_components/show/config/stage/StageTimeline.vue'; +import CrewTimeline from '@/vue_components/show/config/stage/CrewTimeline.vue'; export default { name: 'ConfigCrew', @@ -39,6 +47,7 @@ export default { SceneryList, CrewList, StageTimeline, + CrewTimeline, }, }; diff --git a/client/src/vue_components/show/config/stage/CrewTimeline.vue b/client/src/vue_components/show/config/stage/CrewTimeline.vue new file mode 100644 index 00000000..0cc795ba --- /dev/null +++ b/client/src/vue_components/show/config/stage/CrewTimeline.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/client/src/vue_components/show/config/stage/StageManager.vue b/client/src/vue_components/show/config/stage/StageManager.vue index 693189b0..be06b122 100644 --- a/client/src/vue_components/show/config/stage/StageManager.vue +++ b/client/src/vue_components/show/config/stage/StageManager.vue @@ -31,83 +31,298 @@ Next Scene - - - Scenery - Prop - - + - - + + -
Scenery
- - - - - - -
- -
Props
- - - - - - -
-
+
+ + Allocations + + {{ currentSceneSceneryAllocations.length + currentScenePropsAllocations.length }} + + + + +
+ + + +
+ + Scenery + Prop + +
+ + +
Scenery
+ + + + + + +
+ +
Props
+ + + + + + +
+
+
+
+ + + + + +
+ + SET + {{ setItems.length }} + + + + {{ unassignedSetCount }} unassigned + + + + +
+
+ + +
+
+
+ {{ item.name }} + {{ item.itemType }} +
+
+ + {{ formatCrewName(CREW_MEMBER_BY_ID(assignment.crew_id)) }} + + + + + + No crew assigned + +
+
+ + + + + Add + +
+
+
+
+
+
+ + + + +
+ + STRIKE + {{ strikeItems.length }} + + + + {{ unassignedStrikeCount }} unassigned + + + + +
+
+ + +
+
+
+ {{ item.name }} + {{ item.itemType }} +
+
+ + {{ formatCrewName(CREW_MEMBER_BY_ID(assignment.crew_id)) }} + + + + + + No crew assigned + +
+
+ + + + + Add + +
+
+
+
+
+
({ value: p.id, text: p.name })), })).filter((group) => group.options.length > 0); }, + previousSceneInAct() { + if (this.currentSceneIndex <= 0 || !this.currentScene) return null; + const prev = this.orderedScenes[this.currentSceneIndex - 1]; + return prev && prev.act === this.currentScene.act ? prev : null; + }, + nextSceneInAct() { + if (!this.currentScene || this.currentSceneIndex >= this.orderedScenes.length - 1) + return null; + const next = this.orderedScenes[this.currentSceneIndex + 1]; + return next && next.act === this.currentScene.act ? next : null; + }, + setItems() { + if (!this.currentScene) return []; + const items = []; + const sceneId = this.currentScene.id; + const prevSceneId = this.previousSceneInAct?.id; + + for (const scenery of this.SCENERY_LIST) { + const allocs = this.SCENERY_ALLOCATIONS_BY_ITEM[scenery.id] || []; + const inCurrent = allocs.some((a) => a.scene_id === sceneId); + if (!inCurrent) continue; + const inPrev = prevSceneId && allocs.some((a) => a.scene_id === prevSceneId); + if (!inPrev) { + items.push({ itemId: scenery.id, itemType: 'scenery', name: scenery.name }); + } + } + + for (const prop of this.PROPS_LIST) { + const allocs = this.PROPS_ALLOCATIONS_BY_ITEM[prop.id] || []; + const inCurrent = allocs.some((a) => a.scene_id === sceneId); + if (!inCurrent) continue; + const inPrev = prevSceneId && allocs.some((a) => a.scene_id === prevSceneId); + if (!inPrev) { + items.push({ itemId: prop.id, itemType: 'prop', name: prop.name }); + } + } + + return items; + }, + strikeItems() { + if (!this.currentScene) return []; + const items = []; + const sceneId = this.currentScene.id; + const nextSceneId = this.nextSceneInAct?.id; + + for (const scenery of this.SCENERY_LIST) { + const allocs = this.SCENERY_ALLOCATIONS_BY_ITEM[scenery.id] || []; + const inCurrent = allocs.some((a) => a.scene_id === sceneId); + if (!inCurrent) continue; + const inNext = nextSceneId && allocs.some((a) => a.scene_id === nextSceneId); + if (!inNext) { + items.push({ itemId: scenery.id, itemType: 'scenery', name: scenery.name }); + } + } + + for (const prop of this.PROPS_LIST) { + const allocs = this.PROPS_ALLOCATIONS_BY_ITEM[prop.id] || []; + const inCurrent = allocs.some((a) => a.scene_id === sceneId); + if (!inCurrent) continue; + const inNext = nextSceneId && allocs.some((a) => a.scene_id === nextSceneId); + if (!inNext) { + items.push({ itemId: prop.id, itemType: 'prop', name: prop.name }); + } + } + + return items; + }, + unassignedSetCount() { + return this.setItems.filter( + (item) => this.getAssignmentsForItem(item.itemId, item.itemType, 'set').length === 0 + ).length; + }, + unassignedStrikeCount() { + return this.strikeItems.filter( + (item) => this.getAssignmentsForItem(item.itemId, item.itemType, 'strike').length === 0 + ).length; + }, currentSceneSceneryAllocations() { if (!this.currentScene) return []; return this.SCENERY_ALLOCATIONS.filter((a) => a.scene_id === this.currentScene.id); @@ -353,10 +651,16 @@ export default { 'SCENERY_TYPES', 'SCENERY_TYPE_BY_ID', 'SCENERY_ALLOCATIONS', + 'SCENERY_ALLOCATIONS_BY_ITEM', 'PROPS_LIST', 'PROP_TYPES', 'PROP_TYPE_BY_ID', 'PROPS_ALLOCATIONS', + 'PROPS_ALLOCATIONS_BY_ITEM', + 'CREW_LIST', + 'CREW_MEMBER_BY_ID', + 'CREW_ASSIGNMENTS_BY_PROP', + 'CREW_ASSIGNMENTS_BY_SCENERY', ]), }, async mounted() { @@ -369,6 +673,8 @@ export default { this.GET_PROP_TYPES(), this.GET_PROPS_LIST(), this.GET_PROPS_ALLOCATIONS(), + this.GET_CREW_LIST(), + this.GET_CREW_ASSIGNMENTS(), ]); this.loaded = true; this.calculateNavbarHeight(); @@ -435,11 +741,30 @@ export default { event.preventDefault(); return; } - await this.ADD_SCENERY_ALLOCATION({ - scenery_id: this.addSceneryFormState.scenery_id, - scene_id: this.currentScene.id, - }); - this.resetAddSceneryForm(); + const sceneryId = this.addSceneryFormState.scenery_id; + const orphans = this.findOrphansForItem(sceneryId, 'scenery', 'add', this.currentScene.id); + if (orphans.length > 0) { + event.preventDefault(); + const msgVNode = this.buildOrphanWarningVNode(orphans); + const confirmed = await this.$bvModal.msgBoxConfirm([msgVNode], { + title: 'Crew assignments will be removed', + okTitle: 'Continue', + okVariant: 'warning', + }); + if (confirmed !== true) return; + await this.ADD_SCENERY_ALLOCATION({ + scenery_id: sceneryId, + scene_id: this.currentScene.id, + }); + this.resetAddSceneryForm(); + this.$bvModal.hide('add-scenery'); + } else { + await this.ADD_SCENERY_ALLOCATION({ + scenery_id: sceneryId, + scene_id: this.currentScene.id, + }); + this.resetAddSceneryForm(); + } }, resetAddPropForm() { this.addPropFormState = { props_id: null }; @@ -457,11 +782,99 @@ export default { event.preventDefault(); return; } - await this.ADD_PROPS_ALLOCATION({ - props_id: this.addPropFormState.props_id, - scene_id: this.currentScene.id, - }); - this.resetAddPropForm(); + const propsId = this.addPropFormState.props_id; + const orphans = this.findOrphansForItem(propsId, 'prop', 'add', this.currentScene.id); + if (orphans.length > 0) { + event.preventDefault(); + const msgVNode = this.buildOrphanWarningVNode(orphans); + const confirmed = await this.$bvModal.msgBoxConfirm([msgVNode], { + title: 'Crew assignments will be removed', + okTitle: 'Continue', + okVariant: 'warning', + }); + if (confirmed !== true) return; + await this.ADD_PROPS_ALLOCATION({ + props_id: propsId, + scene_id: this.currentScene.id, + }); + this.resetAddPropForm(); + this.$bvModal.hide('add-prop'); + } else { + await this.ADD_PROPS_ALLOCATION({ + props_id: propsId, + scene_id: this.currentScene.id, + }); + this.resetAddPropForm(); + } + }, + getAssignmentsForItem(itemId, itemType, assignmentType) { + const assignments = + itemType === 'prop' + ? this.CREW_ASSIGNMENTS_BY_PROP[itemId] || [] + : this.CREW_ASSIGNMENTS_BY_SCENERY[itemId] || []; + return assignments.filter( + (a) => a.assignment_type === assignmentType && a.scene_id === this.currentScene?.id + ); + }, + getAvailableCrewForItem(itemId, itemType, assignmentType) { + const assigned = new Set( + this.getAssignmentsForItem(itemId, itemType, assignmentType).map((a) => a.crew_id) + ); + return this.CREW_LIST.filter((c) => !assigned.has(c.id)).map((c) => ({ + value: c.id, + text: this.formatCrewName(c), + })); + }, + formatCrewName(crew) { + if (!crew) return 'Unknown'; + return crew.last_name ? `${crew.first_name} ${crew.last_name}` : crew.first_name; + }, + crewSelectionKey(itemId, itemType, assignmentType) { + return `${itemType}-${itemId}-${assignmentType}`; + }, + async addCrewAssignment(itemId, itemType, assignmentType) { + const key = this.crewSelectionKey(itemId, itemType, assignmentType); + const crewId = this.newCrewSelections[key]; + if (!crewId || !this.currentScene || this.savingAssignment) return; + + this.savingAssignment = true; + try { + const assignment = { + crew_id: crewId, + scene_id: this.currentScene.id, + assignment_type: assignmentType, + }; + if (itemType === 'prop') { + assignment.prop_id = itemId; + } else { + assignment.scenery_id = itemId; + } + + const result = await this.ADD_CREW_ASSIGNMENT(assignment); + if (result.success) { + this.$set(this.newCrewSelections, key, null); + } + } finally { + this.savingAssignment = false; + } + }, + async removeCrewAssignment(assignment) { + if (this.savingAssignment) return; + + const crew = this.CREW_MEMBER_BY_ID(assignment.crew_id); + const crewName = this.formatCrewName(crew); + const confirmed = await this.$bvModal.msgBoxConfirm( + `Remove ${crewName} from this ${assignment.assignment_type.toUpperCase()} assignment?`, + { okTitle: 'Remove', okVariant: 'danger' } + ); + if (confirmed) { + this.savingAssignment = true; + try { + await this.DELETE_CREW_ASSIGNMENT(assignment.id); + } finally { + this.savingAssignment = false; + } + } }, getSceneryById(id) { return this.SCENERY_LIST.find((s) => s.id === id); @@ -469,21 +882,98 @@ export default { getPropById(id) { return this.PROPS_LIST.find((p) => p.id === id); }, + getItemAllocationsForScenery(sceneryId) { + return this.SCENERY_ALLOCATIONS.filter((a) => a.scenery_id === sceneryId); + }, + getItemAllocationsForProp(propId) { + return this.PROPS_ALLOCATIONS.filter((a) => a.props_id === propId); + }, + findOrphansForItem(itemId, itemType, changeType, sceneId) { + const allocations = + itemType === 'scenery' + ? this.getItemAllocationsForScenery(itemId) + : this.getItemAllocationsForProp(itemId); + const crewAssignments = + itemType === 'scenery' + ? this.CREW_ASSIGNMENTS_BY_SCENERY[itemId] || [] + : this.CREW_ASSIGNMENTS_BY_PROP[itemId] || []; + return findOrphanedAssignments({ + orderedScenes: this.orderedScenes, + currentAllocations: allocations, + crewAssignments, + changeType, + changeSceneId: sceneId, + }); + }, + buildOrphanWarningVNode(orphanedAssignments) { + const h = this.$createElement; + const groups = {}; + for (const assignment of orphanedAssignments) { + const itemName = + assignment.prop_id != null + ? this.getPropById(assignment.prop_id)?.name || 'Unknown Prop' + : this.getSceneryById(assignment.scenery_id)?.name || 'Unknown Scenery'; + const scene = this.orderedScenes.find((s) => s.id === assignment.scene_id); + const sceneName = scene?.name || 'Unknown Scene'; + const key = `${itemName} - ${assignment.assignment_type.toUpperCase()} (${sceneName})`; + if (!groups[key]) groups[key] = []; + const crew = this.CREW_MEMBER_BY_ID(assignment.crew_id); + groups[key].push(this.formatCrewName(crew)); + } + const items = Object.entries(groups).map(([label, names]) => + h('li', {}, `${label}: ${names.join(', ')}`) + ); + return h('div', {}, [ + h('p', {}, 'This action will remove the following crew assignments:'), + h('ul', { class: 'mb-2' }, items), + h('p', { class: 'text-muted mb-0' }, 'You can reassign crew after the change.'), + ]); + }, async deleteSceneryAllocation(allocation) { const scenery = this.getSceneryById(allocation.scenery_id); - const msg = `Remove "${scenery?.name}" from this scene?`; - const action = await this.$bvModal.msgBoxConfirm(msg, {}); - if (action === true) { - await this.DELETE_SCENERY_ALLOCATION(allocation.id); + const orphans = this.findOrphansForItem( + allocation.scenery_id, + 'scenery', + 'remove', + this.currentScene.id + ); + if (orphans.length > 0) { + const msgVNode = this.buildOrphanWarningVNode(orphans); + const action = await this.$bvModal.msgBoxConfirm([msgVNode], { + title: 'Crew assignments will be removed', + okTitle: 'Continue', + okVariant: 'danger', + }); + if (action !== true) return; + } else { + const msg = `Remove "${scenery?.name}" from this scene?`; + const action = await this.$bvModal.msgBoxConfirm(msg, {}); + if (action !== true) return; } + await this.DELETE_SCENERY_ALLOCATION(allocation.id); }, async deletePropAllocation(allocation) { const prop = this.getPropById(allocation.props_id); - const msg = `Remove "${prop?.name}" from this scene?`; - const action = await this.$bvModal.msgBoxConfirm(msg, {}); - if (action === true) { - await this.DELETE_PROPS_ALLOCATION(allocation.id); + const orphans = this.findOrphansForItem( + allocation.props_id, + 'prop', + 'remove', + this.currentScene.id + ); + if (orphans.length > 0) { + const msgVNode = this.buildOrphanWarningVNode(orphans); + const action = await this.$bvModal.msgBoxConfirm([msgVNode], { + title: 'Crew assignments will be removed', + okTitle: 'Continue', + okVariant: 'danger', + }); + if (action !== true) return; + } else { + const msg = `Remove "${prop?.name}" from this scene?`; + const action = await this.$bvModal.msgBoxConfirm(msg, {}); + if (action !== true) return; } + await this.DELETE_PROPS_ALLOCATION(allocation.id); }, ...mapActions([ 'GET_ACT_LIST', @@ -498,6 +988,10 @@ export default { 'GET_PROPS_ALLOCATIONS', 'ADD_PROPS_ALLOCATION', 'DELETE_PROPS_ALLOCATION', + 'GET_CREW_LIST', + 'GET_CREW_ASSIGNMENTS', + 'ADD_CREW_ASSIGNMENT', + 'DELETE_CREW_ASSIGNMENT', ]), }, }; @@ -515,4 +1009,59 @@ export default { border-bottom: 1px solid #dee2e6; background: var(--body-background); } + +.section-card-header { + cursor: pointer; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + font-weight: 600; +} + +.crew-card-body { + padding: 0.75rem; +} + +.boundary-items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.5rem; +} + +.boundary-item { + padding: 0.5rem 0.65rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.boundary-item-header { + margin-bottom: 0.25rem; + font-weight: 600; + font-size: 0.85rem; +} + +.assignment-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-bottom: 0.25rem; +} + +.crew-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.4rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.05); + font-size: 0.8rem; +} + +.add-crew-container { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.add-crew-select { + flex: 1; +} diff --git a/client/src/vue_components/show/config/stage/StageTimeline.vue b/client/src/vue_components/show/config/stage/StageTimeline.vue index 14a3d217..c7436651 100644 --- a/client/src/vue_components/show/config/stage/StageTimeline.vue +++ b/client/src/vue_components/show/config/stage/StageTimeline.vue @@ -3,138 +3,150 @@
-
-
- - - Combined - - - Props - +
+
+
+ + + Combined + + + Props + + + Scenery + + - Scenery + - - - - -
-
-
- No allocation data to display for this view
- - - - - - {{ actGroup.actName }} - - - - - - {{ scene.name }} - - - - - - - - +
+
+ No allocation data to display for this view +
+ + + - {{ bar.tooltip }} - {{ bar.label }} + {{ actGroup.actName }} - - - - + - {{ row.name }} + {{ scene.name }} - - + + + + + + + + {{ bar.tooltip }} + + {{ bar.label }} + + + + + + + + + {{ row.name }} + + + + +
+
@@ -142,9 +154,13 @@ diff --git a/client/src/vue_components/show/config/stage/TimelineSidePanel.vue b/client/src/vue_components/show/config/stage/TimelineSidePanel.vue new file mode 100644 index 00000000..28994122 --- /dev/null +++ b/client/src/vue_components/show/config/stage/TimelineSidePanel.vue @@ -0,0 +1,538 @@ + + + + + diff --git a/client/src/vue_components/show/live/StageManagerPane.vue b/client/src/vue_components/show/live/StageManagerPane.vue index a91467c8..8e633037 100644 --- a/client/src/vue_components/show/live/StageManagerPane.vue +++ b/client/src/vue_components/show/live/StageManagerPane.vue @@ -121,6 +121,12 @@ :key="`set-scenery-${item.id}`" > {{ getSceneryDisplayName(item) }} +
+ {{ getCrewNamesForSettingItem(item, 'scenery', smPlanScene).join(', ') }} +

None

@@ -129,6 +135,12 @@
  • {{ getPropDisplayName(item) }} +
    + {{ getCrewNamesForSettingItem(item, 'prop', smPlanScene).join(', ') }} +

None

@@ -166,6 +178,14 @@ :key="`strike-scenery-${item.id}`" > {{ getSceneryDisplayName(item) }} +
+ {{ getCrewNamesForStrikingItem(item, 'scenery', smPlanScene).join(', ') }} +

None

@@ -177,6 +197,12 @@ :key="`strike-prop-${item.id}`" > {{ getPropDisplayName(item) }} +
+ {{ getCrewNamesForStrikingItem(item, 'prop', smPlanScene).join(', ') }} +

None

@@ -273,6 +299,9 @@ export default { 'SCENERY_LIST', 'PROP_TYPES_DICT', 'SCENERY_TYPES_DICT', + 'CREW_ASSIGNMENTS_BY_PROP', + 'CREW_ASSIGNMENTS_BY_SCENERY', + 'CREW_MEMBER_BY_ID', ]), }, watch: { @@ -331,6 +360,8 @@ export default { this.GET_SCENERY_ALLOCATIONS(), this.GET_PROP_TYPES(), this.GET_SCENERY_TYPES(), + this.GET_CREW_LIST(), + this.GET_CREW_ASSIGNMENTS(), ]); this.loaded = true; @@ -481,6 +512,30 @@ export default { (item) => !currentPropsIds.has(item.id) ); }, + formatCrewName(crew) { + if (!crew) return 'Unknown'; + return crew.last_name ? `${crew.first_name} ${crew.last_name}` : crew.first_name; + }, + getCrewNamesForSettingItem(item, itemType, scene) { + const assignments = + itemType === 'scenery' + ? this.CREW_ASSIGNMENTS_BY_SCENERY[item.id] || [] + : this.CREW_ASSIGNMENTS_BY_PROP[item.id] || []; + return assignments + .filter((a) => a.assignment_type === 'set' && a.scene_id === scene.id) + .map((a) => this.formatCrewName(this.CREW_MEMBER_BY_ID(a.crew_id))); + }, + getCrewNamesForStrikingItem(item, itemType, scene) { + const previousScene = this.getPreviousScene(scene); + if (!previousScene) return []; + const assignments = + itemType === 'scenery' + ? this.CREW_ASSIGNMENTS_BY_SCENERY[item.id] || [] + : this.CREW_ASSIGNMENTS_BY_PROP[item.id] || []; + return assignments + .filter((a) => a.assignment_type === 'strike' && a.scene_id === previousScene.id) + .map((a) => this.formatCrewName(this.CREW_MEMBER_BY_ID(a.crew_id))); + }, ...mapActions([ 'GET_ACT_LIST', 'GET_SCENE_LIST', @@ -490,6 +545,8 @@ export default { 'GET_SCENERY_ALLOCATIONS', 'GET_PROP_TYPES', 'GET_SCENERY_TYPES', + 'GET_CREW_LIST', + 'GET_CREW_ASSIGNMENTS', ]), }, }; @@ -646,4 +703,11 @@ export default { .plan-content-col.border-right { border-right: 1px solid #dee2e6; } + +.crew-names { + color: #6c757d; + font-size: 0.8rem; + padding-left: 0.5rem; + font-style: italic; +} 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_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_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_with_allocations.png b/docs/images/config_show/stage_manager_with_allocations.png index 39d67c11..fa3a7dcc 100644 Binary files a/docs/images/config_show/stage_manager_with_allocations.png and b/docs/images/config_show/stage_manager_with_allocations.png differ diff --git a/docs/images/config_show/stage_timeline.png b/docs/images/config_show/stage_timeline.png index 53fa0617..347ecfe2 100644 Binary files a/docs/images/config_show/stage_timeline.png 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/pages/live_show.md b/docs/pages/live_show.md index 034b0175..73ade4ac 100644 --- a/docs/pages/live_show.md +++ b/docs/pages/live_show.md @@ -74,7 +74,10 @@ The Stage Manager pane will appear on the right side of the screen: - **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 for additional scene notes +- **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 + +![](../images/live_show/live_plan_modal_crew.png) 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. diff --git a/docs/pages/show_config/stage_management.md b/docs/pages/show_config/stage_management.md index 15da60bf..9988d86d 100644 --- a/docs/pages/show_config/stage_management.md +++ b/docs/pages/show_config/stage_management.md @@ -66,6 +66,19 @@ The **Props** tab follows the same structure as Scenery, with Prop Types and a P ![](../../images/config_show/stage_props_with_data.png) +#### 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: @@ -92,6 +105,38 @@ 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. + +![](../../images/config_show/stage_allocation_warning.png) + +##### 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. + +![](../../images/config_show/stage_manager_crew_assignments.png) + +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: @@ -120,4 +165,55 @@ The **Timeline** tab provides a visual overview of all props and scenery allocat 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 \ No newline at end of file + - 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: + +![](../../images/config_show/stage_timeline_side_panel.png) + +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: + +![](../../images/config_show/stage_crew_timeline.png) + +- **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/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/constants.py b/server/controllers/api/constants.py index 0e38529c..b487758e 100644 --- a/server/controllers/api/constants.py +++ b/server/controllers/api/constants.py @@ -47,6 +47,9 @@ # 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 @@ -92,6 +95,15 @@ 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 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 index b1c985da..8bb2b6be 100644 --- a/server/controllers/api/show/stage/helpers.py +++ b/server/controllers/api/show/stage/helpers.py @@ -18,7 +18,12 @@ 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): @@ -319,6 +324,25 @@ async def handle_allocation_post( **{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) @@ -328,6 +352,12 @@ async def handle_allocation_post( 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, @@ -385,9 +415,34 @@ async def handle_allocation_delete( 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/scenery.py b/server/controllers/api/show/stage/scenery.py index 72004b84..aa673e5e 100644 --- a/server/controllers/api/show/stage/scenery.py +++ b/server/controllers/api/show/stage/scenery.py @@ -2,7 +2,6 @@ from tornado import escape from controllers.api.constants import ( - ERROR_CAST_MEMBER_NOT_FOUND, ERROR_ID_MISSING, ERROR_INVALID_ID, ERROR_NAME_MISSING, @@ -126,7 +125,7 @@ async def post(self): scenery_type_id = int(scenery_type_id) except ValueError: self.set_status(400) - await self.finish({"message": "Scenery prop type ID"}) + await self.finish({"message": "Invalid scenery type ID"}) return scenery_type: SceneryType = session.get(SceneryType, scenery_type_id) if not scenery_type: @@ -195,7 +194,7 @@ async def patch(self): scenery_type_id = int(scenery_type_id) except ValueError: self.set_status(400) - await self.finish({"message": "Scenery prop type ID"}) + await self.finish({"message": "Invalid scenery type ID"}) return scenery_type: SceneryType = session.get( SceneryType, scenery_type_id @@ -223,7 +222,7 @@ async def patch(self): ) else: self.set_status(404) - await self.finish({"message": ERROR_CAST_MEMBER_NOT_FOUND}) + await self.finish({"message": ERROR_SCENERY_NOT_FOUND}) return else: self.set_status(404) diff --git a/server/models/show.py b/server/models/show.py index 7ba93f54..3ee2e5ac 100644 --- a/server/models/show.py +++ b/server/models/show.py @@ -17,6 +17,7 @@ from models.session import ShowSession from models.stage import ( Crew, + CrewAssignment, Props, PropsAllocation, PropType, @@ -228,3 +229,7 @@ class Scene(db.Model): 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 index 795aefd3..a5cb6ad2 100644 --- a/server/models/stage.py +++ b/server/models/stage.py @@ -2,7 +2,7 @@ from typing import List -from sqlalchemy import ForeignKey, UniqueConstraint +from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from models.models import db @@ -18,6 +18,67 @@ class Crew(db.Model): 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): @@ -90,6 +151,10 @@ class Scenery(db.Model): back_populates="scenery", cascade="all, delete-orphan", ) + crew_assignments: Mapped[List["CrewAssignment"]] = relationship( + back_populates="scenery", + cascade="all, delete-orphan", + ) class PropType(db.Model): @@ -122,3 +187,7 @@ class Props(db.Model): back_populates="prop", cascade="all, delete-orphan", ) + crew_assignments: Mapped[List["CrewAssignment"]] = relationship( + back_populates="prop", + cascade="all, delete-orphan", + ) diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py index d6026206..cee93003 100644 --- a/server/schemas/schemas.py +++ b/server/schemas/schemas.py @@ -16,6 +16,7 @@ from models.show import Act, Cast, Character, CharacterGroup, Scene, Show from models.stage import ( Crew, + CrewAssignment, Props, PropsAllocation, PropType, @@ -137,6 +138,14 @@ class Meta: include_fk = True +@schema +class CrewAssignmentSchema(SQLAlchemyAutoSchema): + class Meta: + model = CrewAssignment + load_instance = True + include_fk = True + + @schema class CharacterSchema(SQLAlchemyAutoSchema): class Meta: 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_scenery.py b/server/test/controllers/api/show/stage/test_scenery.py index 5601ff20..1d08354d 100644 --- a/server/test/controllers/api/show/stage/test_scenery.py +++ b/server/test/controllers/api/show/stage/test_scenery.py @@ -719,9 +719,8 @@ def test_create_scenery_invalid_scenery_type_id(self): headers={"Authorization": f"Bearer {self.token}"}, ) self.assertEqual(400, response.code) - # Note: The controller has a typo in the error message response_body = tornado.escape.json_decode(response.body) - self.assertIn("Scenery prop type ID", response_body["message"]) + 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.""" @@ -922,9 +921,8 @@ def test_update_scenery_invalid_scenery_type_id(self): headers={"Authorization": f"Bearer {self.token}"}, ) self.assertEqual(400, response.code) - # Note: The controller has a typo in the error message response_body = tornado.escape.json_decode(response.body) - self.assertIn("Scenery prop type ID", response_body["message"]) + 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.""" 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/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