date-website

Booking Development Notes

Scope

The booking app owns the public room-booking pages: a list of bookable rooms, a detail page per room with a booking form, and the per-room code that visitors without a website account use to unlock that form. The same models record bookings made by signed-in members, and the Django admin is the management surface.

The app is association-agnostic. Nothing under booking/ reads PROJECT_NAME or STAFF_GROUPS. The one content variable it touches is optional: booking/emails.py reads CONTENT_VARIABLES["ASSOCIATION_EMAIL"] to name the board’s address in the confirmation email, because an email body is rendered without a request and the context processor does not run there. It defaults to an empty string, and the templates that show the same address guard on it being set, so an association that leaves it empty loses the sentence and nothing else. The other association-specific parts are which variants list the app in INSTALLED_APPS, which variants mount its route key, and which variants set the BOOKING_ENABLED capability. An association that installs the app and creates rooms gets working behaviour with no further configuration.

Where the code lives:

Data model (booking/models.py)

Room

Closure

Booking

A start time in the past

clean() refuses a new booking whose start is more than BOOKING_PAST_GRACE (10 minutes) in the past, with “Starttiden kan inte vara i det förflutna.”. The grace exists because the datetime-local input has minute precision and a form takes a moment to fill in, so a start equal to the current minute can be marginally stale by the time the POST lands.

The check is guarded by self._state.adding: only a booking that is being created is refused. A booking that already exists keeps whatever times it has, so the board can still correct the description of one whose time is over from the admin without being told its time is invalid. The public edit form restores the rule for edits: BookingForm.clean_start() refuses a changed start before the same boundary, so a booker cannot move a future booking into the past. A booking can be moved as often as the booker likes while its start is still ahead. Once the stored start has passed, BookingForm.clean() freezes the times: the title and the description can still change, but a start or end that differs from the stored one is refused. Future bookings get a fresh confirmation and invite on every change; a started booking cannot be rescheduled, so it cannot be rewritten after the fact.

The clock comes from booking.access.now_at() through a function-level import in models.py (access imports this module, so a module-level import would be circular). That keeps the single time seam the gate already uses, and it is what lets the tests pin “now” while the views and the model agree on it. Without the rule a mistyped year was saved, confirmed by email, and then invisible on every public list while still occupying the admin, because the public queries filter on end__gte=now.

An external booking

An external booking is an ordinary Booking row with author unset and the name and email recorded in booker_name and booker_email. There is no separate model, no account is created for the booker, and the email address on the row is the only link back to them. An unset author is also what a booking made by a member looks like after that member deletes the account, so author is None means “no account now” rather than “never had one”; the snapshotted booker_name is what tells the two apart.

Overlap prevention

Overlap prevention lives in Booking.clean():

clean() is reached through full_clean(), which in practice means the public forms and the admin form. A direct write such as Booking.objects.create(...) bypasses it, and only the check constraint would catch a reversed pair of times.

The create path in room_detail wraps validation and save in transaction.atomic() and locks the room row with Room.objects.select_for_update() before validating, so two simultaneous submissions for one room cannot both pass the overlap check. SQLite ignores select_for_update, and core/settings/test.py uses in-memory SQLite, so the tests cannot prove that part; production runs PostgreSQL.

Where the code state lives

Room carries code_generation (default 1, editable=False) and rotated_at (nullable, editable=False). Each room has its own, which is what makes the codes independent: the office and the sauna do not share a code, a room lent to an outside group can have a code those people never see for the rest of the house, and rotating one room leaves every other room alone. Room.rotate_code() moves that room to the next generation and stamps rotated_at, bumping the counter with a database-side F('code_generation') + 1 rather than in Python, so two rotations racing each other cannot both read the same value and write it back.

RoomAdmin.save_model() writes only the editable fields. A plain save() would also put the generation and the rotation moment back from whatever the request read, undoing a rotation that landed in between, so the room form does not write them at all.

BookingSettings is now only the association-wide note about how a visitor gets a code: an optional code_instructions TextField, reached through BookingSettings.get_solo(). It has no code of its own and nothing to rotate. The public path reads it directly through _code_instructions() in booking/views.py rather than through get_solo(), so an anonymous page view does not create the row as a side effect.

