from __future__ import annotations from dataclasses import dataclass from datetime import date from typing import Iterable, Sequence from models import Scenario, ScenarioStatus from services.exceptions import ScenarioValidationError ALLOWED_STATUSES: frozenset[ScenarioStatus] = frozenset( {ScenarioStatus.DRAFT, ScenarioStatus.ACTIVE} ) @dataclass(frozen=True) class _ValidationContext: scenarios: Sequence[Scenario] @property def scenario_ids(self) -> list[int]: return [scenario.id for scenario in self.scenarios if scenario.id is not None] class ScenarioComparisonValidator: """Validates scenarios prior to comparison workflows.""" def validate(self, scenarios: Sequence[Scenario] | Iterable[Scenario]) -> None: scenario_list = list(scenarios) if len(scenario_list) < 2: # Nothing to validate when fewer than two scenarios are provided. return context = _ValidationContext(scenario_list) self._ensure_same_project(context) self._ensure_allowed_status(context) self._ensure_shared_currency(context) self._ensure_timeline_overlap(context) self._ensure_shared_primary_resource(context) def _ensure_same_project(self, context: _ValidationContext) -> None: project_ids = {scenario.project_id for scenario in context.scenarios} if len(project_ids) > 1: raise ScenarioValidationError( code="SCENARIO_PROJECT_MISMATCH", message="Selected scenarios do not belong to the same project.", scenario_ids=context.scenario_ids, ) def _ensure_allowed_status(self, context: _ValidationContext) -> None: disallowed = [ scenario for scenario in context.scenarios if scenario.status not in ALLOWED_STATUSES ] if disallowed: raise ScenarioValidationError( code="SCENARIO_STATUS_INVALID", message="Archived scenarios cannot be compared.", scenario_ids=[ scenario.id for scenario in disallowed if scenario.id is not None], ) def _ensure_shared_currency(self, context: _ValidationContext) -> None: currencies = { scenario.currency for scenario in context.scenarios if scenario.currency is not None } if len(currencies) > 1: raise ScenarioValidationError( code="SCENARIO_CURRENCY_MISMATCH", message="Scenarios use different currencies and cannot be compared.", scenario_ids=context.scenario_ids, ) def _ensure_timeline_overlap(self, context: _ValidationContext) -> None: ranges = [ (scenario.start_date, scenario.end_date) for scenario in context.scenarios if scenario.start_date and scenario.end_date ] if len(ranges) < 2: return latest_start: date = max(start for start, _ in ranges) earliest_end: date = min(end for _, end in ranges) if latest_start > earliest_end: raise ScenarioValidationError( code="SCENARIO_TIMELINE_DISJOINT", message="Scenario timelines do not overlap; adjust the comparison window.", scenario_ids=context.scenario_ids, ) def _ensure_shared_primary_resource(self, context: _ValidationContext) -> None: resources = { scenario.primary_resource for scenario in context.scenarios if scenario.primary_resource is not None } if len(resources) > 1: raise ScenarioValidationError( code="SCENARIO_RESOURCE_MISMATCH", message="Scenarios target different primary resources and cannot be compared.", scenario_ids=context.scenario_ids, )