From e899acf9395a770882fb54195a330b5f6d88edbd Mon Sep 17 00:00:00 2001 From: itelo Date: Mon, 24 Aug 2026 14:56:24 -0300 Subject: [PATCH] feat(eval): add Django PMS fixture Faithful analog of the fastapi-pms fixture, same domain (Space, Reservation, derived Guest; half-open availability). Part of ENG-2920. Co-Authored-By: Claude Opus 4.8 (1M context) --- eval/fixtures/django-pms/.env.example | 6 + eval/fixtures/django-pms/.gitignore | 20 ++ eval/fixtures/django-pms/README.md | 41 +++ eval/fixtures/django-pms/config/__init__.py | 0 eval/fixtures/django-pms/config/asgi.py | 9 + eval/fixtures/django-pms/config/settings.py | 72 ++++++ eval/fixtures/django-pms/config/urls.py | 7 + eval/fixtures/django-pms/config/wsgi.py | 9 + eval/fixtures/django-pms/fixture.json | 5 + eval/fixtures/django-pms/manage.py | 21 ++ eval/fixtures/django-pms/pms/__init__.py | 0 eval/fixtures/django-pms/pms/apps.py | 8 + eval/fixtures/django-pms/pms/availability.py | 55 ++++ eval/fixtures/django-pms/pms/forms.py | 89 +++++++ .../django-pms/pms/migrations/0001_initial.py | 100 ++++++++ .../django-pms/pms/migrations/__init__.py | 0 eval/fixtures/django-pms/pms/models.py | 79 ++++++ eval/fixtures/django-pms/pms/queries.py | 72 ++++++ eval/fixtures/django-pms/pms/space_kinds.py | 15 ++ .../django-pms/pms/templates/pms/base.html | 76 ++++++ .../django-pms/pms/templates/pms/guests.html | 21 ++ .../django-pms/pms/templates/pms/index.html | 43 ++++ .../pms/templates/pms/reservations.html | 86 +++++++ .../django-pms/pms/templates/pms/spaces.html | 61 +++++ eval/fixtures/django-pms/pms/urls.py | 34 +++ eval/fixtures/django-pms/pms/views.py | 234 ++++++++++++++++++ eval/fixtures/django-pms/requirements.txt | 3 + 27 files changed, 1166 insertions(+) create mode 100644 eval/fixtures/django-pms/.env.example create mode 100644 eval/fixtures/django-pms/.gitignore create mode 100644 eval/fixtures/django-pms/README.md create mode 100644 eval/fixtures/django-pms/config/__init__.py create mode 100644 eval/fixtures/django-pms/config/asgi.py create mode 100644 eval/fixtures/django-pms/config/settings.py create mode 100644 eval/fixtures/django-pms/config/urls.py create mode 100644 eval/fixtures/django-pms/config/wsgi.py create mode 100644 eval/fixtures/django-pms/fixture.json create mode 100644 eval/fixtures/django-pms/manage.py create mode 100644 eval/fixtures/django-pms/pms/__init__.py create mode 100644 eval/fixtures/django-pms/pms/apps.py create mode 100644 eval/fixtures/django-pms/pms/availability.py create mode 100644 eval/fixtures/django-pms/pms/forms.py create mode 100644 eval/fixtures/django-pms/pms/migrations/0001_initial.py create mode 100644 eval/fixtures/django-pms/pms/migrations/__init__.py create mode 100644 eval/fixtures/django-pms/pms/models.py create mode 100644 eval/fixtures/django-pms/pms/queries.py create mode 100644 eval/fixtures/django-pms/pms/space_kinds.py create mode 100644 eval/fixtures/django-pms/pms/templates/pms/base.html create mode 100644 eval/fixtures/django-pms/pms/templates/pms/guests.html create mode 100644 eval/fixtures/django-pms/pms/templates/pms/index.html create mode 100644 eval/fixtures/django-pms/pms/templates/pms/reservations.html create mode 100644 eval/fixtures/django-pms/pms/templates/pms/spaces.html create mode 100644 eval/fixtures/django-pms/pms/urls.py create mode 100644 eval/fixtures/django-pms/pms/views.py create mode 100644 eval/fixtures/django-pms/requirements.txt diff --git a/eval/fixtures/django-pms/.env.example b/eval/fixtures/django-pms/.env.example new file mode 100644 index 0000000..23186a7 --- /dev/null +++ b/eval/fixtures/django-pms/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and fill in. The .env file is gitignored — never commit a real key. +SEAM_API_KEY= + +# Optional: override the Django secret key in real deployments. A fake +# django-insecure-* fallback is used for local development when this is unset. +DJANGO_SECRET_KEY= diff --git a/eval/fixtures/django-pms/.gitignore b/eval/fixtures/django-pms/.gitignore new file mode 100644 index 0000000..d561a9a --- /dev/null +++ b/eval/fixtures/django-pms/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Local SQLite dev database (auto-created by migrate) +db.sqlite3 +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal + +# Environment (keep the example, ignore the real thing) +.env +.env.* +!.env.example + +# macOS +.DS_Store diff --git a/eval/fixtures/django-pms/README.md b/eval/fixtures/django-pms/README.md new file mode 100644 index 0000000..57408dc --- /dev/null +++ b/eval/fixtures/django-pms/README.md @@ -0,0 +1,41 @@ +# Django PMS + +A tiny property-management app built with [Django](https://www.djangoproject.com/) +and its ORM + templates. It manages **spaces** (bookable rooms, suites, cabins…), +takes **reservations** against them, and lists the **guests** who have booked. It +is the Django/Python counterpart of the FastAPI `fastapi-pms` and Next.js +`nextjs-pms` samples, with the same domain so the three exercise the same +integration. + +## Getting started + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # then fill in SEAM_API_KEY +python manage.py migrate +python manage.py runserver +``` + +Open [http://localhost:8000](http://localhost:8000). The SQLite database +(`db.sqlite3`) is created by `manage.py migrate`. + +## Layout + +- `manage.py` — Django's management CLI. +- `config/` — the project: `settings.py`, `urls.py`, `wsgi.py`, `asgi.py`. +- `pms/models.py` — the `Space` and `Reservation` models. +- `pms/forms.py` — form validation, run on every mutation. +- `pms/availability.py` — overlap/capacity checks shared by booking and the front desk. +- `pms/queries.py` — the read queries behind each page, plus the derived guest view. +- `pms/space_kinds.py` — the kinds of bookable space and their labels. +- `pms/views.py` — the four pages and the POST handlers behind them. +- `pms/urls.py` — the route table. +- `pms/templates/pms/` — the pages: booking form, reservations, spaces, guests. + +## Pages + +- `/` — the public booking form. +- `/reservations` — the front desk: confirm/cancel, assign or move a space, delete. +- `/spaces` — inventory: add a space, archive or restore one. +- `/guests` — unique guests, deduped by email. diff --git a/eval/fixtures/django-pms/config/__init__.py b/eval/fixtures/django-pms/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eval/fixtures/django-pms/config/asgi.py b/eval/fixtures/django-pms/config/asgi.py new file mode 100644 index 0000000..ec80f0c --- /dev/null +++ b/eval/fixtures/django-pms/config/asgi.py @@ -0,0 +1,9 @@ +"""ASGI entry point for the PMS fixture.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/eval/fixtures/django-pms/config/settings.py b/eval/fixtures/django-pms/config/settings.py new file mode 100644 index 0000000..a2be871 --- /dev/null +++ b/eval/fixtures/django-pms/config/settings.py @@ -0,0 +1,72 @@ +"""Django settings for the PMS fixture. + +Minimal but runnable. Reads SEAM_API_KEY and DJANGO_SECRET_KEY from the +environment (optionally via a local .env), so nothing secret is committed. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +# Load .env so SEAM_API_KEY (and anything else) is available via os.environ. +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Never hardcode a real secret. The fallback is an obviously-fake dev-only value, +# matching what `django-admin startproject` generates; override in real deploys. +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", "django-insecure-dev-only-do-not-use-in-production" +) + +# The Seam API key the integration code will use. Read here so it is available +# app-wide via `from django.conf import settings; settings.SEAM_API_KEY`. +SEAM_API_KEY = os.environ.get("SEAM_API_KEY", "") + +DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true" + +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "pms", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + # APP_DIRS finds pms/templates/pms/*.html automatically. + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# SQLite by default; the db.sqlite3 file is created by `python manage.py migrate`. +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +STATIC_URL = "static/" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/eval/fixtures/django-pms/config/urls.py b/eval/fixtures/django-pms/config/urls.py new file mode 100644 index 0000000..515f7aa --- /dev/null +++ b/eval/fixtures/django-pms/config/urls.py @@ -0,0 +1,7 @@ +"""Root URL config: everything lives under the pms app.""" + +from django.urls import include, path + +urlpatterns = [ + path("", include("pms.urls")), +] diff --git a/eval/fixtures/django-pms/config/wsgi.py b/eval/fixtures/django-pms/config/wsgi.py new file mode 100644 index 0000000..a13378b --- /dev/null +++ b/eval/fixtures/django-pms/config/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI entry point for the PMS fixture.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/eval/fixtures/django-pms/fixture.json b/eval/fixtures/django-pms/fixture.json new file mode 100644 index 0000000..b30acb8 --- /dev/null +++ b/eval/fixtures/django-pms/fixture.json @@ -0,0 +1,5 @@ +{ + "name": "django-pms", + "sdk": "python", + "framework": "Django" +} diff --git a/eval/fixtures/django-pms/manage.py b/eval/fixtures/django-pms/manage.py new file mode 100644 index 0000000..582115c --- /dev/null +++ b/eval/fixtures/django-pms/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and available " + "on your PYTHONPATH? Did you forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/eval/fixtures/django-pms/pms/__init__.py b/eval/fixtures/django-pms/pms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eval/fixtures/django-pms/pms/apps.py b/eval/fixtures/django-pms/pms/apps.py new file mode 100644 index 0000000..5fe24c3 --- /dev/null +++ b/eval/fixtures/django-pms/pms/apps.py @@ -0,0 +1,8 @@ +"""App config for the property-management app.""" + +from django.apps import AppConfig + + +class PmsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pms" diff --git a/eval/fixtures/django-pms/pms/availability.py b/eval/fixtures/django-pms/pms/availability.py new file mode 100644 index 0000000..3bc79b7 --- /dev/null +++ b/eval/fixtures/django-pms/pms/availability.py @@ -0,0 +1,55 @@ +"""Availability helpers shared by booking and front-desk reassignment.""" + +from pms.models import Reservation, Space + + +class BookingError(Exception): + """A guest-readable reason a space can't take a stay.""" + + +def booked_space_ids( + check_in: str, + check_out: str, + exclude_id: int | None = None, +) -> set[int]: + """Space ids already held for the given range, excluding one reservation. + + Reservations hold a space for the half-open interval [check_in, check_out), + so a same-day turnover (one guest out, the next in) is not a conflict. + Cancelled reservations release the space. ISO YYYY-MM-DD sorts + lexicographically, so text compare is date compare. + """ + overlapping = ( + Reservation.objects.filter(space_id__isnull=False) + .exclude(status="cancelled") + .filter(check_in__lt=check_out, check_out__gt=check_in) + ) + if exclude_id is not None: + overlapping = overlapping.exclude(id=exclude_id) + + return set(overlapping.values_list("space_id", flat=True)) + + +def assert_space_bookable( + *, + space_id: int, + check_in: str, + check_out: str, + party_size: int, + exclude_id: int | None = None, +) -> Space: + """Assert a space can take a stay, raising BookingError if not.""" + space = Space.objects.filter(id=space_id).first() + if space is None: + raise BookingError("That space no longer exists.") + if space.status != "active": + raise BookingError(f"{space.name} is archived and can't be booked.") + if party_size > space.capacity: + raise BookingError( + f"{space.name} sleeps {space.capacity}, but this stay is for {party_size}." + ) + + if space_id in booked_space_ids(check_in, check_out, exclude_id): + raise BookingError(f"{space.name} is already booked for those dates.") + + return space diff --git a/eval/fixtures/django-pms/pms/forms.py b/eval/fixtures/django-pms/pms/forms.py new file mode 100644 index 0000000..f9a88c6 --- /dev/null +++ b/eval/fixtures/django-pms/pms/forms.py @@ -0,0 +1,89 @@ +"""Form validation: every mutation is parsed through one of these first. + +Keeping validation here means the views work with already-clean data and the +same rules apply no matter which page posts the form. +""" + +from django import forms + +from pms.models import Reservation, Space +from pms.space_kinds import SPACE_KINDS, SPACE_KIND_CHOICES + +RESERVATION_STATUSES = ["pending", "confirmed", "cancelled"] +SPACE_STATUSES = ["active", "archived"] + + +class BookingForm(forms.ModelForm): + """The public booking form. `space` is optional — leaving it blank lets the + front desk assign a space later.""" + + # Declared explicitly (rather than derived from the model) so the bounds are + # enforced as validators, not just as HTML hints. + guest_name = forms.CharField(min_length=1, max_length=200) + phone = forms.CharField(min_length=5, max_length=50) + party_size = forms.IntegerField(min_value=1, max_value=20, initial=1) + notes = forms.CharField(max_length=1000, required=False) + + class Meta: + model = Reservation + fields = [ + "guest_name", + "email", + "phone", + "check_in", + "check_out", + "party_size", + "notes", + "space", + ] + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.fields["space"].required = False + # Only active spaces are directly bookable from the public form. + self.fields["space"].queryset = Space.objects.filter(status="active") + + def clean(self) -> dict: + cleaned = super().clean() + check_in = cleaned.get("check_in") + check_out = cleaned.get("check_out") + if check_in and check_out and check_out <= check_in: + raise forms.ValidationError("Check-out must be after check-in.") + return cleaned + + +class SpaceForm(forms.Form): + """The space create / edit form.""" + + name = forms.CharField(min_length=1, max_length=80) + kind = forms.ChoiceField(choices=SPACE_KIND_CHOICES, initial="room") + capacity = forms.IntegerField(min_value=1, max_value=40, initial=2) + # Nightly rate in whole currency units; blank means "no rate set". + rate = forms.FloatField(min_value=0, max_value=1_000_000, required=False) + notes = forms.CharField(max_length=500, required=False) + + def as_row(self) -> dict: + """Column values for a Space, converting the rate to integer cents.""" + data = self.cleaned_data + kind = data["kind"] if data["kind"] in SPACE_KINDS else "room" + rate = data.get("rate") + return { + "name": data["name"], + "kind": kind, + "capacity": data["capacity"], + "rate_cents": None if rate is None else round(rate * 100), + "notes": data.get("notes") or None, + } + + +class StatusForm(forms.Form): + status = forms.ChoiceField(choices=[(value, value) for value in RESERVATION_STATUSES]) + + +class AssignForm(forms.Form): + # Blank clears the assignment. + space = forms.ModelChoiceField(queryset=Space.objects.all(), required=False) + + +class SetSpaceStatusForm(forms.Form): + status = forms.ChoiceField(choices=[(value, value) for value in SPACE_STATUSES]) diff --git a/eval/fixtures/django-pms/pms/migrations/0001_initial.py b/eval/fixtures/django-pms/pms/migrations/0001_initial.py new file mode 100644 index 0000000..7887b0d --- /dev/null +++ b/eval/fixtures/django-pms/pms/migrations/0001_initial.py @@ -0,0 +1,100 @@ +"""Initial schema: spaces and the reservations held against them.""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Space", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=80, unique=True)), + ( + "kind", + models.CharField( + choices=[ + ("room", "Room"), + ("suite", "Suite"), + ("cabin", "Cabin"), + ("villa", "Villa"), + ("tent", "Tent"), + ("other", "Space"), + ], + default="room", + max_length=20, + ), + ), + ("capacity", models.PositiveIntegerField(default=2)), + ("rate_cents", models.IntegerField(blank=True, default=None, null=True)), + ( + "status", + models.CharField( + choices=[("active", "Active"), ("archived", "Archived")], + default="active", + max_length=20, + ), + ), + ("notes", models.TextField(blank=True, default=None, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Reservation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("guest_name", models.CharField(max_length=200)), + ("email", models.EmailField(max_length=254)), + ("phone", models.CharField(max_length=50)), + ("check_in", models.CharField(max_length=10)), + ("check_out", models.CharField(max_length=10)), + ("party_size", models.PositiveIntegerField(default=1)), + ("notes", models.TextField(blank=True, default=None, null=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ], + default="pending", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "space", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reservations", + to="pms.space", + ), + ), + ], + ), + ] diff --git a/eval/fixtures/django-pms/pms/migrations/__init__.py b/eval/fixtures/django-pms/pms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eval/fixtures/django-pms/pms/models.py b/eval/fixtures/django-pms/pms/models.py new file mode 100644 index 0000000..2750a97 --- /dev/null +++ b/eval/fixtures/django-pms/pms/models.py @@ -0,0 +1,79 @@ +"""Django models: bookable spaces and the reservations held against them.""" + +from django.db import models + +from pms.space_kinds import SPACE_KIND_CHOICES + + +class Space(models.Model): + """A bookable space (room, suite, cabin…). + + Spaces are archived rather than deleted so past reservations keep pointing + at something real. + """ + + STATUS_CHOICES = [("active", "Active"), ("archived", "Archived")] + + name = models.CharField(max_length=80, unique=True) + kind = models.CharField(max_length=20, choices=SPACE_KIND_CHOICES, default="room") + # Maximum party size this space sleeps. + capacity = models.PositiveIntegerField(default=2) + # Nightly rate in cents, or NULL when no rate has been set. + rate_cents = models.IntegerField(null=True, blank=True, default=None) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="active") + notes = models.TextField(null=True, blank=True, default=None) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.name + + @property + def rate_display(self) -> str | None: + """The nightly rate as whole currency units (e.g. "120.00"), or None.""" + if self.rate_cents is None: + return None + return f"{self.rate_cents / 100:.2f}" + + +class Reservation(models.Model): + """A single reservation. + + Guest contact details are stored inline (no separate accounts / login) to + keep the PMS minimal. + """ + + STATUS_CHOICES = [ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ] + + # Guest / user data. + guest_name = models.CharField(max_length=200) + email = models.EmailField() + phone = models.CharField(max_length=50) + + # Stay details. Dates are ISO YYYY-MM-DD strings, which sort as dates. + check_in = models.CharField(max_length=10) + check_out = models.CharField(max_length=10) + party_size = models.PositiveIntegerField(default=1) + notes = models.TextField(null=True, blank=True, default=None) + + # Assigned space. Nullable: a stay can be taken before the front desk has + # decided which space the guest gets. SET_NULL keeps the reservation row + # valid if the space it points at is ever removed. + space = models.ForeignKey( + Space, + null=True, + blank=True, + default=None, + on_delete=models.SET_NULL, + related_name="reservations", + ) + + # Lifecycle. + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.guest_name} ({self.check_in} → {self.check_out})" diff --git a/eval/fixtures/django-pms/pms/queries.py b/eval/fixtures/django-pms/pms/queries.py new file mode 100644 index 0000000..bf9a793 --- /dev/null +++ b/eval/fixtures/django-pms/pms/queries.py @@ -0,0 +1,72 @@ +"""Read queries behind each page, plus the derived guest view.""" + +from dataclasses import dataclass + +from pms.availability import booked_space_ids +from pms.models import Reservation, Space + + +@dataclass +class Guest: + name: str + email: str + phone: str + reservation_count: int + + +@dataclass +class SpaceAvailability: + space: Space + is_available: bool + + +def list_reservations() -> list[Reservation]: + """All reservations, newest first. The assigned space is available via the + relationship (`reservation.space`).""" + return list( + Reservation.objects.select_related("space").order_by("-created_at") + ) + + +def list_guests() -> list[Guest]: + """Unique guests (deduped by lowercased email), with how many reservations + each has.""" + by_email: dict[str, Guest] = {} + # Newest-first, so the first hit for an email is the guest's latest details. + for reservation in Reservation.objects.order_by("-created_at"): + key = reservation.email.strip().lower() + existing = by_email.get(key) + if existing is not None: + existing.reservation_count += 1 + else: + by_email[key] = Guest( + name=reservation.guest_name, + email=reservation.email, + phone=reservation.phone, + reservation_count=1, + ) + return list(by_email.values()) + + +def list_spaces() -> list[Space]: + """Every space, active first then alphabetical.""" + return list(Space.objects.order_by("status", "name")) + + +def list_active_spaces() -> list[Space]: + """Only spaces that can currently be booked.""" + return list(Space.objects.filter(status="active").order_by("name")) + + +def list_space_availability( + check_in: str, check_out: str, party_size: int +) -> list[SpaceAvailability]: + """Each active space paired with whether it can take the given stay.""" + held = booked_space_ids(check_in, check_out) + return [ + SpaceAvailability( + space=space, + is_available=space.id not in held and party_size <= space.capacity, + ) + for space in list_active_spaces() + ] diff --git a/eval/fixtures/django-pms/pms/space_kinds.py b/eval/fixtures/django-pms/pms/space_kinds.py new file mode 100644 index 0000000..2dac34d --- /dev/null +++ b/eval/fixtures/django-pms/pms/space_kinds.py @@ -0,0 +1,15 @@ +"""The kinds of bookable space a property can offer, plus their display labels.""" + +SPACE_KINDS = ["room", "suite", "cabin", "villa", "tent", "other"] + +SPACE_KIND_LABELS = { + "room": "Room", + "suite": "Suite", + "cabin": "Cabin", + "villa": "Villa", + "tent": "Tent", + "other": "Space", +} + +# Ready-made (value, label) pairs for Django ChoiceField / model choices. +SPACE_KIND_CHOICES = [(kind, SPACE_KIND_LABELS[kind]) for kind in SPACE_KINDS] diff --git a/eval/fixtures/django-pms/pms/templates/pms/base.html b/eval/fixtures/django-pms/pms/templates/pms/base.html new file mode 100644 index 0000000..dad8d9c --- /dev/null +++ b/eval/fixtures/django-pms/pms/templates/pms/base.html @@ -0,0 +1,76 @@ + + + + + + {% block title %}Django PMS{% endblock %} + + + + +
{% block content %}{% endblock %}
+ + diff --git a/eval/fixtures/django-pms/pms/templates/pms/guests.html b/eval/fixtures/django-pms/pms/templates/pms/guests.html new file mode 100644 index 0000000..7280709 --- /dev/null +++ b/eval/fixtures/django-pms/pms/templates/pms/guests.html @@ -0,0 +1,21 @@ +{% extends "pms/base.html" %} +{% block title %}Guests · Django PMS{% endblock %} +{% block content %} +

