Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ###
14 changes: 13 additions & 1 deletion backend/src/database/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
97 changes: 97 additions & 0 deletions backend/src/database/models/external.py
Original file line number Diff line number Diff line change
@@ -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()
)
59 changes: 59 additions & 0 deletions backend/src/database/models/hotel.py
Original file line number Diff line number Diff line change
@@ -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"),)
Loading
Loading