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:
booking/models.py: Room, Booking, BookingSettings.18:00 - 20:00, and add the end date with its weekday when the range crosses midnight, so a booking or a closure that runs over several days cannot be read as a same-day one. The end date appears only then, because repeating it on every row is noise.booking/templatetags/booking_dates.py: short_date and year_suffix, the two filters that format a date in the public lists. Weekday always, year only when the date is not in the current year, both taken from the active locale and measured against access.now_at() so a test can pin the year boundary.booking/access.py: the per-room codes, the session token, and the HTTP gate.booking/views.py, booking/forms.py, booking/urls.py: the public pages.booking/admin.py: the admin registrations.booking/emails.py: the confirmation email, sent to whoever booked.booking/ics.py: the calendar invite attached to that email. It is a published event (METHOD:PUBLISH) with a stable UID, UTC timestamps, escaped text and lines folded at 75 octets, and it carries no ATTENDEE, ORGANIZER or VALARM: a mail from an automated address that reads as a personal invitation makes clients offer a reply nobody reads.templates/common/booking/: index.html, room_detail.html, my_bookings.html, edit_booking.html, cancel_booking.html, partials/booking_fields.html, partials/booking_row.html, partials/code_form.html, partials/edit_booking_form.html, partials/board_contact.html, and email/booking_confirmation.html plus email/booking_confirmation.txt (see Public routes and views).static/common/booking/css/booking.css: the shared stylesheet the templates pull in.booking/models.py)Roomname (max 255), description (optional text).bookable_from and bookable_until are optional times of day. Both empty (the default) means the room can be booked at any hour; both set means bookings must start and end inside that window on the same local day. Room.clean() refuses one without the other and a window whose end is not after its start, and Room.has_bookable_hours answers whether a window is in force.code_generation and rotated_at are the per-room code state, described below.('name', 'pk').Closure, and removing one is a delete, which cascades to its bookings.Closureroom (FK, CASCADE, related_name='closures'), start, end, and an optional description shown to visitors.closure_end_after_start, is the backstop, and Closure.clean() refuses an end that is not after its start._upcoming_closures() in booking/views.py feeds the “Stängt” list on the room page, so a visitor sees why the room is unavailable before filling in the form.Bookingbooker_label(author) is the single place that decides what to call the member behind a booking: the profile’s full name when there is one, and the account name otherwise. A row reading a login handle next to a user card reading a full name looks like two different people, so both the snapshot and the display go through it.room: FK to Room, on_delete=CASCADE, related_name='bookings'.author: optional FK to the member model (settings.AUTH_USER_MODEL), on_delete=SET_NULL, related_name='room_bookings'. None means there is no website account behind the booking: either it came from a visitor, or the member who made it deleted the account afterwards.booker_name (max 255, optional), booker_email (optional), start, end, title (max 120, optional at the model level), description (max 400, optional), created (default=timezone.now, not editable).title is what the booking is about, and it is the one part of the booking written for the public pages: the room list, the room page and the front-page block all show it. It is blank=True in the model so a row made before the field existed still saves, while Booking.clean() requires it for every new booking and the public forms require it when a booking is created or when a row that already has a title is edited. A legacy row may keep its blank title, and both the board and the booker can edit it without supplying one; once a row has a title, the public edit form will not let it be cleared. The field is editor content and is not translated, the same as a room’s name and description. The column also carries a database default of '': during a blue-green deploy the old instances do not know about the column yet, and an insert that leaves it out has to land on the empty string rather than fail the NOT NULL.('start', 'pk').save() copies the author’s str() into booker_name when that field is blank and an author is set. author is SET_NULL, so without the copy a deleted member’s booking would lose its booker and read as a booking without an account. A name typed by the board is never overwritten.is_external is author_id is None. booker_display returns the member’s str() when there is an author, otherwise booker_name, otherwise the fallback label “Extern bokning”. Because the name is snapshotted, a booking whose member deleted the account still displays that member’s name.booking_end_after_start check constraint, end > start. It is a backstop only: it says nothing about overlaps, and it fires on write.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 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 lives in Booking.clean():
end <= start produces an error on end.BOOKING_PAST_GRACE in the past produces an error on start (see above).title. The guard is self._state.adding for the same reason as the past-start rule: a booking made before the field existed may keep a blank title, and the board can still correct it.author_id is None and booker_name is blank, it produces an error on booker_name.BOOKING_MAX_DURATION (one week), measured through UTC so a span across a daylight saving change is not an hour out. There is no limit on how far ahead a booking may be made.start < new end and end > new start. The closure message is checked before the overlap message because “the room is closed” is the truer explanation when both apply.end == new start) do not overlap. The overlap query does not filter by time, so an old booking still blocks a new one.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.
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.
booking/access.py)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.
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.
accepted_codes() returns the current generation’s code, plus the previous generation’s code while less than BOOKING_CODE_GRACE (15 minutes) has passed since rotated_at. The window is anchored to the stored moment, so it does not slide forward with every request, and it only opens for a generation above 1: there is no predecessor to keep alive before the first rotation.rotated_at stamped in the future by clock skew does not revive the previous code.check_code() strips whitespace and compares with hmac.compare_digest against every accepted code with no early exit.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(room) is HMAC-SHA256(SECRET_KEY, 'booking-session:<room pk>:<generation>')[:32].hex().booking_access_token, booking_code_attempts and booking_code_lockout_until hold mappings keyed by the room primary key as text, because the default session serializer is JSON and JSON object keys are strings. Attempts and lockouts are therefore per room too: fumbling one room’s code does not lock a visitor out of another’s. _room_state() reads a mapping and treats anything else as empty, because a session is client-visible state and a shape this code does not recognise should cost the visitor one more code entry rather than a server error.SECRET_KEY. The typed code itself is never written to the session._stored_token() accepts either form, and session_has_access() compares with compare_digest.BOOKING_ATTEMPT_LIMIT is 5 and BOOKING_LOCKOUT_SECONDS is 15 * 60. The counter lives in the session under booking_code_attempts; the fifth wrong code also sets booking_code_lockout_until to now plus 900 seconds. A correct code clears both keys.lockout_remaining() returns the seconds left and clears the lockout keys once the time has passed.now_at() is the single time seam, and it now only matters to the grace window and the lockout. Tests patch booking.access.now_at to pin “now”.Room with an explicit primary key and generation and touch no database. at= survives only where the moment is genuinely read, which is the grace window.booking/urls.py sets app_name = 'booking' and the shared URLconf mounts it at booking/:
booking:index at /booking/, RoomListView.booking:room_detail at /booking/<pk>/, views.room_detail.booking:my_bookings at /booking/mine/, MyBookingsView, login required.booking:edit_my_booking at /booking/mine/<pk>/edit/, views.edit_my_booking, login required.booking:cancel at /booking/cancel/, views.cancel_booking, public by design
because the booker it serves has no account.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/room_detail.html with the room, its future bookings (same 50 limit, restricted to that room), and a booking form.BookingForm for a signed-in member (the author is taken from the request), AnonymousBookingForm for a visitor (adds required booker_name and booker_email).BookingForm declares title as a required field, even though the model field is optional, and its help text says the title is shown publicly. description is a Textarea (3 rows) with help_text, overriding the single-line input the model field would produce, and its help text says that only the board reads it. The two help texts are worded so a booker can tell which of the two is public.AnonymousBookingForm.booker_email carries the sentence about the address being used only for the confirmation as its own help_text, so a visitor reads it while typing the address rather than as a line under the submit button. A member is never asked for one, so nothing states it on that path.BookingForm.__init__ sets a min attribute on the start and end widgets to access.now_at() - BOOKING_PAST_GRACE, truncated to the minute. It derives the boundary from the same constant the model uses, so the browser picker offers exactly the range Booking.clean() accepts instead of a stricter one: a floor of “now” would refuse a start the server takes, and would invalidate a start that was picked a moment earlier whenever the form is re-rendered after another error. Truncation leaves the picker a fraction more permissive than the server, never less, and the model check stays the authority. The floor is applied only when the instance is new. An edit carries no floor, because a booking that has already started is still the booker’s to correct and a floor of “now” would refuse the start it already has.core.utils.validate_captcha: once in booking_code_gate() and once on the anonymous booking POST. Both call it with access.captcha_response(request), which turns a missing field into an empty string, because the shared helper only short-circuits locally on "" and a missing field would otherwise reach Cloudflare. A challenge that fails on the gate is reported through the captcha_error context key, and crucially the gate does not look at the submitted code at all in that case: validating it would tell a client that has not passed the challenge whether its guess was right, one guess at a time, without consuming an attempt. A rejected challenge is not a code attempt, so it does not count against the visitor’s five either.emails.notify_booker(booking, request=request) queues the confirmation, a success message is added, and the view redirects back to the same room page (POST/redirect/GET). On a validation error the page is re-rendered with the bound form and its field errors, at status 200.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.
Three session keys, all namespaced with a booking_ prefix so they cannot collide with other apps that keep gate state in the session:
booking_access_token (BOOKING_SESSION_TOKEN_KEY): the per-room unlock, a mapping from room primary key to token.booking_code_attempts (BOOKING_ATTEMPTS_COUNTER): the wrong-code counter.booking_code_lockout_until (BOOKING_LOCKOUT_UNTIL): the epoch second the lockout ends.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.
BOOKING_ENABLED capabilityBOOKING_ENABLED = False in core/settings/common.py, next to the other association capabilities.core/settings/date.py sets BOOKING_ENABLED = env('BOOKING_ENABLED', bool, True), so the capability is on unless the environment sets it to a false value. core/settings/test.py pins it to True so the suite does not depend on a developer’s .env.core/context_processors.py exposes it as BOOKING_ENABLED.core/urls/date.py adds the booking route key to its build_urlpatterns(...) call only when settings.BOOKING_ENABLED is true, and core/urls/common.py maps that key to path('booking/', include('booking.urls')). With the capability off, the booking/ paths are not routed at all.templates/date/date/components/bookings.html is included from templates/date/date/start.html and wrapped in {% if BOOKING_ENABLED %}. Its data comes from _homepage_context() in date/views.py, which imports booking.models.Booking lazily and only when the capability is on and the app is installed, then takes the next five bookings that start within seven days. Each card links to the room page of the booking it describes, not to the room list, because the card names one room and one time.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.
'booking' to that variant’s installed apps (get_installed_apps([...]) in core/settings/<variant>.py). core/settings/date.py is the worked example.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.BOOKING_ENABLED capability for that variant, either directly in the settings module or from an environment variable the module reads.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.
booking/admin.py)RoomAdmin lists name, bookable hours, the current code, when it was rotated and a booking count, searches by name, and carries a BookingInline plus a ClosureInline limited to periods that have not ended, so a room’s bookings and closures can be managed from the room page.ClosureAdmin lists room, period and description, filters by room and start, and carries a date hierarchy.BookingInline lists title, start, end, author, booker name and description, and shows only the room’s upcoming bookings, soonest first, with show_change_link. Django does not paginate inlines, so an unfiltered inline grows without bound for a room with years of history and would open on the oldest rows. Past bookings stay reachable through the Booking changelist, which has the filters and the date drill-down for them.-id fields in request.POST. Cutoff alone is not enough: if a row’s end passes between the page being opened and the form being submitted, the row would leave the queryset, Django would resolve the submitted id to an unsaved instance, save_existing_objects() would skip it, and a checked Delete or an edited description would be dropped while the save still reported success. _submitted_pks() on the inline is what closes that; BookingAdminSurfaceTests pins both halves.BookingAdmin lists room, a start/end time range, the title, the booker display and a “no account” flag, filters on booking origin, room and start, adds a start-date drill-down, searches title, booker name, booker email and description, and marks the booker display and the account flag read-only. Its save_model() and the room page’s inline both write only the fields their form can change, so a save that races a cancellation cannot put the row back: Django reports that as NotUpdated, which the admin turns into a message on the page and the inline drops with a log line, because an error page there would be worse than the row being visibly gone. ordering is ('-start',), so the changelist does not open on the oldest booking ever made; this matches the descending ordering the other time-ordered admins in this project use.BookingOriginFilter (admin.SimpleListFilter) separates member bookings from bookings made through the public form. author alone cannot do it, because SET_NULL gives a deleted member’s booking the same shape as a visitor’s, so the filter also reads booker_email: the public form requires an address and a member booking never records one. “Bokning utan konto, via webbformuläret” is the address-bearing subset of “Bokning utan konto”.is_external, which is also true for a booking whose member deleted the account, so the old label stated something false about that row; the Bokare column carries the snapshotted name.BookingSettingsAdmin holds one editable field, the note visitors read where a code is asked for. It refuses to delete the singleton and only allows adding one while no settings row exists, and booking.add_bookingsettings is what that takes, because nothing else creates the row.RoomAdmin’s action is what moves it. The settings page shows a note, not a code.core/admin_ui.py carries a Booking sidebar group for the Unfold theme holding Bookings, Rooms, Closed periods and Booking settings. Without that group the Unfold sidebar has no route to the closure admin at all, because Unfold replaces the per-app model list, so a model added later has to be added here as well. Each link resolves only when its own permission is held and its URL name exists, so the group disappears for an association that does not install the app.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.
{% cache 300 main_page_fixed LANGUAGE_CODE %} in templates/date/date/start.html, so flipping BOOKING_ENABLED can take up to five minutes to show on the homepage. A stale fragment can even hold a link to a now unmounted route for that long. The app’s own pages are not cached and change on the next request.core.utils.validate_captcha fails open when CF_TURNSTILE_SECRET_KEY is empty, so with no secret there is no captcha and no server-side limit either. Closing that properly needs a rate limit keyed on something the client cannot discard, and that needs a decision about client addresses first: behind an ingress that rewrites the source address, every visitor shares one key and a low threshold would lock out all of them.select_for_update is a no-op on SQLite, so the tests cannot cover the race that the room lock exists to prevent. Verify that path against PostgreSQL.SECRET_KEY, so rotating the secret changes every code and invalidates every unlock at once.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.