suvradeepp's picture
Deploy reno scheduling engine (FastAPI + Streamlit)
45ca73e verified
Raw
History Blame Contribute Delete
10.7 kB
"""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()