Spaces:
Sleeping
Sleeping
| """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") | |
| 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, | |
| }, | |
| ) | |
| async def _cycle(_: Request, exc: CycleError): | |
| return JSONResponse(status_code=409, content={"error": "CYCLE_DETECTED", "cycle": exc.cycle}) | |
| async def _baseline_exists(_: Request, exc: BaselineExistsError): | |
| return JSONResponse( | |
| status_code=409, | |
| content={"error": "BASELINE_EXISTS", "existing_version_id": exc.existing_version_id}, | |
| ) | |
| async def _not_found(_: Request, exc: NotFoundError): | |
| return JSONResponse(status_code=404, content={"error": "NOT_FOUND", "message": str(exc)}) | |
| 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}, | |
| ) | |
| def post_project(body: ProjectCreate): | |
| return project_service.create_project(body.name, body.planned_start) | |
| def post_boq_item(project_id: int, body: BOQItemCreate): | |
| return boq_service.create_boq_item(project_id, body.model_dump()) | |
| 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 | |
| ) | |
| 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"], | |
| } | |
| 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 | |
| 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 ---- | |
| def post_calendar(body: CalendarCreate): | |
| return calendar_service.create_calendar(body.model_dump()) | |
| def post_calendar_exception(calendar_id: int, body: CalendarExceptionIn): | |
| return calendar_service.add_exception(calendar_id, body.model_dump()) | |
| def get_calendar_expanded( | |
| calendar_id: int, | |
| from_: str = Query(alias="from"), | |
| to: str = Query(...), | |
| ): | |
| return calendar_service.expanded(calendar_id, from_, to) | |
| 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", ...), | |
| ) | |
| def post_pin(activity_id: int, body: PinCreate): | |
| return pin_service.upsert_pin( | |
| activity_id, body.pin_type, body.pin_date, body.reason, None | |
| ) | |
| 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) ---- | |
| def list_categories(): | |
| return _query( | |
| "SELECT id, parent_id, level, code, name FROM categories ORDER BY level, id" | |
| ) | |
| def list_projects(): | |
| return _query("SELECT id, name, planned_start, status FROM projects ORDER BY id") | |
| 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,), | |
| ) | |
| 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,), | |
| ) | |
| 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,), | |
| ) | |
| 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() | |