107 lines
3.9 KiB
Python
107 lines
3.9 KiB
Python
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,
|
|
)
|