From 1a652c32f6b716711e3073c8a0c80ffcf41ad179 Mon Sep 17 00:00:00 2001 From: bhuvanh66 Date: Mon, 21 Sep 2026 12:05:01 -0400 Subject: [PATCH] feat: initial schema implementation --- .github/workflows/ci.yml | 41 +++++++ .../72e850f4fa7b_hotel_and_external_schema.py | 115 ++++++++++++++++++ backend/src/database/models/__init__.py | 14 ++- backend/src/database/models/external.py | 97 +++++++++++++++ backend/src/database/models/hotel.py | 59 +++++++++ backend/src/schemas/hotel.py | 74 +++++++++++ 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 backend/src/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py create mode 100644 backend/src/database/models/external.py create mode 100644 backend/src/database/models/hotel.py create mode 100644 backend/src/schemas/hotel.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c997f7d..4620c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,47 @@ jobs: chore revert + commit-messages: + name: Commit Messages (Conventional Commits) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Fetch full PR history so we can validate every commit in the range. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install conventional-pre-commit + run: pip install "conventional-pre-commit==4.1.0" + + - name: Check each commit message + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + types="feat fix docs style refactor perf test build ci chore revert" + status=0 + # Validate every commit introduced by the PR (excludes merge commits). + for sha in $(git rev-list --no-merges "$BASE_SHA".."$HEAD_SHA"); do + msg_file="$(mktemp)" + git log -1 --format=%B "$sha" > "$msg_file" + echo "::group::$(git log -1 --format='%h %s' "$sha")" + if conventional-pre-commit $types "$msg_file"; then + echo "OK" + else + status=1 + fi + echo "::endgroup::" + done + exit $status + lint: name: Lint & Format runs-on: ubuntu-latest diff --git a/backend/src/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py b/backend/src/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py new file mode 100644 index 0000000..9449a41 --- /dev/null +++ b/backend/src/database/migrations/versions/72e850f4fa7b_hotel_and_external_schema.py @@ -0,0 +1,115 @@ +"""hotel and external schema + +Revision ID: 72e850f4fa7b +Revises: +Create Date: 2026-09-25 14:48:50.165775 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '72e850f4fa7b' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('competitor_rates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hotel_name', sa.String(length=200), nullable=False), + sa.Column('city', sa.String(length=120), nullable=False), + sa.Column('stay_date', sa.Date(), nullable=False), + sa.Column('rate', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('is_sold_out', sa.Boolean(), nullable=False), + sa.Column('source', sa.String(length=40), nullable=False), + sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=300), nullable=False), + sa.Column('category', sa.String(length=60), nullable=True), + sa.Column('venue', sa.String(length=200), nullable=True), + sa.Column('city', sa.String(length=120), nullable=False), + sa.Column('start_date', sa.Date(), nullable=False), + sa.Column('end_date', sa.Date(), nullable=False), + sa.Column('expected_attendance', sa.Integer(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('source', sa.String(length=40), nullable=False), + sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('flight_arrivals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('airport', sa.String(length=10), nullable=False), + sa.Column('arrival_date', sa.Date(), nullable=False), + sa.Column('flight_count', sa.Integer(), nullable=True), + sa.Column('avg_fare_eur', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('source', sa.String(length=40), nullable=False), + sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('hotels', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('city', sa.String(length=120), nullable=False), + sa.Column('total_rooms', sa.Integer(), nullable=False), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('weather', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('city', sa.String(length=120), nullable=False), + sa.Column('weather_date', sa.Date(), nullable=False), + sa.Column('temp_max_c', sa.Float(), nullable=True), + sa.Column('temp_min_c', sa.Float(), nullable=True), + sa.Column('precip_mm', sa.Float(), nullable=True), + sa.Column('wind_kph', sa.Float(), nullable=True), + sa.Column('condition', sa.String(length=60), nullable=True), + sa.Column('is_forecast', sa.Boolean(), nullable=False), + sa.Column('source', sa.String(length=40), nullable=False), + sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('room_types', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hotel_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('room_count', sa.Integer(), nullable=False), + sa.Column('base_rate', sa.Numeric(precision=10, scale=2), nullable=False), + sa.ForeignKeyConstraint(['hotel_id'], ['hotels.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('daily_performance', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('room_type_id', sa.Integer(), nullable=False), + sa.Column('stay_date', sa.Date(), nullable=False), + sa.Column('rate', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('rooms_available', sa.Integer(), nullable=False), + sa.Column('rooms_sold', sa.Integer(), nullable=False), + sa.Column('room_revenue', sa.Numeric(precision=10, scale=2), nullable=False), + sa.ForeignKeyConstraint(['room_type_id'], ['room_types.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('room_type_id', 'stay_date') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('daily_performance') + op.drop_table('room_types') + op.drop_table('weather') + op.drop_table('hotels') + op.drop_table('flight_arrivals') + op.drop_table('events') + op.drop_table('competitor_rates') + # ### end Alembic commands ### diff --git a/backend/src/database/models/__init__.py b/backend/src/database/models/__init__.py index d061142..f3862a0 100644 --- a/backend/src/database/models/__init__.py +++ b/backend/src/database/models/__init__.py @@ -1,4 +1,16 @@ # Import all models here so Alembic can detect them + from database.base import Base +from database.models.external import CompetitorRate, Event, FlightArrival, Weather +from database.models.hotel import DailyPerformance, Hotel, RoomType -__all__ = ["Base"] +__all__ = [ + "Base", + "CompetitorRate", + "DailyPerformance", + "Event", + "FlightArrival", + "Hotel", + "RoomType", + "Weather", +] diff --git a/backend/src/database/models/external.py b/backend/src/database/models/external.py new file mode 100644 index 0000000..8f9baa7 --- /dev/null +++ b/backend/src/database/models/external.py @@ -0,0 +1,97 @@ +"""External factors: weather, events, competitor pricing, flights.""" + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from database.base import Base + + +class Weather(Base): + """Daily weather for a city. + + Rows are either a forecast (is_forecast=True, may be revised) or a recorded + actual (is_forecast=False, final). + """ + + __tablename__ = "weather" + + id: Mapped[int] = mapped_column(primary_key=True) + city: Mapped[str] = mapped_column(String(120)) + weather_date: Mapped[date] = mapped_column(Date) # the day being described + + temp_max_c: Mapped[float | None] + temp_min_c: Mapped[float | None] + precip_mm: Mapped[float | None] + wind_kph: Mapped[float | None] + condition: Mapped[str | None] = mapped_column(String(60)) # "rain", "cloudy", ... + + is_forecast: Mapped[bool] + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class Event(Base): + """Something happening nearby that drives demand.""" + + __tablename__ = "events" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(300)) + category: Mapped[str | None] = mapped_column(String(60)) + venue: Mapped[str | None] = mapped_column(String(200)) + city: Mapped[str] = mapped_column(String(120)) + + start_date: Mapped[date] = mapped_column(Date) + end_date: Mapped[date] = mapped_column(Date) + + expected_attendance: Mapped[int | None] + + latitude: Mapped[float | None] + longitude: Mapped[float | None] + + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class CompetitorRate(Base): + """What another hotel is charging for a given night.""" + + __tablename__ = "competitor_rates" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_name: Mapped[str] = mapped_column(String(200)) # the competitor + city: Mapped[str] = mapped_column(String(120)) + + stay_date: Mapped[date] = mapped_column(Date) # the night being priced + rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # null if sold out + is_sold_out: Mapped[bool] = mapped_column(default=False) + + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class FlightArrival(Base): + """How many people are flying in on a given day.""" + + __tablename__ = "flight_arrivals" + + id: Mapped[int] = mapped_column(primary_key=True) + airport: Mapped[str] = mapped_column(String(10)) # "DUB" + arrival_date: Mapped[date] = mapped_column(Date) + + flight_count: Mapped[int | None] + avg_fare_eur: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) + + source: Mapped[str] = mapped_column(String(40)) + captured_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/src/database/models/hotel.py b/backend/src/database/models/hotel.py new file mode 100644 index 0000000..35caf0f --- /dev/null +++ b/backend/src/database/models/hotel.py @@ -0,0 +1,59 @@ +"""Hotel data.""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from database.base import Base + + +class Hotel(Base): + __tablename__ = "hotels" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200)) + city: Mapped[str] = mapped_column(String(120)) + total_rooms: Mapped[int] + + # Used to measure distance to event venues. + latitude: Mapped[float | None] + longitude: Mapped[float | None] + + room_types: Mapped[list["RoomType"]] = relationship(back_populates="hotel") + + +class RoomType(Base): + """A category of room, e.g. "Deluxe King". Price is set per room type.""" + + __tablename__ = "room_types" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_id: Mapped[int] = mapped_column(ForeignKey("hotels.id")) + name: Mapped[str] = mapped_column(String(120)) + room_count: Mapped[int] # how many physical rooms of this type + base_rate: Mapped[Decimal] = mapped_column(Numeric(10, 2)) # normal price + + hotel: Mapped["Hotel"] = relationship(back_populates="room_types") + nights: Mapped[list["DailyPerformance"]] = relationship(back_populates="room_type") + + +class DailyPerformance(Base): + """One room type, one night: what we charged and how it sold.""" + + __tablename__ = "daily_performance" + + id: Mapped[int] = mapped_column(primary_key=True) + room_type_id: Mapped[int] = mapped_column(ForeignKey("room_types.id")) + + stay_date: Mapped[date] = mapped_column(Date) # the night being sold + rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # price we listed + rooms_available: Mapped[int] + rooms_sold: Mapped[int] = mapped_column(default=0) + room_revenue: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) + + room_type: Mapped["RoomType"] = relationship(back_populates="nights") + + # One row per room type per night. + __table_args__ = (UniqueConstraint("room_type_id", "stay_date"),) diff --git a/backend/src/schemas/hotel.py b/backend/src/schemas/hotel.py new file mode 100644 index 0000000..17ac8b6 --- /dev/null +++ b/backend/src/schemas/hotel.py @@ -0,0 +1,74 @@ +"""API shapes for hotel data.""" + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, computed_field + + +class HotelCreate(BaseModel): + name: str + city: str + total_rooms: int + latitude: float | None = None + longitude: float | None = None + + +class HotelRead(HotelCreate): + model_config = ConfigDict(from_attributes=True) + + id: int + + +class RoomTypeCreate(BaseModel): + hotel_id: int + name: str + room_count: int + base_rate: Decimal + + +class RoomTypeRead(RoomTypeCreate): + model_config = ConfigDict(from_attributes=True) + + id: int + + +class DailyPerformanceCreate(BaseModel): + room_type_id: int + stay_date: date + rate: Decimal | None = None + rooms_available: int + rooms_sold: int = 0 + room_revenue: Decimal = Decimal("0") + + +class DailyPerformanceRead(DailyPerformanceCreate): + """Adds the three standard hotel metrics, calculated rather than stored.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + + @computed_field + @property + def occupancy(self) -> float | None: + """Share of rooms sold, 0-1.""" + if not self.rooms_available: + return None + return round(self.rooms_sold / self.rooms_available, 4) + + @computed_field + @property + def adr(self) -> Decimal | None: + """Average Daily Rate: revenue per room sold.""" + if not self.rooms_sold: + return None + return round(self.room_revenue / self.rooms_sold, 2) + + @computed_field + @property + def revpar(self) -> Decimal | None: + """Revenue Per Available Room: revenue per room we had to sell.""" + if not self.rooms_available: + return None + return round(self.room_revenue / self.rooms_available, 2)