code_instructions exists because the distribution channel is not the website’s decision. The board hands a code out however it likes, so nothing in the app names a channel: the default copy says only that the board provides the code, and an association that wants to be specific writes its own sentence. Like Room.name and Room.description, the field is editor content and is not translated, so an English visitor reading a Swedish sentence is the same trade-off the app already makes for rooms.

The booking code (booking/access.py)

Derivation

digest = HMAC-SHA256(SECRET_KEY, 'booking-code:<room pk>:<generation>')
number = int.from_bytes(digest[:8], 'big') % 10**6
code   = str(number).zfill(6)

The result is always six digits, with leading zeros kept. The room’s primary key is in the message, so two rooms on the same generation still have different codes, and the name is not, so renaming a room does not change its code.

Why there is no schedule

A code is a pure function of SECRET_KEY, the room and that room’s code_generation. Nothing in the derivation reads the time, so a code stays exactly where the board left it: current_code(room) takes no moment at all, and no task has to run for codes to remain correct. Rotation is an explicit act by someone holding booking.change_room, through the Byt bokningskoden nu admin action on the room list, which reports each new code with its room name. Codes are never stored, but they are always derivable, so the room list and each room’s page display the current one at any time; the message is only so the board has the new ones in hand straight after rotating.

The trade-off this makes is deliberate and worth stating plainly: a schedule is one of the two things that can bound an automated guesser, and there is no schedule now. That bound is gone. A code nobody rotates stays valid indefinitely, and what is left is weaker than it looks: the five-attempt limit and the lockout live in a session cookie a script simply discards, and the captcha fails open when CF_TURNSTILE_SECRET_KEY is empty. Against a determined guesser of a six-digit code the honest summary is that the code is a speed bump, not a wall, and that the association is relying on the code being shared among people who do not attack it. Rotating after any suspected leak is the control that remains, and rotated_at is displayed so a stale code is at least visible.

Grace window

Cancelling

Two paths, because the two kinds of booker can prove different things.

A member manages their bookings from booking:my_bookings (/booking/mine/, MyBookingsView, login required) and booking:edit_my_booking (/booking/mine/<pk>/edit/, views.edit_my_booking, login required). Both look the row up through _member_upcoming(), which filters on author=request.user and end__gte=now, so an id that is somebody else’s, unknown, or already finished resolves to nothing and becomes a 404. There is no code on this path: the account is the credential. Cancelling is a POST that carries the booking id; editing is a GET that renders the same BookingForm the room page uses, and a POST that saves it. _save_edited_booking() takes an owner argument on this path and checks it again after the row is locked, so an admin who reassigns the booking while the form is open cannot make a member edit somebody else’s row.

Somebody without an account uses booking:cancel (/booking/cancel/), where they paste the code from the confirmation email. The code is HMAC-SHA256(SECRET_KEY, 'booking-cancel:<booking pk>')[:12], derived rather than stored, so booking_with_cancel_code() finds the booking by deriving the code for each upcoming booking and comparing with hmac.compare_digest. Twelve hex characters is deliberate: a room code is typed by people who were told it, while this one deletes a row, so it has to be beyond guessing rather than merely inconvenient. Only upcoming bookings are searched, which is also exactly the set that may be cancelled.

The public page is two steps. The GET that a mail client or a link scanner follows only shows which booking the code belongs to, and the deletion happens on the POST behind the confirmation button. A GET that cancelled would let a mailbox provider that prefetches links delete a booking before its owner ever read the email.

