This commit is contained in:
2025-11-09 16:49:27 +01:00
parent 22ddfb671d
commit d807a50f77
96 changed files with 3 additions and 9689 deletions

View File

@@ -1,52 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator
from sqlalchemy.orm import Session
from models.consumption import Consumption
from routes.dependencies import get_db
router = APIRouter(prefix="/api/consumption", tags=["Consumption"])
class ConsumptionBase(BaseModel):
scenario_id: int
amount: PositiveFloat
description: Optional[str] = None
unit_name: Optional[str] = None
unit_symbol: Optional[str] = None
@field_validator("unit_name", "unit_symbol")
@classmethod
def _normalize_text(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ConsumptionCreate(ConsumptionBase):
pass
class ConsumptionRead(ConsumptionBase):
id: int
model_config = ConfigDict(from_attributes=True)
@router.post(
"/", response_model=ConsumptionRead, status_code=status.HTTP_201_CREATED
)
def create_consumption(item: ConsumptionCreate, db: Session = Depends(get_db)):
db_item = Consumption(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[ConsumptionRead])
def list_consumption(db: Session = Depends(get_db)):
return db.query(Consumption).all()

View File

@@ -1,121 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy.orm import Session
from models.capex import Capex
from models.opex import Opex
from routes.dependencies import get_db
router = APIRouter(prefix="/api/costs", tags=["Costs"])
# Pydantic schemas for CAPEX and OPEX
class _CostBase(BaseModel):
scenario_id: int
amount: float
description: Optional[str] = None
currency_code: Optional[str] = "USD"
currency_id: Optional[int] = None
@field_validator("currency_code")
@classmethod
def _normalize_currency(cls, value: Optional[str]) -> str:
code = (value or "USD").strip().upper()
return code[:3] if len(code) > 3 else code
class CapexCreate(_CostBase):
pass
class CapexRead(_CostBase):
id: int
# use from_attributes so Pydantic reads attributes off SQLAlchemy model
model_config = ConfigDict(from_attributes=True)
# optionally include nested currency info
currency: Optional["CurrencyRead"] = None
class OpexCreate(_CostBase):
pass
class OpexRead(_CostBase):
id: int
model_config = ConfigDict(from_attributes=True)
currency: Optional["CurrencyRead"] = None
class CurrencyRead(BaseModel):
id: int
code: str
name: Optional[str] = None
symbol: Optional[str] = None
is_active: Optional[bool] = True
model_config = ConfigDict(from_attributes=True)
# forward refs
CapexRead.model_rebuild()
OpexRead.model_rebuild()
# Capex endpoints
@router.post("/capex", response_model=CapexRead)
def create_capex(item: CapexCreate, db: Session = Depends(get_db)):
payload = item.model_dump()
# Prefer explicit currency_id if supplied
cid = payload.get("currency_id")
if not cid:
code = (payload.pop("currency_code", "USD") or "USD").strip().upper()
currency_cls = __import__(
"models.currency", fromlist=["Currency"]
).Currency
currency = db.query(currency_cls).filter_by(code=code).one_or_none()
if currency is None:
currency = currency_cls(code=code, name=code, symbol=None)
db.add(currency)
db.flush()
payload["currency_id"] = currency.id
db_item = Capex(**payload)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/capex", response_model=List[CapexRead])
def list_capex(db: Session = Depends(get_db)):
return db.query(Capex).all()
# Opex endpoints
@router.post("/opex", response_model=OpexRead)
def create_opex(item: OpexCreate, db: Session = Depends(get_db)):
payload = item.model_dump()
cid = payload.get("currency_id")
if not cid:
code = (payload.pop("currency_code", "USD") or "USD").strip().upper()
currency_cls = __import__(
"models.currency", fromlist=["Currency"]
).Currency
currency = db.query(currency_cls).filter_by(code=code).one_or_none()
if currency is None:
currency = currency_cls(code=code, name=code, symbol=None)
db.add(currency)
db.flush()
payload["currency_id"] = currency.id
db_item = Opex(**payload)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/opex", response_model=List[OpexRead])
def list_opex(db: Session = Depends(get_db)):
return db.query(Opex).all()

View File

@@ -1,193 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from models.currency import Currency
from routes.dependencies import get_db
router = APIRouter(prefix="/api/currencies", tags=["Currencies"])
DEFAULT_CURRENCY_CODE = "USD"
DEFAULT_CURRENCY_NAME = "US Dollar"
DEFAULT_CURRENCY_SYMBOL = "$"
class CurrencyBase(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
symbol: Optional[str] = Field(default=None, max_length=8)
@staticmethod
def _normalize_symbol(value: Optional[str]) -> Optional[str]:
if value is None:
return None
value = value.strip()
return value or None
@field_validator("name")
@classmethod
def _strip_name(cls, value: str) -> str:
return value.strip()
@field_validator("symbol")
@classmethod
def _strip_symbol(cls, value: Optional[str]) -> Optional[str]:
return cls._normalize_symbol(value)
class CurrencyCreate(CurrencyBase):
code: str = Field(..., min_length=3, max_length=3)
is_active: bool = True
@field_validator("code")
@classmethod
def _normalize_code(cls, value: str) -> str:
return value.strip().upper()
class CurrencyUpdate(CurrencyBase):
is_active: Optional[bool] = None
class CurrencyActivation(BaseModel):
is_active: bool
class CurrencyRead(CurrencyBase):
id: int
code: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
def _ensure_default_currency(db: Session) -> Currency:
existing = (
db.query(Currency)
.filter(Currency.code == DEFAULT_CURRENCY_CODE)
.one_or_none()
)
if existing:
return existing
default_currency = Currency(
code=DEFAULT_CURRENCY_CODE,
name=DEFAULT_CURRENCY_NAME,
symbol=DEFAULT_CURRENCY_SYMBOL,
is_active=True,
)
db.add(default_currency)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = (
db.query(Currency)
.filter(Currency.code == DEFAULT_CURRENCY_CODE)
.one()
)
return existing
db.refresh(default_currency)
return default_currency
def _get_currency_or_404(db: Session, code: str) -> Currency:
normalized = code.strip().upper()
currency = (
db.query(Currency).filter(Currency.code == normalized).one_or_none()
)
if currency is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Currency not found"
)
return currency
@router.get("/", response_model=List[CurrencyRead])
def list_currencies(
include_inactive: bool = Query(
False, description="Include inactive currencies"
),
db: Session = Depends(get_db),
):
_ensure_default_currency(db)
query = db.query(Currency)
if not include_inactive:
query = query.filter(Currency.is_active.is_(True))
currencies = query.order_by(Currency.code).all()
return currencies
@router.post(
"/", response_model=CurrencyRead, status_code=status.HTTP_201_CREATED
)
def create_currency(payload: CurrencyCreate, db: Session = Depends(get_db)):
code = payload.code
existing = db.query(Currency).filter(Currency.code == code).one_or_none()
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Currency '{code}' already exists",
)
currency = Currency(
code=code,
name=payload.name,
symbol=CurrencyBase._normalize_symbol(payload.symbol),
is_active=payload.is_active,
)
db.add(currency)
db.commit()
db.refresh(currency)
return currency
@router.put("/{code}", response_model=CurrencyRead)
def update_currency(
code: str, payload: CurrencyUpdate, db: Session = Depends(get_db)
):
currency = _get_currency_or_404(db, code)
if payload.name is not None:
setattr(currency, "name", payload.name)
if payload.symbol is not None or payload.symbol == "":
setattr(
currency,
"symbol",
CurrencyBase._normalize_symbol(payload.symbol),
)
if payload.is_active is not None:
code_value = getattr(currency, "code")
if code_value == DEFAULT_CURRENCY_CODE and payload.is_active is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The default currency cannot be deactivated.",
)
setattr(currency, "is_active", payload.is_active)
db.add(currency)
db.commit()
db.refresh(currency)
return currency
@router.patch("/{code}/activation", response_model=CurrencyRead)
def toggle_currency_activation(
code: str, body: CurrencyActivation, db: Session = Depends(get_db)
):
currency = _get_currency_or_404(db, code)
code_value = getattr(currency, "code")
if code_value == DEFAULT_CURRENCY_CODE and body.is_active is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The default currency cannot be deactivated.",
)
setattr(currency, "is_active", body.is_active)
db.add(currency)
db.commit()
db.refresh(currency)
return currency

