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
6 changes: 6 additions & 0 deletions eval/fixtures/django-pms/.env.example
Original file line number Diff line number Diff line change
@@ -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=
20 changes: 20 additions & 0 deletions eval/fixtures/django-pms/.gitignore
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions eval/fixtures/django-pms/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
9 changes: 9 additions & 0 deletions eval/fixtures/django-pms/config/asgi.py
Original file line number Diff line number Diff line change
@@ -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()
72 changes: 72 additions & 0 deletions eval/fixtures/django-pms/config/settings.py
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions eval/fixtures/django-pms/config/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Root URL config: everything lives under the pms app."""

from django.urls import include, path

urlpatterns = [
path("", include("pms.urls")),
]
9 changes: 9 additions & 0 deletions eval/fixtures/django-pms/config/wsgi.py
Original file line number Diff line number Diff line change
@@ -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()
5 changes: 5 additions & 0 deletions eval/fixtures/django-pms/fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "django-pms",
"sdk": "python",
"framework": "Django"
}
21 changes: 21 additions & 0 deletions eval/fixtures/django-pms/manage.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file.
8 changes: 8 additions & 0 deletions eval/fixtures/django-pms/pms/apps.py
Original file line number Diff line number Diff line change
@@ -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"
55 changes: 55 additions & 0 deletions eval/fixtures/django-pms/pms/availability.py
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions eval/fixtures/django-pms/pms/forms.py
Original file line number Diff line number Diff line change
@@ -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])
Loading
Loading