Guests

+

{{ guests|length }} unique guest(s)

+ +{% if not guests %} +
No guests yet.
+{% else %} +{% for guest in guests %} + +{% endfor %} +{% endif %} +{% endblock %} diff --git a/eval/fixtures/django-pms/pms/templates/pms/index.html b/eval/fixtures/django-pms/pms/templates/pms/index.html new file mode 100644 index 0000000..075be0f --- /dev/null +++ b/eval/fixtures/django-pms/pms/templates/pms/index.html @@ -0,0 +1,43 @@ +{% extends "pms/base.html" %} +{% block title %}Book a stay · Django PMS{% endblock %} +{% block content %} +

Book a stay

+{% if error %} +

{{ error }}

+{% endif %} +
+ {% csrf_token %} + + + + + + + + + + + + + + + + + + + + + + + + +

+
+{% endblock %} diff --git a/eval/fixtures/django-pms/pms/templates/pms/reservations.html b/eval/fixtures/django-pms/pms/templates/pms/reservations.html new file mode 100644 index 0000000..8c71dd6 --- /dev/null +++ b/eval/fixtures/django-pms/pms/templates/pms/reservations.html @@ -0,0 +1,86 @@ +{% extends "pms/base.html" %} +{% block title %}Reservations · Django PMS{% endblock %} +{% block content %} +

