from __future__ import annotations from datetime import date, datetime from typing import Any, Mapping from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from models import MiningOperationType, ResourceType, ScenarioStatus from services.currency import CurrencyValidationError, normalise_currency PreviewStateLiteral = Literal["new", "update", "skip", "error"] def _normalise_string(value: Any) -> str: if value is None: return "" if isinstance(value, str): return value.strip() return str(value).strip() def _strip_or_none(value: Any | None) -> str | None: if value is None: return None text = _normalise_string(value) return text or None def _coerce_enum(value: Any, enum_cls: Any, aliases: Mapping[str, Any]) -> Any: if value is None: return value if isinstance(value, enum_cls): return value text = _normalise_string(value).lower() if not text: return None if text in aliases: return aliases[text] try: return enum_cls(text) except ValueError as exc: # pragma: no cover - surfaced by Pydantic raise ValueError( f"Invalid value '{value}' for {enum_cls.__name__}") from exc OPERATION_TYPE_ALIASES: dict[str, MiningOperationType] = { "open pit": MiningOperationType.OPEN_PIT, "openpit": MiningOperationType.OPEN_PIT, "underground": MiningOperationType.UNDERGROUND, "in-situ leach": MiningOperationType.IN_SITU_LEACH, "in situ": MiningOperationType.IN_SITU_LEACH, "placer": MiningOperationType.PLACER, "quarry": MiningOperationType.QUARRY, "mountaintop removal": MiningOperationType.MOUNTAINTOP_REMOVAL, "other": MiningOperationType.OTHER, } SCENARIO_STATUS_ALIASES: dict[str, ScenarioStatus] = { "draft": ScenarioStatus.DRAFT, "active": ScenarioStatus.ACTIVE, "archived": ScenarioStatus.ARCHIVED, } RESOURCE_TYPE_ALIASES: dict[str, ResourceType] = { key.replace("_", " ").lower(): value for key, value in ResourceType.__members__.items() } RESOURCE_TYPE_ALIASES.update( {value.value.replace("_", " ").lower(): value for value in ResourceType} ) class ProjectImportRow(BaseModel): name: str location: str | None = None operation_type: MiningOperationType description: str | None = None created_at: datetime | None = None updated_at: datetime | None = None model_config = ConfigDict(extra="forbid") @field_validator("name", mode="before") @classmethod def validate_name(cls, value: Any) -> str: text = _normalise_string(value) if not text: raise ValueError("Project name is required") return text @field_validator("location", "description", mode="before") @classmethod def optional_text(cls, value: Any | None) -> str | None: return _strip_or_none(value) @field_validator("operation_type", mode="before") @classmethod def map_operation_type(cls, value: Any) -> MiningOperationType | None: return _coerce_enum(value, MiningOperationType, OPERATION_TYPE_ALIASES) class ScenarioImportRow(BaseModel): project_name: str name: str status: ScenarioStatus = ScenarioStatus.DRAFT start_date: date | None = None end_date: date | None = None discount_rate: float | None = None currency: str | None = None primary_resource: ResourceType | None = None description: str | None = None created_at: datetime | None = None updated_at: datetime | None = None model_config = ConfigDict(extra="forbid") @field_validator("project_name", "name", mode="before") @classmethod def validate_required_text(cls, value: Any, info) -> str: text = _normalise_string(value) if not text: raise ValueError( f"{info.field_name.replace('_', ' ').title()} is required") return text @field_validator("status", mode="before") @classmethod def map_status(cls, value: Any) -> ScenarioStatus | None: return _coerce_enum(value, ScenarioStatus, SCENARIO_STATUS_ALIASES) @field_validator("primary_resource", mode="before") @classmethod def map_resource(cls, value: Any) -> ResourceType | None: return _coerce_enum(value, ResourceType, RESOURCE_TYPE_ALIASES) @field_validator("description", mode="before") @classmethod def optional_description(cls, value: Any | None) -> str | None: return _strip_or_none(value) @field_validator("currency", mode="before") @classmethod def normalise_currency(cls, value: Any | None) -> str | None: text = _strip_or_none(value) if text is None: return None try: return normalise_currency(text) except CurrencyValidationError as exc: raise ValueError(str(exc)) from exc @field_validator("discount_rate", mode="before") @classmethod def coerce_discount_rate(cls, value: Any | None) -> float | None: if value is None: return None if isinstance(value, (int, float)): return float(value) text = _normalise_string(value) if not text: return None if text.endswith("%"): text = text[:-1] try: return float(text) except ValueError as exc: raise ValueError("Discount rate must be numeric") from exc @model_validator(mode="after") def validate_dates(self) -> "ScenarioImportRow": if self.start_date and self.end_date and self.start_date > self.end_date: raise ValueError("End date must be on or after start date") return self class ImportRowErrorModel(BaseModel): row_number: int field: str | None = None message: str model_config = ConfigDict(from_attributes=True, extra="forbid") class ImportPreviewRowIssueModel(BaseModel): message: str field: str | None = None model_config = ConfigDict(from_attributes=True, extra="forbid") class ImportPreviewRowIssuesModel(BaseModel): row_number: int state: PreviewStateLiteral | None = None issues: list[ImportPreviewRowIssueModel] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True, extra="forbid") class ImportPreviewSummaryModel(BaseModel): total_rows: int accepted: int skipped: int errored: int model_config = ConfigDict(from_attributes=True, extra="forbid") class ProjectImportPreviewRow(BaseModel): row_number: int data: ProjectImportRow state: PreviewStateLiteral issues: list[str] = Field(default_factory=list) context: dict[str, Any] | None = None model_config = ConfigDict(from_attributes=True, extra="forbid") class ScenarioImportPreviewRow(BaseModel): row_number: int data: ScenarioImportRow state: PreviewStateLiteral issues: list[str] = Field(default_factory=list) context: dict[str, Any] | None = None model_config = ConfigDict(from_attributes=True, extra="forbid") class ProjectImportPreviewResponse(BaseModel): rows: list[ProjectImportPreviewRow] summary: ImportPreviewSummaryModel row_issues: list[ImportPreviewRowIssuesModel] = Field(default_factory=list) parser_errors: list[ImportRowErrorModel] = Field(default_factory=list) stage_token: str | None = None model_config = ConfigDict(from_attributes=True, extra="forbid") class ScenarioImportPreviewResponse(BaseModel): rows: list[ScenarioImportPreviewRow] summary: ImportPreviewSummaryModel row_issues: list[ImportPreviewRowIssuesModel] = Field(default_factory=list) parser_errors: list[ImportRowErrorModel] = Field(default_factory=list) stage_token: str | None = None model_config = ConfigDict(from_attributes=True, extra="forbid") class ImportCommitSummaryModel(BaseModel): created: int updated: int model_config = ConfigDict(from_attributes=True, extra="forbid") class ProjectImportCommitRow(BaseModel): row_number: int data: ProjectImportRow context: dict[str, Any] model_config = ConfigDict(from_attributes=True, extra="forbid") class ScenarioImportCommitRow(BaseModel): row_number: int data: ScenarioImportRow context: dict[str, Any] model_config = ConfigDict(from_attributes=True, extra="forbid") class ProjectImportCommitResponse(BaseModel): token: str rows: list[ProjectImportCommitRow] summary: ImportCommitSummaryModel model_config = ConfigDict(from_attributes=True, extra="forbid") class ScenarioImportCommitResponse(BaseModel): token: str rows: list[ScenarioImportCommitRow] summary: ImportCommitSummaryModel model_config = ConfigDict(from_attributes=True, extra="forbid") class ImportCommitRequest(BaseModel): token: str model_config = ConfigDict(extra="forbid")