feat: implement import functionality for projects and scenarios with CSV/XLSX support, including validation and error handling

This commit is contained in:
2025-11-10 09:10:47 +01:00
parent 7058eb4172
commit 3bc124c11f
7 changed files with 1084 additions and 2 deletions

172
schemas/imports.py Normal file
View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Mapping
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from models import MiningOperationType, ResourceType, ScenarioStatus
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:
if value is None:
return None
text = _normalise_string(value).upper()
if not text:
return None
if len(text) != 3:
raise ValueError("Currency code must be a 3-letter ISO value")
return text
@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