diff --git a/main.py b/main.py index bc7b1ad..adde7fe 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,13 @@ from fastapi.middleware import Middleware from middleware.validation import validate_json from config.database import Base, engine 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 Base.metadata.create_all(bind=engine) @@ -18,4 +25,11 @@ app.middleware("http")(validate_json) app.include_router(scenarios_router) app.include_router(parameters_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) diff --git a/models/capex.py b/models/capex.py new file mode 100644 index 0000000..d088910 --- /dev/null +++ b/models/capex.py @@ -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"" \ No newline at end of file diff --git a/models/consumption.py b/models/consumption.py new file mode 100644 index 0000000..d5f0b30 --- /dev/null +++ b/models/consumption.py @@ -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"" diff --git a/models/equipment.py b/models/equipment.py new file mode 100644 index 0000000..e431891 --- /dev/null +++ b/models/equipment.py @@ -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"" diff --git a/models/maintenance.py b/models/maintenance.py new file mode 100644 index 0000000..d5f4672 --- /dev/null +++ b/models/maintenance.py @@ -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"" diff --git a/models/opex.py b/models/opex.py new file mode 100644 index 0000000..50df6c1 --- /dev/null +++ b/models/opex.py @@ -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"" diff --git a/models/production_output.py b/models/production_output.py new file mode 100644 index 0000000..95cdd05 --- /dev/null +++ b/models/production_output.py @@ -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"" diff --git a/models/scenario.py b/models/scenario.py index 2e0a611..3c9f19e 100644 --- a/models/scenario.py +++ b/models/scenario.py @@ -1,5 +1,12 @@ 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 @@ -13,7 +20,19 @@ class Scenario(Base): updated_at = Column(DateTime(timezone=True), onupdate=func.now()) parameters = relationship("Parameter", back_populates="scenario") 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 def __repr__(self): diff --git a/routes/consumption.py b/routes/consumption.py new file mode 100644 index 0000000..982048c --- /dev/null +++ b/routes/consumption.py @@ -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() diff --git a/routes/costs.py b/routes/costs.py new file mode 100644 index 0000000..5d85ca9 --- /dev/null +++ b/routes/costs.py @@ -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() diff --git a/routes/equipment.py b/routes/equipment.py new file mode 100644 index 0000000..99a2566 --- /dev/null +++ b/routes/equipment.py @@ -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() diff --git a/routes/maintenance.py b/routes/maintenance.py new file mode 100644 index 0000000..ea1a40f --- /dev/null +++ b/routes/maintenance.py @@ -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() diff --git a/routes/production.py b/routes/production.py new file mode 100644 index 0000000..7585ff9 --- /dev/null +++ b/routes/production.py @@ -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() diff --git a/routes/reporting.py b/routes/reporting.py index 1d6a6fa..c4adcff 100644 --- a/routes/reporting.py +++ b/routes/reporting.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Request from typing import Dict, Any from services.reporting import generate_report @@ -7,6 +7,7 @@ from config.database import SessionLocal router = APIRouter(prefix="/api/reporting", tags=["Reporting"]) + def get_db(): db = SessionLocal() try: @@ -14,10 +15,12 @@ def get_db(): finally: db.close() + @router.post("/summary", response_model=Dict[str, float]) -async def summary_report(results: Any): - # Expect a list of simulation result dicts - if not isinstance(results, list): +async def summary_report(request: Request): + # Read raw JSON to handle invalid input formats + data = await request.json() + if not isinstance(data, list): raise HTTPException(status_code=400, detail="Invalid input format") - report = generate_report(results) + report = generate_report(data) return report diff --git a/routes/ui.py b/routes/ui.py index b67b4af..e69fecc 100644 --- a/routes/ui.py +++ b/routes/ui.py @@ -18,3 +18,9 @@ async def scenario_form(request: Request): async def parameter_form(request: Request): """Render the parameter input form.""" 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}) diff --git a/tests/unit/test_consumption.py b/tests/unit/test_consumption.py new file mode 100644 index 0000000..b455ed8 --- /dev/null +++ b/tests/unit/test_consumption.py @@ -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) diff --git a/tests/unit/test_costs.py b/tests/unit/test_costs.py new file mode 100644 index 0000000..c7c3576 --- /dev/null +++ b/tests/unit/test_costs.py @@ -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) diff --git a/tests/unit/test_equipment.py b/tests/unit/test_equipment.py new file mode 100644 index 0000000..47b53ae --- /dev/null +++ b/tests/unit/test_equipment.py @@ -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) diff --git a/tests/unit/test_maintenance.py b/tests/unit/test_maintenance.py new file mode 100644 index 0000000..6348f21 --- /dev/null +++ b/tests/unit/test_maintenance.py @@ -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) diff --git a/tests/unit/test_production.py b/tests/unit/test_production.py new file mode 100644 index 0000000..4e96ce3 --- /dev/null +++ b/tests/unit/test_production.py @@ -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)