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
|
||||||
pytest-cov
|
pytest-cov
|
||||||
pytest-httpx
|
pytest-httpx
|
||||||
pytest-playwright
|
|
||||||
python-jose
|
python-jose
|
||||||
ruff
|
ruff
|
||||||
|
black
|
||||||
|
mypy
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
pydantic>=2.0,<3.0
|
pydantic
|
||||||
uvicorn
|
uvicorn
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
psycopg2-binary
|
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