View File

@@ -1,13 +0,0 @@
from collections.abc import Generator
from sqlalchemy.orm import Session
from config.database import SessionLocal
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,38 +0,0 @@
from typing import Dict, List
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from models.distribution import Distribution
from routes.dependencies import get_db
router = APIRouter(prefix="/api/distributions", tags=["Distributions"])
class DistributionCreate(BaseModel):
name: str
distribution_type: str
parameters: Dict[str, float | int]
class DistributionRead(DistributionCreate):
id: int
model_config = ConfigDict(from_attributes=True)
@router.post("/", response_model=DistributionRead)
async def create_distribution(
dist: DistributionCreate, db: Session = Depends(get_db)
):
db_dist = Distribution(**dist.model_dump())
db.add(db_dist)
db.commit()
db.refresh(db_dist)
return db_dist
@router.get("/", response_model=List[DistributionRead])
async def list_distributions(db: Session = Depends(get_db)):
dists = db.query(Distribution).all()
return dists

View File

@@ -1,38 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from models.equipment import Equipment
from routes.dependencies import get_db
router = APIRouter(prefix="/api/equipment", tags=["Equipment"])
# Pydantic schemas
class EquipmentCreate(BaseModel):
scenario_id: int
name: str
description: Optional[str] = None
class EquipmentRead(EquipmentCreate):
id: int
model_config = ConfigDict(from_attributes=True)
@router.post("/", response_model=EquipmentRead)
async def create_equipment(
item: EquipmentCreate, db: Session = Depends(get_db)
):
db_item = Equipment(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[EquipmentRead])
async def list_equipment(db: Session = Depends(get_db)):
return db.query(Equipment).all()

View File

