"""Fastify-equivalent API built on FastAPI. Request bodies validated by Pydantic.""" from typing import Literal from fastapi import FastAPI, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from app import db from app.boq import service as boq_service from app.calendar import service as calendar_service from app.dag.errors import CycleError from app.errors import BaselineExistsError, MissingDurationError, NotFoundError from app.pins import service as pin_service from app.projects import service as project_service from app.rules.errors import NoTemplateError from app.scheduler import service as scheduler_service from app.scheduler import version_repo from app.timeutil import parse_start ProcurementType = Literal["SUPPLY", "APPLY", "SUPPLY_AND_APPLY"] Responsibility = Literal["CONTRACTOR", "CUSTOMER", "ORG"] DepType = Literal["FS", "SS", "FF", "SF"] class ProjectCreate(BaseModel): name: str = Field(min_length=1) planned_start: str | None = None class BOQItemCreate(BaseModel): category_id: int room_id: int | None = None name: str = Field(min_length=1) quantity: float uom: str cost: float | None = None price: float | None = None procurement_type: ProcurementType procurement_responsibility: Responsibility is_material: bool metadata: dict = Field(default_factory=dict) class DependencyCreate(BaseModel): predecessor_id: int successor_id: int dep_type: DepType = "FS" lag_days: float = 0 reason: str | None = None class BaselineCreate(BaseModel): start_date: str ExceptionKind = Literal["HOLIDAY", "REDUCED_PERMIT", "OVERRIDE_OPEN"] PinType = Literal["START_NO_EARLIER", "START_NO_LATER", "MUST_START_ON", "MUST_FINISH_ON"] class WorkingHour(BaseModel): weekday: int = Field(ge=1, le=7) start: str end: str class CalendarExceptionIn(BaseModel): date: str kind: ExceptionKind start: str | None = None end: str | None = None reason: str | None = None class CalendarCreate(BaseModel): name: str = Field(min_length=1) timezone: str = "Asia/Dubai" parent_id: int | None = None hours_per_day: float = 8.0 working_hours: list[WorkingHour] = Field(default_factory=list) exceptions: list[CalendarExceptionIn] = Field(default_factory=list) class ProjectPatch(BaseModel): calendar_id: int | None = None planned_start: str | None = None class PinCreate(BaseModel): pin_type: PinType pin_date: str reason: str | None = None def create_app() -> FastAPI: app = FastAPI(title="Reno Scheduling Engine", version="0.2.0") @app.exception_handler(NoTemplateError) async def _no_template(_: Request, exc: NoTemplateError): return JSONResponse( status_code=422, content={ "error": "NO_TEMPLATE", "message": str(exc), "category_id": exc.category_id, "procurement_type": exc.procurement_type, }, ) @app.exception_handler(CycleError) async def _cycle(_: Request, exc: CycleError): return JSONResponse(status_code=409, content={"error": "CYCLE_DETECTED", "cycle": exc.cycle}) @app.exception_handler(BaselineExistsError) async def _baseline_exists(_: Request, exc: BaselineExistsError): return JSONResponse( status_code=409, content={"error": "BASELINE_EXISTS", "existing_version_id": exc.existing_version_id}, ) @app.exception_handler(NotFoundError) async def _not_found(_: Request, exc: NotFoundError): return JSONResponse(status_code=404, content={"error": "NOT_FOUND", "message": str(exc)}) @app.exception_handler(MissingDurationError) async def _missing_duration(_: Request, exc: MissingDurationError): return JSONResponse( status_code=422, content={"error": "MISSING_DURATION", "message": str(exc), "activity_id": exc.activity_id, "clock": exc.clock}, ) @app.post("/v1/projects", status_code=201) def post_project(body: ProjectCreate): return project_service.create_project(body.name, body.planned_start) @app.post("/v1/projects/{project_id}/boq-items", status_code=201) def post_boq_item(project_id: int, body: BOQItemCreate): return boq_service.create_boq_item(project_id, body.model_dump()) @app.post("/v1/projects/{project_id}/dependencies", status_code=201) def post_dependency(project_id: int, body: DependencyCreate): return project_service.add_dependency( project_id, body.predecessor_id, body.successor_id, body.dep_type, body.lag_days ) @app.post("/v1/projects/{project_id}/schedule/baseline", status_code=201) def post_baseline(project_id: int, body: BaselineCreate): tz = _project_timezone(project_id) result = scheduler_service.compute_baseline(project_id, parse_start(body.start_date, tz)) return { "schedule_version_id": result["id"], "kind": result["kind"], "computed_at": result["computed_at"], "inputs_hash": result["inputs_hash"], "stats": result["stats"], "project_finish": result["project_finish"], } @app.get("/v1/schedule-versions/{version_id}") def get_schedule_version(version_id: int): version = version_repo.get_version(version_id) if version is None: raise NotFoundError(f"schedule_version {version_id}") return version @app.get("/v1/projects/{project_id}/schedule/explain") def explain_schedule(project_id: int, start_date: str | None = None): from app.scheduler.explain import explain conn = db.connect() try: row = conn.execute( """SELECT p.planned_start, COALESCE(c.timezone, 'UTC') AS tz FROM projects p LEFT JOIN calendars c ON c.id = p.calendar_id WHERE p.id = ?""", (project_id,), ).fetchone() if row is None: raise NotFoundError(f"project {project_id}") start = parse_start(start_date or row["planned_start"] or "2026-07-01", row["tz"]) return explain(conn, project_id, start) finally: conn.close() # ---- Stage 3: calendars, pins, project patch ---- @app.post("/v1/calendars", status_code=201) def post_calendar(body: CalendarCreate): return calendar_service.create_calendar(body.model_dump()) @app.post("/v1/calendars/{calendar_id}/exceptions", status_code=201) def post_calendar_exception(calendar_id: int, body: CalendarExceptionIn): return calendar_service.add_exception(calendar_id, body.model_dump()) @app.get("/v1/calendars/{calendar_id}/expanded") def get_calendar_expanded( calendar_id: int, from_: str = Query(alias="from"), to: str = Query(...), ): return calendar_service.expanded(calendar_id, from_, to) @app.patch("/v1/projects/{project_id}") def patch_project(project_id: int, body: ProjectPatch): fields = body.model_dump(exclude_unset=True) return project_service.update_project( project_id, calendar_id=fields.get("calendar_id", ...), planned_start=fields.get("planned_start", ...), ) @app.post("/v1/activities/{activity_id}/pins", status_code=201) def post_pin(activity_id: int, body: PinCreate): return pin_service.upsert_pin( activity_id, body.pin_type, body.pin_date, body.reason, None ) @app.delete("/v1/activities/{activity_id}/pins/{pin_type}") def delete_pin(activity_id: int, pin_type: PinType): return pin_service.delete_pin(activity_id, pin_type) # ---- Read-only helper endpoints (support the Streamlit testing UI) ---- @app.get("/v1/categories") def list_categories(): return _query( "SELECT id, parent_id, level, code, name FROM categories ORDER BY level, id" ) @app.get("/v1/projects") def list_projects(): return _query("SELECT id, name, planned_start, status FROM projects ORDER BY id") @app.get("/v1/projects/{project_id}/activities") def list_activities(project_id: int): return _query( """SELECT id, boq_item_id, kind, name, effort_days, lead_time_days, clock, responsibility, status FROM activities WHERE project_id = ? ORDER BY id""", (project_id,), ) @app.get("/v1/projects/{project_id}/dependencies") def list_dependencies(project_id: int): return _query( """SELECT d.id, d.predecessor_id, d.successor_id, d.dep_type, d.lag_days, d.origin FROM activity_dependencies d JOIN activities a ON a.id = d.successor_id WHERE a.project_id = ? ORDER BY d.id""", (project_id,), ) @app.get("/v1/projects/{project_id}/schedule-versions") def list_schedule_versions(project_id: int): return _query( """SELECT id, kind, trigger, computed_at, inputs_hash, stats FROM schedule_versions WHERE project_id = ? ORDER BY id""", (project_id,), ) @app.get("/v1/projects/{project_id}/boq-items") def list_boq_items(project_id: int): return _query( """SELECT id, category_id, name, quantity, uom, procurement_type, procurement_responsibility, is_material FROM boq_items WHERE project_id = ? ORDER BY id""", (project_id,), ) return app def _query(sql: str, params: tuple = ()) -> list[dict]: conn = db.connect() try: return [dict(r) for r in conn.execute(sql, params).fetchall()] finally: conn.close() def _project_timezone(project_id: int) -> str: """The project's calendar timezone, or 'UTC' for 24/7 (no-calendar) projects.""" conn = db.connect() try: row = conn.execute( """SELECT COALESCE(c.timezone, 'UTC') AS tz FROM projects p LEFT JOIN calendars c ON c.id = p.calendar_id WHERE p.id = ?""", (project_id,), ).fetchone() return row["tz"] if row else "UTC" finally: conn.close() app = create_app()