"""Mission CRUD + snapshot service. A mission is a *materialised* run of one or more scenario templates: when the mission is created (or scenarios are appended later), the service copies the template rows into `mission_scenarios` / `mission_tests` / `mission_test_mitre_tags` verbatim. Editing the source templates afterwards does not touch the mission — that's the snapshot contract from spec §11. Visibility rule (spec §4, last bullet): a non-admin user can only see a mission they are a member of. The decorator layer enforces *which type of action* is allowed (perm codes); this service enforces *which mission* is visible. """ from __future__ import annotations import hashlib import uuid from dataclasses import dataclass from datetime import date, datetime, timezone from typing import Any, Iterable from sqlalchemy import func, or_, select, text from sqlalchemy.orm import Session, selectinload from app.db.session import session_scope from app.db.types import ( MISSION_ROLE_HINTS, MISSION_STATUSES, ) from app.models.auth import User from app.models.mission import ( Mission, MissionMember, MissionScenario, MissionTest, MissionTestMitreTag, ) from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique from app.models.template import ( ScenarioTemplate, TestTemplate, TestTemplateMitreTag, ) _UNSET: Any = object() # Status transition graph. A target status that's not in the source's set is # rejected as InvalidTransition. `archived` is a one-way sink (un-archiving # would require an explicit restore endpoint, out of M6 scope). _VALID_TRANSITIONS: dict[str, frozenset[str]] = { "draft": frozenset({"in_progress", "archived"}), "in_progress": frozenset({"completed", "archived"}), "completed": frozenset({"archived"}), "archived": frozenset(), } # --------------------------------------------------------------------------- # # Exceptions # --------------------------------------------------------------------------- # class MissionNotFound(Exception): """Mission missing, soft-deleted, or not visible to the viewer.""" class UnknownScenarioTemplate(Exception): pass class UnknownUser(Exception): pass class InvalidTransition(Exception): pass class InvalidMemberPayload(Exception): pass # --------------------------------------------------------------------------- # # Views (detached dataclasses — safe to return after session_scope exits) # --------------------------------------------------------------------------- # @dataclass(frozen=True) class MemberAssignment: """Inbound member spec. The service resolves the user and validates the hint.""" user_id: uuid.UUID role_hint: str @dataclass(frozen=True) class MissionMemberView: user_id: uuid.UUID user_email: str user_display_name: str | None role_hint: str @dataclass(frozen=True) class MissionMitreTagView: kind: str external_id: str name: str url: str | None @dataclass(frozen=True) class MissionTestView: id: uuid.UUID position: int snapshot_name: str snapshot_description: str | None snapshot_objective: str | None snapshot_procedure_md: str | None snapshot_prerequisites_md: str | None snapshot_expected_red_md: str | None snapshot_expected_blue_md: str | None snapshot_opsec_level: str snapshot_tags: list[str] snapshot_expected_iocs: list[str] state: str executed_at: datetime | None executed_at_overridden: bool mitre_tags: list[MissionMitreTagView] source_test_template_id: uuid.UUID | None # Annotation fields are surfaced here so the mission detail page can # render the full scenario table without a per-test round trip (the # batch lookups for detection_level_key + last_actor below stay O(1)). red_command: str | None red_output: str | None red_comment_md: str | None blue_comment_md: str | None detection_level_id: uuid.UUID | None detection_level_key: str | None blue_log_source: str | None blue_siem_logs: str | None blue_incident_at: datetime | None blue_incident_number: str | None blue_incident_recipient_email: str | None last_actor_id: uuid.UUID | None last_actor_email: str | None last_actor_display_name: str | None updated_at: datetime @dataclass(frozen=True) class MissionScenarioView: id: uuid.UUID position: int snapshot_name: str snapshot_description: str | None tests: list[MissionTestView] source_scenario_template_id: uuid.UUID | None @dataclass(frozen=True) class MissionListItemView: id: uuid.UUID name: str client_target: str | None date_start: date | None date_end: date | None status: str description_md: str | None visibility_mode: str scenarios_count: int tests_count: int members_count: int deleted_at: datetime | None created_at: datetime updated_at: datetime @dataclass(frozen=True) class MissionView: id: uuid.UUID name: str client_target: str | None date_start: date | None date_end: date | None status: str description_md: str | None visibility_mode: str scenarios_count: int tests_count: int members_count: int deleted_at: datetime | None created_at: datetime updated_at: datetime scenarios: list[MissionScenarioView] members: list[MissionMemberView] # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # def _opt_str(value: str | None) -> str | None: if value is None: return None v = value.strip() return v or None def _normalize_name(value: str) -> str: name = (value or "").strip() if not name: raise ValueError("name is required") if len(name) > 255: raise ValueError("name must be ≤ 255 characters") return name def _validate_dates(date_start: date | None, date_end: date | None) -> None: if date_start and date_end and date_end < date_start: raise ValueError("date_end must be on or after date_start") def _validate_status(value: str) -> str: if value not in MISSION_STATUSES: raise ValueError(f"status must be one of {MISSION_STATUSES}") return value def _validate_role_hint(value: str) -> str: if value not in MISSION_ROLE_HINTS: raise InvalidMemberPayload(f"role_hint must be one of {MISSION_ROLE_HINTS}") return value def _escape_like(raw: str) -> str: """Escape LIKE wildcards so user-typed `%` / `_` / `\\` stay literal.""" return raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") def _lock_scenario_ids_for_snapshot(s: Session, scenario_ids: list[uuid.UUID]) -> None: """Acquire a per-scenario `pg_advisory_xact_lock` for every source scenario we're about to snapshot. Why: a concurrent admin invoking `set_scenario_tests(scenario_id)` (M5) deletes-then-reinserts the `scenario_template_tests` join rows mid-transaction. Under READ COMMITTED, `_snapshot_scenarios` could observe a partial view (selectinload re-queries) and freeze a torn snapshot. Sharing the same lock key as `app.services.scenario_templates.set_scenario_tests` makes the snapshot wait until the reorder commits (and vice versa). The lock keys are derived deterministically from the scenario UUIDs via blake2b (cf. lessons: `hash()` is randomised per-worker). We sort the keys before acquiring to avoid deadlocks with another snapshotter that holds them in a different order. """ if not scenario_ids: return keys: list[int] = [] for sid in scenario_ids: digest = hashlib.blake2b(sid.bytes, digest_size=8).digest() keys.append(int.from_bytes(digest, "big", signed=True)) for key in sorted(keys): s.execute( text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"), {"key": key}, ) def _is_member(s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID) -> bool: return ( s.scalar( select(func.count()) .select_from(MissionMember) .where( MissionMember.mission_id == mission_id, MissionMember.user_id == viewer_id, ) ) or 0 ) > 0 def _membership_filter(viewer_id: uuid.UUID): """SQL predicate restricting to missions where viewer_id is a member.""" return Mission.id.in_( select(MissionMember.mission_id).where(MissionMember.user_id == viewer_id) ) def _load_users_map(s: Session, ids: Iterable[uuid.UUID]) -> dict[uuid.UUID, User]: ids_list = [i for i in ids] if not ids_list: return {} rows = s.scalars( select(User).where(User.id.in_(ids_list), User.deleted_at.is_(None)) ).all() return {u.id: u for u in rows} # --------------------------------------------------------------------------- # # MITRE denormalisation # --------------------------------------------------------------------------- # def _collect_mitre_ids( tag_rows: Iterable[TestTemplateMitreTag], ) -> tuple[set[uuid.UUID], set[uuid.UUID], set[uuid.UUID]]: tactic_ids: set[uuid.UUID] = set() technique_ids: set[uuid.UUID] = set() sub_ids: set[uuid.UUID] = set() for tag in tag_rows: if tag.mitre_kind == "tactic" and tag.tactic_id is not None: tactic_ids.add(tag.tactic_id) elif tag.mitre_kind == "technique" and tag.technique_id is not None: technique_ids.add(tag.technique_id) elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id is not None: sub_ids.add(tag.subtechnique_id) return tactic_ids, technique_ids, sub_ids def _resolve_mitre_lookup( s: Session, tactic_ids: set[uuid.UUID], technique_ids: set[uuid.UUID], sub_ids: set[uuid.UUID], ) -> tuple[ dict[uuid.UUID, MitreTactic], dict[uuid.UUID, MitreTechnique], dict[uuid.UUID, MitreSubtechnique], ]: """Batch-load all MITRE rows referenced by a snapshot in 3 queries.""" tactic_map: dict[uuid.UUID, MitreTactic] = {} technique_map: dict[uuid.UUID, MitreTechnique] = {} sub_map: dict[uuid.UUID, MitreSubtechnique] = {} if tactic_ids: tactic_map = { r.id: r for r in s.scalars( select(MitreTactic).where(MitreTactic.id.in_(tactic_ids)) ).all() } if technique_ids: technique_map = { r.id: r for r in s.scalars( select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids)) ).all() } if sub_ids: sub_map = { r.id: r for r in s.scalars( select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids)) ).all() } return tactic_map, technique_map, sub_map def _snapshot_tag( tag: TestTemplateMitreTag, tactic_map: dict[uuid.UUID, MitreTactic], technique_map: dict[uuid.UUID, MitreTechnique], sub_map: dict[uuid.UUID, MitreSubtechnique], ) -> MissionTestMitreTag | None: """Convert a template's polymorphic MITRE tag into a frozen mission tag. Returns None if the referenced MITRE row vanished between read and snapshot (paranoid: should not happen inside one tx). """ if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map: r = tactic_map[tag.tactic_id] return MissionTestMitreTag( mitre_kind="tactic", mitre_external_id=r.external_id, mitre_name=r.name, mitre_url=r.url, ) if tag.mitre_kind == "technique" and tag.technique_id in technique_map: r = technique_map[tag.technique_id] return MissionTestMitreTag( mitre_kind="technique", mitre_external_id=r.external_id, mitre_name=r.name, mitre_url=r.url, ) if tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map: r = sub_map[tag.subtechnique_id] return MissionTestMitreTag( mitre_kind="subtechnique", mitre_external_id=r.external_id, mitre_name=r.name, mitre_url=r.url, ) return None # --------------------------------------------------------------------------- # # Snapshot # --------------------------------------------------------------------------- # def _load_scenario_templates_for_snapshot( s: Session, scenario_ids: list[uuid.UUID] ) -> dict[uuid.UUID, ScenarioTemplate]: """Load scenarios in eager-load mode and reject unknowns/soft-deleted upfront.""" if not scenario_ids: return {} rows = s.scalars( select(ScenarioTemplate) .options(selectinload(ScenarioTemplate.tests)) .where(ScenarioTemplate.id.in_(scenario_ids)) ).all() by_id = {sc.id: sc for sc in rows} missing = set(scenario_ids) - by_id.keys() if missing: raise UnknownScenarioTemplate( f"unknown scenario_template ids: {sorted(str(m) for m in missing)}" ) deleted = [sc.id for sc in rows if sc.deleted_at is not None] if deleted: raise UnknownScenarioTemplate( f"cannot snapshot soft-deleted scenario_template ids: " f"{sorted(str(d) for d in deleted)}" ) return by_id def _snapshot_scenarios( s: Session, mission_id: uuid.UUID, scenario_ids: list[uuid.UUID], start_position: int, ) -> None: """Append `scenario_ids` as new MissionScenario+MissionTest rows under the mission. Position counter continues from `start_position`. Each scenario_template's `tests` order is preserved 1:1. MITRE tags on the source templates are copied as denormalised `MissionTestMitreTag` rows (frozen external_id/name/url). """ if not scenario_ids: return _lock_scenario_ids_for_snapshot(s, scenario_ids) sc_by_id = _load_scenario_templates_for_snapshot(s, scenario_ids) # Collect the underlying test_template ids in stable order. ordered_test_ids: list[uuid.UUID] = [] for sid in scenario_ids: sc = sc_by_id[sid] for link in sc.tests: ordered_test_ids.append(link.test_template_id) test_template_map: dict[uuid.UUID, TestTemplate] = {} if ordered_test_ids: test_template_rows = s.scalars( select(TestTemplate) .options(selectinload(TestTemplate.mitre_tags)) .where(TestTemplate.id.in_(set(ordered_test_ids))) ).all() test_template_map = {t.id: t for t in test_template_rows} # A test_template may be soft-deleted between the scenario authoring and # the mission creation. We do not refuse the snapshot (the user expects # the scenario's planned tests to appear); we just freeze the last # known content, which is what a snapshot is for. missing_t = set(ordered_test_ids) - test_template_map.keys() if missing_t: raise UnknownScenarioTemplate( f"scenario references missing test_template ids: " f"{sorted(str(m) for m in missing_t)}" ) # Pre-load all MITRE rows referenced by any tag across all involved templates. all_tag_rows: list[TestTemplateMitreTag] = [] for t in test_template_map.values(): all_tag_rows.extend(t.mitre_tags) tactic_map, technique_map, sub_map = _resolve_mitre_lookup( s, *_collect_mitre_ids(all_tag_rows) ) pos = start_position for sid in scenario_ids: sc = sc_by_id[sid] ms = MissionScenario( mission_id=mission_id, source_scenario_template_id=sc.id, snapshot_name=sc.name, snapshot_description=sc.description, position=pos, ) s.add(ms) s.flush() # populate ms.id for the child tests test_pos = 0 for link in sc.tests: tt = test_template_map[link.test_template_id] mt = MissionTest( scenario_id=ms.id, source_test_template_id=tt.id, position=test_pos, snapshot_name=tt.name, snapshot_description=tt.description, snapshot_objective=tt.objective, snapshot_procedure_md=tt.procedure_md, snapshot_prerequisites_md=tt.prerequisites_md, snapshot_expected_red_md=tt.expected_result_red_md, snapshot_expected_blue_md=tt.expected_detection_blue_md, snapshot_opsec_level=tt.opsec_level, snapshot_tags=list(tt.tags or []), snapshot_expected_iocs=list(tt.expected_iocs or []), state="pending", ) s.add(mt) s.flush() for src_tag in tt.mitre_tags: snap = _snapshot_tag(src_tag, tactic_map, technique_map, sub_map) if snap is not None: snap.mission_test_id = mt.id s.add(snap) test_pos += 1 pos += 1 s.flush() # --------------------------------------------------------------------------- # # View assembly # --------------------------------------------------------------------------- # def _member_views(s: Session, mission: Mission) -> list[MissionMemberView]: if not mission.members: return [] users = _load_users_map(s, [m.user_id for m in mission.members]) out: list[MissionMemberView] = [] for m in mission.members: u = users.get(m.user_id) out.append( MissionMemberView( user_id=m.user_id, user_email=u.email if u else "", user_display_name=(u.display_name if u else None), role_hint=m.role_hint, ) ) out.sort(key=lambda mv: (mv.role_hint, mv.user_email)) return out def _scenario_views( s: Session, scenarios: list[MissionScenario] ) -> list[MissionScenarioView]: """Assemble scenario views. `mission_scenarios` and `mission_tests` both carry `SoftDeleteMixin`; M6 doesn't surface soft-deletion of those rows in any endpoint, but the filter is applied here so future deletions (M7+) don't drift the rendered list silently. The annotation fields (red/blue review state) are surfaced too so the front-end scenario table renders in a single GET. To keep the call O(1) in the number of tests, the detection-level keys and last-actor labels are batch-loaded. """ from app.models.auth import User as _User # noqa: PLC0415 — local import to avoid a cycle from app.models.setting import DetectionLevel as _DetectionLevel # noqa: PLC0415 live_scenarios = [sc for sc in scenarios if sc.deleted_at is None] if not live_scenarios: return [] # Collect every (test) annotation FK upfront so we can batch the two # extra queries (detection levels + last-actor users) instead of doing # one s.get() per row. level_ids: set[uuid.UUID] = set() actor_ids: set[uuid.UUID] = set() for sc in live_scenarios: for t in sc.tests: if t.deleted_at is not None: continue if t.detection_level_id is not None: level_ids.add(t.detection_level_id) if t.last_actor_id is not None: actor_ids.add(t.last_actor_id) level_keys: dict[uuid.UUID, str] = {} if level_ids: for row in s.execute( select(_DetectionLevel.id, _DetectionLevel.key).where( _DetectionLevel.id.in_(level_ids) ) ).all(): level_keys[row.id] = row.key actors: dict[uuid.UUID, tuple[str, str | None]] = {} if actor_ids: for row in s.execute( select(_User.id, _User.email, _User.display_name).where( _User.id.in_(actor_ids) ) ).all(): actors[row.id] = (row.email, row.display_name) views: list[MissionScenarioView] = [] for sc in sorted(live_scenarios, key=lambda s_: s_.position): test_views: list[MissionTestView] = [] live_tests = [t for t in sc.tests if t.deleted_at is None] for t in sorted(live_tests, key=lambda t_: t_.position): tag_views = [ MissionMitreTagView( kind=tag.mitre_kind, external_id=tag.mitre_external_id, name=tag.mitre_name, url=tag.mitre_url, ) for tag in sorted( t.mitre_tags, key=lambda tg: (tg.mitre_kind, tg.mitre_external_id) ) ] actor_email: str | None = None actor_display: str | None = None if t.last_actor_id is not None and t.last_actor_id in actors: actor_email, actor_display = actors[t.last_actor_id] test_views.append( MissionTestView( id=t.id, position=t.position, snapshot_name=t.snapshot_name, snapshot_description=t.snapshot_description, snapshot_objective=t.snapshot_objective, snapshot_procedure_md=t.snapshot_procedure_md, snapshot_prerequisites_md=t.snapshot_prerequisites_md, snapshot_expected_red_md=t.snapshot_expected_red_md, snapshot_expected_blue_md=t.snapshot_expected_blue_md, snapshot_opsec_level=t.snapshot_opsec_level, snapshot_tags=list(t.snapshot_tags or []), snapshot_expected_iocs=list(t.snapshot_expected_iocs or []), state=t.state, executed_at=t.executed_at, executed_at_overridden=t.executed_at_overridden, mitre_tags=tag_views, source_test_template_id=t.source_test_template_id, red_command=t.red_command, red_output=t.red_output, red_comment_md=t.red_comment_md, blue_comment_md=t.blue_comment_md, detection_level_id=t.detection_level_id, detection_level_key=( level_keys.get(t.detection_level_id) if t.detection_level_id else None ), blue_log_source=t.blue_log_source, blue_siem_logs=t.blue_siem_logs, blue_incident_at=t.blue_incident_at, blue_incident_number=t.blue_incident_number, blue_incident_recipient_email=t.blue_incident_recipient_email, last_actor_id=t.last_actor_id, last_actor_email=actor_email, last_actor_display_name=actor_display, updated_at=t.updated_at, ) ) views.append( MissionScenarioView( id=sc.id, position=sc.position, snapshot_name=sc.snapshot_name, snapshot_description=sc.snapshot_description, tests=test_views, source_scenario_template_id=sc.source_scenario_template_id, ) ) return views def _to_detail_view(s: Session, m: Mission) -> MissionView: scenarios = [sc for sc in m.scenarios if sc.deleted_at is None] members = _member_views(s, m) scenario_views = _scenario_views(s, scenarios) tests_count = sum(len(sc.tests) for sc in scenario_views) return MissionView( id=m.id, name=m.name, client_target=m.client_target, date_start=m.date_start, date_end=m.date_end, status=m.status, description_md=m.description_md, visibility_mode=m.visibility_mode, scenarios_count=len(scenario_views), tests_count=tests_count, members_count=len(members), deleted_at=m.deleted_at, created_at=m.created_at, updated_at=m.updated_at, scenarios=scenario_views, members=members, ) def _to_list_item(m: Mission) -> MissionListItemView: # Cheap counts via the loaded relationships (selectinloaded by the caller). # We filter soft-deleted children consistently with `_scenario_views` so # the list and the detail page agree. live_scenarios = [sc for sc in m.scenarios if sc.deleted_at is None] tests_count = sum( len([t for t in sc.tests if t.deleted_at is None]) for sc in live_scenarios ) return MissionListItemView( id=m.id, name=m.name, client_target=m.client_target, date_start=m.date_start, date_end=m.date_end, status=m.status, description_md=m.description_md, visibility_mode=m.visibility_mode, scenarios_count=len(live_scenarios), tests_count=tests_count, members_count=len(m.members), deleted_at=m.deleted_at, created_at=m.created_at, updated_at=m.updated_at, ) # --------------------------------------------------------------------------- # # Public API — list / get # --------------------------------------------------------------------------- # def list_missions( *, viewer_id: uuid.UUID, viewer_is_admin: bool, q: str | None = None, status: str | None = None, client: str | None = None, include_deleted: bool = False, limit: int = 100, offset: int = 0, ) -> tuple[list[MissionListItemView], int]: with session_scope() as s: stmt = ( select(Mission) .options( selectinload(Mission.scenarios).selectinload(MissionScenario.tests), selectinload(Mission.members), ) .order_by(Mission.created_at.desc(), Mission.id.desc()) ) count_stmt = select(func.count()).select_from(Mission) if not include_deleted: stmt = stmt.where(Mission.deleted_at.is_(None)) count_stmt = count_stmt.where(Mission.deleted_at.is_(None)) if not viewer_is_admin: stmt = stmt.where(_membership_filter(viewer_id)) count_stmt = count_stmt.where(_membership_filter(viewer_id)) if status: _validate_status(status) stmt = stmt.where(Mission.status == status) count_stmt = count_stmt.where(Mission.status == status) if client: like = f"%{_escape_like(client.lower())}%" cond = func.lower(Mission.client_target).like(like, escape="\\") stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) if q: like = f"%{_escape_like(q.lower())}%" cond = or_( func.lower(Mission.name).like(like, escape="\\"), func.lower(Mission.description_md).like(like, escape="\\"), ) stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) total = s.scalar(count_stmt) or 0 rows = s.scalars( stmt.limit(max(1, min(limit, 500))).offset(max(0, offset)) ).all() return [_to_list_item(m) for m in rows], int(total) def get_mission( mission_id: uuid.UUID, *, viewer_id: uuid.UUID, viewer_is_admin: bool, include_deleted: bool = False, ) -> MissionView: with session_scope() as s: m = s.get(Mission, mission_id) if m is None: raise MissionNotFound() if m.deleted_at is not None and not include_deleted: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() return _to_detail_view(s, m) # --------------------------------------------------------------------------- # # Public API — write # --------------------------------------------------------------------------- # def _validate_members(s: Session, members: list[MemberAssignment]) -> None: """Reject duplicates, bad role hints, unknown/soft-deleted users.""" seen: set[uuid.UUID] = set() for m in members: if m.user_id in seen: raise InvalidMemberPayload(f"duplicate user_id: {m.user_id}") seen.add(m.user_id) _validate_role_hint(m.role_hint) if not members: return user_map = _load_users_map(s, seen) missing = seen - user_map.keys() if missing: raise UnknownUser(f"unknown or deleted user_ids: {sorted(str(u) for u in missing)}") def create_mission( *, name: str, creator_id: uuid.UUID, creator_is_admin: bool, client_target: str | None = None, date_start: date | None = None, date_end: date | None = None, description_md: str | None = None, scenario_template_ids: list[uuid.UUID] | None = None, members: list[MemberAssignment] | None = None, ) -> MissionView: """Create a mission and snapshot the requested scenarios + their tests. Side effect: if `creator_is_admin` is False and the creator is not in `members`, they are added with `role_hint='red'`. This prevents the non-admin creator from immediately losing visibility on the mission they just created (membership-based visibility, see spec §4). """ name_norm = _normalize_name(name) _validate_dates(date_start, date_end) scenarios = list(scenario_template_ids or []) members_list = list(members or []) with session_scope() as s: _validate_members(s, members_list) # Auto-add the non-admin creator as a member so they retain visibility. if not creator_is_admin and not any(m.user_id == creator_id for m in members_list): members_list = [ MemberAssignment(user_id=creator_id, role_hint="red"), *members_list, ] # Defensive re-validation in case the creator id was bogus. _validate_members(s, members_list) mission = Mission( name=name_norm, client_target=_opt_str(client_target), date_start=date_start, date_end=date_end, description_md=_opt_str(description_md), status="draft", visibility_mode="whitebox", ) s.add(mission) s.flush() for m in members_list: s.add( MissionMember( mission_id=mission.id, user_id=m.user_id, role_hint=m.role_hint, ) ) if scenarios: _snapshot_scenarios(s, mission.id, scenarios, start_position=0) s.flush() s.refresh(mission) return _to_detail_view(s, mission) def update_mission_metadata( mission_id: uuid.UUID, *, viewer_id: uuid.UUID, viewer_is_admin: bool, name: str | None = None, client_target: Any = _UNSET, date_start: Any = _UNSET, date_end: Any = _UNSET, description_md: Any = _UNSET, ) -> MissionView: with session_scope() as s: m = s.get(Mission, mission_id) if m is None or m.deleted_at is not None: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() if name is not None: m.name = _normalize_name(name) if client_target is not _UNSET: m.client_target = _opt_str(client_target) if date_start is not _UNSET: m.date_start = date_start if date_end is not _UNSET: m.date_end = date_end # Validate the combined date pair regardless of which side was passed. _validate_dates(m.date_start, m.date_end) if description_md is not _UNSET: m.description_md = _opt_str(description_md) s.flush() s.refresh(m) return _to_detail_view(s, m) def add_scenarios_to_mission( mission_id: uuid.UUID, scenario_template_ids: list[uuid.UUID], *, viewer_id: uuid.UUID, viewer_is_admin: bool, ) -> MissionView: """Append more snapshot scenarios to an existing mission. They land at `current_max_position + 1` and onwards. Empty list is a no-op and just returns the current view. """ with session_scope() as s: m = s.get(Mission, mission_id) if m is None or m.deleted_at is not None: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() if scenario_template_ids: max_pos = s.scalar( select(func.coalesce(func.max(MissionScenario.position), -1)).where( MissionScenario.mission_id == mission_id ) ) _snapshot_scenarios( s, mission_id, list(scenario_template_ids), start_position=int(max_pos) + 1, ) s.flush() s.refresh(m) return _to_detail_view(s, m) def set_mission_members( mission_id: uuid.UUID, members: list[MemberAssignment], *, viewer_id: uuid.UUID, viewer_is_admin: bool, ) -> MissionView: """Replace the entire member set. Wipe + insert, like the scenario reorder.""" with session_scope() as s: m = s.get(Mission, mission_id) if m is None or m.deleted_at is not None: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() _validate_members(s, members) for link in list(m.members): s.delete(link) s.flush() for assignment in members: s.add( MissionMember( mission_id=m.id, user_id=assignment.user_id, role_hint=assignment.role_hint, ) ) s.flush() s.refresh(m) return _to_detail_view(s, m) def transition_mission_status( mission_id: uuid.UUID, target_status: str, *, viewer_id: uuid.UUID, viewer_is_admin: bool, ) -> MissionView: """Move the mission's status one step along the lifecycle graph.""" _validate_status(target_status) with session_scope() as s: m = s.get(Mission, mission_id) if m is None or m.deleted_at is not None: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() if target_status == m.status: # No-op transitions are valid: a client retry should not 409. s.refresh(m) return _to_detail_view(s, m) allowed = _VALID_TRANSITIONS.get(m.status, frozenset()) if target_status not in allowed: raise InvalidTransition( f"cannot transition from {m.status!r} to {target_status!r}" ) m.status = target_status s.flush() s.refresh(m) return _to_detail_view(s, m) def soft_delete_mission( mission_id: uuid.UUID, *, viewer_id: uuid.UUID, viewer_is_admin: bool, ) -> None: with session_scope() as s: m = s.get(Mission, mission_id) if m is None or m.deleted_at is not None: raise MissionNotFound() if not viewer_is_admin and not _is_member(s, mission_id, viewer_id): raise MissionNotFound() m.deleted_at = datetime.now(tz=timezone.utc)