@@ -1,91 +0,0 @@
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, PositiveFloat
from sqlalchemy.orm import Session
from models.maintenance import Maintenance
from routes.dependencies import get_db
router = APIRouter(prefix="/api/maintenance", tags=["Maintenance"])
class MaintenanceBase(BaseModel):
equipment_id: int
scenario_id: int
maintenance_date: date
description: Optional[str] = None
cost: PositiveFloat
class MaintenanceCreate(MaintenanceBase):
pass
class MaintenanceUpdate(MaintenanceBase):
pass
class MaintenanceRead(MaintenanceBase):
id: int
model_config = ConfigDict(from_attributes=True)
def _get_maintenance_or_404(db: Session, maintenance_id: int) -> Maintenance:
maintenance = (
db.query(Maintenance).filter(Maintenance.id == maintenance_id).first()
)
if maintenance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Maintenance record {maintenance_id} not found",
)
return maintenance
@router.post(
"/", response_model=MaintenanceRead, status_code=status.HTTP_201_CREATED
)
def create_maintenance(
maintenance: MaintenanceCreate, db: Session = Depends(get_db)
):
db_maintenance = Maintenance(**maintenance.model_dump())
db.add(db_maintenance)
db.commit()
db.refresh(db_maintenance)
return db_maintenance
@router.get("/", response_model=List[MaintenanceRead])
def list_maintenance(
skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
):
return db.query(Maintenance).offset(skip).limit(limit).all()
@router.get("/{maintenance_id}", response_model=MaintenanceRead)
def get_maintenance(maintenance_id: int, db: Session = Depends(get_db)):
return _get_maintenance_or_404(db, maintenance_id)
@router.put("/{maintenance_id}", response_model=MaintenanceRead)
def update_maintenance(
maintenance_id: int,
payload: MaintenanceUpdate,
db: Session = Depends(get_db),
):
db_maintenance = _get_maintenance_or_404(db, maintenance_id)
for field, value in payload.model_dump().items():
setattr(db_maintenance, field, value)
db.commit()
db.refresh(db_maintenance)
return db_maintenance
@router.delete("/{maintenance_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_maintenance(maintenance_id: int, db: Session = Depends(get_db)):
db_maintenance = _get_maintenance_or_404(db, maintenance_id)
db.delete(db_maintenance)
db.commit()

View File

@@ -1,90 +0,0 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy.orm import Session
from models.distribution import Distribution
from models.parameters import Parameter
from models.scenario import Scenario
from routes.dependencies import get_db
router = APIRouter(prefix="/api/parameters", tags=["parameters"])
class ParameterCreate(BaseModel):
scenario_id: int
name: str
value: float
distribution_id: Optional[int] = None
distribution_type: Optional[str] = None
distribution_parameters: Optional[Dict[str, Any]] = None
@field_validator("distribution_type")
@classmethod
def normalize_type(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return value
normalized = value.strip().lower()
if not normalized:
return None
if normalized not in {"normal", "uniform", "triangular"}:
raise ValueError(
"distribution_type must be normal, uniform, or triangular"
)
return normalized
@field_validator("distribution_parameters")
@classmethod
def empty_dict_to_none(
cls, value: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
if value is None:
return None
return value or None
class ParameterRead(ParameterCreate):
id: int
model_config = ConfigDict(from_attributes=True)
@router.post("/", response_model=ParameterRead)
def create_parameter(param: ParameterCreate, db: Session = Depends(get_db)):
scen = db.query(Scenario).filter(Scenario.id == param.scenario_id).first()
if not scen:
raise HTTPException(status_code=404, detail="Scenario not found")
distribution_id = param.distribution_id
distribution_type = param.distribution_type
distribution_parameters = param.distribution_parameters
if distribution_id is not None:
distribution = (
db.query(Distribution)
.filter(Distribution.id == distribution_id)
.first()
)
if not distribution:
raise HTTPException(
status_code=404, detail="Distribution not found"
)
distribution_type = distribution.distribution_type
distribution_parameters = distribution.parameters or None
new_param = Parameter(
scenario_id=param.scenario_id,
name=param.name,
value=param.value,
distribution_id=distribution_id,
distribution_type=distribution_type,
distribution_parameters=distribution_parameters,
)
db.add(new_param)
db.commit()
db.refresh(new_param)
return new_param
@router.get("/", response_model=List[ParameterRead])
def list_parameters(db: Session = Depends(get_db)):
return db.query(Parameter).all()

View File

@@ -1,56 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator
from sqlalchemy.orm import Session
from models.production_output import ProductionOutput
from routes.dependencies import get_db
router = APIRouter(prefix="/api/production", tags=["Production"])
class ProductionOutputBase(BaseModel):
scenario_id: int
amount: PositiveFloat
description: Optional[str] = None
unit_name: Optional[str] = None
unit_symbol: Optional[str] = None
@field_validator("unit_name", "unit_symbol")
@classmethod
def _normalize_text(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ProductionOutputCreate(ProductionOutputBase):
pass
class ProductionOutputRead(ProductionOutputBase):
id: int
model_config = ConfigDict(from_attributes=True)
@router.post(
"/",
response_model=ProductionOutputRead,
status_code=status.HTTP_201_CREATED,
)
def create_production(
item: ProductionOutputCreate, db: Session = Depends(get_db)
):
db_item = ProductionOutput(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[ProductionOutputRead])
def list_production(db: Session = Depends(get_db)):
return db.query(ProductionOutput).all()

View File

@@ -1,73 +0,0 @@
from typing import Any, Dict, List, cast
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel
from services.reporting import generate_report
router = APIRouter(prefix="/api/reporting", tags=["Reporting"])
def _validate_payload(payload: Any) -> List[Dict[str, float]]:
if not isinstance(payload, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid input format",
)
typed_payload = cast(List[Any], payload)
validated: List[Dict[str, float]] = []
for index, item in enumerate(typed_payload):
if not isinstance(item, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Entry at index {index} must be an object",
)
value = cast(Dict[str, Any], item).get("result")
if not isinstance(value, (int, float)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Entry at index {index} must include numeric 'result'",
)
validated.append({"result": float(value)})
return validated
class ReportSummary(BaseModel):
count: int
mean: float
median: float
min: float
max: float
std_dev: float
variance: float
percentile_10: float
percentile_90: float
percentile_5: float
percentile_95: float
value_at_risk_95: float
expected_shortfall_95: float
@router.post("/summary", response_model=ReportSummary)
async def summary_report(request: Request):
payload = await request.json()
validated_payload = _validate_payload(payload)
summary = generate_report(validated_payload)
return ReportSummary(
count=int(summary["count"]),
mean=float(summary["mean"]),
median=float(summary["median"]),
min=float(summary["min"]),
max=float(summary["max"]),
std_dev=float(summary["std_dev"]),
variance=float(summary["variance"]),
percentile_10=float(summary["percentile_10"]),
percentile_90=float(summary["percentile_90"]),
percentile_5=float(summary["percentile_5"]),
percentile_95=float(summary["percentile_95"]),
value_at_risk_95=float(summary["value_at_risk_95"]),
expected_shortfall_95=float(summary["expected_shortfall_95"]),
)

View File

@@ -1,42 +0,0 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from models.scenario import Scenario
from routes.dependencies import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
# Pydantic schemas
class ScenarioCreate(BaseModel):
name: str
description: Optional[str] = None
class ScenarioRead(ScenarioCreate):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
@router.post("/", response_model=ScenarioRead)
def create_scenario(scenario: ScenarioCreate, db: Session = Depends(get_db)):
db_s = db.query(Scenario).filter(Scenario.name == scenario.name).first()
if db_s:
raise HTTPException(status_code=400, detail="Scenario already exists")
new_s = Scenario(name=scenario.name, description=scenario.description)
db.add(new_s)
db.commit()
db.refresh(new_s)
return new_s
@router.get("/", response_model=list[ScenarioRead])
def list_scenarios(db: Session = Depends(get_db)):
return db.query(Scenario).all()

View File

@@ -1,110 +0,0 @@
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from routes.dependencies import get_db
from services.settings import (
CSS_COLOR_DEFAULTS,
get_css_color_settings,
list_css_env_override_rows,
read_css_color_env_overrides,
update_css_color_settings,
get_theme_settings,
save_theme_settings,
)
router = APIRouter(prefix="/api/settings", tags=["Settings"])
class CSSSettingsPayload(BaseModel):
variables: Dict[str, str] = Field(default_factory=dict)
@model_validator(mode="after")
def _validate_allowed_keys(self) -> "CSSSettingsPayload":
invalid = set(self.variables.keys()) - set(CSS_COLOR_DEFAULTS.keys())
if invalid:
invalid_keys = ", ".join(sorted(invalid))
raise ValueError(
f"Unsupported CSS variables: {invalid_keys}."
" Accepted keys align with the default theme variables."
)
return self
class EnvOverride(BaseModel):
css_key: str
env_var: str
value: str
class CSSSettingsResponse(BaseModel):
variables: Dict[str, str]
env_overrides: Dict[str, str] = Field(default_factory=dict)
env_sources: List[EnvOverride] = Field(default_factory=list)
@router.get("/css", response_model=CSSSettingsResponse)
def read_css_settings(db: Session = Depends(get_db)) -> CSSSettingsResponse:
try:
values = get_css_color_settings(db)
env_overrides = read_css_color_env_overrides()
env_sources = [
EnvOverride(**row) for row in list_css_env_override_rows()
]
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return CSSSettingsResponse(
variables=values,
env_overrides=env_overrides,
env_sources=env_sources,
)
@router.put(
"/css", response_model=CSSSettingsResponse, status_code=status.HTTP_200_OK
)
def update_css_settings(
payload: CSSSettingsPayload, db: Session = Depends(get_db)
) -> CSSSettingsResponse:
try:
values = update_css_color_settings(db, payload.variables)
env_overrides = read_css_color_env_overrides()
env_sources = [
EnvOverride(**row) for row in list_css_env_override_rows()
]
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return CSSSettingsResponse(
variables=values,
env_overrides=env_overrides,
env_sources=env_sources,
)
class ThemeSettings(BaseModel):
theme_name: str
primary_color: str
secondary_color: str
accent_color: str
background_color: str
text_color: str
@router.post("/theme")
async def update_theme(theme_data: ThemeSettings, db: Session = Depends(get_db)):
data_dict = theme_data.model_dump()
save_theme_settings(db, data_dict)
return {"message": "Theme updated", "theme": data_dict}
@router.get("/theme")
async def get_theme(db: Session = Depends(get_db)):
return get_theme_settings(db)

View File

@@ -1,126 +0,0 @@
from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, PositiveInt
from sqlalchemy.orm import Session
from models.parameters import Parameter
from models.scenario import Scenario
from models.simulation_result import SimulationResult
from routes.dependencies import get_db
from services.reporting import generate_report
from services.simulation import run_simulation
router = APIRouter(prefix="/api/simulations", tags=["Simulations"])
class SimulationParameterInput(BaseModel):
name: str
value: float
distribution: Optional[str] = "normal"
std_dev: Optional[float] = None
min: Optional[float] = None
max: Optional[float] = None
mode: Optional[float] = None
class SimulationRunRequest(BaseModel):
scenario_id: int
iterations: PositiveInt = 1000
parameters: Optional[List[SimulationParameterInput]] = None
seed: Optional[int] = None
class SimulationResultItem(BaseModel):
iteration: int
result: float
class SimulationRunResponse(BaseModel):
scenario_id: int
iterations: int
results: List[SimulationResultItem]
summary: Dict[str, float | int]
def _load_parameters(
db: Session, scenario_id: int
) -> List[SimulationParameterInput]:
db_params = (
db.query(Parameter)
.filter(Parameter.scenario_id == scenario_id)
.order_by(Parameter.id)
.all()
)
return [
SimulationParameterInput(
name=item.name,
value=item.value,
)
for item in db_params
]
@router.post("/run", response_model=SimulationRunResponse)
async def simulate(
payload: SimulationRunRequest, db: Session = Depends(get_db)
):
scenario = (
db.query(Scenario).filter(Scenario.id == payload.scenario_id).first()
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
parameters = payload.parameters or _load_parameters(db, payload.scenario_id)
if not parameters:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No parameters provided",
)
raw_results = run_simulation(
[param.model_dump(exclude_none=True) for param in parameters],
iterations=payload.iterations,
seed=payload.seed,
)
if not raw_results:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation produced no results",
)
# Persist results (replace existing values for scenario)
db.query(SimulationResult).filter(
SimulationResult.scenario_id == payload.scenario_id
).delete()
db.bulk_save_objects(
[
SimulationResult(
scenario_id=payload.scenario_id,
iteration=item["iteration"],
result=item["result"],
)
for item in raw_results
]
)
db.commit()
summary = generate_report(raw_results)
response = SimulationRunResponse(
scenario_id=payload.scenario_id,
iterations=payload.iterations,
results=[
SimulationResultItem(
iteration=int(item["iteration"]),
result=float(item["result"]),
)
for item in raw_results
],
summary=summary,
)
return response

View File

@@ -1,784 +0,0 @@
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from models.capex import Capex
from models.consumption import Consumption
from models.equipment import Equipment
from models.maintenance import Maintenance
from models.opex import Opex
from models.parameters import Parameter
from models.production_output import ProductionOutput
from models.scenario import Scenario
from models.simulation_result import SimulationResult
from routes.dependencies import get_db
from services.reporting import generate_report
from models.currency import Currency
from routes.currencies import DEFAULT_CURRENCY_CODE, _ensure_default_currency
from services.settings import (
CSS_COLOR_DEFAULTS,
get_css_color_settings,
list_css_env_override_rows,
read_css_color_env_overrides,
)
CURRENCY_CHOICES: list[Dict[str, Any]] = [
{"id": "USD", "name": "US Dollar (USD)"},
{"id": "EUR", "name": "Euro (EUR)"},
{"id": "CLP", "name": "Chilean Peso (CLP)"},
{"id": "RMB", "name": "Chinese Yuan (RMB)"},
{"id": "GBP", "name": "British Pound (GBP)"},
{"id": "CAD", "name": "Canadian Dollar (CAD)"},
{"id": "AUD", "name": "Australian Dollar (AUD)"},
]
MEASUREMENT_UNITS: list[Dict[str, Any]] = [
{"id": "tonnes", "name": "Tonnes", "symbol": "t"},
{"id": "kilograms", "name": "Kilograms", "symbol": "kg"},
{"id": "pounds", "name": "Pounds", "symbol": "lb"},
{"id": "liters", "name": "Liters", "symbol": "L"},
{"id": "cubic_meters", "name": "Cubic Meters", "symbol": "m3"},
{"id": "kilowatt_hours", "name": "Kilowatt Hours", "symbol": "kWh"},
]
router = APIRouter()
# Set up Jinja2 templates directory
templates = Jinja2Templates(directory="templates")
def _context(
request: Request, extra: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"request": request,
"current_year": datetime.now(timezone.utc).year,
}
if extra:
payload.update(extra)
return payload
def _render(
request: Request,
template_name: str,
extra: Optional[Dict[str, Any]] = None,
):
context = _context(request, extra)
return templates.TemplateResponse(request, template_name, context)
def _format_currency(value: float) -> str:
return f"${value:,.2f}"
def _format_decimal(value: float) -> str:
return f"{value:,.2f}"
def _format_int(value: int) -> str:
return f"{value:,}"
def _load_scenarios(db: Session) -> Dict[str, Any]:
scenarios: list[Dict[str, Any]] = [
{
"id": item.id,
"name": item.name,
"description": item.description,
}
for item in db.query(Scenario).order_by(Scenario.name).all()
]
return {"scenarios": scenarios}
def _load_parameters(db: Session) -> Dict[str, Any]:
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for param in db.query(Parameter).order_by(
Parameter.scenario_id, Parameter.id
):
grouped[param.scenario_id].append(
{
"id": param.id,
"name": param.name,
"value": param.value,
"distribution_type": param.distribution_type,
"distribution_parameters": param.distribution_parameters,
}
)
return {"parameters_by_scenario": dict(grouped)}
def _load_costs(db: Session) -> Dict[str, Any]:
capex_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for capex in db.query(Capex).order_by(Capex.scenario_id, Capex.id).all():
capex_grouped[int(getattr(capex, "scenario_id"))].append(
{
"id": int(getattr(capex, "id")),
"scenario_id": int(getattr(capex, "scenario_id")),
"amount": float(getattr(capex, "amount", 0.0)),
"description": getattr(capex, "description", "") or "",
"currency_code": getattr(capex, "currency_code", "USD")
or "USD",
}
)
opex_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for opex in db.query(Opex).order_by(Opex.scenario_id, Opex.id).all():
opex_grouped[int(getattr(opex, "scenario_id"))].append(
{
"id": int(getattr(opex, "id")),
"scenario_id": int(getattr(opex, "scenario_id")),
"amount": float(getattr(opex, "amount", 0.0)),
"description": getattr(opex, "description", "") or "",
"currency_code": getattr(opex, "currency_code", "USD") or "USD",
}
)
return {
"capex_by_scenario": dict(capex_grouped),
"opex_by_scenario": dict(opex_grouped),
}
def _load_currencies(db: Session) -> Dict[str, Any]:
items: list[Dict[str, Any]] = []
for c in (
db.query(Currency)
.filter_by(is_active=True)
.order_by(Currency.code)
.all()
):
items.append(
{"id": c.code, "name": f"{c.name} ({c.code})", "symbol": c.symbol}
)
if not items:
items.append({"id": "USD", "name": "US Dollar (USD)", "symbol": "$"})
return {"currency_options": items}
def _load_currency_settings(db: Session) -> Dict[str, Any]:
_ensure_default_currency(db)
records = db.query(Currency).order_by(Currency.code).all()
currencies: list[Dict[str, Any]] = []
for record in records:
code_value = getattr(record, "code")
currencies.append(
{
"id": int(getattr(record, "id")),
"code": code_value,
"name": getattr(record, "name"),
"symbol": getattr(record, "symbol"),
"is_active": bool(getattr(record, "is_active", True)),
"is_default": code_value == DEFAULT_CURRENCY_CODE,
}
)
active_count = sum(1 for item in currencies if item["is_active"])
inactive_count = len(currencies) - active_count
return {
"currencies": currencies,
"currency_stats": {
"total": len(currencies),
"active": active_count,
"inactive": inactive_count,
},
"default_currency_code": DEFAULT_CURRENCY_CODE,
"currency_api_base": "/api/currencies",
}
def _load_css_settings(db: Session) -> Dict[str, Any]:
variables = get_css_color_settings(db)
env_overrides = read_css_color_env_overrides()
env_rows = list_css_env_override_rows()
env_meta = {row["css_key"]: row for row in env_rows}
return {
"css_variables": variables,
"css_defaults": CSS_COLOR_DEFAULTS,
"css_env_overrides": env_overrides,
"css_env_override_rows": env_rows,
"css_env_override_meta": env_meta,
}
def _load_consumption(db: Session) -> Dict[str, Any]:
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for record in (
db.query(Consumption)
.order_by(Consumption.scenario_id, Consumption.id)
.all()
):
record_id = int(getattr(record, "id"))
scenario_id = int(getattr(record, "scenario_id"))
amount_value = float(getattr(record, "amount", 0.0))
description = getattr(record, "description", "") or ""
unit_name = getattr(record, "unit_name", None)
unit_symbol = getattr(record, "unit_symbol", None)
grouped[scenario_id].append(
{
"id": record_id,
"scenario_id": scenario_id,
"amount": amount_value,
"description": description,
"unit_name": unit_name,
"unit_symbol": unit_symbol,
}
)
return {"consumption_by_scenario": dict(grouped)}
def _load_production(db: Session) -> Dict[str, Any]:
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for record in (
db.query(ProductionOutput)
.order_by(ProductionOutput.scenario_id, ProductionOutput.id)
.all()
):
record_id = int(getattr(record, "id"))
scenario_id = int(getattr(record, "scenario_id"))
amount_value = float(getattr(record, "amount", 0.0))
description = getattr(record, "description", "") or ""
unit_name = getattr(record, "unit_name", None)
unit_symbol = getattr(record, "unit_symbol", None)
grouped[scenario_id].append(
{
"id": record_id,
"scenario_id": scenario_id,
"amount": amount_value,
"description": description,
"unit_name": unit_name,
"unit_symbol": unit_symbol,
}
)
return {"production_by_scenario": dict(grouped)}
def _load_equipment(db: Session) -> Dict[str, Any]:
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for record in (
db.query(Equipment).order_by(Equipment.scenario_id, Equipment.id).all()
):
record_id = int(getattr(record, "id"))
scenario_id = int(getattr(record, "scenario_id"))
name_value = getattr(record, "name", "") or ""
description = getattr(record, "description", "") or ""
grouped[scenario_id].append(
{
"id": record_id,
"scenario_id": scenario_id,
"name": name_value,
"description": description,
}
)
return {"equipment_by_scenario": dict(grouped)}
def _load_maintenance(db: Session) -> Dict[str, Any]:
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for record in (
db.query(Maintenance)
.order_by(Maintenance.scenario_id, Maintenance.maintenance_date)
.all()
):
record_id = int(getattr(record, "id"))
scenario_id = int(getattr(record, "scenario_id"))
equipment_id = int(getattr(record, "equipment_id"))
equipment_obj = getattr(record, "equipment", None)
equipment_name = (
getattr(equipment_obj, "name", "") if equipment_obj else ""
)
maintenance_date = getattr(record, "maintenance_date", None)
cost_value = float(getattr(record, "cost", 0.0))
description = getattr(record, "description", "") or ""
grouped[scenario_id].append(
{
"id": record_id,
"scenario_id": scenario_id,
"equipment_id": equipment_id,
"equipment_name": equipment_name,
"maintenance_date": (
maintenance_date.isoformat() if maintenance_date else ""
),
"cost": cost_value,
"description": description,
}
)
return {"maintenance_by_scenario": dict(grouped)}
def _load_simulations(db: Session) -> Dict[str, Any]:
scenarios: list[Dict[str, Any]] = [
{
"id": item.id,
"name": item.name,
}
for item in db.query(Scenario).order_by(Scenario.name).all()
]
results_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
for record in (
db.query(SimulationResult)
.order_by(SimulationResult.scenario_id, SimulationResult.iteration)
.all()
):
scenario_id = int(getattr(record, "scenario_id"))
results_grouped[scenario_id].append(
{
"iteration": int(getattr(record, "iteration")),
"result": float(getattr(record, "result", 0.0)),
}
)
runs: list[Dict[str, Any]] = []
sample_limit = 20
for item in scenarios:
scenario_id = int(item["id"])
scenario_results = results_grouped.get(scenario_id, [])
summary = (
generate_report(scenario_results)
if scenario_results
else generate_report([])
)
runs.append(
{
"scenario_id": scenario_id,
"scenario_name": item["name"],
"iterations": int(summary.get("count", 0)),
"summary": summary,
"sample_results": scenario_results[:sample_limit],
}
)
return {
"simulation_scenarios": scenarios,
"simulation_runs": runs,
}
def _load_reporting(db: Session) -> Dict[str, Any]:
scenarios = _load_scenarios(db)["scenarios"]
runs = _load_simulations(db)["simulation_runs"]
summaries: list[Dict[str, Any]] = []
runs_by_scenario = {run["scenario_id"]: run for run in runs}
for scenario in scenarios:
scenario_id = scenario["id"]
run = runs_by_scenario.get(scenario_id)
summary = run["summary"] if run else generate_report([])
summaries.append(
{
"scenario_id": scenario_id,
"scenario_name": scenario["name"],
"summary": summary,
"iterations": run["iterations"] if run else 0,
}
)
return {
"report_summaries": summaries,
}
def _load_dashboard(db: Session) -> Dict[str, Any]:
scenarios = _load_scenarios(db)["scenarios"]
parameters_by_scenario = _load_parameters(db)["parameters_by_scenario"]
costs_context = _load_costs(db)
capex_by_scenario = costs_context["capex_by_scenario"]
opex_by_scenario = costs_context["opex_by_scenario"]
consumption_by_scenario = _load_consumption(db)["consumption_by_scenario"]
production_by_scenario = _load_production(db)["production_by_scenario"]
equipment_by_scenario = _load_equipment(db)["equipment_by_scenario"]
maintenance_by_scenario = _load_maintenance(db)["maintenance_by_scenario"]
simulation_context = _load_simulations(db)
simulation_runs = simulation_context["simulation_runs"]
runs_by_scenario = {run["scenario_id"]: run for run in simulation_runs}
def sum_amounts(
grouped: Dict[int, list[Dict[str, Any]]], field: str = "amount"
) -> float:
total = 0.0
for items in grouped.values():
for item in items:
value = item.get(field, 0.0)
if isinstance(value, (int, float)):
total += float(value)
return total
total_capex = sum_amounts(capex_by_scenario)
total_opex = sum_amounts(opex_by_scenario)
total_consumption = sum_amounts(consumption_by_scenario)
total_production = sum_amounts(production_by_scenario)
total_maintenance_cost = sum_amounts(maintenance_by_scenario, field="cost")
total_parameters = sum(
len(items) for items in parameters_by_scenario.values()
)
total_equipment = sum(
len(items) for items in equipment_by_scenario.values()
)
total_maintenance_events = sum(
len(items) for items in maintenance_by_scenario.values()
)
total_simulation_iterations = sum(
run["iterations"] for run in simulation_runs
)
scenario_rows: list[Dict[str, Any]] = []
scenario_labels: list[str] = []
scenario_capex: list[float] = []
scenario_opex: list[float] = []
activity_labels: list[str] = []
activity_production: list[float] = []
activity_consumption: list[float] = []
for scenario in scenarios:
scenario_id = scenario["id"]
scenario_name = scenario["name"]
param_count = len(parameters_by_scenario.get(scenario_id, []))
equipment_count = len(equipment_by_scenario.get(scenario_id, []))
maintenance_count = len(maintenance_by_scenario.get(scenario_id, []))
capex_total = sum(
float(item.get("amount", 0.0))
for item in capex_by_scenario.get(scenario_id, [])
)
opex_total = sum(
float(item.get("amount", 0.0))
for item in opex_by_scenario.get(scenario_id, [])
)
consumption_total = sum(
float(item.get("amount", 0.0))
for item in consumption_by_scenario.get(scenario_id, [])
)
production_total = sum(
float(item.get("amount", 0.0))
for item in production_by_scenario.get(scenario_id, [])
)
run = runs_by_scenario.get(scenario_id)
summary = run["summary"] if run else generate_report([])
iterations = run["iterations"] if run else 0
mean_value = float(summary.get("mean", 0.0))
scenario_rows.append(
{
"scenario_name": scenario_name,
"parameter_count": param_count,
"parameter_display": _format_int(param_count),
"equipment_count": equipment_count,
"equipment_display": _format_int(equipment_count),
"capex_total": capex_total,
"capex_display": _format_currency(capex_total),
"opex_total": opex_total,
"opex_display": _format_currency(opex_total),
"production_total": production_total,
"production_display": _format_decimal(production_total),
"consumption_total": consumption_total,
"consumption_display": _format_decimal(consumption_total),
"maintenance_count": maintenance_count,
"maintenance_display": _format_int(maintenance_count),
"iterations": iterations,
"iterations_display": _format_int(iterations),
"simulation_mean": mean_value,
"simulation_mean_display": _format_decimal(mean_value),
}
)
scenario_labels.append(scenario_name)
scenario_capex.append(capex_total)
scenario_opex.append(opex_total)
activity_labels.append(scenario_name)
activity_production.append(production_total)
activity_consumption.append(consumption_total)
scenario_rows.sort(key=lambda row: row["scenario_name"].lower())
all_simulation_results = [
{"result": float(getattr(item, "result", 0.0))}
for item in db.query(SimulationResult).all()
]
overall_report = generate_report(all_simulation_results)
overall_report_metrics = [
{
"label": "Runs",
"value": _format_int(int(overall_report.get("count", 0))),
},
{
"label": "Mean",
"value": _format_decimal(float(overall_report.get("mean", 0.0))),
},
{
"label": "Median",
"value": _format_decimal(float(overall_report.get("median", 0.0))),
},
{
"label": "Std Dev",
"value": _format_decimal(float(overall_report.get("std_dev", 0.0))),
},
{
"label": "95th Percentile",
"value": _format_decimal(
float(overall_report.get("percentile_95", 0.0))
),
},
{
"label": "VaR (95%)",
"value": _format_decimal(
float(overall_report.get("value_at_risk_95", 0.0))
),
},
{
"label": "Expected Shortfall (95%)",
"value": _format_decimal(
float(overall_report.get("expected_shortfall_95", 0.0))
),
},
]
recent_simulations: list[Dict[str, Any]] = [
{
"scenario_name": run["scenario_name"],
"iterations": run["iterations"],
"iterations_display": _format_int(run["iterations"]),
"mean_display": _format_decimal(
float(run["summary"].get("mean", 0.0))
),
"p95_display": _format_decimal(
float(run["summary"].get("percentile_95", 0.0))
),
}
for run in simulation_runs
if run["iterations"] > 0
]
recent_simulations.sort(key=lambda item: item["iterations"], reverse=True)
recent_simulations = recent_simulations[:5]
upcoming_maintenance: list[Dict[str, Any]] = []
for record in (
db.query(Maintenance)
.order_by(Maintenance.maintenance_date.asc())
.limit(5)
.all()
):
maintenance_date = getattr(record, "maintenance_date", None)
upcoming_maintenance.append(
{
"scenario_name": getattr(
getattr(record, "scenario", None), "name", "Unknown"
),
"equipment_name": getattr(
getattr(record, "equipment", None), "name", "Unknown"
),
"date_display": (
maintenance_date.strftime("%Y-%m-%d")
if maintenance_date
else ""
),
"cost_display": _format_currency(
float(getattr(record, "cost", 0.0))
),
"description": getattr(record, "description", "") or "",
}
)
cost_chart_has_data = any(value > 0 for value in scenario_capex) or any(
value > 0 for value in scenario_opex
)
activity_chart_has_data = any(
value > 0 for value in activity_production
) or any(value > 0 for value in activity_consumption)
scenario_cost_chart: Dict[str, list[Any]] = {
"labels": scenario_labels,
"capex": scenario_capex,
"opex": scenario_opex,
}
scenario_activity_chart: Dict[str, list[Any]] = {
"labels": activity_labels,
"production": activity_production,
"consumption": activity_consumption,
}
summary_metrics = [
{"label": "Active Scenarios", "value": _format_int(len(scenarios))},
{"label": "Parameters", "value": _format_int(total_parameters)},
{"label": "CAPEX Total", "value": _format_currency(total_capex)},
{"label": "OPEX Total", "value": _format_currency(total_opex)},
{"label": "Equipment Assets", "value": _format_int(total_equipment)},
{
"label": "Maintenance Events",
"value": _format_int(total_maintenance_events),
},
{"label": "Consumption", "value": _format_decimal(total_consumption)},
{"label": "Production", "value": _format_decimal(total_production)},
{
"label": "Simulation Iterations",
"value": _format_int(total_simulation_iterations),
},
{
"label": "Maintenance Cost",
"value": _format_currency(total_maintenance_cost),
},
]
return {
"summary_metrics": summary_metrics,
"scenario_rows": scenario_rows,
"overall_report_metrics": overall_report_metrics,
"recent_simulations": recent_simulations,
"upcoming_maintenance": upcoming_maintenance,
"scenario_cost_chart": scenario_cost_chart,
"scenario_activity_chart": scenario_activity_chart,
"cost_chart_has_data": cost_chart_has_data,
"activity_chart_has_data": activity_chart_has_data,
"report_available": overall_report.get("count", 0) > 0,
}
@router.get("/", response_class=HTMLResponse)
async def dashboard_root(request: Request, db: Session = Depends(get_db)):
"""Render the primary dashboard landing page."""
return _render(request, "Dashboard.html", _load_dashboard(db))
@router.get("/ui/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
"""Render the legacy dashboard route for backward compatibility."""
return _render(request, "Dashboard.html", _load_dashboard(db))
@router.get("/ui/dashboard/data", response_class=JSONResponse)
async def dashboard_data(db: Session = Depends(get_db)) -> JSONResponse:
"""Expose dashboard aggregates as JSON for client-side refreshes."""
return JSONResponse(_load_dashboard(db))
@router.get("/ui/scenarios", response_class=HTMLResponse)
async def scenario_form(request: Request, db: Session = Depends(get_db)):
"""Render the scenario creation form."""
context = _load_scenarios(db)
return _render(request, "ScenarioForm.html", context)
@router.get("/ui/parameters", response_class=HTMLResponse)
async def parameter_form(request: Request, db: Session = Depends(get_db)):
"""Render the parameter input form."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_parameters(db))
return _render(request, "ParameterInput.html", context)
@router.get("/ui/costs", response_class=HTMLResponse)
async def costs_view(request: Request, db: Session = Depends(get_db)):
"""Render the costs view with CAPEX and OPEX data."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_costs(db))
context.update(_load_currencies(db))
return _render(request, "costs.html", context)
@router.get("/ui/consumption", response_class=HTMLResponse)
async def consumption_view(request: Request, db: Session = Depends(get_db)):
"""Render the consumption view with scenario consumption data."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_consumption(db))
context["unit_options"] = MEASUREMENT_UNITS
return _render(request, "consumption.html", context)
@router.get("/ui/production", response_class=HTMLResponse)
async def production_view(request: Request, db: Session = Depends(get_db)):
"""Render the production view with scenario production data."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_production(db))
context["unit_options"] = MEASUREMENT_UNITS
return _render(request, "production.html", context)
@router.get("/ui/equipment", response_class=HTMLResponse)
async def equipment_view(request: Request, db: Session = Depends(get_db)):
"""Render the equipment view with scenario equipment data."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_equipment(db))
return _render(request, "equipment.html", context)
@router.get("/ui/maintenance", response_class=HTMLResponse)
async def maintenance_view(request: Request, db: Session = Depends(get_db)):
"""Render the maintenance view with scenario maintenance data."""
context: Dict[str, Any] = {}
context.update(_load_scenarios(db))
context.update(_load_equipment(db))
context.update(_load_maintenance(db))
return _render(request, "maintenance.html", context)
@router.get("/ui/simulations", response_class=HTMLResponse)
async def simulations_view(request: Request, db: Session = Depends(get_db)):
"""Render the simulations view with scenario information and recent runs."""
return _render(request, "simulations.html", _load_simulations(db))
@router.get("/ui/reporting", response_class=HTMLResponse)
async def reporting_view(request: Request, db: Session = Depends(get_db)):
"""Render the reporting view with scenario KPI summaries."""
return _render(request, "reporting.html", _load_reporting(db))
@router.get("/ui/settings", response_class=HTMLResponse)
async def settings_view(request: Request, db: Session = Depends(get_db)):
"""Render the settings landing page."""
context = _load_css_settings(db)
return _render(request, "settings.html", context)
@router.get("/ui/currencies", response_class=HTMLResponse)
async def currencies_view(request: Request, db: Session = Depends(get_db)):
"""Render the currency administration page with full currency context."""
context = _load_currency_settings(db)
return _render(request, "currencies.html", context)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return _render(request, "login.html")
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
return _render(request, "register.html")
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(request: Request):
return _render(request, "profile.html")
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
return _render(request, "forgot_password.html")
@router.get("/theme-settings", response_class=HTMLResponse)
async def theme_settings_page(request: Request, db: Session = Depends(get_db)):
"""Render the theme settings page."""
context = _load_css_settings(db)
return _render(request, "theme_settings.html", context)

View File

@@ -1,107 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from config.database import get_db
from models.user import User
from services.security import create_access_token, get_current_user
from schemas.user import (
PasswordReset,
PasswordResetRequest,
UserCreate,
UserInDB,
UserLogin,
UserUpdate,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/register", response_model=UserInDB, status_code=status.HTTP_201_CREATED)
async def register_user(user: UserCreate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered")
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
# Get or create default role
from models.role import Role
default_role = db.query(Role).filter(Role.name == "user").first()
if not default_role:
default_role = Role(name="user")
db.add(default_role)
db.commit()
db.refresh(default_role)
new_user = User(username=user.username, email=user.email,
role_id=default_role.id)
new_user.set_password(user.password)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/login")
async def login_user(user: UserLogin, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.username == user.username).first()
if not db_user or not db_user.check_password(user.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password")
access_token = create_access_token(subject=db_user.username)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me")
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=UserInDB)
async def update_user_me(user_update: UserUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if user_update.username and user_update.username != current_user.username:
existing_user = db.query(User).filter(
User.username == user_update.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
setattr(current_user, "username", user_update.username)
if user_update.email and user_update.email != current_user.email:
existing_user = db.query(User).filter(
User.email == user_update.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
setattr(current_user, "email", user_update.email)
if user_update.password:
current_user.set_password(user_update.password)
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
@router.post("/forgot-password")
async def forgot_password(request: PasswordResetRequest):
# In a real application, this would send an email with a reset token
return {"message": "Password reset email sent (not really)"}
@router.post("/reset-password")
async def reset_password(request: PasswordReset, db: Session = Depends(get_db)):
# In a real application, the token would be verified
user = db.query(User).filter(User.username ==
request.token).first() # Use token as username for test
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token or user")
user.set_password(request.new_password)
db.add(user)
db.commit()
return {"message": "Password has been reset successfully"}