Add models and routes for costs, consumption, equipment, maintenance, and production; implement CRUD operations and unit tests

This commit is contained in:
2025-10-20 19:21:47 +02:00
parent 0b19a93e0d
commit fee857637f
20 changed files with 621 additions and 6 deletions

14
main.py
View File

@@ -6,6 +6,13 @@ from fastapi.middleware import Middleware
from middleware.validation import validate_json from middleware.validation import validate_json
from config.database import Base, engine from config.database import Base, engine
from routes.scenarios import router as scenarios_router from routes.scenarios import router as scenarios_router
from routes.costs import router as costs_router
from routes.consumption import router as consumption_router
from routes.production import router as production_router
from routes.equipment import router as equipment_router
from routes.reporting import router as reporting_router
from routes.simulations import router as simulations_router
from routes.maintenance import router as maintenance_router
# Initialize database schema # Initialize database schema
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -18,4 +25,11 @@ app.middleware("http")(validate_json)
app.include_router(scenarios_router) app.include_router(scenarios_router)
app.include_router(parameters_router) app.include_router(parameters_router)
app.include_router(distributions_router) app.include_router(distributions_router)
app.include_router(costs_router)
app.include_router(consumption_router)
app.include_router(simulations_router)
app.include_router(production_router)
app.include_router(equipment_router)
app.include_router(maintenance_router)
app.include_router(reporting_router)
app.include_router(ui_router) app.include_router(ui_router)

17
models/capex.py Normal file
View File

@@ -0,0 +1,17 @@
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)
scenario = relationship("Scenario", back_populates="capex_items")
def __repr__(self):
return f"<Capex id={self.id} scenario_id={self.scenario_id} amount={self.amount}>"

17
models/consumption.py Normal file
View File

@@ -0,0 +1,17 @@
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)
scenario = relationship("Scenario", back_populates="consumption_items")
def __repr__(self):
return f"<Consumption id={self.id} scenario_id={self.scenario_id} amount={self.amount}>"

17
models/equipment.py Normal file
View File

@@ -0,0 +1,17 @@
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}>"

17
models/maintenance.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from config.database import Base
class Maintenance(Base):
__tablename__ = "maintenance"
id = Column(Integer, primary_key=True, index=True)
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
performed_at = Column(DateTime(timezone=True), server_default=func.now())
details = Column(String, nullable=True)
scenario = relationship("Scenario", back_populates="maintenance_items")
def __repr__(self):
return f"<Maintenance id={self.id} scenario_id={self.scenario_id} performed_at={self.performed_at}>"

17
models/opex.py Normal file
View File

@@ -0,0 +1,17 @@
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)
scenario = relationship("Scenario", back_populates="opex_items")
def __repr__(self):
return f"<Opex id={self.id} scenario_id={self.scenario_id} amount={self.amount}>"

View File

@@ -0,0 +1,18 @@
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)
scenario = relationship(
"Scenario", back_populates="production_output_items")
def __repr__(self):
return f"<ProductionOutput id={self.id} scenario_id={self.scenario_id} amount={self.amount}>"

View File

