feat: implement scenario comparison validation and API endpoint with comprehensive unit tests
This commit is contained in:
106
services/scenario_validation.py
Normal file
106
services/scenario_validation.py
Normal file
@@ -0,0 +1,106 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user