The same page also edits the booking, from the same code. A POST carrying edit renders a BookingForm bound to the booking, and a POST carrying save validates and saves it; both carry the code in a hidden field, so it never has to be put in a URL. The edit page shows the booking’s summary row above the form, because the room is not a form field and the visitor needs to see which booking the code belongs to. Both edit paths share _save_edited_booking(), which reads the booking’s current room, locks that room before validation, then locks and re-reads the booking row under select_for_update. If the room changes between the read and the booking lock, it releases both and retries in a new transaction rather than taking a second room after the booking lock, which would reverse the lock order and could deadlock against another edit of the same row. The room is read again rather than taken from the instance the code was resolved against, because an admin can move a booking between rooms, and a save cannot put back a booking that was cancelled in the meantime. A legacy row stays editable without a title, while a row that already has one must keep it. A started booking is not movable: its title and description can still be corrected, but its times are frozen. The form itself is partials/edit_booking_form.html, shared with the member’s edit page; its fields come from partials/booking_fields.html, the same partial the create form uses, so the date and clock controls are the same on both pages. The cancellation page passes the code in through the hidden field, the member page has no code at all. An edit that changes the title, start or end queues a fresh confirmation with the updated calendar invite; a description-only edit and a cancellation send nothing.

The public page has no entry point on the site: the only link to it is the one in the confirmation mail, which is deliberate, because the page is useless without a code and the code only ever arrives by mail. A booker who has lost the mail cannot use it and has to ask the board. A member never needs it, because their own page is linked from the room list whenever they have something upcoming; that page offers an edit link and a cancel button for each row, and the edit page shows the same form with the booking’s own values.

Both paths delete the row rather than marking it cancelled, which is what the admin’s delete does. Nothing is emailed on cancellation, to the booker or to the board, so a board that needs to hear about cancellations has to watch the admin.

A cancel code is a credential for one booking, not for the room. While the booking is still in the future it can move it to any free time without the room’s code or a captcha, and rotating the room code does not end it: the cancel code is derived from the booking’s primary key and the server secret, so only cancelling the booking or letting it end makes it stop working. Once the booking has started the code can still change its title and description, but not its time, and once it has ended the code no longer resolves at all, because only upcoming bookings are searched. Anyone the confirmation mail is forwarded to can use the code the same way. Rescheduling future bookings as often as needed is deliberate: the booker owns the booking and the same mail already lets them cancel it, and the alternative would be another round through the board for a changed time. A board that wants that access to end has to cancel the booking.

Session token and per-room session state

Attempt limit

Test seams

Public routes and views

booking/urls.py sets app_name = 'booking' and the shared URLconf mounts it at booking/:

RoomListView lists every room and attaches up to UPCOMING_BOOKING_LIMIT (50) bookings whose end is still in the future, ordered by start. The queryset is deliberately not cached: an admin edit shows up on the next request.

room_detail:

templates/common/booking/index.html renders the room cards and the upcoming-booking list, says in one line that an account books directly while everyone else needs a code, and links to the member’s own bookings when they have any, which is why that link never leads to an empty page. It deliberately does not say how the code is obtained; that belongs to the page that asks for it. room_detail.html doubles as the code-gate page: it still shows the room name, its description and the upcoming bookings, each row naming the booking’s title above its time, and swaps the booking form for the code form. partials/code_form.html renders the explanation of where the code comes from, then either the code input or, during a lockout, the “too many attempts” message with no form at all. The first sentence of that explanation is code_instructions when the board has written one, and “Bokningskoden får du av styrelsen.” when it has not; the second sentence, that an account needs no code, always follows, because it is a fact about the site rather than about the board’s process. code_instructions reaches the template through the gate’s context, from _code_instructions() in booking/views.py. partials/board_contact.html is the one-line “contact the board at ASSOCIATION_EMAIL” note, included by every public booking page: the room list, the room page and its gate, the member’s own bookings and the cancellation page. Each of them guards on ASSOCIATION_EMAIL being set, because the templates are shared and an association that leaves it empty would otherwise render a broken sentence. It is a contact route, not a claim about how the code arrives. The badge and text of one booking row come from partials/booking_row.html; show_room hides the room name on a page that already names it, so the five pages that list a booking share one row instead of five copies.

