From c4e1370f5af6ded9dc0216a5e04f07f6876f8a6e Mon Sep 17 00:00:00 2001 From: Vichu Selvaraju Date: Mon, 12 Oct 2026 16:56:49 -0400 Subject: [PATCH 1/2] refactor: standardize pydantic schemas on Request/Response Add RequestSchema, ResponseSchema and ExternalPayload base classes and document the standard in agents/schemas.md. Convert the widget and hotel schemas from Create/Read to Request/Response without inheritance. --- AGENTS.md | 3 +- ARCHITECTURE.md | 2 +- agents/repository.md | 3 +- agents/routes.md | 9 ++- agents/schemas.md | 68 ++++++++++++++++++++ agents/services.md | 7 +- backend/foresight/routes/example_widget.py | 22 +++---- backend/foresight/schemas/base.py | 19 ++++++ backend/foresight/schemas/example_widget.py | 19 +++--- backend/foresight/schemas/hotel.py | 37 +++++++---- backend/foresight/services/example_widget.py | 4 +- 11 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 agents/schemas.md create mode 100644 backend/foresight/schemas/base.py diff --git a/AGENTS.md b/AGENTS.md index 2f9b5f5..043c59d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ GitHub Actions · ruff · Conventional Commits. routes → services → repository → database ``` -- **routes** — HTTP layer (FastAPI routers, request/response via Pydantic schemas). +- **routes** — HTTP layer (FastAPI routers, `*Request`/`*Response` Pydantic schemas). - **services** — business logic, orchestration, ML/parsers. - **repository** — data access; the only layer that talks to the ORM/session. - **database** — SQLAlchemy models, migrations, session/engine. @@ -38,6 +38,7 @@ Per-layer detail: - [`agents/services.md`](agents/services.md) - [`agents/repository.md`](agents/repository.md) - [`agents/database.md`](agents/database.md) +- [`agents/schemas.md`](agents/schemas.md) — Pydantic standard (all layers) ## Commands (run from repo root) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ad82925..3b11f0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,7 +101,7 @@ backend/ │ ├── main.py # FastAPI app entrypoint; routers registered here │ ├── config.py # Settings (pydantic-settings) │ ├── routes/ # API endpoints (one module per resource) -│ ├── schemas/ # Pydantic request/response models +│ ├── schemas/ # Pydantic *Request/*Response schemas (agents/schemas.md) │ ├── services/ # Business logic │ │ ├── ml/ # ML models (regression, bayesian, timeseries, ...) │ │ └── parsers/ # Client/external data ingestion diff --git a/agents/repository.md b/agents/repository.md index fa0c9aa..2fddc12 100644 --- a/agents/repository.md +++ b/agents/repository.md @@ -18,7 +18,8 @@ returns models/data back up. No business rules, no HTTP. once per request; CLIs and jobs commit once at the end. Use `flush()` when you need generated ids. - Encapsulate all query/CRUD logic here so services stay persistence-agnostic. -- Return ORM models or simple data; let callers map to schemas. +- Take plain values (never schemas); return ORM models or simple data. Routes map + results to `*Response` schemas. ## Rules / invariants - Only this layer imports `session` / ORM query APIs. diff --git a/agents/routes.md b/agents/routes.md index 3e03922..590504e 100644 --- a/agents/routes.md +++ b/agents/routes.md @@ -11,15 +11,18 @@ and no database access here. - `backend/foresight/routes/` — one module per resource (e.g. `health.py`). - `backend/foresight/main.py` — app setup; routers registered with `app.include_router(...)`. -- `backend/foresight/schemas/` — Pydantic request/response models used here. +- `backend/foresight/schemas/` — Pydantic `*Request` / `*Response` models used + here. See [`schemas.md`](schemas.md) for the standard. ## Patterns & conventions - One `APIRouter` per module, with `tags=[...]`; include it in `main.py`. -- Handlers are `async def`. Type request bodies and responses with schemas. +- Handlers are `async def`. Take bodies as `*Request` schemas; declare + `response_model=` with a matching `*Response` return type. - Inject the session as `Depends(get_session, scope="function")` (see `SessionDep` in the widget example). It commits once per request after the handler returns; handlers and services never commit. -- Call into services; translate service results into responses. Keep handlers thin. +- Call into services; build the response with `XxxResponse.model_validate(result)`. + Keep handlers thin. ## Rules / invariants - Never import the ORM, session, or repository directly — go through a service. diff --git a/agents/schemas.md b/agents/schemas.md new file mode 100644 index 0000000..933b2c0 --- /dev/null +++ b/agents/schemas.md @@ -0,0 +1,68 @@ +# Schemas — Agent Context + +**Updated:** 2026-10-12 + +## Purpose +Pydantic models that validate and convert data at the edges of the app: HTTP +requests and responses, and payloads from external APIs. They are not the +database models (those are SQLAlchemy, in `database/models/`) and they are not +for passing data between our own functions. + +## Key files +- `backend/foresight/schemas/base.py` — `RequestSchema`, `ResponseSchema`, + `ExternalPayload` base classes. +- `backend/foresight/schemas/.py` — HTTP schemas, one module per resource. +- `backend/foresight/schemas/example_widget.py` — reference example. + +## When to use Pydantic +| Data | Use | +|---|---| +| HTTP request body | `Request(RequestSchema)` | +| HTTP response body | `Response(ResponseSchema)` | +| Payload from an external API | `Payload(ExternalPayload)` | +| Config | `pydantic-settings` (`config.py`) | +| Data passed between our own functions | ORM object or `@dataclass` — **not** Pydantic | + +Create one schema per distinct shape that crosses a boundary — not one per table. +A table never sent over HTTP needs no schema. + +## Patterns & conventions +- **Naming by direction:** `Request` for what a client sends, + `Response` for what an endpoint returns. Only create a `Request` if the + endpoint takes a body. +- **Base classes:** + - `RequestSchema` — `extra="forbid"`: unknown fields are rejected with a 422. + - `ResponseSchema` — `from_attributes=True`: build it from an ORM object with + `XxxResponse.model_validate(obj)`. + - `ExternalPayload` — `extra="ignore"`: external APIs add fields; we only + model what we use. +- **No inheritance between Request and Response.** Each lists its fields + explicitly. Duplication is fine; it keeps input fields from silently leaking + into output. +- **Nested items** follow the same naming (e.g. `ContributionResponse` inside + `RecommendationResponse`). +- **Fields:** `Decimal` for money, `date`/`datetime` for time. Use + `Field(description=..., ge=..., le=...)` for constraints and docs — they show + up in `/docs`. Use `@computed_field` for values derived from other fields. + +## How each layer uses them +- **Routes** take `Request` schemas as parameters, declare `response_model=`, + and build the `Response` with `model_validate(...)`. +- **Services** may take a `Request` schema as input. They return ORM objects or + dataclasses — never `Response` schemas (services don't build HTTP responses). +- **Repository** never imports schemas; it takes plain values. +- **External API clients** (services) parse raw responses into `ExternalPayload` + models, then map them to domain objects/ORM models. Payload models live in the + module that calls the API, not in `schemas/`. + +## Rules / invariants +- Every route declares `response_model=` and a matching return type. +- Never return an ORM object's sensitive/internal fields — only what the + `Response` declares is sent. +- No business logic in schemas beyond simple `@computed_field` derivations. + +## Reference example +Copy the pattern in `backend/foresight/schemas/example_widget.py`. + +## How to verify +`just dev`, check the schemas at http://localhost:8000/docs. Run `just lint`. diff --git a/agents/services.md b/agents/services.md index b76a3c4..a891b64 100644 --- a/agents/services.md +++ b/agents/services.md @@ -16,8 +16,11 @@ domain rules live — they're called by routes and call the repository. through `repository/`. ## Patterns & conventions -- Pure-ish domain functions/classes; take plain inputs or schemas, return domain - data — not HTTP responses. +- Pure-ish domain functions/classes; take plain inputs or `*Request` schemas, + return domain data (ORM objects or dataclasses) — never `*Response` schemas. + See [`schemas.md`](schemas.md). +- Parse external API responses into `ExternalPayload` models in the module that + calls the API. - Get data through the repository layer; don't query the ORM here. - Keep ML, parsing, and orchestration in focused modules; prefer small units. diff --git a/backend/foresight/routes/example_widget.py b/backend/foresight/routes/example_widget.py index 1bafa20..3cd9bf2 100644 --- a/backend/foresight/routes/example_widget.py +++ b/backend/foresight/routes/example_widget.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from foresight.database.session import get_session -from foresight.schemas.example_widget import WidgetCreate, WidgetRead +from foresight.schemas.example_widget import WidgetRequest, WidgetResponse from foresight.services import example_widget as widget_service router = APIRouter(prefix="/example/widgets", tags=["example"]) @@ -27,32 +27,32 @@ SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] -@router.post("", response_model=WidgetRead, status_code=status.HTTP_201_CREATED) +@router.post("", response_model=WidgetResponse, status_code=status.HTTP_201_CREATED) async def create_widget( - data: WidgetCreate, + data: WidgetRequest, session: SessionDep, -) -> WidgetRead: +) -> WidgetResponse: widget = await widget_service.create_widget(session, data) - return WidgetRead.model_validate(widget) + return WidgetResponse.model_validate(widget) -@router.get("", response_model=list[WidgetRead]) +@router.get("", response_model=list[WidgetResponse]) async def list_widgets( session: SessionDep, -) -> list[WidgetRead]: +) -> list[WidgetResponse]: widgets = await widget_service.list_widgets(session) - return [WidgetRead.model_validate(w) for w in widgets] + return [WidgetResponse.model_validate(w) for w in widgets] -@router.get("/{widget_id}", response_model=WidgetRead) +@router.get("/{widget_id}", response_model=WidgetResponse) async def get_widget( widget_id: int, session: SessionDep, -) -> WidgetRead: +) -> WidgetResponse: widget = await widget_service.get_widget(session, widget_id) if widget is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Widget {widget_id} not found", ) - return WidgetRead.model_validate(widget) + return WidgetResponse.model_validate(widget) diff --git a/backend/foresight/schemas/base.py b/backend/foresight/schemas/base.py new file mode 100644 index 0000000..a806777 --- /dev/null +++ b/backend/foresight/schemas/base.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, ConfigDict + + +class RequestSchema(BaseModel): + """Base for request bodies. Unknown fields are rejected with a 422.""" + + model_config = ConfigDict(extra="forbid") + + +class ResponseSchema(BaseModel): + """Base for response bodies. Can be built from an ORM object.""" + + model_config = ConfigDict(from_attributes=True) + + +class ExternalPayload(BaseModel): + """Base for data from external APIs. Fields we don't model are ignored.""" + + model_config = ConfigDict(extra="ignore") diff --git a/backend/foresight/schemas/example_widget.py b/backend/foresight/schemas/example_widget.py index 8efc319..c606520 100644 --- a/backend/foresight/schemas/example_widget.py +++ b/backend/foresight/schemas/example_widget.py @@ -3,21 +3,24 @@ Pydantic shapes for the `Widget` reference slice. Layer rules shown here: -- `*Create` schemas describe the request body (no `id`, no server fields). -- `*Read` schemas describe the response and set `from_attributes=True` so they - can be built directly from an ORM model instance. +- `*Request` schemas describe a request body: only the fields a client may set. +- `*Response` schemas describe what an endpoint returns, built from an ORM + object. Each lists its fields explicitly; Request and Response never inherit + from each other. +- See `agents/schemas.md` for the full standard. """ -from pydantic import BaseModel, ConfigDict +from foresight.schemas.base import RequestSchema, ResponseSchema -class WidgetCreate(BaseModel): +class WidgetRequest(RequestSchema): name: str description: str | None = None quantity: int = 0 -class WidgetRead(WidgetCreate): - model_config = ConfigDict(from_attributes=True) - +class WidgetResponse(ResponseSchema): id: int + name: str + description: str | None + quantity: int diff --git a/backend/foresight/schemas/hotel.py b/backend/foresight/schemas/hotel.py index 17ac8b6..27e8e40 100644 --- a/backend/foresight/schemas/hotel.py +++ b/backend/foresight/schemas/hotel.py @@ -3,10 +3,12 @@ from datetime import date from decimal import Decimal -from pydantic import BaseModel, ConfigDict, computed_field +from pydantic import computed_field +from foresight.schemas.base import RequestSchema, ResponseSchema -class HotelCreate(BaseModel): + +class HotelRequest(RequestSchema): name: str city: str total_rooms: int @@ -14,26 +16,31 @@ class HotelCreate(BaseModel): longitude: float | None = None -class HotelRead(HotelCreate): - model_config = ConfigDict(from_attributes=True) - +class HotelResponse(ResponseSchema): id: int + name: str + city: str + total_rooms: int + latitude: float | None + longitude: float | None -class RoomTypeCreate(BaseModel): +class RoomTypeRequest(RequestSchema): hotel_id: int name: str room_count: int base_rate: Decimal -class RoomTypeRead(RoomTypeCreate): - model_config = ConfigDict(from_attributes=True) - +class RoomTypeResponse(ResponseSchema): id: int + hotel_id: int + name: str + room_count: int + base_rate: Decimal -class DailyPerformanceCreate(BaseModel): +class DailyPerformanceRequest(RequestSchema): room_type_id: int stay_date: date rate: Decimal | None = None @@ -42,12 +49,16 @@ class DailyPerformanceCreate(BaseModel): room_revenue: Decimal = Decimal("0") -class DailyPerformanceRead(DailyPerformanceCreate): +class DailyPerformanceResponse(ResponseSchema): """Adds the three standard hotel metrics, calculated rather than stored.""" - model_config = ConfigDict(from_attributes=True) - id: int + room_type_id: int + stay_date: date + rate: Decimal | None + rooms_available: int + rooms_sold: int + room_revenue: Decimal @computed_field @property diff --git a/backend/foresight/services/example_widget.py b/backend/foresight/services/example_widget.py index bcbe8e5..abec566 100644 --- a/backend/foresight/services/example_widget.py +++ b/backend/foresight/services/example_widget.py @@ -17,10 +17,10 @@ from foresight.database.models.example_widget import Widget from foresight.repository import example_widget as widget_repo -from foresight.schemas.example_widget import WidgetCreate +from foresight.schemas.example_widget import WidgetRequest -async def create_widget(session: AsyncSession, data: WidgetCreate) -> Widget: +async def create_widget(session: AsyncSession, data: WidgetRequest) -> Widget: return await widget_repo.create_widget( session, name=data.name, From 7d0f1fd2f1d4dd01cef6c024b8a74b1ee7093b75 Mon Sep 17 00:00:00 2001 From: bhuvanh66 Date: Tue, 13 Oct 2026 14:06:52 -0400 Subject: [PATCH 2/2] feat: add API to serve hotel and signal data Hotels and room types (list, get, create), nightly performance by date range, and read-only weather, events and flight arrivals. List endpoints paginate; unknown ids return 404. The recommendation endpoint returns a placeholder in the final response shape. Schemas follow the Request/Response standard and repositories never commit. Co-authored-by: Vichu Selvaraju --- backend/foresight/main.py | 4 +- backend/foresight/repository/hotel.py | 90 +++++++++++++- backend/foresight/repository/pagination.py | 21 ++++ backend/foresight/repository/signals.py | 68 ++++++++++ backend/foresight/routes/common.py | 42 +++++++ backend/foresight/routes/hotels.py | 124 +++++++++++++++++++ backend/foresight/routes/signals.py | 64 ++++++++++ backend/foresight/schemas/common.py | 10 ++ backend/foresight/schemas/recommendation.py | 35 ++++++ backend/foresight/schemas/signals.py | 62 ++++++++++ backend/foresight/services/hotel.py | 80 ++++++++++++ backend/foresight/services/recommendation.py | 55 ++++++++ backend/foresight/services/signals.py | 59 +++++++++ 13 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 backend/foresight/repository/pagination.py create mode 100644 backend/foresight/repository/signals.py create mode 100644 backend/foresight/routes/common.py create mode 100644 backend/foresight/routes/hotels.py create mode 100644 backend/foresight/routes/signals.py create mode 100644 backend/foresight/schemas/common.py create mode 100644 backend/foresight/schemas/recommendation.py create mode 100644 backend/foresight/schemas/signals.py create mode 100644 backend/foresight/services/hotel.py create mode 100644 backend/foresight/services/recommendation.py create mode 100644 backend/foresight/services/signals.py diff --git a/backend/foresight/main.py b/backend/foresight/main.py index 7d66cae..5db795a 100644 --- a/backend/foresight/main.py +++ b/backend/foresight/main.py @@ -2,7 +2,7 @@ from scalar_fastapi import Theme, get_scalar_api_reference from foresight.config import settings -from foresight.routes import example_widget, health +from foresight.routes import example_widget, health, hotels, signals app = FastAPI( title=settings.app_name, @@ -13,6 +13,8 @@ ) app.include_router(health.router) +app.include_router(hotels.router) +app.include_router(signals.router) app.include_router(example_widget.router) diff --git a/backend/foresight/repository/hotel.py b/backend/foresight/repository/hotel.py index 2df1aa1..b23787d 100644 --- a/backend/foresight/repository/hotel.py +++ b/backend/foresight/repository/hotel.py @@ -2,14 +2,16 @@ from typing import TYPE_CHECKING, Any, cast -from sqlalchemy import func +from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType from foresight.repository import Outcome, upsert_rows +from foresight.repository.pagination import fetch_page if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Iterable, Mapping, Sequence + from datetime import date from decimal import Decimal from sqlalchemy import Table @@ -88,3 +90,87 @@ async def upsert_daily_performance( mutable=_PERF_MEASURES, rows=rows, ) + + +async def create_hotel( + session: AsyncSession, + *, + name: str, + city: str, + total_rooms: int, + latitude: float | None, + longitude: float | None, +) -> Hotel: + hotel = Hotel( + name=name, + city=city, + total_rooms=total_rooms, + latitude=latitude, + longitude=longitude, + ) + session.add(hotel) + await session.flush() + await session.refresh(hotel) + return hotel + + +async def create_room_type( + session: AsyncSession, + *, + hotel_id: int, + name: str, + room_count: int, + base_rate: Decimal, +) -> RoomType: + room_type = RoomType( + hotel_id=hotel_id, name=name, room_count=room_count, base_rate=base_rate + ) + session.add(room_type) + await session.flush() + await session.refresh(room_type) + return room_type + + +async def list_hotels( + session: AsyncSession, *, limit: int, offset: int +) -> tuple[Sequence[Hotel], int]: + return await fetch_page( + session, select(Hotel).order_by(Hotel.id), limit=limit, offset=offset + ) + + +async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType | None: + return await session.get(RoomType, room_type_id) + + +async def list_room_types( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[RoomType], int]: + stmt = select(RoomType).where(RoomType.hotel_id == hotel_id).order_by(RoomType.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def list_performance( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + room_type_id: int | None, + limit: int, + offset: int, +) -> tuple[Sequence[DailyPerformance], int]: + """Nights for every room type of a hotel; `daily_performance` has no hotel_id.""" + stmt = ( + select(DailyPerformance) + .join(RoomType, DailyPerformance.room_type_id == RoomType.id) + .where(RoomType.hotel_id == hotel_id) + ) + if room_type_id is not None: + stmt = stmt.where(DailyPerformance.room_type_id == room_type_id) + if start is not None: + stmt = stmt.where(DailyPerformance.stay_date >= start) + if end is not None: + stmt = stmt.where(DailyPerformance.stay_date <= end) + stmt = stmt.order_by(DailyPerformance.stay_date, DailyPerformance.room_type_id) + return await fetch_page(session, stmt, limit=limit, offset=offset) diff --git a/backend/foresight/repository/pagination.py b/backend/foresight/repository/pagination.py new file mode 100644 index 0000000..cd08a4a --- /dev/null +++ b/backend/foresight/repository/pagination.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import Select, func, select +from sqlalchemy.ext.asyncio import AsyncSession + + +async def fetch_page[T]( + session: AsyncSession, + stmt: Select[tuple[T]], + *, + limit: int, + offset: int, +) -> tuple[Sequence[T], int]: + """Run an ordered SELECT and return one page of it plus the unpaged row count.""" + count_stmt: Select[Any] = select(func.count()).select_from( + stmt.order_by(None).subquery() + ) + total = await session.scalar(count_stmt) + result = await session.scalars(stmt.limit(limit).offset(offset)) + return result.all(), total or 0 diff --git a/backend/foresight/repository/signals.py b/backend/foresight/repository/signals.py new file mode 100644 index 0000000..d4cf2d4 --- /dev/null +++ b/backend/foresight/repository/signals.py @@ -0,0 +1,68 @@ +from collections.abc import Collection, Sequence +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.models.external import Event, FlightArrival, Weather +from foresight.ingest.normalize import EventStatus +from foresight.repository.pagination import fetch_page + + +async def list_weather( + session: AsyncSession, + *, + city: str, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Weather], int]: + # `weather.city` is free text, so compare without regard to case. + stmt = select(Weather).where(func.lower(Weather.city) == city.lower()) + if start is not None: + stmt = stmt.where(Weather.weather_date >= start) + if end is not None: + stmt = stmt.where(Weather.weather_date <= end) + stmt = stmt.order_by(Weather.weather_date, Weather.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def list_events( + session: AsyncSession, + *, + city_slug: str, + start: date | None, + end: date | None, + exclude_statuses: Collection[EventStatus], + limit: int, + offset: int, +) -> tuple[Sequence[Event], int]: + """Events whose inclusive local-date range overlaps [start, end].""" + stmt = select(Event).where(Event.city_slug == city_slug) + if exclude_statuses: + stmt = stmt.where(Event.status.not_in(exclude_statuses)) + if start is not None: + stmt = stmt.where(Event.end_local_date >= start) + if end is not None: + stmt = stmt.where(Event.start_local_date <= end) + stmt = stmt.order_by(Event.start_local_date, Event.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) + + +async def list_flight_arrivals( + session: AsyncSession, + *, + airport: str, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[FlightArrival], int]: + stmt = select(FlightArrival).where(FlightArrival.airport == airport) + if start is not None: + stmt = stmt.where(FlightArrival.arrival_date >= start) + if end is not None: + stmt = stmt.where(FlightArrival.arrival_date <= end) + stmt = stmt.order_by(FlightArrival.arrival_date, FlightArrival.id) + return await fetch_page(session, stmt, limit=limit, offset=offset) diff --git a/backend/foresight/routes/common.py b/backend/foresight/routes/common.py new file mode 100644 index 0000000..da0e6b9 --- /dev/null +++ b/backend/foresight/routes/common.py @@ -0,0 +1,42 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.session import get_session +from foresight.schemas.base import ResponseSchema +from foresight.schemas.common import PageResponse + +SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")] + + +@dataclass +class Pagination: + """`?limit=&offset=` query params shared by every list endpoint.""" + + limit: Annotated[int, Query(ge=1, le=200)] = 50 + offset: Annotated[int, Query(ge=0)] = 0 + + +PageDep = Annotated[Pagination, Depends()] + + +def paginate[T: ResponseSchema]( + schema: type[T], rows: Sequence[Any], total: int, page: Pagination +) -> PageResponse[T]: + """Map ORM rows to `schema` and wrap them in the shared page envelope.""" + return PageResponse( + items=[schema.model_validate(row) for row in rows], + total=total, + limit=page.limit, + offset=page.offset, + ) + + +def not_found(entity: str, entity_id: int) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{entity} {entity_id} not found", + ) diff --git a/backend/foresight/routes/hotels.py b/backend/foresight/routes/hotels.py new file mode 100644 index 0000000..37c27d9 --- /dev/null +++ b/backend/foresight/routes/hotels.py @@ -0,0 +1,124 @@ +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Query, status + +from foresight.routes.common import PageDep, SessionDep, not_found, paginate +from foresight.schemas.common import PageResponse +from foresight.schemas.hotel import ( + DailyPerformanceResponse, + HotelRequest, + HotelResponse, + RoomTypeRequest, + RoomTypeResponse, +) +from foresight.schemas.recommendation import RecommendationResponse +from foresight.services import hotel as hotel_service +from foresight.services import recommendation as recommendation_service + +router = APIRouter(tags=["hotels"]) + + +@router.post( + "/hotels", response_model=HotelResponse, status_code=status.HTTP_201_CREATED +) +async def create_hotel(data: HotelRequest, session: SessionDep) -> HotelResponse: + hotel = await hotel_service.create_hotel(session, data) + return HotelResponse.model_validate(hotel) + + +@router.get("/hotels", response_model=PageResponse[HotelResponse]) +async def list_hotels( + session: SessionDep, page: PageDep +) -> PageResponse[HotelResponse]: + hotels, total = await hotel_service.list_hotels( + session, limit=page.limit, offset=page.offset + ) + return paginate(HotelResponse, hotels, total, page) + + +@router.get("/hotels/{hotel_id}", response_model=HotelResponse) +async def get_hotel(hotel_id: int, session: SessionDep) -> HotelResponse: + hotel = await hotel_service.get_hotel(session, hotel_id) + if hotel is None: + raise not_found("Hotel", hotel_id) + return HotelResponse.model_validate(hotel) + + +@router.post( + "/room-types", response_model=RoomTypeResponse, status_code=status.HTTP_201_CREATED +) +async def create_room_type( + data: RoomTypeRequest, session: SessionDep +) -> RoomTypeResponse: + room_type = await hotel_service.create_room_type(session, data) + if room_type is None: + raise not_found("Hotel", data.hotel_id) + return RoomTypeResponse.model_validate(room_type) + + +@router.get("/room-types/{room_type_id}", response_model=RoomTypeResponse) +async def get_room_type(room_type_id: int, session: SessionDep) -> RoomTypeResponse: + room_type = await hotel_service.get_room_type(session, room_type_id) + if room_type is None: + raise not_found("Room type", room_type_id) + return RoomTypeResponse.model_validate(room_type) + + +@router.get( + "/hotels/{hotel_id}/room-types", response_model=PageResponse[RoomTypeResponse] +) +async def list_room_types( + hotel_id: int, session: SessionDep, page: PageDep +) -> PageResponse[RoomTypeResponse]: + result = await hotel_service.list_room_types( + session, hotel_id, limit=page.limit, offset=page.offset + ) + if result is None: + raise not_found("Hotel", hotel_id) + room_types, total = result + return paginate(RoomTypeResponse, room_types, total, page) + + +@router.get( + "/hotels/{hotel_id}/performance", + response_model=PageResponse[DailyPerformanceResponse], +) +async def list_performance( + hotel_id: int, + session: SessionDep, + page: PageDep, + start: date | None = None, + end: date | None = None, + room_type_id: int | None = None, +) -> PageResponse[DailyPerformanceResponse]: + """Nightly performance across the hotel's room types; `start`/`end` inclusive.""" + result = await hotel_service.list_performance( + session, + hotel_id, + start=start, + end=end, + room_type_id=room_type_id, + limit=page.limit, + offset=page.offset, + ) + if result is None: + raise not_found("Hotel", hotel_id) + nights, total = result + return paginate(DailyPerformanceResponse, nights, total, page) + + +@router.get("/hotels/{hotel_id}/recommendations", response_model=RecommendationResponse) +async def get_recommendation( + hotel_id: int, + session: SessionDep, + # Named `stay_date` in code because `date` is the type; the query key stays `date`. + stay_date: Annotated[date, Query(alias="date")], +) -> RecommendationResponse: + """Placeholder rate for one night, in the final response shape.""" + recommendation = await recommendation_service.get_recommendation( + session, hotel_id, stay_date + ) + if recommendation is None: + raise not_found("Hotel", hotel_id) + return recommendation diff --git a/backend/foresight/routes/signals.py b/backend/foresight/routes/signals.py new file mode 100644 index 0000000..7178a8e --- /dev/null +++ b/backend/foresight/routes/signals.py @@ -0,0 +1,64 @@ +from datetime import date + +from fastapi import APIRouter + +from foresight.routes.common import PageDep, SessionDep, paginate +from foresight.schemas.common import PageResponse +from foresight.schemas.signals import ( + EventResponse, + FlightArrivalResponse, + WeatherResponse, +) +from foresight.services import signals as signals_service + +router = APIRouter(prefix="/signals", tags=["signals"]) + + +@router.get("/weather", response_model=PageResponse[WeatherResponse]) +async def list_weather( + session: SessionDep, + page: PageDep, + city: str, + start: date | None = None, + end: date | None = None, +) -> PageResponse[WeatherResponse]: + """Daily weather for a city. Forecast and recorded rows are both returned.""" + rows, total = await signals_service.list_weather( + session, city=city, start=start, end=end, limit=page.limit, offset=page.offset + ) + return paginate(WeatherResponse, rows, total, page) + + +@router.get("/events", response_model=PageResponse[EventResponse]) +async def list_events( + session: SessionDep, + page: PageDep, + city: str, + start: date | None = None, + end: date | None = None, +) -> PageResponse[EventResponse]: + """Events in a city overlapping the date range; cancelled/deleted are hidden.""" + rows, total = await signals_service.list_events( + session, city=city, start=start, end=end, limit=page.limit, offset=page.offset + ) + return paginate(EventResponse, rows, total, page) + + +@router.get("/flight-arrivals", response_model=PageResponse[FlightArrivalResponse]) +async def list_flight_arrivals( + session: SessionDep, + page: PageDep, + airport: str, + start: date | None = None, + end: date | None = None, +) -> PageResponse[FlightArrivalResponse]: + """Arrivals for an airport code exactly as stored (sources differ: DUB, EIDW).""" + rows, total = await signals_service.list_flight_arrivals( + session, + airport=airport, + start=start, + end=end, + limit=page.limit, + offset=page.offset, + ) + return paginate(FlightArrivalResponse, rows, total, page) diff --git a/backend/foresight/schemas/common.py b/backend/foresight/schemas/common.py new file mode 100644 index 0000000..9160195 --- /dev/null +++ b/backend/foresight/schemas/common.py @@ -0,0 +1,10 @@ +from foresight.schemas.base import ResponseSchema + + +class PageResponse[T](ResponseSchema): + """One page of a list endpoint: the rows plus where they sit in the full set.""" + + items: list[T] + total: int # rows matching the filters, ignoring limit/offset + limit: int + offset: int diff --git a/backend/foresight/schemas/recommendation.py b/backend/foresight/schemas/recommendation.py new file mode 100644 index 0000000..b15027b --- /dev/null +++ b/backend/foresight/schemas/recommendation.py @@ -0,0 +1,35 @@ +from datetime import date +from decimal import Decimal +from typing import Literal + +from foresight.schemas.base import ResponseSchema + + +class FactorResponse(ResponseSchema): + """One signal's pull on the recommended rate.""" + + signal: str # e.g. "events", "weather", "flights" + direction: Literal["up", "down", "neutral"] + detail: str + + +class ExplanationResponse(ResponseSchema): + summary: str + factors: list[FactorResponse] + + +class ConfidenceIntervalResponse(ResponseSchema): + lower: Decimal + upper: Decimal + level: float # coverage, e.g. 0.95 + + +class RecommendationResponse(ResponseSchema): + hotel_id: int + stay_date: date + recommended_rate: Decimal + currency: str + explanation: ExplanationResponse + confidence_interval: ConfidenceIntervalResponse + model_version: str + is_placeholder: bool # true until the pricing model is wired in diff --git a/backend/foresight/schemas/signals.py b/backend/foresight/schemas/signals.py new file mode 100644 index 0000000..16f57d2 --- /dev/null +++ b/backend/foresight/schemas/signals.py @@ -0,0 +1,62 @@ +from datetime import date, datetime +from decimal import Decimal + +from foresight.ingest.normalize import ( + DatePrecision, + EventCategory, + EventStatus, + Source, +) +from foresight.schemas.base import ResponseSchema + + +class WeatherResponse(ResponseSchema): + id: int + city: str + weather_date: date + temp_max_c: float | None + temp_min_c: float | None + precip_mm: float | None + wind_kph: float | None + condition: str | None + is_forecast: bool + source: str + captured_at: datetime + + +class EventResponse(ResponseSchema): + """A merged event. Raw per-source sightings (`event_observations`) stay internal.""" + + id: int + title: str + category: EventCategory + venue: str | None + city: str + country: str | None + latitude: float | None + longitude: float | None + start_local_date: date + end_local_date: date # inclusive + start_at_utc: datetime | None + end_at_utc: datetime | None + timezone: str + date_precision: DatePrecision + expected_attendance: int | None + impact_rank: int | None + local_rank: int | None + confidence: float + status: EventStatus + url: str | None + primary_source: Source + first_seen_at: datetime + last_seen_at: datetime + + +class FlightArrivalResponse(ResponseSchema): + id: int + airport: str + arrival_date: date + flight_count: int | None + avg_fare_eur: Decimal | None + source: str + captured_at: datetime diff --git a/backend/foresight/services/hotel.py b/backend/foresight/services/hotel.py new file mode 100644 index 0000000..c640495 --- /dev/null +++ b/backend/foresight/services/hotel.py @@ -0,0 +1,80 @@ +from collections.abc import Sequence +from datetime import date + +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType +from foresight.repository import hotel as hotel_repo +from foresight.schemas.hotel import HotelRequest, RoomTypeRequest + + +async def create_hotel(session: AsyncSession, data: HotelRequest) -> Hotel: + return await hotel_repo.create_hotel( + session, + name=data.name, + city=data.city, + total_rooms=data.total_rooms, + latitude=data.latitude, + longitude=data.longitude, + ) + + +async def get_hotel(session: AsyncSession, hotel_id: int) -> Hotel | None: + return await hotel_repo.get_hotel(session, hotel_id) + + +async def list_hotels( + session: AsyncSession, *, limit: int, offset: int +) -> tuple[Sequence[Hotel], int]: + return await hotel_repo.list_hotels(session, limit=limit, offset=offset) + + +async def create_room_type( + session: AsyncSession, data: RoomTypeRequest +) -> RoomType | None: + if await hotel_repo.get_hotel(session, data.hotel_id) is None: + return None + return await hotel_repo.create_room_type( + session, + hotel_id=data.hotel_id, + name=data.name, + room_count=data.room_count, + base_rate=data.base_rate, + ) + + +async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType | None: + return await hotel_repo.get_room_type(session, room_type_id) + + +async def list_room_types( + session: AsyncSession, hotel_id: int, *, limit: int, offset: int +) -> tuple[Sequence[RoomType], int] | None: + if await hotel_repo.get_hotel(session, hotel_id) is None: + return None + return await hotel_repo.list_room_types( + session, hotel_id, limit=limit, offset=offset + ) + + +async def list_performance( + session: AsyncSession, + hotel_id: int, + *, + start: date | None, + end: date | None, + room_type_id: int | None, + limit: int, + offset: int, +) -> tuple[Sequence[DailyPerformance], int] | None: + if await hotel_repo.get_hotel(session, hotel_id) is None: + return None + return await hotel_repo.list_performance( + session, + hotel_id, + start=start, + end=end, + room_type_id=room_type_id, + limit=limit, + offset=offset, + ) diff --git a/backend/foresight/services/recommendation.py b/backend/foresight/services/recommendation.py new file mode 100644 index 0000000..6964901 --- /dev/null +++ b/backend/foresight/services/recommendation.py @@ -0,0 +1,55 @@ +from datetime import date +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.repository import hotel as hotel_repo +from foresight.schemas.recommendation import ( + ConfidenceIntervalResponse, + ExplanationResponse, + FactorResponse, + RecommendationResponse, +) + + +def _placeholder(hotel_id: int, stay_date: date) -> RecommendationResponse: + return RecommendationResponse( + hotel_id=hotel_id, + stay_date=stay_date, + recommended_rate=Decimal("150.00"), + currency="EUR", + explanation=ExplanationResponse( + summary="Placeholder recommendation; the pricing model isn't wired in yet.", + factors=[ + FactorResponse( + signal="events", + direction="neutral", + detail="Placeholder: nearby events are not yet considered.", + ), + FactorResponse( + signal="weather", + direction="neutral", + detail="Placeholder: the forecast is not yet considered.", + ), + FactorResponse( + signal="flights", + direction="neutral", + detail="Placeholder: flight arrivals are not yet considered.", + ), + ], + ), + confidence_interval=ConfidenceIntervalResponse( + lower=Decimal("135.00"), upper=Decimal("165.00"), level=0.95 + ), + model_version="stub-0", + is_placeholder=True, + ) + + +async def get_recommendation( + session: AsyncSession, hotel_id: int, stay_date: date +) -> RecommendationResponse | None: + """The recommendation for one night, or `None` if the hotel doesn't exist.""" + if await hotel_repo.get_hotel(session, hotel_id) is None: + return None + return _placeholder(hotel_id, stay_date) diff --git a/backend/foresight/services/signals.py b/backend/foresight/services/signals.py new file mode 100644 index 0000000..1e7f1b9 --- /dev/null +++ b/backend/foresight/services/signals.py @@ -0,0 +1,59 @@ +from collections.abc import Sequence +from datetime import date + +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.models.external import Event, FlightArrival, Weather +from foresight.ingest.normalize import EventStatus, slugify_city +from foresight.repository import signals as signals_repo + +# Withdrawn or called-off events shouldn't count as demand. +_INACTIVE_EVENT_STATUSES = (EventStatus.DELETED, EventStatus.CANCELLED) + + +async def list_weather( + session: AsyncSession, + *, + city: str, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Weather], int]: + return await signals_repo.list_weather( + session, city=city, start=start, end=end, limit=limit, offset=offset + ) + + +async def list_events( + session: AsyncSession, + *, + city: str, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[Event], int]: + return await signals_repo.list_events( + session, + city_slug=slugify_city(city), + start=start, + end=end, + exclude_statuses=_INACTIVE_EVENT_STATUSES, + limit=limit, + offset=offset, + ) + + +async def list_flight_arrivals( + session: AsyncSession, + *, + airport: str, + start: date | None, + end: date | None, + limit: int, + offset: int, +) -> tuple[Sequence[FlightArrival], int]: + return await signals_repo.list_flight_arrivals( + session, airport=airport, start=start, end=end, limit=limit, offset=offset + )