- Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
293 lines
8.7 KiB
Python
293 lines
8.7 KiB
Python
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")
|