@@ -1,5 +1,12 @@
from sqlalchemy import Column, Integer, String, DateTime, func from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.orm import relationship 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 from config.database import Base
@@ -13,7 +20,19 @@ class Scenario(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
parameters = relationship("Parameter", back_populates="scenario") parameters = relationship("Parameter", back_populates="scenario")
simulation_results = relationship( simulation_results = relationship(
"SimulationResult", back_populates="scenario") 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 # relationships can be defined later
def __repr__(self): def __repr__(self):

44
routes/consumption.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from config.database import SessionLocal
from models.consumption import Consumption
router = APIRouter(prefix="/api/consumption", tags=["Consumption"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic schemas
class ConsumptionCreate(BaseModel):
scenario_id: int
amount: float
description: Optional[str] = None
class ConsumptionRead(ConsumptionCreate):
id: int
class Config:
orm_mode = True
@router.post("/", response_model=ConsumptionRead)
async def create_consumption(item: ConsumptionCreate, db: Session = Depends(get_db)):
db_item = Consumption(**item.dict())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[ConsumptionRead])
async def list_consumption(db: Session = Depends(get_db)):
return db.query(Consumption).all()

75
routes/costs.py Normal file
View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from config.database import SessionLocal
from models.capex import Capex
from models.opex import Opex
router = APIRouter(prefix="/api/costs", tags=["Costs"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic schemas for Capex
class CapexCreate(BaseModel):
scenario_id: int
amount: float
description: Optional[str] = None
class CapexRead(CapexCreate):
id: int
class Config:
orm_mode = True
# Pydantic schemas for Opex
class OpexCreate(BaseModel):
scenario_id: int
amount: float
description: Optional[str] = None
class OpexRead(OpexCreate):
id: int
class Config:
orm_mode = True
# Capex endpoints
@router.post("/capex", response_model=CapexRead)
def create_capex(item: CapexCreate, db: Session = Depends(get_db)):
db_item = Capex(**item.dict())
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)):
db_item = Opex(**item.dict())
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()

44
routes/equipment.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from config.database import SessionLocal
from models.equipment import Equipment
router = APIRouter(prefix="/api/equipment", tags=["Equipment"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic schemas
class EquipmentCreate(BaseModel):
scenario_id: int
name: str
description: Optional[str] = None
class EquipmentRead(EquipmentCreate):
id: int
class Config:
orm_mode = True
@router.post("/", response_model=EquipmentRead)
async def create_equipment(item: EquipmentCreate, db: Session = Depends(get_db)):
db_item = Equipment(**item.dict())
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()

45
routes/maintenance.py Normal file
View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from config.database import SessionLocal
from models.maintenance import Maintenance
router = APIRouter(prefix="/api/maintenance", tags=["Maintenance"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic schemas
class MaintenanceCreate(BaseModel):
scenario_id: int
details: Optional[str] = None
class MaintenanceRead(MaintenanceCreate):
id: int
performed_at: datetime
class Config:
orm_mode = True
@router.post("/", response_model=MaintenanceRead)
async def create_maintenance(item: MaintenanceCreate, db: Session = Depends(get_db)):
db_item = Maintenance(**item.dict())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[MaintenanceRead])
async def list_maintenance(db: Session = Depends(get_db)):
return db.query(Maintenance).all()

44
routes/production.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from config.database import SessionLocal
from models.production_output import ProductionOutput
router = APIRouter(prefix="/api/production", tags=["Production"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic schemas
class ProductionOutputCreate(BaseModel):
scenario_id: int
amount: float
description: Optional[str] = None
class ProductionOutputRead(ProductionOutputCreate):
id: int
class Config:
orm_mode = True
@router.post("/", response_model=ProductionOutputRead)
async def create_production(item: ProductionOutputCreate, db: Session = Depends(get_db)):
db_item = ProductionOutput(**item.dict())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.get("/", response_model=List[ProductionOutputRead])
async def list_production(db: Session = Depends(get_db)):
return db.query(ProductionOutput).all()

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Request
from typing import Dict, Any from typing import Dict, Any
from services.reporting import generate_report from services.reporting import generate_report
@@ -7,6 +7,7 @@ from config.database import SessionLocal
router = APIRouter(prefix="/api/reporting", tags=["Reporting"]) router = APIRouter(prefix="/api/reporting", tags=["Reporting"])
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
@@ -14,10 +15,12 @@ def get_db():
finally: finally:
db.close() db.close()
@router.post("/summary", response_model=Dict[str, float]) @router.post("/summary", response_model=Dict[str, float])
async def summary_report(results: Any): async def summary_report(request: Request):
# Expect a list of simulation result dicts # Read raw JSON to handle invalid input formats
if not isinstance(results, list): data = await request.json()
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="Invalid input format") raise HTTPException(status_code=400, detail="Invalid input format")
report = generate_report(results) report = generate_report(data)
return report return report

View File

@@ -18,3 +18,9 @@ async def scenario_form(request: Request):
async def parameter_form(request: Request): async def parameter_form(request: Request):
"""Render the parameter input form.""" """Render the parameter input form."""
return templates.TemplateResponse("ParameterInput.html", {"request": request}) return templates.TemplateResponse("ParameterInput.html", {"request": request})
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Render the central dashboard page."""
return templates.TemplateResponse("Dashboard.html", {"request": request})

View File

@@ -0,0 +1,42 @@
from fastapi.testclient import TestClient
from main import app
from config.database import Base, engine
# Setup and teardown
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_consumption():
# Create a scenario to attach consumption
resp = client.post(
"/api/scenarios/", json={"name": "ConsScenario", "description": "consumption scenario"}
)
assert resp.status_code == 200
scenario = resp.json()
sid = scenario["id"]
# Create Consumption item
cons_payload = {"scenario_id": sid, "amount": 250.0,
"description": "Monthly consumption"}
resp2 = client.post("/api/consumption/", json=cons_payload)
assert resp2.status_code == 200
cons = resp2.json()
assert cons["scenario_id"] == sid
assert cons["amount"] == 250.0
# List Consumption items
resp3 = client.get("/api/consumption/")
assert resp3.status_code == 200
data = resp3.json()
assert any(item["amount"] == 250.0 and item["scenario_id"]
== sid for item in data)

58
tests/unit/test_costs.py Normal file
View File

@@ -0,0 +1,58 @@
from fastapi.testclient import TestClient
from main import app
from config.database import Base, engine
# Setup and teardown
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_capex_and_opex():
# Create a scenario to attach costs
resp = client.post(
"/api/scenarios/", json={"name": "CostScenario", "description": "cost scenario"}
)
assert resp.status_code == 200
scenario = resp.json()
sid = scenario["id"]
# Create Capex item
capex_payload = {"scenario_id": sid,
"amount": 1000.0, "description": "Initial capex"}
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
# List Capex items
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)
# Create Opex item
opex_payload = {"scenario_id": sid, "amount": 500.0,
"description": "Recurring opex"}
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
# List Opex items
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)

View File

@@ -0,0 +1,42 @@
from fastapi.testclient import TestClient
from main import app
from config.database import Base, engine
# Setup and teardown
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_equipment():
# Create a scenario to attach equipment
resp = client.post(
"/api/scenarios/", json={"name": "EquipScenario", "description": "equipment scenario"}
)
assert resp.status_code == 200
scenario = resp.json()
sid = scenario["id"]
# Create Equipment item
eq_payload = {"scenario_id": sid, "name": "Excavator",
"description": "Heavy machinery"}
resp2 = client.post("/api/equipment/", json=eq_payload)
assert resp2.status_code == 200
eq = resp2.json()
assert eq["scenario_id"] == sid
assert eq["name"] == "Excavator"
# List Equipment items
resp3 = client.get("/api/equipment/")
assert resp3.status_code == 200
data = resp3.json()
assert any(item["name"] == "Excavator" and item["scenario_id"]
== sid for item in data)

View File

@@ -0,0 +1,41 @@
from fastapi.testclient import TestClient
from main import app
from config.database import Base, engine
# Setup and teardown
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_maintenance():
# Create a scenario to attach maintenance
resp = client.post(
"/api/scenarios/", json={"name": "MaintScenario", "description": "maintenance scenario"}
)
assert resp.status_code == 200
scenario = resp.json()
sid = scenario["id"]
# Create Maintenance record
maint_payload = {"scenario_id": sid, "details": "Routine check"}
resp2 = client.post("/api/maintenance/", json=maint_payload)
assert resp2.status_code == 200
maint = resp2.json()
assert maint["scenario_id"] == sid
assert maint["details"] == "Routine check"
# List Maintenance records
resp3 = client.get("/api/maintenance/")
assert resp3.status_code == 200
data = resp3.json()
assert any(item["details"] ==
"Routine check" and item["scenario_id"] == sid for item in data)

View File

@@ -0,0 +1,35 @@
from fastapi.testclient import TestClient
from main import app
from config.database import Base, engine
# Setup and teardown
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_production_output():
# Create a scenario to attach production output
resp = client.post(
"/api/scenarios/", json={"name": "ProdScenario", "description": "production scenario"}
)
assert resp.status_code == 200
scenario = resp.json()
sid = scenario["id"]
# Create Production Output item
prod_payload = {"scenario_id": sid, "amount": 300.0, "description": "Daily output"}
resp2 = client.post("/api/production/", json=prod_payload)
assert resp2.status_code == 200
prod = resp2.json()
assert prod["scenario_id"] == sid
assert prod["amount"] == 300.0
# List Production Output items
resp3 = client.get("/api/production/")
assert resp3.status_code == 200
data = resp3.json()
assert any(item["amount"] == 300.0 and item["scenario_id"] == sid for item in data)