Compare commits
2 Commits
22ddfb671d
...
c6a0eb2588
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a0eb2588 | |||
| d807a50f77 |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Copy this file to config/setup_production.env and replace values with production secrets
|
||||
|
||||
# Container image and runtime configuration
|
||||
CALMINER_IMAGE=registry.example.com/calminer/api:latest
|
||||
CALMINER_DOMAIN=calminer.example.com
|
||||
TRAEFIK_ACME_EMAIL=ops@example.com
|
||||
CALMINER_API_PORT=8000
|
||||
UVICORN_WORKERS=4
|
||||
UVICORN_LOG_LEVEL=info
|
||||
CALMINER_NETWORK=calminer_backend
|
||||
API_LIMIT_CPUS=1.0
|
||||
API_LIMIT_MEMORY=1g
|
||||
API_RESERVATION_MEMORY=512m
|
||||
TRAEFIK_LIMIT_CPUS=0.5
|
||||
TRAEFIK_LIMIT_MEMORY=512m
|
||||
POSTGRES_LIMIT_CPUS=1.0
|
||||
POSTGRES_LIMIT_MEMORY=2g
|
||||
POSTGRES_RESERVATION_MEMORY=1g
|
||||
|
||||
# Application database connection
|
||||
DATABASE_DRIVER=postgresql+psycopg2
|
||||
DATABASE_HOST=production-db.internal
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=calminer
|
||||
DATABASE_USER=calminer_app
|
||||
DATABASE_PASSWORD=ChangeMe123!
|
||||
DATABASE_SCHEMA=public
|
||||
|
||||
# Optional consolidated SQLAlchemy URL (overrides granular settings when set)
|
||||
# DATABASE_URL=postgresql+psycopg2://calminer_app:ChangeMe123!@production-db.internal:5432/calminer
|
||||
|
||||
# Superuser credentials used by scripts/setup_database.py for migrations/seed data
|
||||
DATABASE_SUPERUSER=postgres
|
||||
DATABASE_SUPERUSER_PASSWORD=ChangeMeSuper123!
|
||||
DATABASE_SUPERUSER_DB=postgres
|
||||
@@ -1,11 +0,0 @@
|
||||
# Sample environment configuration for staging deployment
|
||||
DATABASE_HOST=staging-db.internal
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=calminer_staging
|
||||
DATABASE_USER=calminer_app
|
||||
DATABASE_PASSWORD=<app-password>
|
||||
|
||||
# Admin connection used for provisioning database and roles
|
||||
DATABASE_SUPERUSER=postgres
|
||||
DATABASE_SUPERUSER_PASSWORD=<admin-password>
|
||||
DATABASE_SUPERUSER_DB=postgres
|
||||
@@ -1,14 +0,0 @@
|
||||
# Sample environment configuration for running scripts/setup_database.py against a test instance
|
||||
DATABASE_DRIVER=postgresql
|
||||
DATABASE_HOST=postgres
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=calminer_test
|
||||
DATABASE_USER=calminer_test
|
||||
DATABASE_PASSWORD=<test-password>
|
||||
# optional: specify schema if different from 'public'
|
||||
#DATABASE_SCHEMA=public
|
||||
|
||||
# Admin connection used for provisioning database and roles
|
||||
DATABASE_SUPERUSER=postgres
|
||||
DATABASE_SUPERUSER_PASSWORD=<superuser-password>
|
||||
DATABASE_SUPERUSER_DB=postgres
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
models package initializer. Import key models so they're registered
|
||||
with the shared Base.metadata when the package is imported by tests.
|
||||
"""
|
||||
|
||||
from . import application_setting # noqa: F401
|
||||
from . import currency # noqa: F401
|
||||
from . import role # noqa: F401
|
||||
from . import user # noqa: F401
|
||||
from . import theme_setting # noqa: F401
|
||||
@@ -1,38 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class ApplicationSetting(Base):
|
||||
__tablename__ = "application_setting"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
value_type: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="string"
|
||||
)
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="general"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
is_editable: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApplicationSetting key={self.key} category={self.category}>"
|
||||
@@ -1,71 +0,0 @@
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy import Column, Integer, Float, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Capex(Base):
|
||||
__tablename__ = "capex"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
currency_id = Column(Integer, ForeignKey("currency.id"), nullable=False)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="capex_items")
|
||||
currency = relationship("Currency", back_populates="capex_items")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Capex id={self.id} scenario_id={self.scenario_id} "
|
||||
f"amount={self.amount} currency_id={self.currency_id}>"
|
||||
)
|
||||
|
||||
@property
|
||||
def currency_code(self) -> str:
|
||||
return self.currency.code if self.currency else None
|
||||
|
||||
@currency_code.setter
|
||||
def currency_code(self, value: str) -> None:
|
||||
# store pending code so application code or migrations can pick it up
|
||||
setattr(
|
||||
self, "_currency_code_pending", (value or "USD").strip().upper()
|
||||
)
|
||||
|
||||
|
||||
# SQLAlchemy event handlers to ensure currency_id is set before insert/update
|
||||
|
||||
|
||||
def _resolve_currency(mapper, connection, target):
|
||||
# If currency_id already set, nothing to do
|
||||
if getattr(target, "currency_id", None):
|
||||
return
|
||||
code = getattr(target, "_currency_code_pending", None) or "USD"
|
||||
# Try to find existing currency id
|
||||
row = connection.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"), {"code": code}
|
||||
).fetchone()
|
||||
if row:
|
||||
cid = row[0]
|
||||
else:
|
||||
# Insert new currency and attempt to get lastrowid
|
||||
res = connection.execute(
|
||||
text(
|
||||
"INSERT INTO currency (code, name, symbol, is_active) VALUES (:code, :name, :symbol, :active)"
|
||||
),
|
||||
{"code": code, "name": code, "symbol": None, "active": True},
|
||||
)
|
||||
try:
|
||||
cid = res.lastrowid
|
||||
except Exception:
|
||||
# fallback: select after insert
|
||||
cid = connection.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"),
|
||||
{"code": code},
|
||||
).scalar()
|
||||
target.currency_id = cid
|
||||
|
||||
|
||||
event.listen(Capex, "before_insert", _resolve_currency)
|
||||
event.listen(Capex, "before_update", _resolve_currency)
|
||||
@@ -1,22 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, Float, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Consumption(Base):
|
||||
__tablename__ = "consumption"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
unit_name = Column(String(64), nullable=True)
|
||||
unit_symbol = Column(String(16), nullable=True)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="consumption_items")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Consumption id={self.id} scenario_id={self.scenario_id} "
|
||||
f"amount={self.amount} unit={self.unit_symbol or self.unit_name}>"
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Currency(Base):
|
||||
__tablename__ = "currency"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(3), nullable=False, unique=True, index=True)
|
||||
name = Column(String(128), nullable=False)
|
||||
symbol = Column(String(8), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# reverse relationships (optional)
|
||||
capex_items = relationship(
|
||||
"Capex", back_populates="currency", lazy="select"
|
||||
)
|
||||
opex_items = relationship("Opex", back_populates="currency", lazy="select")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Currency code={self.code} name={self.name} symbol={self.symbol}>"
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, JSON
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Distribution(Base):
|
||||
__tablename__ = "distribution"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
distribution_type = Column(String, nullable=False)
|
||||
parameters = Column(JSON, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Distribution id={self.id} name={self.name} type={self.distribution_type}>"
|
||||
@@ -1,17 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Equipment(Base):
|
||||
__tablename__ = "equipment"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="equipment_items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Equipment id={self.id} scenario_id={self.scenario_id} name={self.name}>"
|
||||
@@ -1,23 +0,0 @@
|
||||
from sqlalchemy import Column, Date, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Maintenance(Base):
|
||||
__tablename__ = "maintenance"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
equipment_id = Column(Integer, ForeignKey("equipment.id"), nullable=False)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
maintenance_date = Column(Date, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
cost = Column(Float, nullable=False)
|
||||
|
||||
equipment = relationship("Equipment")
|
||||
scenario = relationship("Scenario", back_populates="maintenance_items")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Maintenance id={self.id} equipment_id={self.equipment_id} "
|
||||
f"scenario_id={self.scenario_id} date={self.maintenance_date} cost={self.cost}>"
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy import Column, Integer, Float, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Opex(Base):
|
||||
__tablename__ = "opex"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
currency_id = Column(Integer, ForeignKey("currency.id"), nullable=False)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="opex_items")
|
||||
currency = relationship("Currency", back_populates="opex_items")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Opex id={self.id} scenario_id={self.scenario_id} "
|
||||
f"amount={self.amount} currency_id={self.currency_id}>"
|
||||
)
|
||||
|
||||
@property
|
||||
def currency_code(self) -> str:
|
||||
return self.currency.code if self.currency else None
|
||||
|
||||
@currency_code.setter
|
||||
def currency_code(self, value: str) -> None:
|
||||
setattr(
|
||||
self, "_currency_code_pending", (value or "USD").strip().upper()
|
||||
)
|
||||
|
||||
|
||||
def _resolve_currency_opex(mapper, connection, target):
|
||||
if getattr(target, "currency_id", None):
|
||||
return
|
||||
code = getattr(target, "_currency_code_pending", None) or "USD"
|
||||
row = connection.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"), {"code": code}
|
||||
).fetchone()
|
||||
if row:
|
||||
cid = row[0]
|
||||
else:
|
||||
res = connection.execute(
|
||||
text(
|
||||
"INSERT INTO currency (code, name, symbol, is_active) VALUES (:code, :name, :symbol, :active)"
|
||||
),
|
||||
{"code": code, "name": code, "symbol": None, "active": True},
|
||||
)
|
||||
try:
|
||||
cid = res.lastrowid
|
||||
except Exception:
|
||||
cid = connection.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"),
|
||||
{"code": code},
|
||||
).scalar()
|
||||
target.currency_id = cid
|
||||
|
||||
|
||||
event.listen(Opex, "before_insert", _resolve_currency_opex)
|
||||
event.listen(Opex, "before_update", _resolve_currency_opex)
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Parameter(Base):
|
||||
__tablename__ = "parameter"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenario.id"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
value: Mapped[float] = mapped_column(nullable=False)
|
||||
distribution_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("distribution.id"), nullable=True
|
||||
)
|
||||
distribution_type: Mapped[Optional[str]] = mapped_column(nullable=True)
|
||||
distribution_parameters: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="parameters")
|
||||
distribution = relationship("Distribution")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Parameter id={self.id} name={self.name} value={self.value}>"
|
||||
@@ -1,24 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, Float, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class ProductionOutput(Base):
|
||||
__tablename__ = "production_output"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
unit_name = Column(String(64), nullable=True)
|
||||
unit_symbol = Column(String(16), nullable=True)
|
||||
|
||||
scenario = relationship(
|
||||
"Scenario", back_populates="production_output_items"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ProductionOutput id={self.id} scenario_id={self.scenario_id} "
|
||||
f"amount={self.amount} unit={self.unit_symbol or self.unit_name}>"
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True)
|
||||
|
||||
users = relationship("User", back_populates="role")
|
||||
@@ -1,36 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from models.simulation_result import SimulationResult
|
||||
from models.capex import Capex
|
||||
from models.opex import Opex
|
||||
from models.consumption import Consumption
|
||||
from models.production_output import ProductionOutput
|
||||
from models.equipment import Equipment
|
||||
from models.maintenance import Maintenance
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
__tablename__ = "scenario"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
parameters = relationship("Parameter", back_populates="scenario")
|
||||
simulation_results = relationship(
|
||||
SimulationResult, back_populates="scenario"
|
||||
)
|
||||
capex_items = relationship(Capex, back_populates="scenario")
|
||||
opex_items = relationship(Opex, back_populates="scenario")
|
||||
consumption_items = relationship(Consumption, back_populates="scenario")
|
||||
production_output_items = relationship(
|
||||
ProductionOutput, back_populates="scenario"
|
||||
)
|
||||
equipment_items = relationship(Equipment, back_populates="scenario")
|
||||
maintenance_items = relationship(Maintenance, back_populates="scenario")
|
||||
|
||||
# relationships can be defined later
|
||||
def __repr__(self):
|
||||
return f"<Scenario id={self.id} name={self.name}>"
|
||||
@@ -1,14 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class SimulationResult(Base):
|
||||
__tablename__ = "simulation_result"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
|
||||
iteration = Column(Integer, nullable=False)
|
||||
result = Column(Float, nullable=False)
|
||||
|
||||
scenario = relationship("Scenario", back_populates="simulation_results")
|
||||
@@ -1,15 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class ThemeSetting(Base):
|
||||
__tablename__ = "theme_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
theme_name = Column(String, unique=True, index=True)
|
||||
primary_color = Column(String)
|
||||
secondary_color = Column(String)
|
||||
accent_color = Column(String)
|
||||
background_color = Column(String)
|
||||
text_color = Column(String)
|
||||
@@ -1,23 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from config.database import Base
|
||||
from services.security import get_password_hash, verify_password
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
email = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"))
|
||||
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
def set_password(self, password: str):
|
||||
self.hashed_password = get_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return verify_password(password, str(self.hashed_password))
|
||||
@@ -1,7 +1,7 @@
|
||||
playwright
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-httpx
|
||||
pytest-playwright
|
||||
python-jose
|
||||
ruff
|
||||
black
|
||||
mypy
|
||||
@@ -1,5 +1,5 @@
|
||||
fastapi
|
||||
pydantic>=2.0,<3.0
|
||||
pydantic
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
psycopg2-binary
|
||||
|
||||
@@ -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()
|
||||
121
routes/costs.py
121
routes/costs.py
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
784
routes/ui.py
784
routes/ui.py
@@ -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)
|
||||
107
routes/users.py
107
routes/users.py
@@ -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"}
|
||||
@@ -1,41 +0,0 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserInDB(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
Backfill script to populate currency_id for capex and opex rows using existing currency_code.
|
||||
|
||||
Usage:
|
||||
python scripts/backfill_currency.py --dry-run
|
||||
python scripts/backfill_currency.py --create-missing
|
||||
|
||||
This script is intentionally cautious: it defaults to dry-run mode and will refuse to run
|
||||
if database connection settings are missing. It supports creating missing currency rows when `--create-missing`
|
||||
is provided. Always run against a development/staging database first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text, create_engine
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
def load_database_url() -> str:
|
||||
try:
|
||||
db_module = importlib.import_module("config.database")
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(
|
||||
"Database configuration missing: set DATABASE_URL or provide granular "
|
||||
"variables (DATABASE_DRIVER, DATABASE_HOST, DATABASE_PORT, DATABASE_USER, "
|
||||
"DATABASE_PASSWORD, DATABASE_NAME, optional DATABASE_SCHEMA)."
|
||||
) from exc
|
||||
|
||||
return getattr(db_module, "DATABASE_URL")
|
||||
|
||||
|
||||
def backfill(
|
||||
db_url: str, dry_run: bool = True, create_missing: bool = False
|
||||
) -> None:
|
||||
engine = create_engine(db_url)
|
||||
with engine.begin() as conn:
|
||||
# Ensure currency table exists
|
||||
if db_url.startswith("sqlite:"):
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='currency';"
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(text("SELECT to_regclass('public.currency');"))
|
||||
# Note: we don't strictly depend on the above - we assume migration was already applied
|
||||
|
||||
# Helper: find or create currency by code
|
||||
def find_currency_id(code: str):
|
||||
r = conn.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"),
|
||||
{"code": code},
|
||||
).fetchone()
|
||||
if r:
|
||||
return r[0]
|
||||
if create_missing:
|
||||
# insert and return id
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO currency (code, name, symbol, is_active) VALUES (:c, :n, NULL, TRUE)"
|
||||
),
|
||||
{"c": code, "n": code},
|
||||
)
|
||||
r2 = conn.execute(
|
||||
text("SELECT id FROM currency WHERE code = :code"),
|
||||
{"code": code},
|
||||
).fetchone()
|
||||
if not r2:
|
||||
raise RuntimeError(
|
||||
f"Unable to determine currency ID for '{code}' after insert"
|
||||
)
|
||||
return r2[0]
|
||||
return None
|
||||
|
||||
# Process tables capex and opex
|
||||
for table in ("capex", "opex"):
|
||||
# Check if currency_id column exists
|
||||
try:
|
||||
cols = (
|
||||
conn.execute(
|
||||
text(
|
||||
f"SELECT 1 FROM information_schema.columns WHERE table_name = '{table}' AND column_name = 'currency_id'"
|
||||
)
|
||||
)
|
||||
if not db_url.startswith("sqlite:")
|
||||
else [(1,)]
|
||||
)
|
||||
except Exception:
|
||||
cols = [(1,)]
|
||||
|
||||
if not cols:
|
||||
print(f"Skipping {table}: no currency_id column found")
|
||||
continue
|
||||
|
||||
# Find rows where currency_id IS NULL but currency_code exists
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"SELECT id, currency_code FROM {table} WHERE currency_id IS NULL OR currency_id = ''"
|
||||
)
|
||||
)
|
||||
changed = 0
|
||||
for r in rows:
|
||||
rid = r[0]
|
||||
code = (r[1] or "USD").strip().upper()
|
||||
cid = find_currency_id(code)
|
||||
if cid is None:
|
||||
print(
|
||||
f"Row {table}:{rid} has unknown currency code '{code}' and create_missing=False; skipping"
|
||||
)
|
||||
continue
|
||||
if dry_run:
|
||||
print(
|
||||
f"[DRY RUN] Would set {table}.currency_id = {cid} for row id={rid} (code={code})"
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
text(
|
||||
f"UPDATE {table} SET currency_id = :cid WHERE id = :rid"
|
||||
),
|
||||
{"cid": cid, "rid": rid},
|
||||
)
|
||||
changed += 1
|
||||
|
||||
print(f"{table}: processed, changed={changed} (dry_run={dry_run})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill currency_id from currency_code for capex/opex tables"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Show actions without writing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-missing",
|
||||
action="store_true",
|
||||
help="Create missing currency rows in the currency table",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = load_database_url()
|
||||
backfill(db, dry_run=args.dry_run, create_missing=args.create_missing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Simple Markdown link checker for local docs/ files.
|
||||
|
||||
Checks only local file links (relative paths) and reports missing targets.
|
||||
|
||||
Run from the repository root using the project's Python environment.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOCS = ROOT / "docs"
|
||||
|
||||
MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
|
||||
errors = []
|
||||
|
||||
for md in DOCS.rglob("*.md"):
|
||||
text = md.read_text(encoding="utf-8")
|
||||
for m in MD_LINK_RE.finditer(text):
|
||||
label, target = m.groups()
|
||||
# skip URLs
|
||||
if (
|
||||
target.startswith("http://")
|
||||
or target.startswith("https://")
|
||||
or target.startswith("#")
|
||||
):
|
||||
continue
|
||||
# strip anchors
|
||||
target_path = target.split("#")[0]
|
||||
# if link is to a directory index, allow
|
||||
candidate = (md.parent / target_path).resolve()
|
||||
if candidate.exists():
|
||||
continue
|
||||
# check common implicit index: target/ -> target/README.md or target/index.md
|
||||
candidate_dir = md.parent / target_path
|
||||
if candidate_dir.is_dir():
|
||||
if (candidate_dir / "README.md").exists() or (
|
||||
candidate_dir / "index.md"
|
||||
).exists():
|
||||
continue
|
||||
errors.append((str(md.relative_to(ROOT)), target, label))
|
||||
|
||||
if errors:
|
||||
print("Broken local links found:")
|
||||
for src, tgt, label in errors:
|
||||
print(f"- {src} -> {tgt} ({label})")
|
||||
exit(2)
|
||||
|
||||
print("No broken local links detected.")
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Lightweight Markdown formatter: normalizes first-line H1, adds code-fence language hints for common shebangs, trims trailing whitespace.
|
||||
|
||||
This is intentionally small and non-destructive; it touches only files under docs/ and makes safe changes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DOCS = Path(__file__).resolve().parents[1] / "docs"
|
||||
|
||||
CODE_LANG_HINTS = {
|
||||
"powershell": ("powershell",),
|
||||
"bash": ("bash", "sh"),
|
||||
"sql": ("sql",),
|
||||
"python": ("python",),
|
||||
}
|
||||
|
||||
|
||||
def add_code_fence_language(match):
|
||||
fence = match.group(0)
|
||||
inner = match.group(1)
|
||||
# If language already present, return unchanged
|
||||
if fence.startswith("```") and len(fence.splitlines()[0].strip()) > 3:
|
||||
return fence
|
||||
# Try to infer language from the code content
|
||||
code = inner.strip().splitlines()[0] if inner.strip() else ""
|
||||
lang = ""
|
||||
if (
|
||||
code.startswith("$")
|
||||
or code.startswith("PS")
|
||||
or code.lower().startswith("powershell")
|
||||
):
|
||||
lang = "powershell"
|
||||
elif (
|
||||
code.startswith("#")
|
||||
or code.startswith("import")
|
||||
or code.startswith("from")
|
||||
):
|
||||
lang = "python"
|
||||
elif re.match(r"^(select|insert|update|create)\b", code.strip(), re.I):
|
||||
lang = "sql"
|
||||
elif (
|
||||
code.startswith("git")
|
||||
or code.startswith("./")
|
||||
or code.startswith("sudo")
|
||||
):
|
||||
lang = "bash"
|
||||
if lang:
|
||||
return f"```{lang}\n{inner}\n```"
|
||||
return fence
|
||||
|
||||
|
||||
def normalize_file(path: Path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
orig = text
|
||||
# Trim trailing whitespace and ensure single trailing newline
|
||||
text = "\n".join(line.rstrip() for line in text.splitlines()) + "\n"
|
||||
# Ensure first non-empty line is H1
|
||||
lines = text.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.strip():
|
||||
if not ln.startswith("#"):
|
||||
lines[i] = "# " + ln
|
||||
break
|
||||
text = "\n".join(lines) + "\n"
|
||||
# Add basic code fence languages where missing (simple heuristic)
|
||||
text = re.sub(r"```\n([\s\S]*?)\n```", add_code_fence_language, text)
|
||||
if text != orig:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
changed = []
|
||||
for p in DOCS.rglob("*.md"):
|
||||
if p.is_file():
|
||||
try:
|
||||
if normalize_file(p):
|
||||
changed.append(str(p.relative_to(Path.cwd())))
|
||||
except Exception as e:
|
||||
print(f"Failed to format {p}: {e}")
|
||||
if changed:
|
||||
print("Formatted files:")
|
||||
for c in changed:
|
||||
print(" -", c)
|
||||
else:
|
||||
print("No formatting changes required.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,189 +0,0 @@
|
||||
-- Baseline migration for CalMiner database schema
|
||||
-- Date: 2025-10-25
|
||||
-- Purpose: Consolidate foundational tables and reference data
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Currency reference table
|
||||
CREATE TABLE IF NOT EXISTS currency (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(3) NOT NULL UNIQUE,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
symbol VARCHAR(8),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
INSERT INTO currency (code, name, symbol, is_active)
|
||||
VALUES
|
||||
('USD', 'United States Dollar', 'USD$', TRUE),
|
||||
('EUR', 'Euro', 'EUR', TRUE),
|
||||
('CLP', 'Chilean Peso', 'CLP$', TRUE),
|
||||
('RMB', 'Chinese Yuan', 'RMB', TRUE),
|
||||
('GBP', 'British Pound', 'GBP', TRUE),
|
||||
('CAD', 'Canadian Dollar', 'CAD$', TRUE),
|
||||
('AUD', 'Australian Dollar', 'AUD$', TRUE)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
is_active = EXCLUDED.is_active;
|
||||
|
||||
-- Application-level settings table
|
||||
CREATE TABLE IF NOT EXISTS application_setting (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(128) NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
value_type VARCHAR(32) NOT NULL DEFAULT 'string',
|
||||
category VARCHAR(32) NOT NULL DEFAULT 'general',
|
||||
description TEXT,
|
||||
is_editable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_application_setting_key
|
||||
ON application_setting (key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_application_setting_category
|
||||
ON application_setting (category);
|
||||
|
||||
-- Measurement unit reference table
|
||||
CREATE TABLE IF NOT EXISTS measurement_unit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
symbol VARCHAR(16),
|
||||
unit_type VARCHAR(32) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO measurement_unit (code, name, symbol, unit_type, is_active)
|
||||
VALUES
|
||||
('tonnes', 'Tonnes', 't', 'mass', TRUE),
|
||||
('kilograms', 'Kilograms', 'kg', 'mass', TRUE),
|
||||
('pounds', 'Pounds', 'lb', 'mass', TRUE),
|
||||
('liters', 'Liters', 'L', 'volume', TRUE),
|
||||
('cubic_meters', 'Cubic Meters', 'm3', 'volume', TRUE),
|
||||
('kilowatt_hours', 'Kilowatt Hours', 'kWh', 'energy', TRUE)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
unit_type = EXCLUDED.unit_type,
|
||||
is_active = EXCLUDED.is_active;
|
||||
|
||||
-- Consumption and production measurement metadata
|
||||
ALTER TABLE consumption
|
||||
ADD COLUMN IF NOT EXISTS unit_name VARCHAR(64);
|
||||
ALTER TABLE consumption
|
||||
ADD COLUMN IF NOT EXISTS unit_symbol VARCHAR(16);
|
||||
|
||||
ALTER TABLE production_output
|
||||
ADD COLUMN IF NOT EXISTS unit_name VARCHAR(64);
|
||||
ALTER TABLE production_output
|
||||
ADD COLUMN IF NOT EXISTS unit_symbol VARCHAR(16);
|
||||
|
||||
-- Currency integration for CAPEX and OPEX
|
||||
ALTER TABLE capex
|
||||
ADD COLUMN IF NOT EXISTS currency_id INTEGER;
|
||||
ALTER TABLE opex
|
||||
ADD COLUMN IF NOT EXISTS currency_id INTEGER;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
usd_id INTEGER;
|
||||
BEGIN
|
||||
-- Ensure currency_id columns align with legacy currency_code values when present
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'capex' AND column_name = 'currency_code'
|
||||
) THEN
|
||||
UPDATE capex AS c
|
||||
SET currency_id = cur.id
|
||||
FROM currency AS cur
|
||||
WHERE c.currency_code = cur.code
|
||||
AND (c.currency_id IS DISTINCT FROM cur.id);
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'opex' AND column_name = 'currency_code'
|
||||
) THEN
|
||||
UPDATE opex AS o
|
||||
SET currency_id = cur.id
|
||||
FROM currency AS cur
|
||||
WHERE o.currency_code = cur.code
|
||||
AND (o.currency_id IS DISTINCT FROM cur.id);
|
||||
END IF;
|
||||
|
||||
SELECT id INTO usd_id FROM currency WHERE code = 'USD';
|
||||
IF usd_id IS NOT NULL THEN
|
||||
UPDATE capex SET currency_id = usd_id WHERE currency_id IS NULL;
|
||||
UPDATE opex SET currency_id = usd_id WHERE currency_id IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE capex
|
||||
ALTER COLUMN currency_id SET NOT NULL;
|
||||
ALTER TABLE opex
|
||||
ALTER COLUMN currency_id SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'capex'
|
||||
AND constraint_name = 'fk_capex_currency'
|
||||
) THEN
|
||||
ALTER TABLE capex
|
||||
ADD CONSTRAINT fk_capex_currency FOREIGN KEY (currency_id)
|
||||
REFERENCES currency (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'opex'
|
||||
AND constraint_name = 'fk_opex_currency'
|
||||
) THEN
|
||||
ALTER TABLE opex
|
||||
ADD CONSTRAINT fk_opex_currency FOREIGN KEY (currency_id)
|
||||
REFERENCES currency (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE capex
|
||||
DROP COLUMN IF EXISTS currency_code;
|
||||
ALTER TABLE opex
|
||||
DROP COLUMN IF EXISTS currency_code;
|
||||
|
||||
-- Role-based access control tables
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
role_id INTEGER NOT NULL REFERENCES roles (id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_users_username ON users (username);
|
||||
CREATE INDEX IF NOT EXISTS ix_users_email ON users (email);
|
||||
|
||||
-- Theme settings configuration table
|
||||
CREATE TABLE IF NOT EXISTS theme_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
theme_name VARCHAR(255) UNIQUE NOT NULL,
|
||||
primary_color VARCHAR(7) NOT NULL,
|
||||
secondary_color VARCHAR(7) NOT NULL,
|
||||
accent_color VARCHAR(7) NOT NULL,
|
||||
background_color VARCHAR(7) NOT NULL,
|
||||
text_color VARCHAR(7) NOT NULL
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Seed baseline data for CalMiner in an idempotent manner.
|
||||
|
||||
Usage examples
|
||||
--------------
|
||||
|
||||
```powershell
|
||||
# Use existing environment variables (or load from setup_test.env.example)
|
||||
python scripts/seed_data.py --currencies --units --defaults
|
||||
|
||||
# Dry-run to preview actions
|
||||
python scripts/seed_data.py --currencies --dry-run
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import errors
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
from scripts.setup_database import DatabaseConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENCY_SEEDS = (
|
||||
("USD", "United States Dollar", "USD$", True),
|
||||
("EUR", "Euro", "EUR", True),
|
||||
("CLP", "Chilean Peso", "CLP$", True),
|
||||
("RMB", "Chinese Yuan", "RMB", True),
|
||||
("GBP", "British Pound", "GBP", True),
|
||||
("CAD", "Canadian Dollar", "CAD$", True),
|
||||
("AUD", "Australian Dollar", "AUD$", True),
|
||||
)
|
||||
|
||||
MEASUREMENT_UNIT_SEEDS = (
|
||||
("tonnes", "Tonnes", "t", "mass", True),
|
||||
("kilograms", "Kilograms", "kg", "mass", True),
|
||||
("pounds", "Pounds", "lb", "mass", True),
|
||||
("liters", "Liters", "L", "volume", True),
|
||||
("cubic_meters", "Cubic Meters", "m3", "volume", True),
|
||||
("kilowatt_hours", "Kilowatt Hours", "kWh", "energy", True),
|
||||
)
|
||||
|
||||
THEME_SETTING_SEEDS = (
|
||||
("--color-background", "#f4f5f7", "color",
|
||||
"theme", "CSS variable --color-background", True),
|
||||
("--color-surface", "#ffffff", "color",
|
||||
"theme", "CSS variable --color-surface", True),
|
||||
("--color-text-primary", "#2a1f33", "color",
|
||||
"theme", "CSS variable --color-text-primary", True),
|
||||
("--color-text-secondary", "#624769", "color",
|
||||
"theme", "CSS variable --color-text-secondary", True),
|
||||
("--color-text-muted", "#64748b", "color",
|
||||
"theme", "CSS variable --color-text-muted", True),
|
||||
("--color-text-subtle", "#94a3b8", "color",
|
||||
"theme", "CSS variable --color-text-subtle", True),
|
||||
("--color-text-invert", "#ffffff", "color",
|
||||
"theme", "CSS variable --color-text-invert", True),
|
||||
("--color-text-dark", "#0f172a", "color",
|
||||
"theme", "CSS variable --color-text-dark", True),
|
||||
("--color-text-strong", "#111827", "color",
|
||||
"theme", "CSS variable --color-text-strong", True),
|
||||
("--color-primary", "#5f320d", "color",
|
||||
"theme", "CSS variable --color-primary", True),
|
||||
("--color-primary-strong", "#7e4c13", "color",
|
||||
"theme", "CSS variable --color-primary-strong", True),
|
||||
("--color-primary-stronger", "#837c15", "color",
|
||||
"theme", "CSS variable --color-primary-stronger", True),
|
||||
("--color-accent", "#bff838", "color",
|
||||
"theme", "CSS variable --color-accent", True),
|
||||
("--color-border", "#e2e8f0", "color",
|
||||
"theme", "CSS variable --color-border", True),
|
||||
("--color-border-strong", "#cbd5e1", "color",
|
||||
"theme", "CSS variable --color-border-strong", True),
|
||||
("--color-highlight", "#eef2ff", "color",
|
||||
"theme", "CSS variable --color-highlight", True),
|
||||
("--color-panel-shadow", "rgba(15, 23, 42, 0.08)", "color",
|
||||
"theme", "CSS variable --color-panel-shadow", True),
|
||||
("--color-panel-shadow-deep", "rgba(15, 23, 42, 0.12)", "color",
|
||||
"theme", "CSS variable --color-panel-shadow-deep", True),
|
||||
("--color-surface-alt", "#f8fafc", "color",
|
||||
"theme", "CSS variable --color-surface-alt", True),
|
||||
("--color-success", "#047857", "color",
|
||||
"theme", "CSS variable --color-success", True),
|
||||
("--color-error", "#b91c1c", "color",
|
||||
"theme", "CSS variable --color-error", True),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Seed baseline CalMiner data")
|
||||
parser.add_argument(
|
||||
"--currencies", action="store_true", help="Seed currency table"
|
||||
)
|
||||
parser.add_argument("--units", action="store_true", help="Seed unit table")
|
||||
parser.add_argument(
|
||||
"--theme", action="store_true", help="Seed theme settings"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--defaults", action="store_true", help="Seed default records"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Print actions without executing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="count",
|
||||
default=0,
|
||||
help="Increase logging verbosity",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _configure_logging(args: argparse.Namespace) -> None:
|
||||
level = logging.WARNING - (10 * min(args.verbose, 2))
|
||||
logging.basicConfig(
|
||||
level=max(level, logging.INFO), format="%(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
run_with_namespace(args)
|
||||
|
||||
|
||||
def run_with_namespace(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
config: Optional[DatabaseConfig] = None,
|
||||
) -> None:
|
||||
if not hasattr(args, "verbose"):
|
||||
args.verbose = 0
|
||||
if not hasattr(args, "dry_run"):
|
||||
args.dry_run = False
|
||||
|
||||
_configure_logging(args)
|
||||
|
||||
currencies = bool(getattr(args, "currencies", False))
|
||||
units = bool(getattr(args, "units", False))
|
||||
theme = bool(getattr(args, "theme", False))
|
||||
defaults = bool(getattr(args, "defaults", False))
|
||||
dry_run = bool(getattr(args, "dry_run", False))
|
||||
|
||||
if not any((currencies, units, theme, defaults)):
|
||||
logger.info("No seeding options provided; exiting")
|
||||
return
|
||||
|
||||
config = config or DatabaseConfig.from_env()
|
||||
|
||||
with psycopg2.connect(config.application_dsn()) as conn:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cursor:
|
||||
if currencies:
|
||||
_seed_currencies(cursor, dry_run=dry_run)
|
||||
if units:
|
||||
_seed_units(cursor, dry_run=dry_run)
|
||||
if theme:
|
||||
_seed_theme(cursor, dry_run=dry_run)
|
||||
if defaults:
|
||||
_seed_defaults(cursor, dry_run=dry_run)
|
||||
|
||||
|
||||
def _seed_currencies(cursor, *, dry_run: bool) -> None:
|
||||
logger.info("Seeding currency table (%d rows)", len(CURRENCY_SEEDS))
|
||||
if dry_run:
|
||||
for code, name, symbol, active in CURRENCY_SEEDS:
|
||||
logger.info("Dry run: would upsert currency %s (%s)", code, name)
|
||||
return
|
||||
|
||||
execute_values(
|
||||
cursor,
|
||||
"""
|
||||
INSERT INTO currency (code, name, symbol, is_active)
|
||||
VALUES %s
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
is_active = EXCLUDED.is_active
|
||||
""",
|
||||
CURRENCY_SEEDS,
|
||||
)
|
||||
logger.info("Currency seed complete")
|
||||
|
||||
|
||||
def _seed_units(cursor, *, dry_run: bool) -> None:
|
||||
total = len(MEASUREMENT_UNIT_SEEDS)
|
||||
logger.info("Seeding measurement_unit table (%d rows)", total)
|
||||
if dry_run:
|
||||
for code, name, symbol, unit_type, _ in MEASUREMENT_UNIT_SEEDS:
|
||||
logger.info(
|
||||
"Dry run: would upsert measurement unit %s (%s - %s)",
|
||||
code,
|
||||
name,
|
||||
unit_type,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
execute_values(
|
||||
cursor,
|
||||
"""
|
||||
INSERT INTO measurement_unit (code, name, symbol, unit_type, is_active)
|
||||
VALUES %s
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
unit_type = EXCLUDED.unit_type,
|
||||
is_active = EXCLUDED.is_active
|
||||
""",
|
||||
MEASUREMENT_UNIT_SEEDS,
|
||||
)
|
||||
except errors.UndefinedTable:
|
||||
logger.warning(
|
||||
"measurement_unit table does not exist; skipping unit seeding."
|
||||
)
|
||||
cursor.connection.rollback()
|
||||
return
|
||||
|
||||
logger.info("Measurement unit seed complete")
|
||||
|
||||
|
||||
def _seed_theme(cursor, *, dry_run: bool) -> None:
|
||||
logger.info("Seeding theme settings (%d rows)", len(THEME_SETTING_SEEDS))
|
||||
if dry_run:
|
||||
for key, value, _, _, _, _ in THEME_SETTING_SEEDS:
|
||||
logger.info(
|
||||
"Dry run: would upsert theme setting %s = %s", key, value)
|
||||
return
|
||||
|
||||
try:
|
||||
execute_values(
|
||||
cursor,
|
||||
"""
|
||||
INSERT INTO application_setting (key, value, value_type, category, description, is_editable)
|
||||
VALUES %s
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
value_type = EXCLUDED.value_type,
|
||||
category = EXCLUDED.category,
|
||||
description = EXCLUDED.description,
|
||||
is_editable = EXCLUDED.is_editable
|
||||
""",
|
||||
THEME_SETTING_SEEDS,
|
||||
)
|
||||
except errors.UndefinedTable:
|
||||
logger.warning(
|
||||
"application_setting table does not exist; skipping theme seeding."
|
||||
)
|
||||
cursor.connection.rollback()
|
||||
return
|
||||
|
||||
logger.info("Theme settings seed complete")
|
||||
|
||||
|
||||
def _seed_defaults(cursor, *, dry_run: bool) -> None:
|
||||
logger.info("Seeding default records")
|
||||
_seed_theme(cursor, dry_run=dry_run)
|
||||
logger.info("Default records seed complete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
||||
from statistics import mean, median, pstdev
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Union, cast
|
||||
|
||||
|
||||
def _extract_results(simulation_results: Iterable[object]) -> List[float]:
|
||||
values: List[float] = []
|
||||
for item in simulation_results:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
mapping_item = cast(Mapping[str, Any], item)
|
||||
value = mapping_item.get("result")
|
||||
if isinstance(value, (int, float)):
|
||||
values.append(float(value))
|
||||
return values
|
||||
|
||||
|
||||
def _percentile(values: List[float], percentile: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_values = sorted(values)
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
index = (percentile / 100) * (len(sorted_values) - 1)
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(sorted_values) - 1)
|
||||
weight = index - lower
|
||||
return sorted_values[lower] * (1 - weight) + sorted_values[upper] * weight
|
||||
|
||||
|
||||
def generate_report(
|
||||
simulation_results: List[Dict[str, float]],
|
||||
) -> Dict[str, Union[float, int]]:
|
||||
"""Aggregate basic statistics for simulation outputs."""
|
||||
|
||||
values = _extract_results(simulation_results)
|
||||
|
||||
if not values:
|
||||
return {
|
||||
"count": 0,
|
||||
"mean": 0.0,
|
||||
"median": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.0,
|
||||
"std_dev": 0.0,
|
||||
"variance": 0.0,
|
||||
"percentile_10": 0.0,
|
||||
"percentile_90": 0.0,
|
||||
"percentile_5": 0.0,
|
||||
"percentile_95": 0.0,
|
||||
"value_at_risk_95": 0.0,
|
||||
"expected_shortfall_95": 0.0,
|
||||
}
|
||||
|
||||
summary: Dict[str, Union[float, int]] = {
|
||||
"count": len(values),
|
||||
"mean": mean(values),
|
||||
"median": median(values),
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"percentile_10": _percentile(values, 10),
|
||||
"percentile_90": _percentile(values, 90),
|
||||
"percentile_5": _percentile(values, 5),
|
||||
"percentile_95": _percentile(values, 95),
|
||||
}
|
||||
|
||||
std_dev = pstdev(values) if len(values) > 1 else 0.0
|
||||
summary["std_dev"] = std_dev
|
||||
summary["variance"] = std_dev**2
|
||||
|
||||
var_95 = summary["percentile_5"]
|
||||
summary["value_at_risk_95"] = var_95
|
||||
|
||||
tail_values = [value for value in values if value <= var_95]
|
||||
if tail_values:
|
||||
summary["expected_shortfall_95"] = mean(tail_values)
|
||||
else:
|
||||
summary["expected_shortfall_95"] = var_95
|
||||
|
||||
return summary
|
||||
@@ -1,59 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config.database import get_db
|
||||
|
||||
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
SECRET_KEY = "your-secret-key" # Change this in production
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="users/login")
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: Union[timedelta, None] = None
|
||||
) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
from models.user import User
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -1,230 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.application_setting import ApplicationSetting
|
||||
from models.theme_setting import ThemeSetting # Import ThemeSetting model
|
||||
|
||||
CSS_COLOR_CATEGORY = "theme"
|
||||
CSS_COLOR_VALUE_TYPE = "color"
|
||||
CSS_ENV_PREFIX = "CALMINER_THEME_"
|
||||
|
||||
CSS_COLOR_DEFAULTS: Dict[str, str] = {
|
||||
"--color-background": "#f4f5f7",
|
||||
"--color-surface": "#ffffff",
|
||||
"--color-text-primary": "#2a1f33",
|
||||
"--color-text-secondary": "#624769",
|
||||
"--color-text-muted": "#64748b",
|
||||
"--color-text-subtle": "#94a3b8",
|
||||
"--color-text-invert": "#ffffff",
|
||||
"--color-text-dark": "#0f172a",
|
||||
"--color-text-strong": "#111827",
|
||||
"--color-primary": "#5f320d",
|
||||
"--color-primary-strong": "#7e4c13",
|
||||
"--color-primary-stronger": "#837c15",
|
||||
"--color-accent": "#bff838",
|
||||
"--color-border": "#e2e8f0",
|
||||
"--color-border-strong": "#cbd5e1",
|
||||
"--color-highlight": "#eef2ff",
|
||||
"--color-panel-shadow": "rgba(15, 23, 42, 0.08)",
|
||||
"--color-panel-shadow-deep": "rgba(15, 23, 42, 0.12)",
|
||||
"--color-surface-alt": "#f8fafc",
|
||||
"--color-success": "#047857",
|
||||
"--color-error": "#b91c1c",
|
||||
}
|
||||
|
||||
_COLOR_VALUE_PATTERN = re.compile(
|
||||
r"^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def ensure_css_color_settings(db: Session) -> Dict[str, ApplicationSetting]:
|
||||
"""Ensure the CSS color defaults exist in the settings table."""
|
||||
|
||||
existing = (
|
||||
db.query(ApplicationSetting)
|
||||
.filter(ApplicationSetting.key.in_(CSS_COLOR_DEFAULTS.keys()))
|
||||
.all()
|
||||
)
|
||||
by_key = {setting.key: setting for setting in existing}
|
||||
|
||||
created = False
|
||||
for key, default_value in CSS_COLOR_DEFAULTS.items():
|
||||
if key in by_key:
|
||||
continue
|
||||
setting = ApplicationSetting(
|
||||
key=key,
|
||||
value=default_value,
|
||||
value_type=CSS_COLOR_VALUE_TYPE,
|
||||
category=CSS_COLOR_CATEGORY,
|
||||
description=f"CSS variable {key}",
|
||||
is_editable=True,
|
||||
)
|
||||
db.add(setting)
|
||||
by_key[key] = setting
|
||||
created = True
|
||||
|
||||
if created:
|
||||
db.commit()
|
||||
for key, setting in by_key.items():
|
||||
db.refresh(setting)
|
||||
|
||||
return by_key
|
||||
|
||||
|
||||
def get_css_color_settings(db: Session) -> Dict[str, str]:
|
||||
"""Return CSS color variables, filling missing values with defaults."""
|
||||
|
||||
settings = ensure_css_color_settings(db)
|
||||
values: Dict[str, str] = {
|
||||
key: settings[key].value if key in settings else default
|
||||
for key, default in CSS_COLOR_DEFAULTS.items()
|
||||
}
|
||||
|
||||
env_overrides = read_css_color_env_overrides(os.environ)
|
||||
if env_overrides:
|
||||
values.update(env_overrides)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def update_css_color_settings(
|
||||
db: Session, updates: Mapping[str, str]
|
||||
) -> Dict[str, str]:
|
||||
"""Persist provided CSS color overrides and return the final values."""
|
||||
|
||||
if not updates:
|
||||
return get_css_color_settings(db)
|
||||
|
||||
invalid_keys = sorted(set(updates.keys()) - set(CSS_COLOR_DEFAULTS.keys()))
|
||||
if invalid_keys:
|
||||
invalid_list = ", ".join(invalid_keys)
|
||||
raise ValueError(f"Unsupported CSS variables: {invalid_list}")
|
||||
|
||||
normalized: Dict[str, str] = {}
|
||||
for key, value in updates.items():
|
||||
normalized[key] = _normalize_color_value(value)
|
||||
|
||||
settings = ensure_css_color_settings(db)
|
||||
changed = False
|
||||
|
||||
for key, value in normalized.items():
|
||||
setting = settings[key]
|
||||
if setting.value != value:
|
||||
setting.value = value
|
||||
changed = True
|
||||
if setting.value_type != CSS_COLOR_VALUE_TYPE:
|
||||
setting.value_type = CSS_COLOR_VALUE_TYPE
|
||||
changed = True
|
||||
if setting.category != CSS_COLOR_CATEGORY:
|
||||
setting.category = CSS_COLOR_CATEGORY
|
||||
changed = True
|
||||
if not setting.is_editable:
|
||||
setting.is_editable = True
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.commit()
|
||||
for key in normalized.keys():
|
||||
db.refresh(settings[key])
|
||||
|
||||
return get_css_color_settings(db)
|
||||
|
||||
|
||||
def read_css_color_env_overrides(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Return validated CSS overrides sourced from environment variables."""
|
||||
|
||||
if env is None:
|
||||
env = os.environ
|
||||
|
||||
overrides: Dict[str, str] = {}
|
||||
for css_key in CSS_COLOR_DEFAULTS.keys():
|
||||
env_name = css_key_to_env_var(css_key)
|
||||
raw_value = env.get(env_name)
|
||||
if raw_value is None:
|
||||
continue
|
||||
overrides[css_key] = _normalize_color_value(raw_value)
|
||||
|
||||
return overrides
|
||||
|
||||
|
||||
def _normalize_color_value(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Color value must be a string")
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("Color value cannot be empty")
|
||||
if not _COLOR_VALUE_PATTERN.match(trimmed):
|
||||
raise ValueError(
|
||||
"Color value must be a hex code or an rgb/rgba/hsl/hsla expression"
|
||||
)
|
||||
_validate_functional_color(trimmed)
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_functional_color(value: str) -> None:
|
||||
lowered = value.lower()
|
||||
if lowered.startswith("rgb(") or lowered.startswith("hsl("):
|
||||
_ensure_component_count(value, expected=3)
|
||||
elif lowered.startswith("rgba(") or lowered.startswith("hsla("):
|
||||
_ensure_component_count(value, expected=4)
|
||||
|
||||
|
||||
def _ensure_component_count(value: str, expected: int) -> None:
|
||||
if not value.endswith(")"):
|
||||
raise ValueError(
|
||||
"Color function expressions must end with a closing parenthesis"
|
||||
)
|
||||
inner = value[value.index("(") + 1: -1]
|
||||
parts = [segment.strip() for segment in inner.split(",")]
|
||||
if len(parts) != expected:
|
||||
raise ValueError(
|
||||
"Color function expressions must provide the expected number of components"
|
||||
)
|
||||
if any(not component for component in parts):
|
||||
raise ValueError("Color function components cannot be empty")
|
||||
|
||||
|
||||
def css_key_to_env_var(css_key: str) -> str:
|
||||
sanitized = css_key.lstrip("-").replace("-", "_").upper()
|
||||
return f"{CSS_ENV_PREFIX}{sanitized}"
|
||||
|
||||
|
||||
def list_css_env_override_rows(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> list[Dict[str, str]]:
|
||||
overrides = read_css_color_env_overrides(env)
|
||||
rows: list[Dict[str, str]] = []
|
||||
for css_key, value in overrides.items():
|
||||
rows.append(
|
||||
{
|
||||
"css_key": css_key,
|
||||
"env_var": css_key_to_env_var(css_key),
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def save_theme_settings(db: Session, theme_data: dict):
|
||||
theme = db.query(ThemeSetting).first() or ThemeSetting()
|
||||
for key, value in theme_data.items():
|
||||
setattr(theme, key, value)
|
||||
db.add(theme)
|
||||
db.commit()
|
||||
db.refresh(theme)
|
||||
return theme
|
||||
|
||||
|
||||
def get_theme_settings(db: Session):
|
||||
theme = db.query(ThemeSetting).first()
|
||||
if theme:
|
||||
return {c.name: getattr(theme, c.name) for c in theme.__table__.columns}
|
||||
return {}
|
||||
@@ -1,144 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from random import Random
|
||||
from typing import Dict, List, Literal, Optional, Sequence
|
||||
|
||||
|
||||
DEFAULT_STD_DEV_RATIO = 0.1
|
||||
DEFAULT_UNIFORM_SPAN_RATIO = 0.15
|
||||
DistributionType = Literal["normal", "uniform", "triangular"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationParameter:
|
||||
name: str
|
||||
base_value: float
|
||||
distribution: DistributionType
|
||||
std_dev: Optional[float] = None
|
||||
minimum: Optional[float] = None
|
||||
maximum: Optional[float] = None
|
||||
mode: Optional[float] = None
|
||||
|
||||
|
||||
def _ensure_positive_span(span: float, fallback: float) -> float:
|
||||
return span if span and span > 0 else fallback
|
||||
|
||||
|
||||
def _compile_parameters(
|
||||
parameters: Sequence[Dict[str, float]],
|
||||
) -> List[SimulationParameter]:
|
||||
compiled: List[SimulationParameter] = []
|
||||
for index, item in enumerate(parameters):
|
||||
if "value" not in item:
|
||||
raise ValueError(f"Parameter at index {index} must include 'value'")
|
||||
name = str(item.get("name", f"param_{index}"))
|
||||
base_value = float(item["value"])
|
||||
distribution = str(item.get("distribution", "normal")).lower()
|
||||
if distribution not in {"normal", "uniform", "triangular"}:
|
||||
raise ValueError(
|
||||
f"Parameter '{name}' has unsupported distribution '{distribution}'"
|
||||
)
|
||||
|
||||
span_default = abs(base_value) * DEFAULT_UNIFORM_SPAN_RATIO or 1.0
|
||||
|
||||
if distribution == "normal":
|
||||
std_dev = item.get("std_dev")
|
||||
std_dev_value = (
|
||||
float(std_dev)
|
||||
if std_dev is not None
|
||||
else abs(base_value) * DEFAULT_STD_DEV_RATIO or 1.0
|
||||
)
|
||||
compiled.append(
|
||||
SimulationParameter(
|
||||
name=name,
|
||||
base_value=base_value,
|
||||
distribution="normal",
|
||||
std_dev=_ensure_positive_span(std_dev_value, 1.0),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
minimum = item.get("min")
|
||||
maximum = item.get("max")
|
||||
if minimum is None or maximum is None:
|
||||
minimum = base_value - span_default
|
||||
maximum = base_value + span_default
|
||||
minimum = float(minimum)
|
||||
maximum = float(maximum)
|
||||
if minimum >= maximum:
|
||||
raise ValueError(
|
||||
f"Parameter '{name}' requires 'min' < 'max' for {distribution} distribution"
|
||||
)
|
||||
|
||||
if distribution == "uniform":
|
||||
compiled.append(
|
||||
SimulationParameter(
|
||||
name=name,
|
||||
base_value=base_value,
|
||||
distribution="uniform",
|
||||
minimum=minimum,
|
||||
maximum=maximum,
|
||||
)
|
||||
)
|
||||
else: # triangular
|
||||
mode = item.get("mode")
|
||||
if mode is None:
|
||||
mode = base_value
|
||||
mode_value = float(mode)
|
||||
if not (minimum <= mode_value <= maximum):
|
||||
raise ValueError(
|
||||
f"Parameter '{name}' mode must be within min/max bounds for triangular distribution"
|
||||
)
|
||||
compiled.append(
|
||||
SimulationParameter(
|
||||
name=name,
|
||||
base_value=base_value,
|
||||
distribution="triangular",
|
||||
minimum=minimum,
|
||||
maximum=maximum,
|
||||
mode=mode_value,
|
||||
)
|
||||
)
|
||||
return compiled
|
||||
|
||||
|
||||
def _sample_parameter(rng: Random, param: SimulationParameter) -> float:
|
||||
if param.distribution == "normal":
|
||||
assert param.std_dev is not None
|
||||
return rng.normalvariate(param.base_value, param.std_dev)
|
||||
if param.distribution == "uniform":
|
||||
assert param.minimum is not None and param.maximum is not None
|
||||
return rng.uniform(param.minimum, param.maximum)
|
||||
# triangular
|
||||
assert (
|
||||
param.minimum is not None
|
||||
and param.maximum is not None
|
||||
and param.mode is not None
|
||||
)
|
||||
return rng.triangular(param.minimum, param.maximum, param.mode)
|
||||
|
||||
|
||||
def run_simulation(
|
||||
parameters: Sequence[Dict[str, float]],
|
||||
iterations: int = 1000,
|
||||
seed: Optional[int] = None,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""Run a lightweight Monte Carlo simulation using configurable distributions."""
|
||||
|
||||
if iterations <= 0:
|
||||
return []
|
||||
|
||||
compiled_params = _compile_parameters(parameters)
|
||||
if not compiled_params:
|
||||
return []
|
||||
|
||||
rng = Random(seed)
|
||||
results: List[Dict[str, float]] = []
|
||||
for iteration in range(1, iterations + 1):
|
||||
total = 0.0
|
||||
for param in compiled_params:
|
||||
sample = _sample_parameter(rng, param)
|
||||
total += sample
|
||||
results.append({"iteration": iteration, "result": total})
|
||||
return results
|
||||
@@ -1,205 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("consumption-data");
|
||||
let data = { scenarios: [], consumption: {}, unit_options: [] };
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
data = {
|
||||
scenarios: Array.isArray(parsed.scenarios) ? parsed.scenarios : [],
|
||||
consumption:
|
||||
parsed.consumption && typeof parsed.consumption === "object"
|
||||
? parsed.consumption
|
||||
: {},
|
||||
unit_options: Array.isArray(parsed.unit_options)
|
||||
? parsed.unit_options
|
||||
: [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse consumption data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const consumptionByScenario = data.consumption;
|
||||
const filterSelect = document.getElementById("consumption-scenario-filter");
|
||||
const tableWrapper = document.getElementById("consumption-table-wrapper");
|
||||
const tableBody = document.getElementById("consumption-table-body");
|
||||
const emptyState = document.getElementById("consumption-empty");
|
||||
const form = document.getElementById("consumption-form");
|
||||
const feedbackEl = document.getElementById("consumption-feedback");
|
||||
const unitSelect = document.getElementById("consumption-form-unit");
|
||||
const unitSymbolInput = document.getElementById(
|
||||
"consumption-form-unit-symbol"
|
||||
);
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
feedbackEl.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.textContent = "";
|
||||
};
|
||||
|
||||
const formatAmount = (value) =>
|
||||
Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const formatMeasurement = (amount, symbol, name) => {
|
||||
if (symbol) {
|
||||
return `${formatAmount(amount)} ${symbol}`;
|
||||
}
|
||||
if (name) {
|
||||
return `${formatAmount(amount)} ${name}`;
|
||||
}
|
||||
return formatAmount(amount);
|
||||
};
|
||||
|
||||
const renderConsumptionRows = (scenarioId) => {
|
||||
if (!tableBody || !tableWrapper || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(scenarioId);
|
||||
const records = consumptionByScenario[key] || [];
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!records.length) {
|
||||
emptyState.textContent = "No consumption records for this scenario yet.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add("hidden");
|
||||
tableWrapper.classList.remove("hidden");
|
||||
|
||||
records.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${formatMeasurement(
|
||||
record.amount,
|
||||
record.unit_symbol,
|
||||
record.unit_name
|
||||
)}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) {
|
||||
if (emptyState && tableWrapper && tableBody) {
|
||||
emptyState.textContent =
|
||||
"Choose a scenario to review its consumption records.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
tableBody.innerHTML = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderConsumptionRows(value);
|
||||
});
|
||||
}
|
||||
|
||||
const submitConsumption = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const unitName = formData.get("unit_name");
|
||||
const unitSymbol = formData.get("unit_symbol");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
amount: Number(formData.get("amount")),
|
||||
description: formData.get("description") || null,
|
||||
unit_name: unitName ? String(unitName) : null,
|
||||
unit_symbol: unitSymbol ? String(unitSymbol) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/consumption/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorDetail.detail || "Unable to add consumption record."
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
|
||||
if (!Array.isArray(consumptionByScenario[mapKey])) {
|
||||
consumptionByScenario[mapKey] = [];
|
||||
}
|
||||
consumptionByScenario[mapKey].push(result);
|
||||
|
||||
form.reset();
|
||||
syncUnitSelection();
|
||||
showFeedback("Consumption record saved.", "success");
|
||||
|
||||
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
|
||||
renderConsumptionRows(filterSelect.value);
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", submitConsumption);
|
||||
}
|
||||
|
||||
const syncUnitSelection = () => {
|
||||
if (!unitSelect || !unitSymbolInput) {
|
||||
return;
|
||||
}
|
||||
if (!unitSelect.value && unitSelect.options.length > 0) {
|
||||
const firstOption = Array.from(unitSelect.options).find(
|
||||
(option) => option.value
|
||||
);
|
||||
if (firstOption) {
|
||||
firstOption.selected = true;
|
||||
}
|
||||
}
|
||||
const selectedOption = unitSelect.options[unitSelect.selectedIndex];
|
||||
unitSymbolInput.value = selectedOption
|
||||
? selectedOption.getAttribute("data-symbol") || ""
|
||||
: "";
|
||||
};
|
||||
|
||||
if (unitSelect) {
|
||||
unitSelect.addEventListener("change", syncUnitSelection);
|
||||
syncUnitSelection();
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
renderConsumptionRows(filterSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("costs-payload");
|
||||
let capexByScenario = {};
|
||||
let opexByScenario = {};
|
||||
let currencyOptions = [];
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed.capex && typeof parsed.capex === "object") {
|
||||
capexByScenario = parsed.capex;
|
||||
}
|
||||
if (parsed.opex && typeof parsed.opex === "object") {
|
||||
opexByScenario = parsed.opex;
|
||||
}
|
||||
if (Array.isArray(parsed.currency_options)) {
|
||||
currencyOptions = parsed.currency_options;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse cost data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const filterSelect = document.getElementById("costs-scenario-filter");
|
||||
const costsEmptyState = document.getElementById("costs-empty");
|
||||
const costsDataWrapper = document.getElementById("costs-data");
|
||||
const capexTableBody = document.getElementById("capex-table-body");
|
||||
const opexTableBody = document.getElementById("opex-table-body");
|
||||
const capexEmpty = document.getElementById("capex-empty");
|
||||
const opexEmpty = document.getElementById("opex-empty");
|
||||
const capexTotal = document.getElementById("capex-total");
|
||||
const opexTotal = document.getElementById("opex-total");
|
||||
const capexForm = document.getElementById("capex-form");
|
||||
const opexForm = document.getElementById("opex-form");
|
||||
const capexFeedback = document.getElementById("capex-feedback");
|
||||
const opexFeedback = document.getElementById("opex-feedback");
|
||||
const capexFormScenario = document.getElementById("capex-form-scenario");
|
||||
const opexFormScenario = document.getElementById("opex-form-scenario");
|
||||
const capexCurrencySelect = document.getElementById("capex-form-currency");
|
||||
const opexCurrencySelect = document.getElementById("opex-form-currency");
|
||||
|
||||
// If no currency options were injected server-side, fetch from API
|
||||
const fetchCurrencyOptions = async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/currencies/");
|
||||
if (!resp.ok) return;
|
||||
const list = await resp.json();
|
||||
if (Array.isArray(list) && list.length) {
|
||||
currencyOptions = list;
|
||||
populateCurrencySelects();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Unable to fetch currency options", err);
|
||||
}
|
||||
};
|
||||
|
||||
const populateCurrencySelects = () => {
|
||||
const selectElements = [capexCurrencySelect, opexCurrencySelect].filter(Boolean);
|
||||
selectElements.forEach((sel) => {
|
||||
if (!sel) return;
|
||||
// Clear non-empty options except the empty placeholder
|
||||
const placeholder = sel.querySelector("option[value='']");
|
||||
sel.innerHTML = "";
|
||||
if (placeholder) sel.appendChild(placeholder);
|
||||
currencyOptions.forEach((opt) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = opt.id;
|
||||
option.textContent = opt.name || opt.id;
|
||||
sel.appendChild(option);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// populate from injected options first, then fetch to refresh
|
||||
if (currencyOptions && currencyOptions.length) populateCurrencySelects();
|
||||
else fetchCurrencyOptions();
|
||||
|
||||
const showFeedback = (element, message, type = "success") => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.textContent = message;
|
||||
element.classList.remove("hidden", "success", "error");
|
||||
element.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = (element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.add("hidden");
|
||||
element.textContent = "";
|
||||
};
|
||||
|
||||
const formatAmount = (value) =>
|
||||
Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const formatCurrencyAmount = (value, currencyCode) => {
|
||||
if (!currencyCode) {
|
||||
return formatAmount(value);
|
||||
}
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(value));
|
||||
} catch (error) {
|
||||
return `${currencyCode} ${formatAmount(value)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const sumAmount = (records) =>
|
||||
records.reduce((total, record) => total + Number(record.amount || 0), 0);
|
||||
|
||||
const describeTotal = (records) => {
|
||||
if (!records || records.length === 0) {
|
||||
return "—";
|
||||
}
|
||||
const total = sumAmount(records);
|
||||
const currencyCodes = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => (record.currency_code || "").trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
|
||||
if (currencyCodes.length === 1) {
|
||||
return formatCurrencyAmount(total, currencyCodes[0]);
|
||||
}
|
||||
return `${formatAmount(total)} (mixed)`;
|
||||
};
|
||||
|
||||
const renderCostTables = (scenarioId) => {
|
||||
if (
|
||||
!capexTableBody ||
|
||||
!opexTableBody ||
|
||||
!capexEmpty ||
|
||||
!opexEmpty ||
|
||||
!capexTotal ||
|
||||
!opexTotal
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capexRecords = capexByScenario[String(scenarioId)] || [];
|
||||
const opexRecords = opexByScenario[String(scenarioId)] || [];
|
||||
|
||||
capexTableBody.innerHTML = "";
|
||||
opexTableBody.innerHTML = "";
|
||||
|
||||
if (!capexRecords.length) {
|
||||
capexEmpty.classList.remove("hidden");
|
||||
} else {
|
||||
capexEmpty.classList.add("hidden");
|
||||
capexRecords.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${formatCurrencyAmount(record.amount, record.currency_code)}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
capexTableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
if (!opexRecords.length) {
|
||||
opexEmpty.classList.remove("hidden");
|
||||
} else {
|
||||
opexEmpty.classList.add("hidden");
|
||||
opexRecords.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${formatCurrencyAmount(record.amount, record.currency_code)}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
opexTableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
capexTotal.textContent = describeTotal(capexRecords);
|
||||
opexTotal.textContent = describeTotal(opexRecords);
|
||||
};
|
||||
|
||||
const toggleCostView = (show) => {
|
||||
if (
|
||||
!costsEmptyState ||
|
||||
!costsDataWrapper ||
|
||||
!capexTableBody ||
|
||||
!opexTableBody
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (show) {
|
||||
costsEmptyState.classList.add("hidden");
|
||||
costsDataWrapper.classList.remove("hidden");
|
||||
} else {
|
||||
costsEmptyState.classList.remove("hidden");
|
||||
costsDataWrapper.classList.add("hidden");
|
||||
capexTableBody.innerHTML = "";
|
||||
opexTableBody.innerHTML = "";
|
||||
if (capexTotal) {
|
||||
capexTotal.textContent = "—";
|
||||
}
|
||||
if (opexTotal) {
|
||||
opexTotal.textContent = "—";
|
||||
}
|
||||
if (capexEmpty) {
|
||||
capexEmpty.classList.add("hidden");
|
||||
}
|
||||
if (opexEmpty) {
|
||||
opexEmpty.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncFormSelections = (value) => {
|
||||
if (capexFormScenario) {
|
||||
capexFormScenario.value = value || "";
|
||||
}
|
||||
if (opexFormScenario) {
|
||||
opexFormScenario.value = value || "";
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCurrencySelection = (selectElement) => {
|
||||
if (!selectElement || selectElement.value) {
|
||||
return;
|
||||
}
|
||||
const firstOption = selectElement.querySelector(
|
||||
"option[value]:not([value=''])"
|
||||
);
|
||||
if (firstOption && firstOption.value) {
|
||||
selectElement.value = firstOption.value;
|
||||
}
|
||||
};
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) {
|
||||
toggleCostView(false);
|
||||
syncFormSelections("");
|
||||
return;
|
||||
}
|
||||
toggleCostView(true);
|
||||
renderCostTables(value);
|
||||
syncFormSelections(value);
|
||||
});
|
||||
}
|
||||
|
||||
const submitCostEntry = async (event, targetUrl, storageMap, feedbackEl) => {
|
||||
event.preventDefault();
|
||||
hideFeedback(feedbackEl);
|
||||
|
||||
const formData = new FormData(event.target);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const currencyCode = formData.get("currency_code");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
amount: Number(formData.get("amount")),
|
||||
description: formData.get("description") || null,
|
||||
currency_code: currencyCode ? String(currencyCode).toUpperCase() : null,
|
||||
};
|
||||
|
||||
if (!payload.scenario_id) {
|
||||
showFeedback(feedbackEl, "Select a scenario before submitting.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.currency_code) {
|
||||
showFeedback(feedbackEl, "Choose a currency before submitting.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(errorDetail.detail || "Unable to save cost entry.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
|
||||
if (!Array.isArray(storageMap[mapKey])) {
|
||||
storageMap[mapKey] = [];
|
||||
}
|
||||
|
||||
storageMap[mapKey].push(result);
|
||||
|
||||
event.target.reset();
|
||||
ensureCurrencySelection(event.target.querySelector("select[name='currency_code']"));
|
||||
showFeedback(feedbackEl, "Entry saved successfully.", "success");
|
||||
|
||||
if (filterSelect && filterSelect.value === mapKey) {
|
||||
renderCostTables(mapKey);
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(
|
||||
feedbackEl,
|
||||
error.message || "An unexpected error occurred.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (capexForm) {
|
||||
ensureCurrencySelection(capexCurrencySelect);
|
||||
capexForm.addEventListener("submit", (event) =>
|
||||
submitCostEntry(event, "/api/costs/capex", capexByScenario, capexFeedback)
|
||||
);
|
||||
}
|
||||
|
||||
if (opexForm) {
|
||||
ensureCurrencySelection(opexCurrencySelect);
|
||||
opexForm.addEventListener("submit", (event) =>
|
||||
submitCostEntry(event, "/api/costs/opex", opexByScenario, opexFeedback)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
toggleCostView(true);
|
||||
renderCostTables(filterSelect.value);
|
||||
syncFormSelections(filterSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,537 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("currencies-data");
|
||||
const editorSection = document.getElementById("currencies-editor");
|
||||
const tableBody = document.getElementById("currencies-table-body");
|
||||
const tableEmptyState = document.getElementById("currencies-table-empty");
|
||||
const metrics = {
|
||||
total: document.getElementById("currency-metric-total"),
|
||||
active: document.getElementById("currency-metric-active"),
|
||||
inactive: document.getElementById("currency-metric-inactive"),
|
||||
};
|
||||
|
||||
const form = document.getElementById("currency-form");
|
||||
const existingSelect = document.getElementById("currency-form-existing");
|
||||
const codeInput = document.getElementById("currency-form-code");
|
||||
const nameInput = document.getElementById("currency-form-name");
|
||||
const symbolInput = document.getElementById("currency-form-symbol");
|
||||
const statusSelect = document.getElementById("currency-form-status");
|
||||
const resetButton = document.getElementById("currency-form-reset");
|
||||
const feedbackElement = document.getElementById("currency-form-feedback");
|
||||
|
||||
const saveButton = form ? form.querySelector("button[type='submit']") : null;
|
||||
|
||||
const uppercaseCode = (value) =>
|
||||
(value || "").toString().trim().toUpperCase();
|
||||
const normalizeSymbol = (value) => {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeApiBase = (value) => {
|
||||
if (!value || typeof value !== "string") {
|
||||
return "/api/currencies";
|
||||
}
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
};
|
||||
|
||||
let currencies = [];
|
||||
let apiBase = "/api/currencies";
|
||||
let defaultCurrencyCode = "USD";
|
||||
|
||||
const buildCurrencyRecord = (record) => {
|
||||
if (!record || typeof record !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = uppercaseCode(record.code);
|
||||
return {
|
||||
id: record.id ?? null,
|
||||
code,
|
||||
name: record.name || "",
|
||||
symbol: record.symbol || "",
|
||||
is_active: Boolean(record.is_active),
|
||||
is_default: code === defaultCurrencyCode,
|
||||
};
|
||||
};
|
||||
|
||||
const findCurrencyIndex = (code) => {
|
||||
return currencies.findIndex((item) => item.code === code);
|
||||
};
|
||||
|
||||
const upsertCurrency = (record) => {
|
||||
const normalized = buildCurrencyRecord(record);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const existingIndex = findCurrencyIndex(normalized.code);
|
||||
if (existingIndex >= 0) {
|
||||
currencies[existingIndex] = normalized;
|
||||
} else {
|
||||
currencies.push(normalized);
|
||||
}
|
||||
currencies.sort((a, b) => a.code.localeCompare(b.code));
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const replaceCurrencyList = (records) => {
|
||||
if (!Array.isArray(records)) {
|
||||
return;
|
||||
}
|
||||
currencies = records
|
||||
.map((record) => buildCurrencyRecord(record))
|
||||
.filter((record) => record !== null)
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
};
|
||||
|
||||
const applyPayload = () => {
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed.default_currency_code) {
|
||||
defaultCurrencyCode = uppercaseCode(parsed.default_currency_code);
|
||||
}
|
||||
if (parsed.currency_api_base) {
|
||||
apiBase = normalizeApiBase(parsed.currency_api_base);
|
||||
}
|
||||
if (Array.isArray(parsed.currencies)) {
|
||||
replaceCurrencyList(parsed.currencies);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse currencies payload", error);
|
||||
}
|
||||
};
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackElement) {
|
||||
return;
|
||||
}
|
||||
feedbackElement.textContent = message;
|
||||
feedbackElement.classList.remove("hidden", "success", "error");
|
||||
feedbackElement.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackElement) {
|
||||
return;
|
||||
}
|
||||
feedbackElement.classList.add("hidden");
|
||||
feedbackElement.classList.remove("success", "error");
|
||||
feedbackElement.textContent = "";
|
||||
};
|
||||
|
||||
const setButtonLoading = (button, isLoading) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.disabled = isLoading;
|
||||
button.classList.toggle("is-loading", isLoading);
|
||||
};
|
||||
|
||||
const updateMetrics = () => {
|
||||
const total = currencies.length;
|
||||
const active = currencies.filter((item) => item.is_active).length;
|
||||
const inactive = total - active;
|
||||
if (metrics.total) {
|
||||
metrics.total.textContent = String(total);
|
||||
}
|
||||
if (metrics.active) {
|
||||
metrics.active.textContent = String(active);
|
||||
}
|
||||
if (metrics.inactive) {
|
||||
metrics.inactive.textContent = String(inactive);
|
||||
}
|
||||
};
|
||||
|
||||
const renderExistingOptions = (
|
||||
selectedCode = existingSelect ? existingSelect.value : ""
|
||||
) => {
|
||||
if (!existingSelect) {
|
||||
return;
|
||||
}
|
||||
const placeholder = existingSelect.querySelector("option[value='']");
|
||||
const placeholderClone = placeholder ? placeholder.cloneNode(true) : null;
|
||||
existingSelect.innerHTML = "";
|
||||
if (placeholderClone) {
|
||||
existingSelect.appendChild(placeholderClone);
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
currencies.forEach((currency) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = currency.code;
|
||||
option.textContent = currency.name
|
||||
? `${currency.name} (${currency.code})`
|
||||
: currency.code;
|
||||
if (selectedCode === currency.code) {
|
||||
option.selected = true;
|
||||
}
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
existingSelect.appendChild(fragment);
|
||||
if (
|
||||
selectedCode &&
|
||||
!currencies.some((item) => item.code === selectedCode)
|
||||
) {
|
||||
existingSelect.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
if (!tableBody) {
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = "";
|
||||
if (!currencies.length) {
|
||||
if (tableEmptyState) {
|
||||
tableEmptyState.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tableEmptyState) {
|
||||
tableEmptyState.classList.add("hidden");
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
currencies.forEach((currency) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const codeCell = document.createElement("td");
|
||||
codeCell.textContent = currency.code;
|
||||
row.appendChild(codeCell);
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = currency.name || "—";
|
||||
row.appendChild(nameCell);
|
||||
|
||||
const symbolCell = document.createElement("td");
|
||||
symbolCell.textContent = currency.symbol || "—";
|
||||
row.appendChild(symbolCell);
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.textContent = currency.is_active ? "Active" : "Inactive";
|
||||
if (currency.is_default) {
|
||||
statusCell.textContent += " (Default)";
|
||||
}
|
||||
row.appendChild(statusCell);
|
||||
|
||||
const actionsCell = document.createElement("td");
|
||||
const editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
editButton.className = "btn";
|
||||
editButton.dataset.action = "edit";
|
||||
editButton.dataset.code = currency.code;
|
||||
editButton.textContent = "Edit";
|
||||
editButton.style.marginRight = "0.5rem";
|
||||
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.className = "btn";
|
||||
toggleButton.dataset.action = "toggle";
|
||||
toggleButton.dataset.code = currency.code;
|
||||
toggleButton.textContent = currency.is_active ? "Deactivate" : "Activate";
|
||||
if (currency.is_default && currency.is_active) {
|
||||
toggleButton.disabled = true;
|
||||
toggleButton.title = "The default currency must remain active.";
|
||||
}
|
||||
|
||||
actionsCell.appendChild(editButton);
|
||||
actionsCell.appendChild(toggleButton);
|
||||
|
||||
row.appendChild(actionsCell);
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
tableBody.appendChild(fragment);
|
||||
};
|
||||
|
||||
const refreshUI = (selectedCode) => {
|
||||
currencies.sort((a, b) => a.code.localeCompare(b.code));
|
||||
renderTable();
|
||||
renderExistingOptions(selectedCode);
|
||||
updateMetrics();
|
||||
};
|
||||
|
||||
const findCurrency = (code) =>
|
||||
currencies.find((item) => item.code === code) || null;
|
||||
|
||||
const setFormForCurrency = (currency) => {
|
||||
if (!form || !codeInput || !nameInput || !symbolInput || !statusSelect) {
|
||||
return;
|
||||
}
|
||||
if (!currency) {
|
||||
form.reset();
|
||||
if (existingSelect) {
|
||||
existingSelect.value = "";
|
||||
}
|
||||
codeInput.readOnly = false;
|
||||
codeInput.value = "";
|
||||
nameInput.value = "";
|
||||
symbolInput.value = "";
|
||||
statusSelect.disabled = false;
|
||||
statusSelect.value = "true";
|
||||
statusSelect.title = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingSelect) {
|
||||
existingSelect.value = currency.code;
|
||||
}
|
||||
codeInput.readOnly = true;
|
||||
codeInput.value = currency.code;
|
||||
nameInput.value = currency.name || "";
|
||||
symbolInput.value = currency.symbol || "";
|
||||
statusSelect.value = currency.is_active ? "true" : "false";
|
||||
if (currency.is_default) {
|
||||
statusSelect.disabled = true;
|
||||
statusSelect.value = "true";
|
||||
statusSelect.title = "The default currency must remain active.";
|
||||
} else {
|
||||
statusSelect.disabled = false;
|
||||
statusSelect.title = "";
|
||||
}
|
||||
};
|
||||
|
||||
const resetFormState = () => {
|
||||
setFormForCurrency(null);
|
||||
};
|
||||
|
||||
const parseError = async (response, fallbackMessage) => {
|
||||
try {
|
||||
const detail = await response.json();
|
||||
if (detail && typeof detail === "object" && detail.detail) {
|
||||
return detail.detail;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const fetchCurrenciesFromApi = async () => {
|
||||
const url = `${apiBase}/?include_inactive=true`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const list = await response.json();
|
||||
if (Array.isArray(list)) {
|
||||
replaceCurrencyList(list);
|
||||
refreshUI(existingSelect ? existingSelect.value : undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Unable to refresh currency list", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
if (!form || !codeInput || !nameInput || !statusSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editingCode = existingSelect
|
||||
? uppercaseCode(existingSelect.value)
|
||||
: "";
|
||||
const codeValue = uppercaseCode(codeInput.value);
|
||||
const nameValue = (nameInput.value || "").trim();
|
||||
const symbolValue = normalizeSymbol(symbolInput ? symbolInput.value : "");
|
||||
const isActive = statusSelect.value !== "false";
|
||||
|
||||
if (!nameValue) {
|
||||
showFeedback("Provide a currency name.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingCode) {
|
||||
if (!codeValue || codeValue.length !== 3) {
|
||||
showFeedback("Provide a three-letter currency code.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = editingCode
|
||||
? {
|
||||
name: nameValue,
|
||||
symbol: symbolValue,
|
||||
is_active: isActive,
|
||||
}
|
||||
: {
|
||||
code: codeValue,
|
||||
name: nameValue,
|
||||
symbol: symbolValue,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
const targetCode = editingCode || codeValue;
|
||||
const url = editingCode
|
||||
? `${apiBase}/${encodeURIComponent(editingCode)}`
|
||||
: `${apiBase}/`;
|
||||
|
||||
setButtonLoading(saveButton, true);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: editingCode ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(
|
||||
response,
|
||||
editingCode
|
||||
? "Unable to update the currency."
|
||||
: "Unable to create the currency."
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const updated = upsertCurrency(result);
|
||||
defaultCurrencyCode = uppercaseCode(defaultCurrencyCode);
|
||||
refreshUI(updated ? updated.code : targetCode);
|
||||
|
||||
if (editingCode) {
|
||||
showFeedback("Currency updated successfully.");
|
||||
if (updated) {
|
||||
setFormForCurrency(updated);
|
||||
}
|
||||
} else {
|
||||
showFeedback("Currency created successfully.");
|
||||
resetFormState();
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
} finally {
|
||||
setButtonLoading(saveButton, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (code, button) => {
|
||||
const record = findCurrency(code);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
hideFeedback();
|
||||
const nextState = !record.is_active;
|
||||
const url = `${apiBase}/${encodeURIComponent(code)}/activation`;
|
||||
setButtonLoading(button, true);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_active: nextState }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(
|
||||
response,
|
||||
nextState
|
||||
? "Unable to activate the currency."
|
||||
: "Unable to deactivate the currency."
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const updated = upsertCurrency(result);
|
||||
refreshUI(updated ? updated.code : code);
|
||||
if (existingSelect && existingSelect.value === code && updated) {
|
||||
setFormForCurrency(updated);
|
||||
}
|
||||
const actionMessage = nextState
|
||||
? `Currency ${code} activated.`
|
||||
: `Currency ${code} deactivated.`;
|
||||
showFeedback(actionMessage);
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableClick = (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const code = uppercaseCode(button.dataset.code);
|
||||
const action = button.dataset.action;
|
||||
if (!code || !action) {
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
const currency = findCurrency(code);
|
||||
if (currency) {
|
||||
setFormForCurrency(currency);
|
||||
hideFeedback();
|
||||
if (nameInput) {
|
||||
nameInput.focus();
|
||||
}
|
||||
}
|
||||
} else if (action === "toggle") {
|
||||
handleToggle(code, button);
|
||||
}
|
||||
};
|
||||
|
||||
applyPayload();
|
||||
if (editorSection && editorSection.dataset.defaultCode) {
|
||||
defaultCurrencyCode = uppercaseCode(editorSection.dataset.defaultCode);
|
||||
currencies = currencies.map((record) => {
|
||||
return record
|
||||
? {
|
||||
...record,
|
||||
is_default: record.code === defaultCurrencyCode,
|
||||
}
|
||||
: record;
|
||||
});
|
||||
}
|
||||
apiBase = normalizeApiBase(apiBase);
|
||||
|
||||
refreshUI();
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", handleSubmit);
|
||||
}
|
||||
|
||||
if (existingSelect) {
|
||||
existingSelect.addEventListener("change", (event) => {
|
||||
const selectedCode = uppercaseCode(event.target.value);
|
||||
if (!selectedCode) {
|
||||
hideFeedback();
|
||||
resetFormState();
|
||||
return;
|
||||
}
|
||||
const currency = findCurrency(selectedCode);
|
||||
if (currency) {
|
||||
setFormForCurrency(currency);
|
||||
hideFeedback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
resetFormState();
|
||||
});
|
||||
}
|
||||
|
||||
if (codeInput) {
|
||||
codeInput.addEventListener("input", () => {
|
||||
const value = uppercaseCode(codeInput.value).slice(0, 3);
|
||||
codeInput.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
tableBody.addEventListener("click", handleTableClick);
|
||||
}
|
||||
|
||||
fetchCurrenciesFromApi();
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
(() => {
|
||||
const dataElement = document.getElementById("dashboard-data");
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = {};
|
||||
try {
|
||||
state = JSON.parse(dataElement.textContent || "{}");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse dashboard data", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusElement = document.getElementById("dashboard-status");
|
||||
const summaryContainer = document.getElementById("summary-metrics");
|
||||
const summaryEmpty = document.getElementById("summary-empty");
|
||||
const scenarioTableBody = document.querySelector("#scenario-table tbody");
|
||||
const scenarioEmpty = document.getElementById("scenario-table-empty");
|
||||
const overallMetricsList = document.getElementById("overall-metrics");
|
||||
const overallMetricsEmpty = document.getElementById("overall-metrics-empty");
|
||||
const recentList = document.getElementById("recent-simulations");
|
||||
const recentEmpty = document.getElementById("recent-simulations-empty");
|
||||
const maintenanceList = document.getElementById("upcoming-maintenance");
|
||||
const maintenanceEmpty = document.getElementById(
|
||||
"upcoming-maintenance-empty"
|
||||
);
|
||||
const refreshButton = document.getElementById("refresh-dashboard");
|
||||
const costChartCanvas = document.getElementById("cost-chart");
|
||||
const costChartEmpty = document.getElementById("cost-chart-empty");
|
||||
const activityChartCanvas = document.getElementById("activity-chart");
|
||||
const activityChartEmpty = document.getElementById("activity-chart-empty");
|
||||
|
||||
let costChartInstance = null;
|
||||
let activityChartInstance = null;
|
||||
|
||||
const setStatus = (message, variant = "success") => {
|
||||
if (!statusElement) {
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
statusElement.hidden = true;
|
||||
statusElement.textContent = "";
|
||||
statusElement.classList.remove("success", "error");
|
||||
return;
|
||||
}
|
||||
statusElement.textContent = message;
|
||||
statusElement.hidden = false;
|
||||
statusElement.classList.toggle("success", variant === "success");
|
||||
statusElement.classList.toggle("error", variant !== "success");
|
||||
};
|
||||
|
||||
const renderSummaryMetrics = () => {
|
||||
if (!summaryContainer || !summaryEmpty) {
|
||||
return;
|
||||
}
|
||||
summaryContainer.innerHTML = "";
|
||||
const metrics = Array.isArray(state.summary_metrics)
|
||||
? state.summary_metrics
|
||||
: [];
|
||||
metrics.forEach((metric) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "metric-card";
|
||||
card.innerHTML = `
|
||||
<span class="metric-label">${metric.label}</span>
|
||||
<span class="metric-value">${metric.value}</span>
|
||||
`;
|
||||
summaryContainer.appendChild(card);
|
||||
});
|
||||
summaryEmpty.hidden = metrics.length > 0;
|
||||
};
|
||||
|
||||
const renderScenarioTable = () => {
|
||||
if (!scenarioTableBody || !scenarioEmpty) {
|
||||
return;
|
||||
}
|
||||
scenarioTableBody.innerHTML = "";
|
||||
const rows = Array.isArray(state.scenario_rows) ? state.scenario_rows : [];
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${row.scenario_name}</td>
|
||||
<td>${row.parameter_display}</td>
|
||||
<td>${row.equipment_display}</td>
|
||||
<td>${row.capex_display}</td>
|
||||
<td>${row.opex_display}</td>
|
||||
<td>${row.production_display}</td>
|
||||
<td>${row.consumption_display}</td>
|
||||
<td>${row.maintenance_display}</td>
|
||||
<td>${row.iterations_display}</td>
|
||||
<td>${row.simulation_mean_display}</td>
|
||||
`;
|
||||
scenarioTableBody.appendChild(tr);
|
||||
});
|
||||
scenarioEmpty.hidden = rows.length > 0;
|
||||
};
|
||||
|
||||
const renderOverallMetrics = () => {
|
||||
if (!overallMetricsList || !overallMetricsEmpty) {
|
||||
return;
|
||||
}
|
||||
overallMetricsList.innerHTML = "";
|
||||
const items = Array.isArray(state.overall_report_metrics)
|
||||
? state.overall_report_metrics
|
||||
: [];
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "metric-list-item";
|
||||
li.textContent = `${item.label}: ${item.value}`;
|
||||
overallMetricsList.appendChild(li);
|
||||
});
|
||||
overallMetricsEmpty.hidden = items.length > 0;
|
||||
};
|
||||
|
||||
const renderRecentSimulations = () => {
|
||||
if (!recentList || !recentEmpty) {
|
||||
return;
|
||||
}
|
||||
recentList.innerHTML = "";
|
||||
const runs = Array.isArray(state.recent_simulations)
|
||||
? state.recent_simulations
|
||||
: [];
|
||||
runs.forEach((run) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "metric-list-item";
|
||||
item.textContent = `${run.scenario_name} · ${run.iterations_display} iterations · ${run.mean_display}`;
|
||||
recentList.appendChild(item);
|
||||
});
|
||||
recentEmpty.hidden = runs.length > 0;
|
||||
};
|
||||
|
||||
const renderMaintenanceReminders = () => {
|
||||
if (!maintenanceList || !maintenanceEmpty) {
|
||||
return;
|
||||
}
|
||||
maintenanceList.innerHTML = "";
|
||||
const items = Array.isArray(state.upcoming_maintenance)
|
||||
? state.upcoming_maintenance
|
||||
: [];
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `
|
||||
<span class="list-title">${item.equipment_name} · ${item.scenario_name}</span>
|
||||
<span class="list-detail">${item.date_display} · ${item.cost_display} · ${item.description}</span>
|
||||
`;
|
||||
maintenanceList.appendChild(li);
|
||||
});
|
||||
maintenanceEmpty.hidden = items.length > 0;
|
||||
};
|
||||
|
||||
const buildChartConfig = (dataset, overrides = {}) => ({
|
||||
type: dataset.type || "bar",
|
||||
data: {
|
||||
labels: dataset.labels || [],
|
||||
datasets: dataset.datasets || [],
|
||||
},
|
||||
options: Object.assign(
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "top" },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: dataset.stacked ?? false },
|
||||
y: { stacked: dataset.stacked ?? false, beginAtZero: true },
|
||||
},
|
||||
},
|
||||
overrides.options || {}
|
||||
),
|
||||
});
|
||||
|
||||
const renderCharts = () => {
|
||||
if (costChartInstance) {
|
||||
costChartInstance.destroy();
|
||||
}
|
||||
if (activityChartInstance) {
|
||||
activityChartInstance.destroy();
|
||||
}
|
||||
|
||||
const costData = state.scenario_cost_chart || {};
|
||||
const activityData = state.scenario_activity_chart || {};
|
||||
|
||||
if (costChartCanvas && state.cost_chart_has_data) {
|
||||
costChartInstance = new Chart(
|
||||
costChartCanvas,
|
||||
buildChartConfig(costData, {
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
if (costChartEmpty) {
|
||||
costChartEmpty.hidden = true;
|
||||
}
|
||||
costChartCanvas.classList.remove("hidden");
|
||||
} else if (costChartEmpty && costChartCanvas) {
|
||||
costChartEmpty.hidden = false;
|
||||
costChartCanvas.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (activityChartCanvas && state.activity_chart_has_data) {
|
||||
activityChartInstance = new Chart(
|
||||
activityChartCanvas,
|
||||
buildChartConfig(activityData, {
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
if (activityChartEmpty) {
|
||||
activityChartEmpty.hidden = true;
|
||||
}
|
||||
activityChartCanvas.classList.remove("hidden");
|
||||
} else if (activityChartEmpty && activityChartCanvas) {
|
||||
activityChartEmpty.hidden = false;
|
||||
activityChartCanvas.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
renderSummaryMetrics();
|
||||
renderScenarioTable();
|
||||
renderOverallMetrics();
|
||||
renderRecentSimulations();
|
||||
renderMaintenanceReminders();
|
||||
renderCharts();
|
||||
};
|
||||
|
||||
const refreshDashboard = async () => {
|
||||
setStatus("Refreshing dashboard…", "success");
|
||||
if (refreshButton) {
|
||||
refreshButton.classList.add("is-loading");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/ui/dashboard/data", {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to refresh dashboard data.");
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
state = payload || {};
|
||||
renderView();
|
||||
setStatus("Dashboard updated.", "success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus(error.message || "Failed to refresh dashboard.", "error");
|
||||
} finally {
|
||||
if (refreshButton) {
|
||||
refreshButton.classList.remove("is-loading");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderView();
|
||||
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener("click", refreshDashboard);
|
||||
}
|
||||
})();
|
||||
@@ -1,145 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("equipment-data");
|
||||
let equipmentByScenario = {};
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed.equipment && typeof parsed.equipment === "object") {
|
||||
equipmentByScenario = parsed.equipment;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse equipment data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const filterSelect = document.getElementById("equipment-scenario-filter");
|
||||
const tableWrapper = document.getElementById("equipment-table-wrapper");
|
||||
const tableBody = document.getElementById("equipment-table-body");
|
||||
const emptyState = document.getElementById("equipment-empty");
|
||||
const form = document.getElementById("equipment-form");
|
||||
const feedbackEl = document.getElementById("equipment-feedback");
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
feedbackEl.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.textContent = "";
|
||||
};
|
||||
|
||||
const renderEquipmentRows = (scenarioId) => {
|
||||
if (!tableBody || !tableWrapper || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(scenarioId);
|
||||
const records = equipmentByScenario[key] || [];
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!records.length) {
|
||||
emptyState.textContent = "No equipment recorded for this scenario yet.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add("hidden");
|
||||
tableWrapper.classList.remove("hidden");
|
||||
|
||||
records.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${record.name || "—"}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) {
|
||||
if (emptyState && tableWrapper && tableBody) {
|
||||
emptyState.textContent =
|
||||
"Choose a scenario to review the equipment list.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
tableBody.innerHTML = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderEquipmentRows(value);
|
||||
});
|
||||
}
|
||||
|
||||
const submitEquipment = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/equipment/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorDetail.detail || "Unable to add equipment record."
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
|
||||
if (!Array.isArray(equipmentByScenario[mapKey])) {
|
||||
equipmentByScenario[mapKey] = [];
|
||||
}
|
||||
equipmentByScenario[mapKey].push(result);
|
||||
|
||||
form.reset();
|
||||
showFeedback("Equipment saved.", "success");
|
||||
|
||||
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
|
||||
renderEquipmentRows(filterSelect.value);
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", submitEquipment);
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
renderEquipmentRows(filterSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("maintenance-data");
|
||||
let equipmentByScenario = {};
|
||||
let maintenanceByScenario = {};
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed.equipment && typeof parsed.equipment === "object") {
|
||||
equipmentByScenario = parsed.equipment;
|
||||
}
|
||||
if (parsed.maintenance && typeof parsed.maintenance === "object") {
|
||||
maintenanceByScenario = parsed.maintenance;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse maintenance data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const filterSelect = document.getElementById("maintenance-scenario-filter");
|
||||
const tableWrapper = document.getElementById("maintenance-table-wrapper");
|
||||
const tableBody = document.getElementById("maintenance-table-body");
|
||||
const emptyState = document.getElementById("maintenance-empty");
|
||||
const form = document.getElementById("maintenance-form");
|
||||
const feedbackEl = document.getElementById("maintenance-feedback");
|
||||
const formScenarioSelect = document.getElementById(
|
||||
"maintenance-form-scenario"
|
||||
);
|
||||
const equipmentSelect = document.getElementById("maintenance-form-equipment");
|
||||
const equipmentEmptyState = document.getElementById(
|
||||
"maintenance-equipment-empty"
|
||||
);
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
feedbackEl.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.textContent = "";
|
||||
};
|
||||
|
||||
const formatCost = (value) =>
|
||||
Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleDateString();
|
||||
};
|
||||
|
||||
const renderMaintenanceRows = (scenarioId) => {
|
||||
if (!tableBody || !tableWrapper || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(scenarioId);
|
||||
const records = maintenanceByScenario[key] || [];
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!records.length) {
|
||||
emptyState.textContent =
|
||||
"No maintenance entries recorded for this scenario yet.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add("hidden");
|
||||
tableWrapper.classList.remove("hidden");
|
||||
|
||||
records.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${formatDate(record.maintenance_date)}</td>
|
||||
<td>${record.equipment_name || "—"}</td>
|
||||
<td>${formatCost(record.cost)}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const populateEquipmentOptions = (scenarioId) => {
|
||||
if (!equipmentSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
equipmentSelect.innerHTML =
|
||||
'<option value="" disabled selected>Select equipment</option>';
|
||||
equipmentSelect.disabled = true;
|
||||
|
||||
if (equipmentEmptyState) {
|
||||
equipmentEmptyState.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (!scenarioId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = equipmentByScenario[String(scenarioId)] || [];
|
||||
if (!list.length) {
|
||||
if (equipmentEmptyState) {
|
||||
equipmentEmptyState.textContent =
|
||||
"Add equipment for this scenario before scheduling maintenance.";
|
||||
equipmentEmptyState.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.id;
|
||||
option.textContent = item.name || `Equipment ${item.id}`;
|
||||
equipmentSelect.appendChild(option);
|
||||
});
|
||||
|
||||
equipmentSelect.disabled = false;
|
||||
};
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) {
|
||||
if (emptyState && tableWrapper && tableBody) {
|
||||
emptyState.textContent =
|
||||
"Choose a scenario to review upcoming or completed maintenance.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
tableBody.innerHTML = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderMaintenanceRows(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (formScenarioSelect) {
|
||||
formScenarioSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
populateEquipmentOptions(value);
|
||||
});
|
||||
}
|
||||
|
||||
const submitMaintenance = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const equipmentId = formData.get("equipment_id");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
equipment_id: equipmentId ? Number(equipmentId) : null,
|
||||
maintenance_date: formData.get("maintenance_date"),
|
||||
cost: Number(formData.get("cost")),
|
||||
description: formData.get("description") || null,
|
||||
};
|
||||
|
||||
if (!payload.scenario_id || !payload.equipment_id) {
|
||||
showFeedback(
|
||||
"Select a scenario and equipment before submitting.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/maintenance/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorDetail.detail || "Unable to add maintenance entry."
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
|
||||
if (!Array.isArray(maintenanceByScenario[mapKey])) {
|
||||
maintenanceByScenario[mapKey] = [];
|
||||
}
|
||||
|
||||
const equipmentList = equipmentByScenario[mapKey] || [];
|
||||
const matchedEquipment = equipmentList.find(
|
||||
(item) => Number(item.id) === Number(result.equipment_id)
|
||||
);
|
||||
result.equipment_name = matchedEquipment ? matchedEquipment.name : "";
|
||||
|
||||
maintenanceByScenario[mapKey].push(result);
|
||||
|
||||
form.reset();
|
||||
populateEquipmentOptions(null);
|
||||
showFeedback("Maintenance entry saved.", "success");
|
||||
|
||||
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
|
||||
renderMaintenanceRows(filterSelect.value);
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", submitMaintenance);
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
renderMaintenanceRows(filterSelect.value);
|
||||
}
|
||||
|
||||
if (formScenarioSelect && formScenarioSelect.value) {
|
||||
populateEquipmentOptions(formScenarioSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("parameters-data");
|
||||
let parametersByScenario = {};
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
parametersByScenario = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse parameter data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const form = document.getElementById("parameter-form");
|
||||
const scenarioSelect = /** @type {HTMLSelectElement | null} */ (
|
||||
document.getElementById("scenario_id")
|
||||
);
|
||||
const nameInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("name")
|
||||
);
|
||||
const valueInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("value")
|
||||
);
|
||||
const feedback = document.getElementById("parameter-feedback");
|
||||
const tableBody = document.getElementById("parameter-table-body");
|
||||
|
||||
const setFeedback = (message, variant) => {
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
feedback.textContent = message;
|
||||
feedback.classList.remove("success", "error");
|
||||
if (variant) {
|
||||
feedback.classList.add(variant);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = (scenarioId) => {
|
||||
if (!tableBody) {
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = "";
|
||||
const rows = parametersByScenario[String(scenarioId)] || [];
|
||||
if (!rows.length) {
|
||||
const emptyRow = document.createElement("tr");
|
||||
emptyRow.id = "parameter-empty-state";
|
||||
emptyRow.innerHTML =
|
||||
'<td colspan="4">No parameters recorded for this scenario yet.</td>';
|
||||
tableBody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${row.name}</td>
|
||||
<td>${row.value}</td>
|
||||
<td>${row.distribution_type ?? "—"}</td>
|
||||
<td>${
|
||||
row.distribution_parameters
|
||||
? JSON.stringify(row.distribution_parameters)
|
||||
: "—"
|
||||
}</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
};
|
||||
|
||||
if (scenarioSelect) {
|
||||
renderTable(scenarioSelect.value);
|
||||
scenarioSelect.addEventListener("change", () =>
|
||||
renderTable(scenarioSelect.value)
|
||||
);
|
||||
}
|
||||
|
||||
if (!form || !scenarioSelect || !nameInput || !valueInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const scenarioId = scenarioSelect.value;
|
||||
const payload = {
|
||||
scenario_id: Number(scenarioId),
|
||||
name: nameInput.value.trim(),
|
||||
value: Number(valueInput.value),
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
setFeedback("Parameter name is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(payload.value)) {
|
||||
setFeedback("Enter a numeric value.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/parameters/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
setFeedback(`Error saving parameter: ${errorText}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const scenarioKey = String(scenarioId);
|
||||
parametersByScenario[scenarioKey] = parametersByScenario[scenarioKey] || [];
|
||||
parametersByScenario[scenarioKey].push(data);
|
||||
|
||||
form.reset();
|
||||
scenarioSelect.value = scenarioKey;
|
||||
renderTable(scenarioKey);
|
||||
nameInput.focus();
|
||||
setFeedback("Parameter saved.", "success");
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("production-data");
|
||||
let data = { scenarios: [], production: {}, unit_options: [] };
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
data = {
|
||||
scenarios: Array.isArray(parsed.scenarios) ? parsed.scenarios : [],
|
||||
production:
|
||||
parsed.production && typeof parsed.production === "object"
|
||||
? parsed.production
|
||||
: {},
|
||||
unit_options: Array.isArray(parsed.unit_options)
|
||||
? parsed.unit_options
|
||||
: [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse production data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const productionByScenario = data.production;
|
||||
const filterSelect = document.getElementById("production-scenario-filter");
|
||||
const tableWrapper = document.getElementById("production-table-wrapper");
|
||||
const tableBody = document.getElementById("production-table-body");
|
||||
const emptyState = document.getElementById("production-empty");
|
||||
const form = document.getElementById("production-form");
|
||||
const feedbackEl = document.getElementById("production-feedback");
|
||||
const unitSelect = document.getElementById("production-form-unit");
|
||||
const unitSymbolInput = document.getElementById("production-form-unit-symbol");
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
feedbackEl.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.textContent = "";
|
||||
};
|
||||
|
||||
const formatAmount = (value) =>
|
||||
Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const formatMeasurement = (amount, symbol, name) => {
|
||||
if (symbol) {
|
||||
return `${formatAmount(amount)} ${symbol}`;
|
||||
}
|
||||
if (name) {
|
||||
return `${formatAmount(amount)} ${name}`;
|
||||
}
|
||||
return formatAmount(amount);
|
||||
};
|
||||
|
||||
const renderProductionRows = (scenarioId) => {
|
||||
if (!tableBody || !tableWrapper || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(scenarioId);
|
||||
const records = productionByScenario[key] || [];
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!records.length) {
|
||||
emptyState.textContent =
|
||||
"No production output recorded for this scenario yet.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add("hidden");
|
||||
tableWrapper.classList.remove("hidden");
|
||||
|
||||
records.forEach((record) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${formatMeasurement(
|
||||
record.amount,
|
||||
record.unit_symbol,
|
||||
record.unit_name
|
||||
)}</td>
|
||||
<td>${record.description || "—"}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (!value) {
|
||||
if (emptyState && tableWrapper && tableBody) {
|
||||
emptyState.textContent =
|
||||
"Choose a scenario to review its production output.";
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
tableBody.innerHTML = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderProductionRows(value);
|
||||
});
|
||||
}
|
||||
|
||||
const submitProduction = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const unitName = formData.get("unit_name");
|
||||
const unitSymbol = formData.get("unit_symbol");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
amount: Number(formData.get("amount")),
|
||||
description: formData.get("description") || null,
|
||||
unit_name: unitName ? String(unitName) : null,
|
||||
unit_symbol: unitSymbol ? String(unitSymbol) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/production/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorDetail.detail || "Unable to add production output record."
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
|
||||
if (!Array.isArray(productionByScenario[mapKey])) {
|
||||
productionByScenario[mapKey] = [];
|
||||
}
|
||||
productionByScenario[mapKey].push(result);
|
||||
|
||||
form.reset();
|
||||
syncUnitSelection();
|
||||
showFeedback("Production output saved.", "success");
|
||||
|
||||
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
|
||||
renderProductionRows(filterSelect.value);
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", submitProduction);
|
||||
}
|
||||
|
||||
const syncUnitSelection = () => {
|
||||
if (!unitSelect || !unitSymbolInput) {
|
||||
return;
|
||||
}
|
||||
if (!unitSelect.value && unitSelect.options.length > 0) {
|
||||
const firstOption = Array.from(unitSelect.options).find(
|
||||
(option) => option.value
|
||||
);
|
||||
if (firstOption) {
|
||||
firstOption.selected = true;
|
||||
}
|
||||
}
|
||||
const selectedOption = unitSelect.options[unitSelect.selectedIndex];
|
||||
unitSymbolInput.value = selectedOption
|
||||
? selectedOption.getAttribute("data-symbol") || ""
|
||||
: "";
|
||||
};
|
||||
|
||||
if (unitSelect) {
|
||||
unitSelect.addEventListener("change", syncUnitSelection);
|
||||
syncUnitSelection();
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
renderProductionRows(filterSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("reporting-data");
|
||||
let reportingSummaries = [];
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "[]");
|
||||
if (Array.isArray(parsed)) {
|
||||
reportingSummaries = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse reporting data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_FIELDS = [
|
||||
{ key: "iterations", label: "Iterations", decimals: 0 },
|
||||
{ key: "mean", label: "Mean Result", decimals: 2 },
|
||||
{ key: "variance", label: "Variance", decimals: 2 },
|
||||
{ key: "std_dev", label: "Std. Dev", decimals: 2 },
|
||||
{ key: "percentile_5", label: "Percentile 5", decimals: 2 },
|
||||
{ key: "percentile_95", label: "Percentile 95", decimals: 2 },
|
||||
{ key: "value_at_risk_95", label: "Value at Risk (95%)", decimals: 2 },
|
||||
{
|
||||
key: "expected_shortfall_95",
|
||||
label: "Expected Shortfall (95%)",
|
||||
decimals: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const tableWrapper = document.getElementById("reporting-table-wrapper");
|
||||
const tableBody = document.getElementById("reporting-table-body");
|
||||
const emptyState = document.getElementById("reporting-empty");
|
||||
const refreshButton = document.getElementById("report-refresh");
|
||||
const feedbackEl = document.getElementById("report-feedback");
|
||||
|
||||
const formatNumber = (value, decimals = 2) => {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return "—";
|
||||
}
|
||||
return Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
feedbackEl.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackEl) {
|
||||
return;
|
||||
}
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.textContent = "";
|
||||
};
|
||||
|
||||
const renderReportingTable = (summaryData) => {
|
||||
if (!tableBody || !tableWrapper || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = "";
|
||||
|
||||
if (!summaryData.length) {
|
||||
emptyState.classList.remove("hidden");
|
||||
tableWrapper.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add("hidden");
|
||||
tableWrapper.classList.remove("hidden");
|
||||
|
||||
summaryData.forEach((entry) => {
|
||||
const row = document.createElement("tr");
|
||||
const scenarioCell = document.createElement("td");
|
||||
scenarioCell.textContent = entry.scenario_name;
|
||||
row.appendChild(scenarioCell);
|
||||
|
||||
REPORT_FIELDS.forEach((field) => {
|
||||
const cell = document.createElement("td");
|
||||
const source = field.key === "iterations" ? entry : entry.summary || {};
|
||||
cell.textContent = formatNumber(source[field.key], field.decimals);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshMetrics = async () => {
|
||||
hideFeedback();
|
||||
showFeedback("Refreshing metrics…", "success");
|
||||
|
||||
try {
|
||||
const response = await fetch("/ui/reporting", {
|
||||
method: "GET",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to refresh reporting data.");
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(text, "text/html");
|
||||
const newTable = doc.querySelector("#reporting-table-wrapper");
|
||||
const newFeedback = doc.querySelector("#report-feedback");
|
||||
|
||||
if (!newTable) {
|
||||
throw new Error("Unexpected response while refreshing.");
|
||||
}
|
||||
|
||||
const newEmptyState = doc.querySelector("#reporting-empty");
|
||||
|
||||
if (emptyState && newEmptyState) {
|
||||
emptyState.className = newEmptyState.className;
|
||||
emptyState.textContent = newEmptyState.textContent;
|
||||
}
|
||||
|
||||
if (tableWrapper) {
|
||||
tableWrapper.className = newTable.className;
|
||||
tableWrapper.innerHTML = newTable.innerHTML;
|
||||
}
|
||||
|
||||
if (newFeedback && feedbackEl) {
|
||||
feedbackEl.className = newFeedback.className;
|
||||
feedbackEl.textContent = newFeedback.textContent;
|
||||
}
|
||||
|
||||
showFeedback("Metrics refreshed.", "success");
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
renderReportingTable(reportingSummaries);
|
||||
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener("click", refreshMetrics);
|
||||
}
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("scenario-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("name")
|
||||
);
|
||||
const descriptionInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("description")
|
||||
);
|
||||
const table = document.getElementById("scenario-table");
|
||||
const tableBody = document.getElementById("scenario-table-body");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!nameInput || !descriptionInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: nameInput.value.trim(),
|
||||
description: descriptionInput.value.trim() || null,
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/scenarios/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Scenario creation failed", errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.scenarioId = String(data.id);
|
||||
row.innerHTML = `
|
||||
<td>${data.name}</td>
|
||||
<td>${data.description ?? "—"}</td>
|
||||
`;
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
if (table) {
|
||||
table.classList.remove("hidden");
|
||||
table.removeAttribute("aria-hidden");
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
nameInput.focus();
|
||||
|
||||
const feedback = document.getElementById("feedback");
|
||||
if (feedback) {
|
||||
feedback.textContent = `Scenario "${data.name}" created successfully.`;
|
||||
feedback.classList.remove("hidden");
|
||||
setTimeout(() => {
|
||||
feedback.classList.add("hidden");
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
(function () {
|
||||
const dataScript = document.getElementById("theme-settings-data");
|
||||
const form = document.getElementById("theme-settings-form");
|
||||
const feedbackEl = document.getElementById("theme-settings-feedback");
|
||||
const resetBtn = document.getElementById("theme-settings-reset");
|
||||
const panel = document.getElementById("theme-settings");
|
||||
|
||||
if (!dataScript || !form || !feedbackEl || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = panel.getAttribute("data-api");
|
||||
if (!apiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(dataScript.textContent || "{}");
|
||||
const currentValues = { ...(parsed.variables || {}) };
|
||||
const defaultValues = parsed.defaults || {};
|
||||
let envOverrides = { ...(parsed.envOverrides || {}) };
|
||||
|
||||
const previewElements = new Map();
|
||||
const inputs = Array.from(form.querySelectorAll(".color-value-input"));
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const key = input.name;
|
||||
const field = input.closest(".color-form-field");
|
||||
const preview = field ? field.querySelector(".color-preview") : null;
|
||||
if (preview) {
|
||||
previewElements.set(input, preview);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||
const overrideValue = envOverrides[key];
|
||||
input.value = overrideValue;
|
||||
input.disabled = true;
|
||||
input.setAttribute("aria-disabled", "true");
|
||||
input.dataset.envOverride = "true";
|
||||
if (field) {
|
||||
field.classList.add("is-env-override");
|
||||
}
|
||||
if (preview) {
|
||||
preview.style.background = overrideValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const previewEl = previewElements.get(input);
|
||||
if (previewEl) {
|
||||
previewEl.style.background = input.value || defaultValues[key] || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setFeedback(message, type) {
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.classList.remove("hidden", "success", "error");
|
||||
if (type) {
|
||||
feedbackEl.classList.add(type);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFeedback() {
|
||||
feedbackEl.textContent = "";
|
||||
feedbackEl.classList.add("hidden");
|
||||
feedbackEl.classList.remove("success", "error");
|
||||
}
|
||||
|
||||
function updateRootVariables(values) {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (typeof key === "string" && typeof value === "string") {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetTo(source) {
|
||||
inputs.forEach((input) => {
|
||||
const key = input.name;
|
||||
if (input.disabled) {
|
||||
const previewEl = previewElements.get(input);
|
||||
const fallback = envOverrides[key] || currentValues[key];
|
||||
if (previewEl && fallback) {
|
||||
previewEl.style.background = fallback;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
input.value = source[key];
|
||||
const previewEl = previewElements.get(input);
|
||||
if (previewEl) {
|
||||
previewEl.style.background = source[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize previews to current values after page load.
|
||||
resetTo(currentValues);
|
||||
|
||||
resetBtn?.addEventListener("click", () => {
|
||||
resetTo(defaultValues);
|
||||
clearFeedback();
|
||||
setFeedback("Reverted to default values. Submit to save.", "success");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearFeedback();
|
||||
|
||||
const payload = {};
|
||||
inputs.forEach((input) => {
|
||||
if (input.disabled) {
|
||||
return;
|
||||
}
|
||||
payload[input.name] = input.value.trim();
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ variables: payload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "Unable to save theme settings.";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData?.detail) {
|
||||
detail = Array.isArray(errorData.detail)
|
||||
? errorData.detail.map((item) => item.msg || item).join("; ")
|
||||
: errorData.detail;
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Ignore JSON parse errors and use default detail message.
|
||||
}
|
||||
setFeedback(detail, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const variables = data?.variables || {};
|
||||
const responseOverrides = data?.env_overrides || {};
|
||||
|
||||
Object.assign(currentValues, variables);
|
||||
envOverrides = { ...responseOverrides };
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const key = input.name;
|
||||
const field = input.closest(".color-form-field");
|
||||
const previewEl = previewElements.get(input);
|
||||
const isOverride = Object.prototype.hasOwnProperty.call(
|
||||
envOverrides,
|
||||
key,
|
||||
);
|
||||
|
||||
if (isOverride) {
|
||||
const overrideValue = envOverrides[key];
|
||||
input.value = overrideValue;
|
||||
if (!input.disabled) {
|
||||
input.disabled = true;
|
||||
input.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
if (field) {
|
||||
field.classList.add("is-env-override");
|
||||
}
|
||||
if (previewEl) {
|
||||
previewEl.style.background = overrideValue;
|
||||
}
|
||||
} else if (input.disabled) {
|
||||
input.disabled = false;
|
||||
input.removeAttribute("aria-disabled");
|
||||
if (field) {
|
||||
field.classList.remove("is-env-override");
|
||||
}
|
||||
if (
|
||||
previewEl &&
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
) {
|
||||
previewEl.style.background = variables[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateRootVariables(variables);
|
||||
resetTo(variables);
|
||||
setFeedback("Theme colors updated successfully.", "success");
|
||||
} catch (error) {
|
||||
setFeedback("Network error: unable to save settings.", "error");
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,354 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("simulations-data");
|
||||
let simulationScenarios = [];
|
||||
let initialRuns = [];
|
||||
|
||||
if (dataElement) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (Array.isArray(parsed.scenarios)) {
|
||||
simulationScenarios = parsed.scenarios;
|
||||
}
|
||||
if (Array.isArray(parsed.runs)) {
|
||||
initialRuns = parsed.runs;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse simulations data", error);
|
||||
}
|
||||
}
|
||||
|
||||
const SUMMARY_FIELDS = [
|
||||
{ key: "count", label: "Iterations", decimals: 0 },
|
||||
{ key: "mean", label: "Mean Result", decimals: 2 },
|
||||
{ key: "median", label: "Median Result", decimals: 2 },
|
||||
{ key: "min", label: "Minimum", decimals: 2 },
|
||||
{ key: "max", label: "Maximum", decimals: 2 },
|
||||
{ key: "variance", label: "Variance", decimals: 2 },
|
||||
{ key: "std_dev", label: "Standard Deviation", decimals: 2 },
|
||||
{ key: "percentile_5", label: "Percentile 5", decimals: 2 },
|
||||
{ key: "percentile_95", label: "Percentile 95", decimals: 2 },
|
||||
{ key: "value_at_risk_95", label: "Value at Risk (95%)", decimals: 2 },
|
||||
{
|
||||
key: "expected_shortfall_95",
|
||||
label: "Expected Shortfall (95%)",
|
||||
decimals: 2,
|
||||
},
|
||||
];
|
||||
const SAMPLE_RESULT_LIMIT = 20;
|
||||
|
||||
const filterSelect = document.getElementById("simulations-scenario-filter");
|
||||
const overviewWrapper = document.getElementById(
|
||||
"simulations-overview-wrapper"
|
||||
);
|
||||
const overviewBody = document.getElementById("simulations-overview-body");
|
||||
const overviewEmpty = document.getElementById("simulations-overview-empty");
|
||||
const emptyState = document.getElementById("simulations-empty");
|
||||
const summaryWrapper = document.getElementById("simulations-summary-wrapper");
|
||||
const summaryBody = document.getElementById("simulations-summary-body");
|
||||
const summaryEmpty = document.getElementById("simulations-summary-empty");
|
||||
const resultsWrapper = document.getElementById("simulations-results-wrapper");
|
||||
const resultsBody = document.getElementById("simulations-results-body");
|
||||
const resultsEmpty = document.getElementById("simulations-results-empty");
|
||||
const simulationForm = document.getElementById("simulation-run-form");
|
||||
const simulationFeedback = document.getElementById("simulation-feedback");
|
||||
const formScenarioSelect = document.getElementById(
|
||||
"simulation-form-scenario"
|
||||
);
|
||||
|
||||
const simulationRunsMap = Object.create(null);
|
||||
|
||||
const getScenarioName = (id) => {
|
||||
const match = simulationScenarios.find(
|
||||
(scenario) => String(scenario.id) === String(id)
|
||||
);
|
||||
return match ? match.name : `Scenario ${id}`;
|
||||
};
|
||||
|
||||
const formatNumber = (value, decimals = 2) => {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return "—";
|
||||
}
|
||||
return Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
const showFeedback = (element, message, type = "success") => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.textContent = message;
|
||||
element.classList.remove("hidden", "success", "error");
|
||||
element.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = (element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.add("hidden");
|
||||
element.textContent = "";
|
||||
};
|
||||
|
||||
const initializeRunsMap = () => {
|
||||
simulationScenarios.forEach((scenario) => {
|
||||
const key = String(scenario.id);
|
||||
simulationRunsMap[key] = {
|
||||
scenario_id: scenario.id,
|
||||
scenario_name: scenario.name,
|
||||
iterations: 0,
|
||||
summary: null,
|
||||
sample_results: [],
|
||||
};
|
||||
});
|
||||
|
||||
initialRuns.forEach((run) => {
|
||||
const key = String(run.scenario_id);
|
||||
simulationRunsMap[key] = {
|
||||
scenario_id: run.scenario_id,
|
||||
scenario_name: run.scenario_name || getScenarioName(key),
|
||||
iterations: run.iterations || 0,
|
||||
summary: run.summary || null,
|
||||
sample_results: Array.isArray(run.sample_results)
|
||||
? run.sample_results
|
||||
: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renderOverviewTable = () => {
|
||||
if (!overviewBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
overviewBody.innerHTML = "";
|
||||
|
||||
if (!simulationScenarios.length) {
|
||||
if (overviewWrapper) {
|
||||
overviewWrapper.classList.add("hidden");
|
||||
}
|
||||
if (overviewEmpty) {
|
||||
overviewEmpty.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (overviewWrapper) {
|
||||
overviewWrapper.classList.remove("hidden");
|
||||
}
|
||||
if (overviewEmpty) {
|
||||
overviewEmpty.classList.add("hidden");
|
||||
}
|
||||
|
||||
simulationScenarios.forEach((scenario) => {
|
||||
const key = String(scenario.id);
|
||||
const run = simulationRunsMap[key];
|
||||
const iterations = run && run.iterations ? run.iterations : 0;
|
||||
const meanValue =
|
||||
iterations && run && run.summary ? run.summary.mean : null;
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${scenario.name}</td>
|
||||
<td>${iterations || 0}</td>
|
||||
<td>${iterations ? formatNumber(meanValue) : "—"}</td>
|
||||
`;
|
||||
overviewBody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderScenarioDetails = (scenarioId) => {
|
||||
if (!scenarioId) {
|
||||
if (emptyState) {
|
||||
emptyState.classList.remove("hidden");
|
||||
}
|
||||
if (summaryWrapper) {
|
||||
summaryWrapper.classList.add("hidden");
|
||||
}
|
||||
if (summaryEmpty) {
|
||||
summaryEmpty.classList.add("hidden");
|
||||
}
|
||||
if (resultsWrapper) {
|
||||
resultsWrapper.classList.add("hidden");
|
||||
}
|
||||
if (resultsEmpty) {
|
||||
resultsEmpty.classList.add("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.classList.add("hidden");
|
||||
}
|
||||
|
||||
const run = simulationRunsMap[String(scenarioId)];
|
||||
const summary = run ? run.summary : null;
|
||||
const samples = run ? run.sample_results || [] : [];
|
||||
|
||||
if (!summary) {
|
||||
if (summaryWrapper) {
|
||||
summaryWrapper.classList.add("hidden");
|
||||
}
|
||||
if (summaryEmpty) {
|
||||
summaryEmpty.classList.remove("hidden");
|
||||
}
|
||||
} else {
|
||||
if (summaryWrapper) {
|
||||
summaryWrapper.classList.remove("hidden");
|
||||
}
|
||||
if (summaryEmpty) {
|
||||
summaryEmpty.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (summaryBody) {
|
||||
summaryBody.innerHTML = "";
|
||||
SUMMARY_FIELDS.forEach((field) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${field.label}</td>
|
||||
<td>${formatNumber(summary[field.key], field.decimals)}</td>
|
||||
`;
|
||||
summaryBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!samples.length) {
|
||||
if (resultsWrapper) {
|
||||
resultsWrapper.classList.add("hidden");
|
||||
}
|
||||
if (resultsEmpty) {
|
||||
resultsEmpty.classList.remove("hidden");
|
||||
}
|
||||
} else {
|
||||
if (resultsWrapper) {
|
||||
resultsWrapper.classList.remove("hidden");
|
||||
}
|
||||
if (resultsEmpty) {
|
||||
resultsEmpty.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (resultsBody) {
|
||||
resultsBody.innerHTML = "";
|
||||
samples.slice(0, SAMPLE_RESULT_LIMIT).forEach((item, index) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>${formatNumber(item)}</td>
|
||||
`;
|
||||
resultsBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runSimulation = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback(simulationFeedback);
|
||||
|
||||
if (!simulationForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(simulationForm);
|
||||
const scenarioId = formData.get("scenario_id");
|
||||
const payload = {
|
||||
scenario_id: scenarioId ? Number(scenarioId) : null,
|
||||
iterations: Number(formData.get("iterations")),
|
||||
seed: formData.get("seed") ? Number(formData.get("seed")) : null,
|
||||
};
|
||||
|
||||
if (!payload.scenario_id) {
|
||||
showFeedback(
|
||||
simulationFeedback,
|
||||
"Select a scenario before running a simulation.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/simulations/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(errorDetail.detail || "Unable to run simulation.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const mapKey = String(result.scenario_id);
|
||||
const summary =
|
||||
result.summary && typeof result.summary === "object"
|
||||
? result.summary
|
||||
: null;
|
||||
const iterations =
|
||||
summary && typeof summary.count === "number"
|
||||
? summary.count
|
||||
: payload.iterations || 0;
|
||||
|
||||
simulationRunsMap[mapKey] = {
|
||||
scenario_id: result.scenario_id,
|
||||
scenario_name: getScenarioName(mapKey),
|
||||
iterations,
|
||||
summary,
|
||||
sample_results: Array.isArray(result.sample_results)
|
||||
? result.sample_results
|
||||
: [],
|
||||
};
|
||||
|
||||
renderOverviewTable();
|
||||
renderScenarioDetails(mapKey);
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.value = mapKey;
|
||||
}
|
||||
if (formScenarioSelect) {
|
||||
formScenarioSelect.value = mapKey;
|
||||
}
|
||||
|
||||
simulationForm.reset();
|
||||
showFeedback(simulationFeedback, "Simulation completed.", "success");
|
||||
} catch (error) {
|
||||
showFeedback(
|
||||
simulationFeedback,
|
||||
error.message || "An unexpected error occurred.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
initializeRunsMap();
|
||||
renderOverviewTable();
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
renderScenarioDetails(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (formScenarioSelect) {
|
||||
formScenarioSelect.addEventListener("change", (event) => {
|
||||
const value = event.target.value;
|
||||
if (filterSelect) {
|
||||
filterSelect.value = value;
|
||||
}
|
||||
renderScenarioDetails(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (simulationForm) {
|
||||
simulationForm.addEventListener("submit", runSimulation);
|
||||
}
|
||||
|
||||
if (filterSelect && filterSelect.value) {
|
||||
renderScenarioDetails(filterSelect.value);
|
||||
}
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
||||
block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h2>Operations Overview</h2>
|
||||
<p class="dashboard-subtitle">
|
||||
Unified insight across scenarios, costs, production, maintenance, and
|
||||
simulations.
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
<button id="refresh-dashboard" type="button" class="btn primary">
|
||||
Refresh Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="dashboard-status" class="feedback" hidden></p>
|
||||
|
||||
<section>
|
||||
<div id="summary-metrics" class="dashboard-metrics-grid">
|
||||
{% for metric in summary_metrics %}
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">{{ metric.label }}</span>
|
||||
<span class="metric-value">{{ metric.value }}</span>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p id="summary-empty" class="empty-state" {% if summary_metrics|length>
|
||||
0 %} hidden{% endif %}> Add project inputs to populate summary metrics.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-charts">
|
||||
<article class="panel chart-card">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h3>Scenario Cost Mix</h3>
|
||||
<p class="chart-subtitle">CAPEX vs OPEX totals per scenario</p>
|
||||
</div>
|
||||
</header>
|
||||
<canvas
|
||||
id="cost-chart"
|
||||
height="220"
|
||||
{%
|
||||
if
|
||||
not
|
||||
cost_chart_has_data
|
||||
%}
|
||||
hidden{%
|
||||
endif
|
||||
%}
|
||||
></canvas>
|
||||
<p
|
||||
id="cost-chart-empty"
|
||||
class="empty-state"
|
||||
{%
|
||||
if
|
||||
cost_chart_has_data
|
||||
%}
|
||||
hidden{%
|
||||
endif
|
||||
%}
|
||||
>
|
||||
Add CAPEX or OPEX entries to display this chart.
|
||||
</p>
|
||||
</article>
|
||||
<article class="panel chart-card">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h3>Production vs Consumption</h3>
|
||||
<p class="chart-subtitle">Throughput comparison by scenario</p>
|
||||
</div>
|
||||
</header>
|
||||
<canvas
|
||||
id="activity-chart"
|
||||
height="220"
|
||||
{%
|
||||
if
|
||||
not
|
||||
activity_chart_has_data
|
||||
%}
|
||||
hidden{%
|
||||
endif
|
||||
%}
|
||||
></canvas>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="dashboard-data" type="application/json">
|
||||
{{ {"summary_metrics": summary_metrics, "scenario_rows": scenario_rows, "overall_report_metrics": overall_report_metrics, "recent_simulations": recent_simulations, "upcoming_maintenance": upcoming_maintenance} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Process Parameters · CalMiner{%
|
||||
endblock %} {% block content %}
|
||||
<section class="panel">
|
||||
<h2>Scenario Parameters</h2>
|
||||
{% if scenarios %}
|
||||
<form id="parameter-form" class="form-grid">
|
||||
<label>
|
||||
<span>Scenario</span>
|
||||
<select name="scenario_id" id="scenario_id">
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" id="name" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Value</span>
|
||||
<input type="number" name="value" id="value" step="any" required />
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add Parameter</button>
|
||||
</form>
|
||||
<p id="parameter-feedback" class="feedback" role="status"></p>
|
||||
<div class="table-container">
|
||||
<table id="parameter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Parameter</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col">Distribution</th>
|
||||
<th scope="col">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="parameter-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
No scenarios available. Create a <a href="scenarios">scenario</a> before
|
||||
adding parameters.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="parameters-data" type="application/json">
|
||||
{{ parameters_by_scenario | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/parameters.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Scenario Management · CalMiner{%
|
||||
endblock %} {% block content %}
|
||||
<section class="panel">
|
||||
<h2>Create a New Scenario</h2>
|
||||
<form id="scenario-form" class="form-grid">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" id="name" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input type="text" name="description" id="description" />
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Create Scenario</button>
|
||||
</form>
|
||||
<div id="feedback" class="feedback hidden" aria-live="polite"></div>
|
||||
<div class="table-container">
|
||||
{% if scenarios %}
|
||||
<table id="scenario-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scenario-table-body">
|
||||
{% for scenario in scenarios %}
|
||||
<tr data-scenario-id="{{ scenario.id }}">
|
||||
<td>{{ scenario.name }}</td>
|
||||
<td>{{ scenario.description or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p id="empty-state" class="empty-state">
|
||||
No scenarios yet. Create one to get started.
|
||||
</p>
|
||||
<table id="scenario-table" class="hidden" aria-hidden="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scenario-table-body"></tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script src="/static/js/scenario-form.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,76 +0,0 @@
|
||||
{% extends "base.html" %} {% from "partials/components.html" import
|
||||
select_field, feedback, empty_state, table_container with context %} {% block
|
||||
title %}Consumption · CalMiner{% endblock %} {% block content %}
|
||||
<section class="panel">
|
||||
<h2>Consumption Tracking</h2>
|
||||
<div class="form-grid">
|
||||
{{ select_field( "Scenario filter", "consumption-scenario-filter",
|
||||
options=scenarios, placeholder="Select a scenario" ) }}
|
||||
</div>
|
||||
{{ empty_state( "consumption-empty", "Choose a scenario to review its
|
||||
consumption records." ) }} {% call table_container(
|
||||
"consumption-table-wrapper", hidden=True, aria_label="Scenario consumption
|
||||
records" ) %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="consumption-table-body"></tbody>
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add Consumption Record</h2>
|
||||
{% if scenarios %}
|
||||
<form id="consumption-form" class="form-grid">
|
||||
{{ select_field( "Scenario", "consumption-form-scenario",
|
||||
name="scenario_id", options=scenarios, required=True, placeholder="Select a
|
||||
scenario", placeholder_disabled=True ) }}
|
||||
<label for="consumption-form-unit">
|
||||
Unit
|
||||
<select id="consumption-form-unit" name="unit_name" required>
|
||||
<option value="" disabled selected>Select unit</option>
|
||||
{% for unit in unit_options %}
|
||||
<option value="{{ unit.name }}" data-symbol="{{ unit.symbol }}">
|
||||
{{ unit.name }} ({{ unit.symbol }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input id="consumption-form-unit-symbol" type="hidden" name="unit_symbol" />
|
||||
<label for="consumption-form-amount">
|
||||
Amount
|
||||
<input
|
||||
id="consumption-form-amount"
|
||||
type="number"
|
||||
name="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="consumption-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="consumption-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add Record</button>
|
||||
</form>
|
||||
{{ feedback("consumption-feedback") }} {% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> before adding consumption records.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="consumption-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "consumption": consumption_by_scenario, "unit_options": unit_options} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/consumption.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,129 +0,0 @@
|
||||
{% extends "base.html" %} {% from "partials/components.html" import
|
||||
select_field, feedback, empty_state, table_container with context %} {% block
|
||||
title %}Costs · CalMiner{% endblock %} {% block content %}
|
||||
<section class="panel">
|
||||
<h2>Cost Overview</h2>
|
||||
{% if scenarios %}
|
||||
<div class="form-grid">
|
||||
{{ select_field( "Scenario filter", "costs-scenario-filter",
|
||||
options=scenarios, placeholder="Select a scenario" ) }}
|
||||
</div>
|
||||
{% else %} {{ empty_state( "costs-scenario-empty", "Create a scenario to
|
||||
review cost information." ) }} {% endif %} {{ empty_state( "costs-empty",
|
||||
"Choose a scenario to review CAPEX and OPEX details." ) }}
|
||||
|
||||
<div id="costs-data" class="hidden">
|
||||
{% call table_container( "capex-table-container", aria_label="Scenario CAPEX
|
||||
records", heading="Capital Expenditures (CAPEX)" ) %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="capex-table-body"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row">Total</th>
|
||||
<th id="capex-total">—</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endcall %} {{ empty_state( "capex-empty", "No CAPEX records for this
|
||||
scenario yet.", hidden=True ) }} {% call table_container(
|
||||
"opex-table-container", aria_label="Scenario OPEX records",
|
||||
heading="Operational Expenditures (OPEX)" ) %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opex-table-body"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row">Total</th>
|
||||
<th id="opex-total">—</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endcall %} {{ empty_state( "opex-empty", "No OPEX records for this
|
||||
scenario yet.", hidden=True ) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add CAPEX Entry</h2>
|
||||
{% if scenarios %}
|
||||
<form id="capex-form" class="form-grid">
|
||||
{{ select_field( "Scenario", "capex-form-scenario", name="scenario_id",
|
||||
options=scenarios, required=True, placeholder="Select a scenario",
|
||||
placeholder_disabled=True ) }} {{ select_field( "Currency",
|
||||
"capex-form-currency", name="currency_code", options=currency_options,
|
||||
required=True, placeholder="Select currency", placeholder_disabled=True,
|
||||
value_attr="id", label_attr="name" ) }}
|
||||
<label for="capex-form-amount">
|
||||
Amount
|
||||
<input
|
||||
id="capex-form-amount"
|
||||
type="number"
|
||||
name="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="capex-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="capex-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add CAPEX</button>
|
||||
</form>
|
||||
{{ feedback("capex-feedback") }} {% else %} {{ empty_state(
|
||||
"capex-form-empty", "Create a scenario before adding CAPEX entries." ) }} {%
|
||||
endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add OPEX Entry</h2>
|
||||
{% if scenarios %}
|
||||
<form id="opex-form" class="form-grid">
|
||||
{{ select_field( "Scenario", "opex-form-scenario", name="scenario_id",
|
||||
options=scenarios, required=True, placeholder="Select a scenario",
|
||||
placeholder_disabled=True ) }} {{ select_field( "Currency",
|
||||
"opex-form-currency", name="currency_code", options=currency_options,
|
||||
required=True, placeholder="Select currency", placeholder_disabled=True,
|
||||
value_attr="id", label_attr="name" ) }}
|
||||
<label for="opex-form-amount">
|
||||
Amount
|
||||
<input
|
||||
id="opex-form-amount"
|
||||
type="number"
|
||||
name="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="opex-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="opex-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add OPEX</button>
|
||||
</form>
|
||||
{{ feedback("opex-feedback") }} {% else %} {{ empty_state( "opex-form-empty",
|
||||
"Create a scenario before adding OPEX entries." ) }} {% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="costs-payload" type="application/json">
|
||||
{{ {"capex": capex_by_scenario, "opex": opex_by_scenario, "currency_options": currency_options} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/costs.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,131 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "partials/components.html" import select_field, feedback, empty_state, table_container with context %}
|
||||
|
||||
{% block title %}Currencies · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel" id="currencies-overview">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>Currency Overview</h2>
|
||||
<p class="chart-subtitle">
|
||||
Current availability of currencies for project inputs.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if currency_stats %}
|
||||
<div class="dashboard-metrics-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Total Currencies</span>
|
||||
<span class="metric-value" id="currency-metric-total">{{ currency_stats.total }}</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Active</span>
|
||||
<span class="metric-value" id="currency-metric-active">{{ currency_stats.active }}</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Inactive</span>
|
||||
<span class="metric-value" id="currency-metric-inactive">{{ currency_stats.inactive }}</span>
|
||||
</article>
|
||||
</div>
|
||||
{% else %} {{ empty_state("currencies-overview-empty", "No currency data
|
||||
available yet.") }} {% endif %} {% call table_container(
|
||||
"currencies-table-container", aria_label="Configured currencies",
|
||||
heading="Configured Currencies" ) %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Code</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Symbol</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="currencies-table-body"></tbody>
|
||||
{% endcall %} {{ empty_state( "currencies-table-empty", "No currencies
|
||||
configured yet.", hidden=currencies|length > 0 ) }}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="panel"
|
||||
id="currencies-editor"
|
||||
data-default-code="{{ default_currency_code }}"
|
||||
>
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>Manage Currencies</h2>
|
||||
<p class="chart-subtitle">
|
||||
Create new currencies or update existing configurations inline.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% set status_options = [ {"id": "true", "name": "Active"}, {"id": "false",
|
||||
"name": "Inactive"} ] %}
|
||||
|
||||
<form id="currency-form" class="form-grid" novalidate>
|
||||
{{ select_field( "Currency to update (leave blank for new)",
|
||||
"currency-form-existing", name="existing_code", options=currencies,
|
||||
placeholder="Create a new currency", value_attr="code", label_attr="name" )
|
||||
}}
|
||||
|
||||
<label for="currency-form-code">
|
||||
Currency code
|
||||
<input
|
||||
id="currency-form-code"
|
||||
name="code"
|
||||
type="text"
|
||||
maxlength="3"
|
||||
required
|
||||
autocomplete="off"
|
||||
placeholder="e.g. USD"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="currency-form-name">
|
||||
Currency name
|
||||
<input
|
||||
id="currency-form-name"
|
||||
name="name"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
required
|
||||
autocomplete="off"
|
||||
placeholder="e.g. US Dollar"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="currency-form-symbol">
|
||||
Currency symbol (optional)
|
||||
<input
|
||||
id="currency-form-symbol"
|
||||
name="symbol"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
autocomplete="off"
|
||||
placeholder="$"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{{ select_field( "Status", "currency-form-status", name="is_active",
|
||||
options=status_options, include_blank=False ) }}
|
||||
|
||||
<div class="button-row">
|
||||
<button type="submit" class="btn primary">Save Currency</button>
|
||||
<button type="button" class="btn" id="currency-form-reset">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ feedback("currency-form-feedback") }}
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="currencies-data" type="application/json">
|
||||
{{ {
|
||||
"currencies": currencies,
|
||||
"currency_stats": currency_stats,
|
||||
"default_currency_code": default_currency_code,
|
||||
"currency_api_base": currency_api_base
|
||||
} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/currencies.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,78 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Equipment · CalMiner{% endblock %} {%
|
||||
block content %}
|
||||
<section class="panel">
|
||||
<h2>Equipment Inventory</h2>
|
||||
{% if scenarios %}
|
||||
<div class="form-grid">
|
||||
<label for="equipment-scenario-filter">
|
||||
Scenario filter
|
||||
<select id="equipment-scenario-filter">
|
||||
<option value="">Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> to view equipment inventory.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div id="equipment-empty" class="empty-state">
|
||||
Choose a scenario to review the equipment list.
|
||||
</div>
|
||||
<div id="equipment-table-wrapper" class="table-container hidden">
|
||||
<table aria-label="Scenario equipment inventory">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="equipment-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add Equipment</h2>
|
||||
{% if scenarios %}
|
||||
<form id="equipment-form" class="form-grid">
|
||||
<label for="equipment-form-scenario">
|
||||
Scenario
|
||||
<select id="equipment-form-scenario" name="scenario_id" required>
|
||||
<option value="" disabled selected>Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label for="equipment-form-name">
|
||||
Equipment name
|
||||
<input id="equipment-form-name" type="text" name="name" required />
|
||||
</label>
|
||||
<label for="equipment-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="equipment-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add Equipment</button>
|
||||
</form>
|
||||
<p id="equipment-feedback" class="feedback hidden" role="status"></p>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> before managing equipment.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="equipment-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "equipment": equipment_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/equipment.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,111 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Maintenance · CalMiner{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h2>Maintenance Schedule</h2>
|
||||
{% if scenarios %}
|
||||
<div class="form-grid">
|
||||
<label for="maintenance-scenario-filter">
|
||||
Scenario filter
|
||||
<select id="maintenance-scenario-filter">
|
||||
<option value="">Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> to view maintenance entries.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div id="maintenance-empty" class="empty-state">
|
||||
Choose a scenario to review upcoming or completed maintenance.
|
||||
</div>
|
||||
<div id="maintenance-table-wrapper" class="table-container hidden">
|
||||
<table aria-label="Scenario maintenance records">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Equipment</th>
|
||||
<th scope="col">Cost</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="maintenance-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add Maintenance Entry</h2>
|
||||
{% if scenarios %}
|
||||
<form id="maintenance-form" class="form-grid">
|
||||
<label for="maintenance-form-scenario">
|
||||
Scenario
|
||||
<select id="maintenance-form-scenario" name="scenario_id" required>
|
||||
<option value="" disabled selected>Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label for="maintenance-form-equipment">
|
||||
Equipment
|
||||
<select
|
||||
id="maintenance-form-equipment"
|
||||
name="equipment_id"
|
||||
required
|
||||
disabled
|
||||
>
|
||||
<option value="" disabled selected>Select equipment</option>
|
||||
</select>
|
||||
</label>
|
||||
<p id="maintenance-equipment-empty" class="empty-state hidden">
|
||||
Add equipment for this scenario before scheduling maintenance.
|
||||
</p>
|
||||
<label for="maintenance-form-date">
|
||||
Date
|
||||
<input
|
||||
id="maintenance-form-date"
|
||||
type="date"
|
||||
name="maintenance_date"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="maintenance-form-cost">
|
||||
Cost
|
||||
<input
|
||||
id="maintenance-form-cost"
|
||||
type="number"
|
||||
name="cost"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="maintenance-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="maintenance-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add Maintenance</button>
|
||||
</form>
|
||||
<p id="maintenance-feedback" class="feedback hidden" role="status"></p>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> before managing maintenance
|
||||
entries.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="maintenance-data" type="application/json">
|
||||
{{ {"equipment": equipment_by_scenario, "maintenance": maintenance_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/maintenance.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,97 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Production · CalMiner{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h2>Production Output</h2>
|
||||
{% if scenarios %}
|
||||
<div class="form-grid">
|
||||
<label for="production-scenario-filter">
|
||||
Scenario filter
|
||||
<select id="production-scenario-filter">
|
||||
<option value="">Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> to view production output data.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div id="production-empty" class="empty-state">
|
||||
Choose a scenario to review its production output.
|
||||
</div>
|
||||
<div id="production-table-wrapper" class="table-container hidden">
|
||||
<table aria-label="Scenario production records">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="production-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add Production Output</h2>
|
||||
{% if scenarios %}
|
||||
<form id="production-form" class="form-grid">
|
||||
<label for="production-form-scenario">
|
||||
Scenario
|
||||
<select id="production-form-scenario" name="scenario_id" required>
|
||||
<option value="" disabled selected>Select a scenario</option>
|
||||
{% for scenario in scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label for="production-form-unit">
|
||||
Unit
|
||||
<select id="production-form-unit" name="unit_name" required>
|
||||
<option value="" disabled selected>Select unit</option>
|
||||
{% for unit in unit_options %}
|
||||
<option value="{{ unit.name }}" data-symbol="{{ unit.symbol }}">
|
||||
{{ unit.name }} ({{ unit.symbol }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input id="production-form-unit-symbol" type="hidden" name="unit_symbol" />
|
||||
<label for="production-form-amount">
|
||||
Amount
|
||||
<input
|
||||
id="production-form-amount"
|
||||
type="number"
|
||||
name="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label for="production-form-description">
|
||||
Description (optional)
|
||||
<textarea
|
||||
id="production-form-description"
|
||||
name="description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn primary">Add Record</button>
|
||||
</form>
|
||||
<p id="production-feedback" class="feedback hidden" role="status"></p>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
Create a <a href="scenarios">scenario</a> before adding production output.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="production-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "production": production_by_scenario, "unit_options": unit_options} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/production.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Reporting · CalMiner{% endblock %} {%
|
||||
block content %}
|
||||
<section class="panel">
|
||||
<h2>Scenario KPI Summary</h2>
|
||||
<div class="button-row">
|
||||
<button id="report-refresh" class="btn" type="button">
|
||||
Refresh Metrics
|
||||
</button>
|
||||
</div>
|
||||
<p id="report-feedback" class="feedback hidden" role="status"></p>
|
||||
|
||||
<div id="reporting-empty" class="empty-state hidden">
|
||||
No reporting data available. Run a simulation to generate metrics.
|
||||
</div>
|
||||
|
||||
<div id="reporting-table-wrapper" class="table-container hidden">
|
||||
<table aria-label="Scenario reporting summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scenario</th>
|
||||
<th scope="col">Iterations</th>
|
||||
<th scope="col">Mean Result</th>
|
||||
<th scope="col">Variance</th>
|
||||
<th scope="col">Std. Dev</th>
|
||||
<th scope="col">Percentile 5</th>
|
||||
<th scope="col">Percentile 95</th>
|
||||
<th scope="col">Value at Risk (95%)</th>
|
||||
<th scope="col">Expected Shortfall (95%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reporting-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="reporting-data" type="application/json">
|
||||
{{ report_summaries | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/reporting.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Settings · CalMiner{% endblock %} {%
|
||||
block content %}
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p class="page-subtitle">
|
||||
Configure platform defaults and administrative options.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-grid">
|
||||
<article class="settings-card">
|
||||
<h2>Currency Management</h2>
|
||||
<p>
|
||||
Manage available currencies, symbols, and default selections from the
|
||||
Currency Management page.
|
||||
</p>
|
||||
<a class="button-link" href="/ui/currencies">Go to Currency Management</a>
|
||||
</article>
|
||||
<article class="settings-card">
|
||||
<h2>Themes</h2>
|
||||
<p>Adjust CalMiner theme colors and preview changes instantly.</p>
|
||||
<a class="button-link" href="/theme-settings">Go to Theme Settings</a>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Simulations · CalMiner{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h2>Monte Carlo Simulations</h2>
|
||||
{% if simulation_scenarios %}
|
||||
<div class="form-grid">
|
||||
<label for="simulations-scenario-filter">
|
||||
Scenario filter
|
||||
<select id="simulations-scenario-filter">
|
||||
<option value="">Select a scenario</option>
|
||||
{% for scenario in simulation_scenarios %}
|
||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Create a <a href="scenarios">scenario</a> before running simulations.</p>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="simulations-overview-wrapper"
|
||||
class="table-container{% if not simulation_scenarios %} hidden{% endif %}"
|
||||
>
|
||||
<h3>Scenario Run History</h3>
|
||||
<table aria-label="Simulation run history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scenario</th>
|
||||
<th scope="col">Iterations</th>
|
||||
<th scope="col">Mean Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="simulations-data" type="application/json">
|
||||
{{ {"scenarios": simulation_scenarios, "runs": simulation_runs} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/simulations.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,125 +0,0 @@
|
||||
{% extends "base.html" %} {% block title %}Theme Settings · CalMiner{% endblock
|
||||
%} {% block content %}
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<h1>Theme Settings</h1>
|
||||
<p class="page-subtitle">
|
||||
Adjust CalMiner theme colors and preview changes instantly.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="theme-settings" data-api="/api/settings/css">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>Theme Colors</h2>
|
||||
<p class="chart-subtitle">
|
||||
Update global CSS variables to customize CalMiner's appearance.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<form id="theme-settings-form" class="form-grid color-form-grid" novalidate>
|
||||
{% for key, value in css_variables.items() %} {% set env_meta =
|
||||
css_env_override_meta.get(key) %}
|
||||
<label
|
||||
class="color-form-field{% if env_meta %} is-env-override{% endif %}"
|
||||
data-variable="{{ key }}"
|
||||
>
|
||||
<span class="color-field-header">
|
||||
<span class="color-field-name">{{ key }}</span>
|
||||
<span class="color-field-default"
|
||||
>Default: {{ css_defaults[key] }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="color-field-helper" id="color-helper-{{ loop.index }}"
|
||||
>Accepts hex, rgb(a), or hsl(a) values.</span
|
||||
>
|
||||
{% if env_meta %}
|
||||
<span class="color-env-flag"
|
||||
>Managed via {{ env_meta.env_var }} (read-only)</span
|
||||
>
|
||||
{% endif %}
|
||||
<span class="color-input-row">
|
||||
<input
|
||||
type="text"
|
||||
name="{{ key }}"
|
||||
class="color-value-input"
|
||||
value="{{ value }}"
|
||||
autocomplete="off"
|
||||
aria-describedby="color-helper-{{ loop.index }}"
|
||||
{%
|
||||
if
|
||||
env_meta
|
||||
%}disabled
|
||||
aria-disabled="true"
|
||||
data-env-override="true"
|
||||
{%
|
||||
endif
|
||||
%}
|
||||
/>
|
||||
<span
|
||||
class="color-preview"
|
||||
aria-hidden="true"
|
||||
style="background: {{ value }}"
|
||||
></span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
<div class="button-row">
|
||||
<button type="submit" class="btn primary">Save Theme</button>
|
||||
<button type="button" class="btn" id="theme-settings-reset">
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% from "partials/components.html" import feedback with context %} {{
|
||||
feedback("theme-settings-feedback") }}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="theme-env-overrides">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>Environment Overrides</h2>
|
||||
<p class="chart-subtitle">
|
||||
The following CSS variables are controlled via environment variables and
|
||||
take precedence over database values.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{% if css_env_override_rows %}
|
||||
<div class="table-container env-overrides-table">
|
||||
<table aria-label="Environment-controlled theme variables">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">CSS Variable</th>
|
||||
<th scope="col">Environment Variable</th>
|
||||
<th scope="col">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in css_env_override_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.css_key }}</code></td>
|
||||
<td><code>{{ row.env_var }}</code></td>
|
||||
<td><code>{{ row.value }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">No environment overrides configured.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="theme-settings-data" type="application/json">
|
||||
{{ {
|
||||
"variables": css_variables,
|
||||
"defaults": css_defaults,
|
||||
"envOverrides": css_env_overrides,
|
||||
"envSources": css_env_override_rows
|
||||
} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/settings.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,170 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Dict, Generator
|
||||
|
||||
import pytest
|
||||
|
||||
# type: ignore[import]
|
||||
from playwright.sync_api import Browser, Page, Playwright, sync_playwright
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
# Use a different port for the test server to avoid conflicts
|
||||
TEST_PORT = 8001
|
||||
BASE_URL = f"http://localhost:{TEST_PORT}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def live_server() -> Generator[str, None, None]:
|
||||
"""Launch a live test server in a separate process."""
|
||||
env = _prepare_database_environment(os.environ.copy())
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"uvicorn",
|
||||
"main:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
f"--port={TEST_PORT}",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
|
||||
deadline = time.perf_counter() + 30
|
||||
last_error: Exception | None = None
|
||||
while time.perf_counter() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("uvicorn server exited before becoming ready")
|
||||
try:
|
||||
response = httpx.get(BASE_URL, timeout=1.0, trust_env=False)
|
||||
if response.status_code < 500:
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
raise TimeoutError(
|
||||
"Timed out waiting for uvicorn test server to start"
|
||||
) from last_error
|
||||
|
||||
try:
|
||||
yield BASE_URL
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def seed_default_currencies(live_server: str) -> None:
|
||||
"""Ensure a baseline set of currencies exists for UI flows."""
|
||||
|
||||
seeds = [
|
||||
{"code": "EUR", "name": "Euro", "symbol": "EUR", "is_active": True},
|
||||
{
|
||||
"code": "CLP",
|
||||
"name": "Chilean Peso",
|
||||
"symbol": "CLP$",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
with httpx.Client(
|
||||
base_url=live_server, timeout=5.0, trust_env=False
|
||||
) as client:
|
||||
try:
|
||||
response = client.get("/api/currencies/?include_inactive=true")
|
||||
response.raise_for_status()
|
||||
existing_codes = {
|
||||
str(item.get("code"))
|
||||
for item in response.json()
|
||||
if isinstance(item, dict) and item.get("code")
|
||||
}
|
||||
except httpx.HTTPError as exc: # noqa: BLE001
|
||||
raise RuntimeError("Failed to read existing currencies") from exc
|
||||
|
||||
for payload in seeds:
|
||||
if payload["code"] in existing_codes:
|
||||
continue
|
||||
try:
|
||||
create_response = client.post("/api/currencies/", json=payload)
|
||||
except httpx.HTTPError as exc: # noqa: BLE001
|
||||
raise RuntimeError("Failed to seed currencies") from exc
|
||||
|
||||
if create_response.status_code == 409:
|
||||
continue
|
||||
create_response.raise_for_status()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def playwright_instance() -> Generator[Playwright, None, None]:
|
||||
"""Provide a Playwright instance for the test session."""
|
||||
with sync_playwright() as p:
|
||||
yield p
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser(
|
||||
playwright_instance: Playwright,
|
||||
) -> Generator[Browser, None, None]:
|
||||
"""Provide a browser instance for the test session."""
|
||||
browser = playwright_instance.chromium.launch()
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(browser: Browser, live_server: str) -> Generator[Page, None, None]:
|
||||
"""Provide a new page for each test."""
|
||||
page = browser.new_page(base_url=live_server)
|
||||
page.goto("/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
yield page
|
||||
page.close()
|
||||
|
||||
|
||||
def _prepare_database_environment(env: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Ensure granular database env vars are available for the app under test."""
|
||||
|
||||
required = (
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_NAME",
|
||||
"DATABASE_PASSWORD",
|
||||
)
|
||||
if all(env.get(key) for key in required):
|
||||
return env
|
||||
|
||||
legacy_url = env.get("DATABASE_URL")
|
||||
if not legacy_url:
|
||||
return env
|
||||
|
||||
url = make_url(legacy_url)
|
||||
env.setdefault("DATABASE_DRIVER", url.drivername)
|
||||
if url.host:
|
||||
env.setdefault("DATABASE_HOST", url.host)
|
||||
if url.port:
|
||||
env.setdefault("DATABASE_PORT", str(url.port))
|
||||
if url.username:
|
||||
env.setdefault("DATABASE_USER", url.username)
|
||||
if url.password:
|
||||
env.setdefault("DATABASE_PASSWORD", url.password)
|
||||
if url.database:
|
||||
env.setdefault("DATABASE_NAME", url.database)
|
||||
|
||||
query_options = dict(url.query) if url.query else {}
|
||||
options = query_options.get("options")
|
||||
if isinstance(options, str) and "search_path=" in options:
|
||||
env.setdefault("DATABASE_SCHEMA", options.split("search_path=")[-1])
|
||||
|
||||
return env
|
||||
@@ -1,50 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_consumption_form_loads(page: Page):
|
||||
"""Verify the consumption form page loads correctly."""
|
||||
page.goto("/ui/consumption")
|
||||
expect(page).to_have_title("Consumption · CalMiner")
|
||||
expect(
|
||||
page.locator("h2:has-text('Add Consumption Record')")
|
||||
).to_be_visible()
|
||||
|
||||
|
||||
def test_create_consumption_item(page: Page):
|
||||
"""Test creating a new consumption item through the UI."""
|
||||
# First, create a scenario to associate the consumption with.
|
||||
page.goto("/ui/scenarios")
|
||||
scenario_name = f"Consumption Test Scenario {uuid4()}"
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/scenarios/"):
|
||||
pass # Wait for the scenario to be created
|
||||
|
||||
# Now, navigate to the consumption page and add an item.
|
||||
page.goto("/ui/consumption")
|
||||
|
||||
# Create a consumption item.
|
||||
consumption_desc = "Diesel for generators"
|
||||
page.select_option("#consumption-form-scenario", label=scenario_name)
|
||||
page.fill("textarea[name='description']", consumption_desc)
|
||||
page.fill("input[name='amount']", "5000")
|
||||
page.click("button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/consumption/") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 201
|
||||
|
||||
# Verify the new item appears in the table.
|
||||
page.select_option("#consumption-scenario-filter", label=scenario_name)
|
||||
expect(
|
||||
page.locator("#consumption-table-body tr").filter(
|
||||
has_text=consumption_desc
|
||||
)
|
||||
).to_be_visible()
|
||||
|
||||
# Verify the feedback message.
|
||||
expect(page.locator("#consumption-feedback")).to_have_text(
|
||||
"Consumption record saved."
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_costs_form_loads(page: Page):
|
||||
"""Verify the costs form page loads correctly."""
|
||||
page.goto("/ui/costs")
|
||||
expect(page).to_have_title("Costs · CalMiner")
|
||||
expect(page.locator("h2:has-text('Add CAPEX Entry')")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_capex_and_opex_items(page: Page):
|
||||
"""Test creating new CAPEX and OPEX items through the UI."""
|
||||
# First, create a scenario to associate the costs with.
|
||||
page.goto("/ui/scenarios")
|
||||
scenario_name = f"Cost Test Scenario {uuid4()}"
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/scenarios/"):
|
||||
pass # Wait for the scenario to be created
|
||||
|
||||
# Now, navigate to the costs page and add CAPEX and OPEX items.
|
||||
page.goto("/ui/costs")
|
||||
|
||||
# Create a CAPEX item.
|
||||
capex_desc = "Initial drilling equipment"
|
||||
page.select_option("#capex-form-scenario", label=scenario_name)
|
||||
page.fill("#capex-form-description", capex_desc)
|
||||
page.fill("#capex-form-amount", "150000")
|
||||
page.click("#capex-form button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/costs/capex") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 200
|
||||
|
||||
# Create an OPEX item.
|
||||
opex_desc = "Monthly fuel costs"
|
||||
page.select_option("#opex-form-scenario", label=scenario_name)
|
||||
page.fill("#opex-form-description", opex_desc)
|
||||
page.fill("#opex-form-amount", "25000")
|
||||
page.click("#opex-form button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/costs/opex") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 200
|
||||
|
||||
# Verify the new items appear in their respective tables.
|
||||
page.select_option("#costs-scenario-filter", label=scenario_name)
|
||||
expect(
|
||||
page.locator("#capex-table-body tr").filter(has_text=capex_desc)
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator("#opex-table-body tr").filter(has_text=opex_desc)
|
||||
).to_be_visible()
|
||||
|
||||
# Verify the feedback messages.
|
||||
expect(page.locator("#capex-feedback")).to_have_text(
|
||||
"Entry saved successfully."
|
||||
)
|
||||
expect(page.locator("#opex-feedback")).to_have_text(
|
||||
"Entry saved successfully."
|
||||
)
|
||||
@@ -1,135 +0,0 @@
|
||||
import random
|
||||
import string
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def _unique_currency_code(existing: set[str]) -> str:
|
||||
"""Generate a unique three-letter code not present in *existing*."""
|
||||
alphabet = string.ascii_uppercase
|
||||
for _ in range(100):
|
||||
candidate = "".join(random.choices(alphabet, k=3))
|
||||
if candidate not in existing and candidate != "USD":
|
||||
return candidate
|
||||
raise AssertionError(
|
||||
"Unable to generate a unique currency code for the test run."
|
||||
)
|
||||
|
||||
|
||||
def _metric_value(page: Page, element_id: str) -> int:
|
||||
locator = page.locator(f"#{element_id}")
|
||||
expect(locator).to_be_visible()
|
||||
return int(locator.inner_text().strip())
|
||||
|
||||
|
||||
def _expect_feedback(page: Page, expected_text: str) -> None:
|
||||
page.wait_for_function(
|
||||
"expected => {"
|
||||
" const el = document.getElementById('currency-form-feedback');"
|
||||
" if (!el) return false;"
|
||||
" const text = (el.textContent || '').trim();"
|
||||
" return !el.classList.contains('hidden') && text === expected;"
|
||||
"}",
|
||||
arg=expected_text,
|
||||
)
|
||||
feedback = page.locator("#currency-form-feedback")
|
||||
expect(feedback).to_have_text(expected_text)
|
||||
|
||||
|
||||
def test_currency_workflow_create_update_toggle(page: Page) -> None:
|
||||
"""Exercise create, update, and toggle flows on the currency settings page."""
|
||||
page.goto("/ui/currencies")
|
||||
expect(page).to_have_title("Currencies · CalMiner")
|
||||
expect(page.locator("h2:has-text('Currency Overview')")).to_be_visible()
|
||||
|
||||
code_cells = page.locator("#currencies-table-body tr td:nth-child(1)")
|
||||
existing_codes = {
|
||||
text.strip().upper() for text in code_cells.all_inner_texts()
|
||||
}
|
||||
|
||||
total_before = _metric_value(page, "currency-metric-total")
|
||||
active_before = _metric_value(page, "currency-metric-active")
|
||||
inactive_before = _metric_value(page, "currency-metric-inactive")
|
||||
|
||||
new_code = _unique_currency_code(existing_codes)
|
||||
new_name = f"Test Currency {new_code}"
|
||||
new_symbol = new_code[0]
|
||||
|
||||
page.fill("#currency-form-code", new_code)
|
||||
page.fill("#currency-form-name", new_name)
|
||||
page.fill("#currency-form-symbol", new_symbol)
|
||||
page.select_option("#currency-form-status", "true")
|
||||
|
||||
with page.expect_response("**/api/currencies/") as create_info:
|
||||
page.click("button[type='submit']")
|
||||
create_response = create_info.value
|
||||
assert create_response.status == 201
|
||||
|
||||
_expect_feedback(page, "Currency created successfully.")
|
||||
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-total').textContent.trim()) === expected",
|
||||
arg=total_before + 1,
|
||||
)
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-active').textContent.trim()) === expected",
|
||||
arg=active_before + 1,
|
||||
)
|
||||
|
||||
row = page.locator("#currencies-table-body tr").filter(has_text=new_code)
|
||||
expect(row).to_be_visible()
|
||||
expect(row.locator("td").nth(3)).to_have_text("Active")
|
||||
|
||||
# Switch to update mode using the existing currency option.
|
||||
page.select_option("#currency-form-existing", new_code)
|
||||
updated_name = f"{new_name} Updated"
|
||||
updated_symbol = f"{new_symbol}$"
|
||||
page.fill("#currency-form-name", updated_name)
|
||||
page.fill("#currency-form-symbol", updated_symbol)
|
||||
page.select_option("#currency-form-status", "false")
|
||||
|
||||
with page.expect_response(f"**/api/currencies/{new_code}") as update_info:
|
||||
page.click("button[type='submit']")
|
||||
update_response = update_info.value
|
||||
assert update_response.status == 200
|
||||
|
||||
_expect_feedback(page, "Currency updated successfully.")
|
||||
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-active').textContent.trim()) === expected",
|
||||
arg=active_before,
|
||||
)
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-inactive').textContent.trim()) === expected",
|
||||
arg=inactive_before + 1,
|
||||
)
|
||||
|
||||
expect(row.locator("td").nth(1)).to_have_text(updated_name)
|
||||
expect(row.locator("td").nth(2)).to_have_text(updated_symbol)
|
||||
expect(row.locator("td").nth(3)).to_contain_text("Inactive")
|
||||
|
||||
toggle_button = row.locator("button[data-action='toggle']")
|
||||
expect(toggle_button).to_have_text("Activate")
|
||||
|
||||
with page.expect_response(
|
||||
f"**/api/currencies/{new_code}/activation"
|
||||
) as toggle_info:
|
||||
toggle_button.click()
|
||||
toggle_response = toggle_info.value
|
||||
assert toggle_response.status == 200
|
||||
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-active').textContent.trim()) === expected",
|
||||
arg=active_before + 1,
|
||||
)
|
||||
page.wait_for_function(
|
||||
"expected => Number(document.getElementById('currency-metric-inactive').textContent.trim()) === expected",
|
||||
arg=inactive_before,
|
||||
)
|
||||
|
||||
_expect_feedback(page, f"Currency {new_code} activated.")
|
||||
|
||||
expect(row.locator("td").nth(3)).to_contain_text("Active")
|
||||
expect(row.locator("button[data-action='toggle']")).to_have_text(
|
||||
"Deactivate"
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_dashboard_loads_and_has_title(page: Page):
|
||||
"""Verify the dashboard page loads and the title is correct."""
|
||||
expect(page).to_have_title("Dashboard · CalMiner")
|
||||
|
||||
|
||||
def test_dashboard_shows_summary_metrics_panel(page: Page):
|
||||
"""Check that the summary metrics panel is visible."""
|
||||
expect(page.locator("h2:has-text('Operations Overview')")).to_be_visible()
|
||||
|
||||
|
||||
def test_dashboard_renders_cost_chart(page: Page):
|
||||
"""Ensure the scenario cost chart canvas is present."""
|
||||
expect(page.locator("#cost-chart")).to_be_attached()
|
||||
expect(page.locator("#cost-chart-empty")).to_be_visible()
|
||||
@@ -1,45 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_equipment_form_loads(page: Page):
|
||||
"""Verify the equipment form page loads correctly."""
|
||||
page.goto("/ui/equipment")
|
||||
expect(page).to_have_title("Equipment · CalMiner")
|
||||
expect(page.locator("h2:has-text('Add Equipment')")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_equipment_item(page: Page):
|
||||
"""Test creating a new equipment item through the UI."""
|
||||
# First, create a scenario to associate the equipment with.
|
||||
page.goto("/ui/scenarios")
|
||||
scenario_name = f"Equipment Test Scenario {uuid4()}"
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/scenarios/"):
|
||||
pass # Wait for the scenario to be created
|
||||
|
||||
# Now, navigate to the equipment page and add an item.
|
||||
page.goto("/ui/equipment")
|
||||
|
||||
# Create an equipment item.
|
||||
equipment_name = "Haul Truck HT-05"
|
||||
equipment_desc = "Primary haul truck for ore transport."
|
||||
page.select_option("#equipment-form-scenario", label=scenario_name)
|
||||
page.fill("#equipment-form-name", equipment_name)
|
||||
page.fill("#equipment-form-description", equipment_desc)
|
||||
page.click("button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/equipment/") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 200
|
||||
|
||||
# Verify the new item appears in the table.
|
||||
page.select_option("#equipment-scenario-filter", label=scenario_name)
|
||||
expect(
|
||||
page.locator("#equipment-table-body tr").filter(has_text=equipment_name)
|
||||
).to_be_visible()
|
||||
|
||||
# Verify the feedback message.
|
||||
expect(page.locator("#equipment-feedback")).to_have_text("Equipment saved.")
|
||||
@@ -1,58 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_maintenance_form_loads(page: Page):
|
||||
"""Verify the maintenance form page loads correctly."""
|
||||
page.goto("/ui/maintenance")
|
||||
expect(page).to_have_title("Maintenance · CalMiner")
|
||||
expect(page.locator("h2:has-text('Add Maintenance Entry')")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_maintenance_item(page: Page):
|
||||
"""Test creating a new maintenance item through the UI."""
|
||||
# First, create a scenario and an equipment item.
|
||||
page.goto("/ui/scenarios")
|
||||
scenario_name = f"Maintenance Test Scenario {uuid4()}"
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/scenarios/"):
|
||||
pass
|
||||
|
||||
page.goto("/ui/equipment")
|
||||
equipment_name = f"Excavator EX-12 {uuid4()}"
|
||||
page.select_option("#equipment-form-scenario", label=scenario_name)
|
||||
page.fill("#equipment-form-name", equipment_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/equipment/"):
|
||||
pass
|
||||
|
||||
# Now, navigate to the maintenance page and add an item.
|
||||
page.goto("/ui/maintenance")
|
||||
|
||||
# Create a maintenance item.
|
||||
maintenance_desc = "Scheduled engine overhaul"
|
||||
page.select_option("#maintenance-form-scenario", label=scenario_name)
|
||||
page.select_option("#maintenance-form-equipment", label=equipment_name)
|
||||
page.fill("#maintenance-form-date", "2025-12-01")
|
||||
page.fill("#maintenance-form-description", maintenance_desc)
|
||||
page.fill("#maintenance-form-cost", "12000")
|
||||
page.click("button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/maintenance/") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 201
|
||||
|
||||
# Verify the new item appears in the table.
|
||||
page.select_option("#maintenance-scenario-filter", label=scenario_name)
|
||||
expect(
|
||||
page.locator("#maintenance-table-body tr").filter(
|
||||
has_text=maintenance_desc
|
||||
)
|
||||
).to_be_visible()
|
||||
|
||||
# Verify the feedback message.
|
||||
expect(page.locator("#maintenance-feedback")).to_have_text(
|
||||
"Maintenance entry saved."
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_production_form_loads(page: Page):
|
||||
"""Verify the production form page loads correctly."""
|
||||
page.goto("/ui/production")
|
||||
expect(page).to_have_title("Production · CalMiner")
|
||||
expect(page.locator("h2:has-text('Add Production Output')")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_production_item(page: Page):
|
||||
"""Test creating a new production item through the UI."""
|
||||
# First, create a scenario to associate the production with.
|
||||
page.goto("/ui/scenarios")
|
||||
scenario_name = f"Production Test Scenario {uuid4()}"
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.click("button[type='submit']")
|
||||
with page.expect_response("**/api/scenarios/"):
|
||||
pass # Wait for the scenario to be created
|
||||
|
||||
# Now, navigate to the production page and add an item.
|
||||
page.goto("/ui/production")
|
||||
|
||||
# Create a production item.
|
||||
production_desc = "Ore extracted - Grade A"
|
||||
page.select_option("#production-form-scenario", label=scenario_name)
|
||||
page.fill("#production-form-description", production_desc)
|
||||
page.fill("#production-form-amount", "1500")
|
||||
page.click("button[type='submit']")
|
||||
|
||||
with page.expect_response("**/api/production/") as response_info:
|
||||
pass
|
||||
assert response_info.value.status == 201
|
||||
|
||||
# Verify the new item appears in the table.
|
||||
page.select_option("#production-scenario-filter", label=scenario_name)
|
||||
expect(
|
||||
page.locator("#production-table-body tr").filter(
|
||||
has_text=production_desc
|
||||
)
|
||||
).to_be_visible()
|
||||
|
||||
# Verify the feedback message.
|
||||
expect(page.locator("#production-feedback")).to_have_text(
|
||||
"Production output saved."
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_reporting_view_loads(page: Page):
|
||||
"""Verify the reporting view page loads correctly."""
|
||||
page.get_by_role("link", name="Reporting").click()
|
||||
expect(page).to_have_url("http://localhost:8001/ui/reporting")
|
||||
expect(page).to_have_title("Reporting · CalMiner")
|
||||
expect(page.locator("h2:has-text('Scenario KPI Summary')")).to_be_visible()
|
||||
@@ -1,43 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def test_scenario_form_loads(page: Page):
|
||||
"""Verify the scenario form page loads correctly."""
|
||||
page.goto("/ui/scenarios")
|
||||
expect(page).to_have_url(
|
||||
"http://localhost:8001/ui/scenarios"
|
||||
) # Updated port
|
||||
expect(page.locator("h2:has-text('Create a New Scenario')")).to_be_visible()
|
||||
|
||||
|
||||
def test_create_new_scenario(page: Page):
|
||||
"""Test creating a new scenario via the UI form."""
|
||||
page.goto("/ui/scenarios")
|
||||
|
||||
scenario_name = f"E2E Test Scenario {uuid4()}"
|
||||
scenario_desc = "A scenario created during an end-to-end test."
|
||||
|
||||
page.fill("input[name='name']", scenario_name)
|
||||
page.fill("input[name='description']", scenario_desc)
|
||||
|
||||
# Expect a network response from the POST request after clicking the submit button.
|
||||
with page.expect_response("**/api/scenarios/") as response_info:
|
||||
page.click("button[type='submit']")
|
||||
|
||||
response = response_info.value
|
||||
assert response.status == 200
|
||||
|
||||
# After a successful submission, the new scenario should be visible in the table.
|
||||
# The table is dynamically updated, so we might need to wait for it to appear.
|
||||
new_row = page.locator(f"tr:has-text('{scenario_name}')")
|
||||
expect(new_row).to_be_visible()
|
||||
expect(new_row.locator("td").nth(1)).to_have_text(scenario_desc)
|
||||
|
||||
# Verify the feedback message.
|
||||
feedback = page.locator("#feedback")
|
||||
expect(feedback).to_be_visible()
|
||||
expect(feedback).to_have_text(
|
||||
f'Scenario "{scenario_name}" created successfully.'
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
# A list of UI routes to check, with their URL, expected title, and a key heading text.
|
||||
UI_ROUTES = [
|
||||
("/", "Dashboard · CalMiner", "Operations Overview"),
|
||||
("/ui/dashboard", "Dashboard · CalMiner", "Operations Overview"),
|
||||
(
|
||||
"/ui/scenarios",
|
||||
"Scenario Management · CalMiner",
|
||||
"Create a New Scenario",
|
||||
),
|
||||
("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"),
|
||||
("/ui/settings", "Settings · CalMiner", "Settings"),
|
||||
("/ui/costs", "Costs · CalMiner", "Cost Overview"),
|
||||
("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"),
|
||||
("/ui/production", "Production · CalMiner", "Production Output"),
|
||||
("/ui/equipment", "Equipment · CalMiner", "Equipment Inventory"),
|
||||
("/ui/maintenance", "Maintenance · CalMiner", "Maintenance Schedule"),
|
||||
("/ui/simulations", "Simulations · CalMiner", "Monte Carlo Simulations"),
|
||||
("/ui/reporting", "Reporting · CalMiner", "Scenario KPI Summary"),
|
||||
("/ui/currencies", "Currencies · CalMiner", "Currency Overview"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url, title, heading", UI_ROUTES)
|
||||
def test_ui_pages_load_correctly(
|
||||
page: Page, url: str, title: str, heading: str
|
||||
):
|
||||
"""Verify that all UI pages load with the correct title and a visible heading."""
|
||||
page.goto(url)
|
||||
expect(page).to_have_title(title)
|
||||
# The app uses a mix of h1 and h2 for main page headings.
|
||||
heading_locator = page.locator(
|
||||
f"h1:has-text('{heading}'), h2:has-text('{heading}')"
|
||||
)
|
||||
expect(heading_locator.first).to_be_visible()
|
||||
|
||||
|
||||
def test_settings_theme_form_interaction(page: Page):
|
||||
page.goto("/theme-settings")
|
||||
expect(page).to_have_title("Theme Settings · CalMiner")
|
||||
|
||||
env_rows = page.locator("#theme-env-overrides tbody tr")
|
||||
disabled_inputs = page.locator(
|
||||
"#theme-settings-form input.color-value-input[disabled]"
|
||||
)
|
||||
env_row_count = env_rows.count()
|
||||
disabled_count = disabled_inputs.count()
|
||||
assert disabled_count == env_row_count
|
||||
|
||||
color_input = page.locator(
|
||||
"#theme-settings-form input[name='--color-primary']"
|
||||
)
|
||||
expect(color_input).to_be_visible()
|
||||
expect(color_input).to_be_enabled()
|
||||
|
||||
original_value = color_input.input_value()
|
||||
candidate_values = ("#114455", "#225566")
|
||||
new_value = (
|
||||
candidate_values[0]
|
||||
if original_value != candidate_values[0]
|
||||
else candidate_values[1]
|
||||
)
|
||||
|
||||
color_input.fill(new_value)
|
||||
page.click("#theme-settings-form button[type='submit']")
|
||||
|
||||
feedback = page.locator("#theme-settings-feedback")
|
||||
expect(feedback).to_contain_text("updated successfully")
|
||||
|
||||
computed_color = page.evaluate(
|
||||
"() => getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()"
|
||||
)
|
||||
assert computed_color.lower() == new_value.lower()
|
||||
|
||||
page.reload()
|
||||
expect(color_input).to_have_value(new_value)
|
||||
|
||||
color_input.fill(original_value)
|
||||
page.click("#theme-settings-form button[type='submit']")
|
||||
expect(feedback).to_contain_text("updated successfully")
|
||||
|
||||
page.reload()
|
||||
expect(color_input).to_have_value(original_value)
|
||||
@@ -1,266 +0,0 @@
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from config.database import Base
|
||||
from main import app
|
||||
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
|
||||
|
||||
SQLALCHEMY_TEST_URL = "sqlite:///:memory:"
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_TEST_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database() -> Generator[None, None, None]:
|
||||
# Ensure all model metadata is registered before creating tables
|
||||
from models import (
|
||||
application_setting,
|
||||
capex,
|
||||
consumption,
|
||||
currency,
|
||||
distribution,
|
||||
equipment,
|
||||
maintenance,
|
||||
opex,
|
||||
parameters,
|
||||
production_output,
|
||||
role,
|
||||
scenario,
|
||||
simulation_result,
|
||||
theme_setting,
|
||||
user,
|
||||
) # noqa: F401 - imported for side effects
|
||||
|
||||
_ = (
|
||||
capex,
|
||||
consumption,
|
||||
currency,
|
||||
distribution,
|
||||
equipment,
|
||||
maintenance,
|
||||
application_setting,
|
||||
opex,
|
||||
parameters,
|
||||
production_output,
|
||||
role,
|
||||
scenario,
|
||||
simulation_result,
|
||||
theme_setting,
|
||||
user,
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session() -> Generator[Session, None, None]:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client(db_session: Session) -> Generator[TestClient, None, None]:
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
pass
|
||||
|
||||
from routes.dependencies import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_ui_data(
|
||||
db_session: Session,
|
||||
) -> Generator[Dict[str, Any], None, None]:
|
||||
"""Populate a scenario with representative related records for UI tests."""
|
||||
scenario_name = f"Scenario Alpha {uuid4()}"
|
||||
scenario = Scenario(name=scenario_name, description="Seeded UI scenario")
|
||||
db_session.add(scenario)
|
||||
db_session.flush()
|
||||
|
||||
parameter = Parameter(
|
||||
scenario_id=scenario.id,
|
||||
name="Ore Grade",
|
||||
value=1.5,
|
||||
distribution_type="normal",
|
||||
distribution_parameters={"mean": 1.5, "std_dev": 0.1},
|
||||
)
|
||||
capex = Capex(
|
||||
scenario_id=scenario.id,
|
||||
amount=1_000_000.0,
|
||||
description="Drill purchase",
|
||||
currency_code="USD",
|
||||
)
|
||||
opex = Opex(
|
||||
scenario_id=scenario.id,
|
||||
amount=250_000.0,
|
||||
description="Fuel spend",
|
||||
currency_code="USD",
|
||||
)
|
||||
consumption = Consumption(
|
||||
scenario_id=scenario.id,
|
||||
amount=1_200.0,
|
||||
description="Diesel (L)",
|
||||
unit_name="Liters",
|
||||
unit_symbol="L",
|
||||
)
|
||||
production = ProductionOutput(
|
||||
scenario_id=scenario.id,
|
||||
amount=800.0,
|
||||
description="Ore (tonnes)",
|
||||
unit_name="Tonnes",
|
||||
unit_symbol="t",
|
||||
)
|
||||
equipment = Equipment(
|
||||
scenario_id=scenario.id,
|
||||
name="Excavator 42",
|
||||
description="Primary loader",
|
||||
)
|
||||
db_session.add_all(
|
||||
[parameter, capex, opex, consumption, production, equipment]
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
maintenance = Maintenance(
|
||||
scenario_id=scenario.id,
|
||||
equipment_id=equipment.id,
|
||||
maintenance_date=date(2025, 1, 15),
|
||||
description="Hydraulic service",
|
||||
cost=15_000.0,
|
||||
)
|
||||
simulation_results = [
|
||||
SimulationResult(
|
||||
scenario_id=scenario.id,
|
||||
iteration=index,
|
||||
result=value,
|
||||
)
|
||||
for index, value in enumerate(
|
||||
(950_000.0, 975_000.0, 990_000.0), start=1
|
||||
)
|
||||
]
|
||||
|
||||
db_session.add(maintenance)
|
||||
db_session.add_all(simulation_results)
|
||||
db_session.commit()
|
||||
|
||||
try:
|
||||
yield {
|
||||
"scenario": scenario,
|
||||
"equipment": equipment,
|
||||
"simulation_results": simulation_results,
|
||||
}
|
||||
finally:
|
||||
db_session.query(SimulationResult).filter_by(
|
||||
scenario_id=scenario.id
|
||||
).delete()
|
||||
db_session.query(Maintenance).filter_by(
|
||||
scenario_id=scenario.id
|
||||
).delete()
|
||||
db_session.query(Equipment).filter_by(id=equipment.id).delete()
|
||||
db_session.query(ProductionOutput).filter_by(
|
||||
scenario_id=scenario.id
|
||||
).delete()
|
||||
db_session.query(Consumption).filter_by(
|
||||
scenario_id=scenario.id
|
||||
).delete()
|
||||
db_session.query(Opex).filter_by(scenario_id=scenario.id).delete()
|
||||
db_session.query(Capex).filter_by(scenario_id=scenario.id).delete()
|
||||
db_session.query(Parameter).filter_by(scenario_id=scenario.id).delete()
|
||||
db_session.query(Scenario).filter_by(id=scenario.id).delete()
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def invalid_request_payloads(
|
||||
db_session: Session,
|
||||
) -> Generator[Dict[str, Any], None, None]:
|
||||
"""Provide reusable invalid request bodies for exercising validation branches."""
|
||||
duplicate_name = f"Scenario Duplicate {uuid4()}"
|
||||
existing = Scenario(
|
||||
name=duplicate_name,
|
||||
description="Existing scenario for duplicate checks",
|
||||
)
|
||||
db_session.add(existing)
|
||||
db_session.commit()
|
||||
|
||||
payloads: Dict[str, Any] = {
|
||||
"existing_scenario": existing,
|
||||
"scenario_duplicate": {
|
||||
"name": duplicate_name,
|
||||
"description": "Second scenario should fail with duplicate name",
|
||||
},
|
||||
"parameter_missing_scenario": {
|
||||
"scenario_id": existing.id + 99,
|
||||
"name": "Invalid Parameter",
|
||||
"value": 1.0,
|
||||
},
|
||||
"parameter_invalid_distribution": {
|
||||
"scenario_id": existing.id,
|
||||
"name": "Weird Dist",
|
||||
"value": 2.5,
|
||||
"distribution_type": "invalid",
|
||||
},
|
||||
"simulation_unknown_scenario": {
|
||||
"scenario_id": existing.id + 99,
|
||||
"iterations": 10,
|
||||
"parameters": [
|
||||
{"name": "grade", "value": 1.2, "distribution": "normal"}
|
||||
],
|
||||
},
|
||||
"simulation_missing_parameters": {
|
||||
"scenario_id": existing.id,
|
||||
"iterations": 5,
|
||||
"parameters": [],
|
||||
},
|
||||
"reporting_non_list_payload": {"result": 10.0},
|
||||
"reporting_missing_result": [{"value": 12.0}],
|
||||
"maintenance_negative_cost": {
|
||||
"equipment_id": 1,
|
||||
"scenario_id": existing.id,
|
||||
"maintenance_date": "2025-01-15",
|
||||
"cost": -500.0,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
yield payloads
|
||||
finally:
|
||||
db_session.query(Scenario).filter_by(id=existing.id).delete()
|
||||
db_session.commit()
|
||||
@@ -1,231 +0,0 @@
|
||||
from services.security import get_password_hash, verify_password
|
||||
|
||||
|
||||
def test_password_hashing():
|
||||
password = "testpassword"
|
||||
hashed_password = get_password_hash(password)
|
||||
assert verify_password(password, hashed_password)
|
||||
assert not verify_password("wrongpassword", hashed_password)
|
||||
|
||||
|
||||
def test_register_user(api_client):
|
||||
response = api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username"] == "testuser"
|
||||
assert data["email"] == "test@example.com"
|
||||
assert "id" in data
|
||||
assert "role_id" in data
|
||||
|
||||
response = api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"email": "another@example.com",
|
||||
"password": "testpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Username already registered"}
|
||||
|
||||
response = api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "anotheruser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Email already registered"}
|
||||
|
||||
|
||||
def test_login_user(api_client):
|
||||
# Register a user first
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "loginuser",
|
||||
"email": "login@example.com",
|
||||
"password": "loginpassword",
|
||||
},
|
||||
)
|
||||
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "loginuser", "password": "loginpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "loginuser", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Incorrect username or password"}
|
||||
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "nonexistent", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Incorrect username or password"}
|
||||
|
||||
|
||||
def test_read_users_me(api_client):
|
||||
# Register a user first
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "profileuser",
|
||||
"email": "profile@example.com",
|
||||
"password": "profilepassword",
|
||||
},
|
||||
)
|
||||
# Login to get a token
|
||||
login_response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "profileuser", "password": "profilepassword"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
response = api_client.get(
|
||||
"/users/me", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "profileuser"
|
||||
assert data["email"] == "profile@example.com"
|
||||
|
||||
|
||||
def test_update_users_me(api_client):
|
||||
# Register a user first
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "updateuser",
|
||||
"email": "update@example.com",
|
||||
"password": "updatepassword",
|
||||
},
|
||||
)
|
||||
# Login to get a token
|
||||
login_response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "updateuser", "password": "updatepassword"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
response = api_client.put(
|
||||
"/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"username": "updateduser",
|
||||
"email": "updated@example.com",
|
||||
"password": "newpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "updateduser"
|
||||
assert data["email"] == "updated@example.com"
|
||||
|
||||
# Verify password change
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "updateduser", "password": "newpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Test username already taken
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "anotherupdateuser",
|
||||
"email": "anotherupdate@example.com",
|
||||
"password": "password",
|
||||
},
|
||||
)
|
||||
response = api_client.put(
|
||||
"/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"username": "anotherupdateuser",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Username already taken"}
|
||||
|
||||
# Test email already registered
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "yetanotheruser",
|
||||
"email": "yetanother@example.com",
|
||||
"password": "password",
|
||||
},
|
||||
)
|
||||
response = api_client.put(
|
||||
"/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"email": "yetanother@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Email already registered"}
|
||||
|
||||
|
||||
def test_forgot_password(api_client):
|
||||
response = api_client.post(
|
||||
"/users/forgot-password", json={"email": "nonexistent@example.com"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Password reset email sent (not really)"}
|
||||
|
||||
|
||||
def test_reset_password(api_client):
|
||||
# Register a user first
|
||||
api_client.post(
|
||||
"/users/register",
|
||||
json={
|
||||
"username": "resetuser",
|
||||
"email": "reset@example.com",
|
||||
"password": "oldpassword",
|
||||
},
|
||||
)
|
||||
|
||||
response = api_client.post(
|
||||
"/users/reset-password",
|
||||
json={
|
||||
"token": "resetuser", # Use username as token for test
|
||||
"new_password": "newpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Password has been reset successfully"}
|
||||
|
||||
# Verify password change
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "resetuser", "password": "newpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.post(
|
||||
"/users/login",
|
||||
json={"username": "resetuser", "password": "oldpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -1,77 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_client: TestClient) -> TestClient:
|
||||
return api_client
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient) -> int:
|
||||
payload = {
|
||||
"name": f"Consumption Scenario {uuid4()}",
|
||||
"description": "Scenario for consumption tests",
|
||||
}
|
||||
response = client.post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_create_consumption(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
payload = {
|
||||
"scenario_id": scenario_id,
|
||||
"amount": 125.5,
|
||||
"description": "Fuel usage baseline",
|
||||
"unit_name": "Liters",
|
||||
"unit_symbol": "L",
|
||||
}
|
||||
|
||||
response = client.post("/api/consumption/", json=payload)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["id"] > 0
|
||||
assert body["scenario_id"] == scenario_id
|
||||
assert body["amount"] == pytest.approx(125.5)
|
||||
assert body["description"] == "Fuel usage baseline"
|
||||
assert body["unit_symbol"] == "L"
|
||||
|
||||
|
||||
def test_list_consumption_returns_created_items(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
values = [50.0, 80.75]
|
||||
for amount in values:
|
||||
response = client.post(
|
||||
"/api/consumption/",
|
||||
json={
|
||||
"scenario_id": scenario_id,
|
||||
"amount": amount,
|
||||
"description": f"Consumption {amount}",
|
||||
"unit_name": "Tonnes",
|
||||
"unit_symbol": "t",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
list_response = client.get("/api/consumption/")
|
||||
assert list_response.status_code == 200
|
||||
items = [
|
||||
item
|
||||
for item in list_response.json()
|
||||
if item["scenario_id"] == scenario_id
|
||||
]
|
||||
assert {item["amount"] for item in items} == set(values)
|
||||
|
||||
|
||||
def test_create_consumption_rejects_negative_amount(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
payload = {
|
||||
"scenario_id": scenario_id,
|
||||
"amount": -10,
|
||||
"description": "Invalid negative amount",
|
||||
}
|
||||
|
||||
response = client.post("/api/consumption/", json=payload)
|
||||
assert response.status_code == 422
|
||||
@@ -1,123 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.database import Base, engine
|
||||
from main import app
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def teardown_module(module):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _create_scenario() -> int:
|
||||
payload = {
|
||||
"name": f"CostScenario-{uuid4()}",
|
||||
"description": "Cost tracking test scenario",
|
||||
}
|
||||
response = client.post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_create_and_list_capex_and_opex():
|
||||
sid = _create_scenario()
|
||||
|
||||
capex_payload = {
|
||||
"scenario_id": sid,
|
||||
"amount": 1000.0,
|
||||
"description": "Initial capex",
|
||||
"currency_code": "USD",
|
||||
}
|
||||
resp2 = client.post("/api/costs/capex", json=capex_payload)
|
||||
assert resp2.status_code == 200
|
||||
capex = resp2.json()
|
||||
assert capex["scenario_id"] == sid
|
||||
assert capex["amount"] == 1000.0
|
||||
assert capex["currency_code"] == "USD"
|
||||
|
||||
resp3 = client.get("/api/costs/capex")
|
||||
assert resp3.status_code == 200
|
||||
data = resp3.json()
|
||||
assert any(
|
||||
item["amount"] == 1000.0 and item["scenario_id"] == sid for item in data
|
||||
)
|
||||
|
||||
opex_payload = {
|
||||
"scenario_id": sid,
|
||||
"amount": 500.0,
|
||||
"description": "Recurring opex",
|
||||
"currency_code": "USD",
|
||||
}
|
||||
resp4 = client.post("/api/costs/opex", json=opex_payload)
|
||||
assert resp4.status_code == 200
|
||||
opex = resp4.json()
|
||||
assert opex["scenario_id"] == sid
|
||||
assert opex["amount"] == 500.0
|
||||
assert opex["currency_code"] == "USD"
|
||||
|
||||
resp5 = client.get("/api/costs/opex")
|
||||
assert resp5.status_code == 200
|
||||
data_o = resp5.json()
|
||||
assert any(
|
||||
item["amount"] == 500.0 and item["scenario_id"] == sid
|
||||
for item in data_o
|
||||
)
|
||||
|
||||
|
||||
def test_multiple_capex_entries():
|
||||
sid = _create_scenario()
|
||||
amounts = [250.0, 750.0]
|
||||
for amount in amounts:
|
||||
resp = client.post(
|
||||
"/api/costs/capex",
|
||||
json={
|
||||
"scenario_id": sid,
|
||||
"amount": amount,
|
||||
"description": f"Capex {amount}",
|
||||
"currency_code": "EUR",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/api/costs/capex")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
retrieved_amounts = [
|
||||
item["amount"] for item in data if item["scenario_id"] == sid
|
||||
]
|
||||
for amount in amounts:
|
||||
assert amount in retrieved_amounts
|
||||
|
||||
|
||||
def test_multiple_opex_entries():
|
||||
sid = _create_scenario()
|
||||
amounts = [120.0, 340.0]
|
||||
for amount in amounts:
|
||||
resp = client.post(
|
||||
"/api/costs/opex",
|
||||
json={
|
||||
"scenario_id": sid,
|
||||
"amount": amount,
|
||||
"description": f"Opex {amount}",
|
||||
"currency_code": "CAD",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/api/costs/opex")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
retrieved_amounts = [
|
||||
item["amount"] for item in data if item["scenario_id"] == sid
|
||||
]
|
||||
for amount in amounts:
|
||||
assert amount in retrieved_amounts
|
||||
@@ -1,125 +0,0 @@
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from models.currency import Currency
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup_currencies(db_session):
|
||||
db_session.query(Currency).delete()
|
||||
db_session.commit()
|
||||
yield
|
||||
db_session.query(Currency).delete()
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def _assert_currency(
|
||||
payload: Dict[str, object],
|
||||
code: str,
|
||||
name: str,
|
||||
symbol: str | None,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
assert payload["code"] == code
|
||||
assert payload["name"] == name
|
||||
assert payload["is_active"] is is_active
|
||||
if symbol is None:
|
||||
assert payload["symbol"] is None
|
||||
else:
|
||||
assert payload["symbol"] == symbol
|
||||
|
||||
|
||||
def test_list_returns_default_currency(api_client, db_session):
|
||||
response = api_client.get("/api/currencies/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert any(item["code"] == "USD" for item in data)
|
||||
|
||||
|
||||
def test_create_currency_success(api_client, db_session):
|
||||
payload = {"code": "EUR", "name": "Euro", "symbol": "€", "is_active": True}
|
||||
response = api_client.post("/api/currencies/", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
_assert_currency(data, "EUR", "Euro", "€", True)
|
||||
|
||||
stored = db_session.query(Currency).filter_by(code="EUR").one()
|
||||
assert stored.name == "Euro"
|
||||
assert stored.symbol == "€"
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
def test_create_currency_conflict(api_client, db_session):
|
||||
api_client.post(
|
||||
"/api/currencies/",
|
||||
json={
|
||||
"code": "CAD",
|
||||
"name": "Canadian Dollar",
|
||||
"symbol": "$",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
duplicate = api_client.post(
|
||||
"/api/currencies/",
|
||||
json={
|
||||
"code": "CAD",
|
||||
"name": "Canadian Dollar",
|
||||
"symbol": "$",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
|
||||
|
||||
def test_update_currency_fields(api_client, db_session):
|
||||
api_client.post(
|
||||
"/api/currencies/",
|
||||
json={
|
||||
"code": "GBP",
|
||||
"name": "British Pound",
|
||||
"symbol": "£",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
|
||||
response = api_client.put(
|
||||
"/api/currencies/GBP",
|
||||
json={"name": "Pound Sterling", "symbol": "£", "is_active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
_assert_currency(data, "GBP", "Pound Sterling", "£", False)
|
||||
|
||||
|
||||
def test_toggle_currency_activation(api_client, db_session):
|
||||
api_client.post(
|
||||
"/api/currencies/",
|
||||
json={
|
||||
"code": "AUD",
|
||||
"name": "Australian Dollar",
|
||||
"symbol": "A$",
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
"/api/currencies/AUD/activation",
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
_assert_currency(data, "AUD", "Australian Dollar", "A$", False)
|
||||
|
||||
|
||||
def test_default_currency_cannot_be_deactivated(api_client, db_session):
|
||||
api_client.get("/api/currencies/")
|
||||
response = api_client.patch(
|
||||
"/api/currencies/USD/activation",
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "The default currency cannot be deactivated."
|
||||
)
|
||||
@@ -1,75 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from models.currency import Currency
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_currency(db_session):
|
||||
currency = Currency(code="GBP", name="British Pound", symbol="GBP")
|
||||
db_session.add(currency)
|
||||
db_session.commit()
|
||||
db_session.refresh(currency)
|
||||
|
||||
try:
|
||||
yield currency
|
||||
finally:
|
||||
db_session.delete(currency)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def _create_scenario(api_client):
|
||||
payload = {
|
||||
"name": f"CurrencyScenario-{uuid4()}",
|
||||
"description": "Currency workflow scenario",
|
||||
}
|
||||
resp = api_client.post("/api/scenarios/", json=payload)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_create_capex_with_currency_code_and_list(api_client, seeded_currency):
|
||||
sid = _create_scenario(api_client)
|
||||
|
||||
payload = {
|
||||
"scenario_id": sid,
|
||||
"amount": 500.0,
|
||||
"description": "Capex with GBP",
|
||||
"currency_code": seeded_currency.code,
|
||||
}
|
||||
resp = api_client.post("/api/costs/capex", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert (
|
||||
data.get("currency_code") == seeded_currency.code
|
||||
or data.get("currency", {}).get("code") == seeded_currency.code
|
||||
)
|
||||
|
||||
|
||||
def test_create_opex_with_currency_id(api_client, seeded_currency):
|
||||
sid = _create_scenario(api_client)
|
||||
|
||||
resp = api_client.get("/api/currencies/")
|
||||
assert resp.status_code == 200
|
||||
currencies = resp.json()
|
||||
assert any(c["id"] == seeded_currency.id for c in currencies)
|
||||
|
||||
payload = {
|
||||
"scenario_id": sid,
|
||||
"amount": 120.0,
|
||||
"description": "Opex with explicit id",
|
||||
"currency_id": seeded_currency.id,
|
||||
}
|
||||
resp = api_client.post("/api/costs/opex", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["currency_id"] == seeded_currency.id
|
||||
|
||||
|
||||
def test_list_currencies_endpoint(api_client, seeded_currency):
|
||||
resp = api_client.get("/api/currencies/")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert any(c["id"] == seeded_currency.id for c in data)
|
||||
@@ -1,71 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.database import Base, engine
|
||||
from main import app
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def teardown_module(module):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_create_and_list_distribution():
|
||||
dist_name = f"NormalDist-{uuid4()}"
|
||||
payload = {
|
||||
"name": dist_name,
|
||||
"distribution_type": "normal",
|
||||
"parameters": {"mu": 0, "sigma": 1},
|
||||
}
|
||||
resp = client.post("/api/distributions/", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == dist_name
|
||||
|
||||
resp2 = client.get("/api/distributions/")
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert any(d["name"] == dist_name for d in data2)
|
||||
|
||||
|
||||
def test_duplicate_distribution_name_allowed():
|
||||
dist_name = f"DupDist-{uuid4()}"
|
||||
payload = {
|
||||
"name": dist_name,
|
||||
"distribution_type": "uniform",
|
||||
"parameters": {"min": 0, "max": 1},
|
||||
}
|
||||
first = client.post("/api/distributions/", json=payload)
|
||||
assert first.status_code == 200
|
||||
|
||||
duplicate = client.post("/api/distributions/", json=payload)
|
||||
assert duplicate.status_code == 200
|
||||
|
||||
resp = client.get("/api/distributions/")
|
||||
assert resp.status_code == 200
|
||||
matching = [item for item in resp.json() if item["name"] == dist_name]
|
||||
assert len(matching) >= 2
|
||||
|
||||
|
||||
def test_list_distributions_returns_all():
|
||||
names = {f"ListDist-{uuid4()}" for _ in range(2)}
|
||||
for name in names:
|
||||
payload = {
|
||||
"name": name,
|
||||
"distribution_type": "triangular",
|
||||
"parameters": {"min": 0, "max": 10, "mode": 5},
|
||||
}
|
||||
resp = client.post("/api/distributions/", json=payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/api/distributions/")
|
||||
assert resp.status_code == 200
|
||||
found_names = {item["name"] for item in resp.json()}
|
||||
assert names.issubset(found_names)
|
||||
@@ -1,77 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_client: TestClient) -> TestClient:
|
||||
return api_client
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient) -> int:
|
||||
payload = {
|
||||
"name": f"Equipment Scenario {uuid4()}",
|
||||
"description": "Scenario for equipment tests",
|
||||
}
|
||||
response = client.post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_create_equipment(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
payload = {
|
||||
"scenario_id": scenario_id,
|
||||
"name": "Excavator",
|
||||
"description": "Heavy machinery",
|
||||
}
|
||||
|
||||
response = client.post("/api/equipment/", json=payload)
|
||||
assert response.status_code == 200
|
||||
created = response.json()
|
||||
assert created["id"] > 0
|
||||
assert created["scenario_id"] == scenario_id
|
||||
assert created["name"] == "Excavator"
|
||||
assert created["description"] == "Heavy machinery"
|
||||
|
||||
|
||||
def test_list_equipment_filters_by_scenario(client: TestClient) -> None:
|
||||
target_scenario = _create_scenario(client)
|
||||
other_scenario = _create_scenario(client)
|
||||
|
||||
for scenario_id, name in [
|
||||
(target_scenario, "Bulldozer"),
|
||||
(target_scenario, "Loader"),
|
||||
(other_scenario, "Conveyor"),
|
||||
]:
|
||||
response = client.post(
|
||||
"/api/equipment/",
|
||||
json={
|
||||
"scenario_id": scenario_id,
|
||||
"name": name,
|
||||
"description": f"Equipment {name}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
list_response = client.get("/api/equipment/")
|
||||
assert list_response.status_code == 200
|
||||
items = [
|
||||
item
|
||||
for item in list_response.json()
|
||||
if item["scenario_id"] == target_scenario
|
||||
]
|
||||
assert {item["name"] for item in items} == {"Bulldozer", "Loader"}
|
||||
|
||||
|
||||
def test_create_equipment_requires_name(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
response = client.post(
|
||||
"/api/equipment/",
|
||||
json={
|
||||
"scenario_id": scenario_id,
|
||||
"description": "Missing name",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -1,125 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_client: TestClient) -> TestClient:
|
||||
return api_client
|
||||
|
||||
|
||||
def _create_scenario_and_equipment(client: TestClient):
|
||||
scenario_payload = {
|
||||
"name": f"Test Scenario {uuid4()}",
|
||||
"description": "Scenario for maintenance tests",
|
||||
}
|
||||
scenario_response = client.post("/api/scenarios/", json=scenario_payload)
|
||||
assert scenario_response.status_code == 200
|
||||
scenario_id = scenario_response.json()["id"]
|
||||
|
||||
equipment_payload = {
|
||||
"scenario_id": scenario_id,
|
||||
"name": f"Test Equipment {uuid4()}",
|
||||
"description": "Equipment linked to maintenance",
|
||||
}
|
||||
equipment_response = client.post("/api/equipment/", json=equipment_payload)
|
||||
assert equipment_response.status_code == 200
|
||||
equipment_id = equipment_response.json()["id"]
|
||||
return scenario_id, equipment_id
|
||||
|
||||
|
||||
def _create_maintenance_payload(
|
||||
equipment_id: int, scenario_id: int, description: str
|
||||
):
|
||||
return {
|
||||
"equipment_id": equipment_id,
|
||||
"scenario_id": scenario_id,
|
||||
"maintenance_date": "2025-10-20",
|
||||
"description": description,
|
||||
"cost": 100.0,
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_list_maintenance(client: TestClient):
|
||||
scenario_id, equipment_id = _create_scenario_and_equipment(client)
|
||||
payload = _create_maintenance_payload(
|
||||
equipment_id, scenario_id, "Create maintenance"
|
||||
)
|
||||
|
||||
response = client.post("/api/maintenance/", json=payload)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["equipment_id"] == equipment_id
|
||||
assert created["scenario_id"] == scenario_id
|
||||
assert created["description"] == "Create maintenance"
|
||||
|
||||
list_response = client.get("/api/maintenance/")
|
||||
assert list_response.status_code == 200
|
||||
items = list_response.json()
|
||||
assert any(item["id"] == created["id"] for item in items)
|
||||
|
||||
|
||||
def test_get_maintenance(client: TestClient):
|
||||
scenario_id, equipment_id = _create_scenario_and_equipment(client)
|
||||
payload = _create_maintenance_payload(
|
||||
equipment_id, scenario_id, "Retrieve maintenance"
|
||||
)
|
||||
create_response = client.post("/api/maintenance/", json=payload)
|
||||
assert create_response.status_code == 201
|
||||
maintenance_id = create_response.json()["id"]
|
||||
|
||||
response = client.get(f"/api/maintenance/{maintenance_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == maintenance_id
|
||||
assert data["equipment_id"] == equipment_id
|
||||
assert data["description"] == "Retrieve maintenance"
|
||||
|
||||
|
||||
def test_update_maintenance(client: TestClient):
|
||||
scenario_id, equipment_id = _create_scenario_and_equipment(client)
|
||||
create_response = client.post(
|
||||
"/api/maintenance/",
|
||||
json=_create_maintenance_payload(
|
||||
equipment_id, scenario_id, "Maintenance before update"
|
||||
),
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
maintenance_id = create_response.json()["id"]
|
||||
|
||||
update_payload = {
|
||||
"equipment_id": equipment_id,
|
||||
"scenario_id": scenario_id,
|
||||
"maintenance_date": "2025-11-01",
|
||||
"description": "Maintenance after update",
|
||||
"cost": 250.0,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/maintenance/{maintenance_id}", json=update_payload
|
||||
)
|
||||
assert response.status_code == 200
|
||||
updated = response.json()
|
||||
assert updated["maintenance_date"] == "2025-11-01"
|
||||
assert updated["description"] == "Maintenance after update"
|
||||
assert updated["cost"] == 250.0
|
||||
|
||||
|
||||
def test_delete_maintenance(client: TestClient):
|
||||
scenario_id, equipment_id = _create_scenario_and_equipment(client)
|
||||
create_response = client.post(
|
||||
"/api/maintenance/",
|
||||
json=_create_maintenance_payload(
|
||||
equipment_id, scenario_id, "Delete maintenance"
|
||||
),
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
maintenance_id = create_response.json()["id"]
|
||||
|
||||
delete_response = client.delete(f"/api/maintenance/{maintenance_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
get_response = client.get(f"/api/maintenance/{maintenance_id}")
|
||||
assert get_response.status_code == 404
|
||||
@@ -1,126 +0,0 @@
|
||||
from typing import Any, Dict, List
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.database import Base, engine
|
||||
from main import app
|
||||
|
||||
|
||||
def setup_module(module: object) -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def teardown_module(module: object) -> None:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _create_scenario(name: str | None = None) -> int:
|
||||
payload: Dict[str, Any] = {
|
||||
"name": name or f"ParamScenario-{uuid4()}",
|
||||
"description": "Parameter test scenario",
|
||||
}
|
||||
response = TestClient(app).post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _create_distribution() -> int:
|
||||
payload: Dict[str, Any] = {
|
||||
"name": f"NormalDist-{uuid4()}",
|
||||
"distribution_type": "normal",
|
||||
"parameters": {"mu": 10, "sigma": 2},
|
||||
}
|
||||
response = TestClient(app).post("/api/distributions/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_create_and_list_parameter():
|
||||
scenario_id = _create_scenario()
|
||||
distribution_id = _create_distribution()
|
||||
parameter_payload: Dict[str, Any] = {
|
||||
"scenario_id": scenario_id,
|
||||
"name": f"param-{uuid4()}",
|
||||
"value": 3.14,
|
||||
"distribution_id": distribution_id,
|
||||
}
|
||||
|
||||
create_response = client.post("/api/parameters/", json=parameter_payload)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["scenario_id"] == scenario_id
|
||||
assert created["name"] == parameter_payload["name"]
|
||||
assert created["value"] == parameter_payload["value"]
|
||||
assert created["distribution_id"] == distribution_id
|
||||
assert created["distribution_type"] == "normal"
|
||||
assert created["distribution_parameters"] == {"mu": 10, "sigma": 2}
|
||||
|
||||
list_response = client.get("/api/parameters/")
|
||||
assert list_response.status_code == 200
|
||||
params = list_response.json()
|
||||
assert any(p["id"] == created["id"] for p in params)
|
||||
|
||||
|
||||
def test_create_parameter_for_missing_scenario():
|
||||
payload: Dict[str, Any] = {
|
||||
"scenario_id": 0,
|
||||
"name": "invalid",
|
||||
"value": 1.0,
|
||||
}
|
||||
response = client.post("/api/parameters/", json=payload)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Scenario not found"
|
||||
|
||||
|
||||
def test_multiple_parameters_listed():
|
||||
scenario_id = _create_scenario()
|
||||
payloads: List[Dict[str, Any]] = [
|
||||
{"scenario_id": scenario_id, "name": f"alpha-{i}", "value": float(i)}
|
||||
for i in range(2)
|
||||
]
|
||||
|
||||
for payload in payloads:
|
||||
resp = client.post("/api/parameters/", json=payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
list_response = client.get("/api/parameters/")
|
||||
assert list_response.status_code == 200
|
||||
names = {item["name"] for item in list_response.json()}
|
||||
for payload in payloads:
|
||||
assert payload["name"] in names
|
||||
|
||||
|
||||
def test_parameter_inline_distribution_metadata():
|
||||
scenario_id = _create_scenario()
|
||||
payload: Dict[str, Any] = {
|
||||
"scenario_id": scenario_id,
|
||||
"name": "inline-param",
|
||||
"value": 7.5,
|
||||
"distribution_type": "uniform",
|
||||
"distribution_parameters": {"min": 5, "max": 10},
|
||||
}
|
||||
|
||||
response = client.post("/api/parameters/", json=payload)
|
||||
assert response.status_code == 200
|
||||
created = response.json()
|
||||
assert created["distribution_id"] is None
|
||||
assert created["distribution_type"] == "uniform"
|
||||
assert created["distribution_parameters"] == {"min": 5, "max": 10}
|
||||
|
||||
|
||||
def test_parameter_with_missing_distribution_reference():
|
||||
scenario_id = _create_scenario()
|
||||
payload: Dict[str, Any] = {
|
||||
"scenario_id": scenario_id,
|
||||
"name": "missing-dist",
|
||||
"value": 1.0,
|
||||
"distribution_id": 9999,
|
||||
}
|
||||
|
||||
response = client.post("/api/parameters/", json=payload)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Distribution not found"
|
||||
@@ -1,82 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_client: TestClient) -> TestClient:
|
||||
return api_client
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient) -> int:
|
||||
payload = {
|
||||
"name": f"Production Scenario {uuid4()}",
|
||||
"description": "Scenario for production tests",
|
||||
}
|
||||
response = client.post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_create_production_record(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
payload: dict[str, any] = {
|
||||
"scenario_id": scenario_id,
|
||||
"amount": 475.25,
|
||||
"description": "Daily output",
|
||||
"unit_name": "Tonnes",
|
||||
"unit_symbol": "t",
|
||||
}
|
||||
|
||||
response = client.post("/api/production/", json=payload)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["scenario_id"] == scenario_id
|
||||
assert created["amount"] == pytest.approx(475.25)
|
||||
assert created["description"] == "Daily output"
|
||||
assert created["unit_symbol"] == "t"
|
||||
|
||||
|
||||
def test_list_production_filters_by_scenario(client: TestClient) -> None:
|
||||
target_scenario = _create_scenario(client)
|
||||
other_scenario = _create_scenario(client)
|
||||
|
||||
for scenario_id, amount in [
|
||||
(target_scenario, 100.0),
|
||||
(target_scenario, 150.0),
|
||||
(other_scenario, 200.0),
|
||||
]:
|
||||
response = client.post(
|
||||
"/api/production/",
|
||||
json={
|
||||
"scenario_id": scenario_id,
|
||||
"amount": amount,
|
||||
"description": f"Output {amount}",
|
||||
"unit_name": "Kilograms",
|
||||
"unit_symbol": "kg",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
list_response = client.get("/api/production/")
|
||||
assert list_response.status_code == 200
|
||||
items = [
|
||||
item
|
||||
for item in list_response.json()
|
||||
if item["scenario_id"] == target_scenario
|
||||
]
|
||||
assert {item["amount"] for item in items} == {100.0, 150.0}
|
||||
|
||||
|
||||
def test_create_production_rejects_negative_amount(client: TestClient) -> None:
|
||||
scenario_id = _create_scenario(client)
|
||||
response = client.post(
|
||||
"/api/production/",
|
||||
json={
|
||||
"scenario_id": scenario_id,
|
||||
"amount": -5,
|
||||
"description": "Invalid output",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -1,123 +0,0 @@
|
||||
import math
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.reporting import generate_report
|
||||
|
||||
|
||||
def test_generate_report_empty():
|
||||
report = generate_report([])
|
||||
assert report == {
|
||||
"count": 0,
|
||||
"mean": 0.0,
|
||||
"median": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.0,
|
||||
"std_dev": 0.0,
|
||||
"variance": 0.0,
|
||||
"percentile_10": 0.0,
|
||||
"percentile_90": 0.0,
|
||||
"percentile_5": 0.0,
|
||||
"percentile_95": 0.0,
|
||||
"value_at_risk_95": 0.0,
|
||||
"expected_shortfall_95": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_generate_report_with_values():
|
||||
values: List[Dict[str, float]] = [
|
||||
{"iteration": 1, "result": 10.0},
|
||||
{"iteration": 2, "result": 20.0},
|
||||
{"iteration": 3, "result": 30.0},
|
||||
]
|
||||
report = generate_report(values)
|
||||
assert report["count"] == 3
|
||||
assert math.isclose(float(report["mean"]), 20.0)
|
||||
assert math.isclose(float(report["median"]), 20.0)
|
||||
assert math.isclose(float(report["min"]), 10.0)
|
||||
assert math.isclose(float(report["max"]), 30.0)
|
||||
assert math.isclose(float(report["std_dev"]), 8.1649658, rel_tol=1e-6)
|
||||
assert math.isclose(float(report["variance"]), 66.6666666, rel_tol=1e-6)
|
||||
assert math.isclose(float(report["percentile_10"]), 12.0)
|
||||
assert math.isclose(float(report["percentile_90"]), 28.0)
|
||||
assert math.isclose(float(report["percentile_5"]), 11.0)
|
||||
assert math.isclose(float(report["percentile_95"]), 29.0)
|
||||
assert math.isclose(float(report["value_at_risk_95"]), 11.0)
|
||||
assert math.isclose(float(report["expected_shortfall_95"]), 10.0)
|
||||
|
||||
|
||||
def test_generate_report_single_value():
|
||||
report = generate_report(
|
||||
[
|
||||
{"iteration": 1, "result": 42.0},
|
||||
]
|
||||
)
|
||||
assert report["count"] == 1
|
||||
assert report["std_dev"] == 0.0
|
||||
assert report["variance"] == 0.0
|
||||
assert report["percentile_10"] == 42.0
|
||||
assert report["expected_shortfall_95"] == 42.0
|
||||
|
||||
|
||||
def test_generate_report_ignores_invalid_entries():
|
||||
raw_values: List[Any] = [
|
||||
{"iteration": 1, "result": 10.0},
|
||||
"not-a-mapping",
|
||||
{"iteration": 2},
|
||||
{"iteration": 3, "result": None},
|
||||
{"iteration": 4, "result": 20},
|
||||
]
|
||||
report = generate_report(raw_values)
|
||||
assert report["count"] == 2
|
||||
assert math.isclose(float(report["mean"]), 15.0)
|
||||
assert math.isclose(float(report["min"]), 10.0)
|
||||
assert math.isclose(float(report["max"]), 20.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_client: TestClient) -> TestClient:
|
||||
return api_client
|
||||
|
||||
|
||||
def test_reporting_endpoint_invalid_input(client: TestClient):
|
||||
resp = client.post("/api/reporting/summary", json={})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid input format"
|
||||
|
||||
|
||||
def test_reporting_endpoint_success(client: TestClient):
|
||||
input_data: List[Dict[str, float]] = [
|
||||
{"iteration": 1, "result": 10.0},
|
||||
{"iteration": 2, "result": 20.0},
|
||||
{"iteration": 3, "result": 30.0},
|
||||
]
|
||||
resp = client.post("/api/reporting/summary", json=input_data)
|
||||
assert resp.status_code == 200
|
||||
data: Dict[str, Any] = resp.json()
|
||||
assert data["count"] == 3
|
||||
assert math.isclose(float(data["mean"]), 20.0)
|
||||
assert math.isclose(float(data["variance"]), 66.6666666, rel_tol=1e-6)
|
||||
assert math.isclose(float(data["value_at_risk_95"]), 11.0)
|
||||
assert math.isclose(float(data["expected_shortfall_95"]), 10.0)
|
||||
|
||||
|
||||
validation_error_cases: List[tuple[List[Any], str]] = [
|
||||
(["not-a-dict"], "Entry at index 0 must be an object"),
|
||||
([{"iteration": 1}], "Entry at index 0 must include numeric 'result'"),
|
||||
(
|
||||
[{"iteration": 1, "result": "bad"}],
|
||||
"Entry at index 0 must include numeric 'result'",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload,expected_detail", validation_error_cases)
|
||||
def test_reporting_endpoint_validation_errors(
|
||||
client: TestClient, payload: List[Any], expected_detail: str
|
||||
):
|
||||
resp = client.post("/api/reporting/summary", json=payload)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == expected_detail
|
||||
@@ -1,94 +0,0 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_duplicate_scenario_returns_400(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["scenario_duplicate"]
|
||||
response = api_client.post("/api/scenarios/", json=payload)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["detail"] == "Scenario already exists"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_parameter_create_missing_scenario_returns_404(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["parameter_missing_scenario"]
|
||||
response = api_client.post("/api/parameters/", json=payload)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Scenario not found"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_parameter_create_invalid_distribution_is_422(
|
||||
api_client: TestClient,
|
||||
) -> None:
|
||||
response = api_client.post(
|
||||
"/api/parameters/",
|
||||
json={
|
||||
"scenario_id": 1,
|
||||
"name": "Bad Dist",
|
||||
"value": 2.0,
|
||||
"distribution_type": "invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
errors = response.json()["detail"]
|
||||
assert any("distribution_type" in err["loc"] for err in errors)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_simulation_unknown_scenario_returns_404(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["simulation_unknown_scenario"]
|
||||
response = api_client.post("/api/simulations/run", json=payload)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Scenario not found"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_simulation_missing_parameters_returns_400(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["simulation_missing_parameters"]
|
||||
response = api_client.post("/api/simulations/run", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "No parameters provided"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_reporting_summary_rejects_non_list_payload(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["reporting_non_list_payload"]
|
||||
response = api_client.post("/api/reporting/summary", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid input format"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_reporting_summary_requires_result_field(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["reporting_missing_result"]
|
||||
response = api_client.post("/api/reporting/summary", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert "must include numeric 'result'" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("invalid_request_payloads")
|
||||
def test_maintenance_negative_cost_rejected_by_schema(
|
||||
api_client: TestClient, invalid_request_payloads: Dict[str, Any]
|
||||
) -> None:
|
||||
payload = invalid_request_payloads["maintenance_negative_cost"]
|
||||
response = api_client.post("/api/maintenance/", json=payload)
|
||||
assert response.status_code == 422
|
||||
error_locations = [tuple(item["loc"]) for item in response.json()["detail"]]
|
||||
assert ("body", "cost") in error_locations
|
||||
@@ -1,45 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from config.database import Base, engine
|
||||
from main import app
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def teardown_module(module):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_create_and_list_scenario():
|
||||
scenario_name = f"Scenario-{uuid4()}"
|
||||
response = client.post(
|
||||
"/api/scenarios/",
|
||||
json={"name": scenario_name, "description": "Integration test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == scenario_name
|
||||
|
||||
response2 = client.get("/api/scenarios/")
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
assert any(s["name"] == scenario_name for s in data2)
|
||||
|
||||
|
||||
def test_create_duplicate_scenario_rejected():
|
||||
scenario_name = f"Duplicate-{uuid4()}"
|
||||
payload = {"name": scenario_name, "description": "Primary"}
|
||||
|
||||
first_resp = client.post("/api/scenarios/", json=payload)
|
||||
assert first_resp.status_code == 200
|
||||
|
||||
second_resp = client.post("/api/scenarios/", json=payload)
|
||||
assert second_resp.status_code == 400
|
||||
assert second_resp.json()["detail"] == "Scenario already exists"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user