The booking form is a grid rather than a stack of paragraphs: each field row is itself a grid with the label in a fixed 9rem column and the widget beside it, the field’s help text sits in the widget’s column under the widget, and the submit button and the captcha widget are placed in that same column. So the widgets, their help text and the button share one left edge, and the form is as wide as the panel it sits in, which is what the note under it already was. The label column is fixed rather than a percentage because these labels are one or two words, and 30% of a full-width form leaves a wide gap between a short label and its widget; the event registration form keeps the percentage, so the two forms are no longer the same width in that one respect. Field errors are rendered as a span inside their row, since a <p> cannot contain the div they used to be, and .booking-error carries display: block so they still read as a band across the row.

Start and end are one row under a single Tid label, and with JavaScript one question. room_detail.html renders the two datetime inputs with the word “till” between them, and static/common/booking/js/booking-time-range.js hides that pair and shows a date control and two clock controls in its place: the day is picked once and belongs to both ends, only the two times are asked for separately, and nothing is pre-filled, so a visitor never receives a time they did not choose. The pair stays in the DOM and stays what posts, so no view, form or model changed, and a visitor without the script still books in two steps. Four details are deliberate. The script clears required on the hidden pair, because a hidden required input is refused by the browser with a message it has nowhere to show; the three visible controls are required instead and can show that message themselves. It composes the submitted values in BOOKING_DATETIME_FORMAT from what was picked, never by parsing displayed text. It sets the end clock’s min from the start clock, so the browser itself refuses an end before the start. And it reads its words from data-booking-* attributes on the row rather than from CSS classes, so renaming a class for styling cannot silently break the behaviour; start and end keep their aria-label because one visible label covers both. The controls are native, so no picker bundle is loaded, and their text format follows the visitor’s browser locale (“18:00” or “06:00 PM”), which is not the site’s language and cannot be set. booking.css gives them the panel’s dark colours together with color-scheme: dark: a native field that keeps a white background under the dark scheme draws its own indicator icon in white, which makes the icon vanish, so the whole field goes dark and the icon, the text and the calendar that opens belong to one scheme. A room without bookable hours can also take a booking that runs past midnight or over several days, and the row offers both only for such a room: the template writes data-booking-longer from room.has_bookable_hours, and the script builds neither control when it is 0, because a daily window refuses both on the server and an option that can only fail is worse than no option. An overnight booking is a checkbox beside the clocks that puts the end on the next day. A booking over several days is a second checkbox, Flera dagar, which puts the end’s own day into the row beside the end clock, so the end reads as one pair of day and clock and the two-day case stays out of the way of every other visitor. The end date’s min is the picked day and its max a week later, the end clock’s min is only set while both ends share a day, and a page re-rendered after an error rebuilds the longer state from the two values: exactly one day later becomes the overnight checkbox, anything else ticks the multi day one. A repeated error is shown once: when both fields report the same message the end’s copy is dropped, because one empty range is one mistake.

booking/emails.py sends one confirmation per booking created through the public form, member or not, with notify_booker(booking, request=request). An edit that changes the title, start or end calls it again: the stable invite UID makes the new calendar invite update the booker’s existing event instead of adding a second one, and the booker gets the corrected details in writing. The mail is queued after the transaction commits, through transaction.on_commit and the Celery task core.utils.send_email_with_attachments_task. The booking path deliberately does not use core.utils.enqueue_task_on_commit: that helper lets a broker error out of the callback, and by then the booking is committed, so an unreachable broker would show the booker an error page for a booking that exists. The failure is logged instead. A booking the board creates in the admin sends nothing, which is stated in the admin guide. That task exists because send_mail cannot carry a file: it builds an EmailMultiAlternatives itself and attaches the calendar invite, keeping the same failure handling as send_email_task, where a mail problem is logged rather than raised because the row it describes is already saved. The booking title appears in the mail body and, when there is one, is the invite’s SUMMARY, with the room name as the fallback for a legacy row and the room still named in the body.

The message complements the confirmation on the page rather than replacing it. The view adds its Tack! Din bokning är registrerad. message whether or not there is an address to send to, and the mail is a second copy of the same news with the details worth keeping.

Bodies are templates/common/booking/email/booking_confirmation.html and .txt, rendered as a multipart pair. Both are rendered without a request, so the context processor that exposes the association’s variables does not run: emails.py passes association_email from settings.CONTENT_VARIABLES into the context itself, and the email names that address, which is otherwise the booker’s only route to the board.