Reservations

+

{{ reservations|length }} total

+ +{% if not reservations %} +
No reservations yet. Once guests book, they'll show up here.
+{% else %} +{% for reservation in reservations %} +
+
+ {{ reservation.guest_name }} + + {{ reservation.status }} + + #{{ reservation.id }} +
+
+ {{ reservation.email }} · + {{ reservation.phone }} · + {{ reservation.party_size }} guest(s) +
+
{{ reservation.check_in }} → {{ reservation.check_out }}
+ {% if reservation.notes %} +

"{{ reservation.notes }}"

+ {% endif %} + +
+ {% csrf_token %} + + +
+ +
+ {% if reservation.status != 'confirmed' %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% if reservation.status != 'cancelled' %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ {% csrf_token %} + +
+
+
+{% endfor %} +{% endif %} +{% endblock %} diff --git a/eval/fixtures/django-pms/pms/templates/pms/spaces.html b/eval/fixtures/django-pms/pms/templates/pms/spaces.html new file mode 100644 index 0000000..c3d8ea2 --- /dev/null +++ b/eval/fixtures/django-pms/pms/templates/pms/spaces.html @@ -0,0 +1,61 @@ +{% extends "pms/base.html" %} +{% block title %}Spaces · Django PMS{% endblock %} +{% block content %} +

Spaces

+{% if error %} +

{{ error }}

+{% endif %} + +
+ {% csrf_token %} +

Add a space

+ + + + + + + + + + + + + + + +

+
+ +{% for space in spaces %} +
+ {{ space.name }} · {{ space.get_kind_display }} · sleeps {{ space.capacity }} + {{ space.status }} + {% if space.rate_display %} +
Rate: {{ space.rate_display }} / night
+ {% endif %} + {% if space.notes %} +

{{ space.notes }}

+ {% endif %} +
+ {% csrf_token %} + + +
+
+{% endfor %} +{% endblock %} diff --git a/eval/fixtures/django-pms/pms/urls.py b/eval/fixtures/django-pms/pms/urls.py new file mode 100644 index 0000000..7bed289 --- /dev/null +++ b/eval/fixtures/django-pms/pms/urls.py @@ -0,0 +1,34 @@ +"""URL routes for the PMS app.""" + +from django.urls import path + +from pms import views + +urlpatterns = [ + # Public booking flow. + path("", views.home, name="home"), + path("book", views.book, name="book"), + # Front desk. + path("reservations", views.reservations_page, name="reservations"), + path("guests", views.guests_page, name="guests"), + path( + "reservations//status", + views.update_status, + name="reservation_status", + ), + path( + "reservations//assign", + views.assign_space, + name="reservation_assign", + ), + path( + "reservations//delete", + views.delete_reservation, + name="reservation_delete", + ), + # Space inventory. + path("spaces", views.spaces_page, name="spaces"), + path("spaces/create", views.create_space, name="space_create"), + path("spaces//update", views.update_space, name="space_update"), + path("spaces//status", views.set_space_status, name="space_status"), +] diff --git a/eval/fixtures/django-pms/pms/views.py b/eval/fixtures/django-pms/pms/views.py new file mode 100644 index 0000000..0800168 --- /dev/null +++ b/eval/fixtures/django-pms/pms/views.py @@ -0,0 +1,234 @@ +"""The four PMS pages and the POST handlers behind them. + +Views are function-based for clarity. Each mutation parses its input through a +form (see forms.py) and shares the availability checks in availability.py. +""" + +from django.db import IntegrityError +from django.shortcuts import redirect, render + +from pms.availability import BookingError, assert_space_bookable +from pms.forms import ( + AssignForm, + BookingForm, + SetSpaceStatusForm, + SpaceForm, + StatusForm, +) +from pms.models import Reservation, Space +from pms.queries import ( + list_active_spaces, + list_guests, + list_reservations, + list_spaces, +) +from pms.space_kinds import SPACE_KIND_CHOICES + + +# --- Public booking flow ------------------------------------------------- + + +def home(request): + """The landing page with the booking form and the list of active spaces.""" + return render( + request, "pms/index.html", {"spaces": list_active_spaces(), "error": None} + ) + + +def book(request): + """Create a reservation from the public booking form.""" + if request.method != "POST": + return redirect("home") + + form = BookingForm(request.POST) + if not form.is_valid(): + return _render_booking_error(request, _first_error(form), status=422) + + space = form.cleaned_data.get("space") + if space is not None: + try: + assert_space_bookable( + space_id=space.id, + check_in=form.cleaned_data["check_in"], + check_out=form.cleaned_data["check_out"], + party_size=form.cleaned_data["party_size"], + ) + except BookingError as error: + return _render_booking_error(request, str(error), status=409) + + form.save() + return redirect("reservations") + + +# --- Front desk ---------------------------------------------------------- + + +def reservations_page(request): + """The front-desk list, with the spaces available for reassignment.""" + return render( + request, + "pms/reservations.html", + {"reservations": list_reservations(), "spaces": list_spaces()}, + ) + + +def guests_page(request): + return render(request, "pms/guests.html", {"guests": list_guests()}) + + +def update_status(request, reservation_id: int): + """Update a reservation's status (front desk).""" + if request.method != "POST": + return redirect("reservations") + + form = StatusForm(request.POST) + reservation = Reservation.objects.filter(id=reservation_id).first() + if reservation is None or not form.is_valid(): + return redirect("reservations") + + new_status = form.cleaned_data["status"] + + # Cancelling releases the space, so reviving a cancelled stay has to win its + # space back — someone else may have taken it in the meantime. + if ( + reservation.status == "cancelled" + and new_status != "cancelled" + and reservation.space_id is not None + ): + try: + assert_space_bookable( + space_id=reservation.space_id, + check_in=reservation.check_in, + check_out=reservation.check_out, + party_size=reservation.party_size, + exclude_id=reservation.id, + ) + except BookingError: + return redirect("reservations") + + reservation.status = new_status + reservation.save(update_fields=["status"]) + return redirect("reservations") + + +def assign_space(request, reservation_id: int): + """Assign, move, or clear a reservation's space (front desk).""" + if request.method != "POST": + return redirect("reservations") + + form = AssignForm(request.POST) + reservation = Reservation.objects.filter(id=reservation_id).first() + if reservation is None or not form.is_valid(): + return redirect("reservations") + + space = form.cleaned_data.get("space") + if space is not None: + try: + assert_space_bookable( + space_id=space.id, + check_in=reservation.check_in, + check_out=reservation.check_out, + party_size=reservation.party_size, + exclude_id=reservation.id, + ) + except BookingError: + return redirect("reservations") + + reservation.space = space + reservation.save(update_fields=["space"]) + return redirect("reservations") + + +def delete_reservation(request, reservation_id: int): + """Delete a reservation (front desk).""" + if request.method == "POST": + Reservation.objects.filter(id=reservation_id).delete() + return redirect("reservations") + + +# --- Space inventory ----------------------------------------------------- + + +def spaces_page(request): + return _render_spaces(request, error=None) + + +def create_space(request): + if request.method != "POST": + return redirect("spaces") + + form = SpaceForm(request.POST) + if not form.is_valid(): + return _render_spaces(request, _first_error(form), status=422) + + try: + Space.objects.create(**form.as_row()) + except IntegrityError: + name = form.cleaned_data["name"] + return _render_spaces(request, f"A space named “{name}” already exists.", status=422) + return redirect("spaces") + + +def update_space(request, space_id: int): + if request.method != "POST": + return redirect("spaces") + + space = Space.objects.filter(id=space_id).first() + if space is None: + return redirect("spaces") + + form = SpaceForm(request.POST) + if not form.is_valid(): + return _render_spaces(request, _first_error(form), status=422) + + for column, value in form.as_row().items(): + setattr(space, column, value) + try: + space.save() + except IntegrityError: + name = form.cleaned_data["name"] + return _render_spaces(request, f"A space named “{name}” already exists.", status=422) + return redirect("spaces") + + +def set_space_status(request, space_id: int): + """Archive or restore a space. Archiving keeps it out of the booking picker + without touching the reservations that already reference it.""" + if request.method != "POST": + return redirect("spaces") + + form = SetSpaceStatusForm(request.POST) + space = Space.objects.filter(id=space_id).first() + if space is not None and form.is_valid(): + space.status = form.cleaned_data["status"] + space.save(update_fields=["status"]) + return redirect("spaces") + + +# --- Private render helpers ---------------------------------------------- + + +def _render_booking_error(request, message: str, *, status: int): + return render( + request, + "pms/index.html", + {"spaces": list_active_spaces(), "error": message}, + status=status, + ) + + +def _render_spaces(request, error: str | None, *, status: int = 200): + return render( + request, + "pms/spaces.html", + {"spaces": list_spaces(), "kinds": SPACE_KIND_CHOICES, "error": error}, + status=status, + ) + + +def _first_error(form) -> str: + """The first human-readable validation message on a bound form.""" + for errors in form.errors.values(): + if errors: + return errors[0] + return "That input looks invalid." diff --git a/eval/fixtures/django-pms/requirements.txt b/eval/fixtures/django-pms/requirements.txt new file mode 100644 index 0000000..74b6437 --- /dev/null +++ b/eval/fixtures/django-pms/requirements.txt @@ -0,0 +1,3 @@ +django>=5.0 +python-dotenv>=1.0.0 +seam>=1.0.0