notify_booker returns early when there is no address to send to, which is booking.author.email for a member and booking.booker_email for somebody without an account. The body is one template for both, and three things differ: the recipient, whether cancel_code holds a code, and which cancellation page cancel_url points at, which is booking:my_bookings for a member and booking:cancel for everybody else. All three are decided in the same place, from the booking’s own account state, so a member cannot be sent to a page that asks for a code they will never be given.

Links in the mail come from _site_base(), which is CONTENT_VARIABLES['SITE_URL'], and only falls back to the request’s own host when that variable is empty. Behind the ingress a request carries an internal name, and a mail telling a booker to visit it is worse than no link at all. The invite’s event identity comes from the same address, so two installations cannot generate the same calendar identity for unrelated bookings, and an event does not change identity when a request arrives through a different host.

Session keys and the gate function

Three session keys, all namespaced with a booking_ prefix so they cannot collide with other apps that keep gate state in the session:

booking_code_gate() is a function rather than a decorator, and room_detail calls it itself. The room list is public, and the room page still renders the room’s name, description and upcoming bookings; only the booking form is replaced by the code form, so the gate belongs inside the view that owns the form instead of in a decorator that would have to cover a whole view. The view routes a request to the gate when the visitor has no unlock, and also whenever the POST carries the code form, so an older tab cannot submit a code into the booking form after the visitor has unlocked the room elsewhere. The status codes mirror the exam bank gate: a fresh gate is 200, a wrong code is 403, and a lockout is 429, including the request that reaches the attempt limit. The redirect target is a URL name or an already resolved URL chosen by the view (next_url), never a value taken from the request, so the gate cannot be turned into an open redirect.

The BOOKING_ENABLED capability

Only DaTe parses the variable. No other module under core/settings/ reads or sets BOOKING_ENABLED, and none of them lists booking in its installed apps or its URLconf, so setting BOOKING_ENABLED on another association’s release has no effect.

To disable the feature on DaTe, set BOOKING_ENABLED to a false value (False, 0, off, no, or an empty value; the shared env helper treats any string that is not a true value as false). The booking/ paths and the homepage block disappear, while the app, its admin pages and all booking rows stay in place.

The repository’s environment template is .env.example, the file contributors copy to .env. BOOKING_ENABLED is not listed there today, so add it there if the switch becomes part of local setup.

Adopting the app in another association

  1. Add 'booking' to that variant’s installed apps (get_installed_apps([...]) in core/settings/<variant>.py). core/settings/date.py is the worked example.
  2. Add the booking route key to that variant’s build_urlpatterns(...) call. core/urls/date.py gates the key behind the capability; a variant that always wants the routes can pass the key unconditionally.
  3. Set the BOOKING_ENABLED capability for that variant, either directly in the settings module or from an environment variable the module reads.
  4. Run that variant’s migrations so the booking tables exist.

The public templates under templates/common/booking/ and static/common/booking/css/booking.css are shared, so nothing else is required. An association-specific override follows the usual templates/<association>/ rules described in docs/dev/templates.md.

Admin surface (booking/admin.py)

Testing

Run the app’s tests with the test settings, which install the DaTe app set and set BOOKING_ENABLED=True:

uv run python manage.py test booking

Through the Docker helper, after source env.sh:

date-test booking

The codes are testable without freezegun because of the seams above: patch booking.access.now_at, or pass at= straight to the access functions. The app’s tests live in booking/tests.py, and the confirmation email and the calendar invite have their own file in booking/tests_email.py.

Risks and gotchas

Permissions and provisioned staff groups

If this app is ever installed for an association whose staff group permissions are provisioned from code, a single-group restriction cannot be enforced with Django permissions alone. members/provisioning.py runs on post_migrate from members/apps.py and, when the SF_ROLE_PERMISSION_SCOPES setting (core/settings/sf.py) is present, grants every permission of every local app to several broad groups. booking is a local app, so its permissions would be handed to those groups automatically and the board-only rule would be defeated. Issue #1193 tracks that provisioning behaviour.