The attendance app owns the digital meeting attendance list: an event gets a public check-in page, and the app records who arrives and who leaves, so a meeting can stop passing a paper list around. Events are created in the Django admin, and a participant checks in with a short code that the site shows to the readers who hold its permission and rotates.
The app is installed for the date variant only: core/settings/date.py lists attendance in its get_installed_apps([...]) call, and no other core/settings/<variant>.py does. It has no capability flag of its own. core/urls/date.py passes the attendance route key to build_urlpatterns(...) unconditionally, next to booking, which is mounted only when settings.BOOKING_ENABLED is true. Removing attendance from a release would be a code change, not an environment variable.
Access is a permission of the app: the overview page, the websocket that feeds it, the attendee names and the change log on the event page all test attendance.view_attendanceevent. An association grants it to a group in the admin, so it decides which groups read the code, the feed and the log, and membership of a staff group (settings.STAFF_GROUPS, which for date is styrelse, admin, fotograf and rösträknare) is not enough on its own: being staff only opens the admin and nothing about attendance. polls and attendance both hold readers who hold the permission without being staff, which is the point of using it rather than staff status. The one exception is the report: it is a view on the admin site, so admin_view asks for staff status first and has_view_permission() asks for the attendance permission after that. A permitted member who is not staff therefore reads the overview page and the log, but not the report, and the event page does not offer them its link (can_see_report in AttendanceEventDetailView.get_ctx answers both questions).
attendance/models.py defines everything, in four models.
AttendanceEvent: title (max 255), description (max 255, optional), start_datetime, optional end_datetime, allow_non_members (default False, so a new meeting is members-only until an editor ticks the switch), code_secret (default django_otp.util.random_hex), code_validity_time (a SmallIntegerField, default 30 seconds), and a unique slug (max 50, allow_unicode=False, so the URL segment is ASCII). clean() refuses an end_datetime that is not strictly later than start_datetime, and raises it on the end field so a ModelForm shows it there; the admin is the only writer, and it calls full_clean() through the form. Neither field has an upper bound relative to now: a meeting may run long, and one may be entered after the fact.NonMemberAttendee: a single name field (max 255, unique), and a UniqueConstraint on Lower("name") named unique_non_member_name_ci, so the folded name is unique too. It has no link to an event, which is what makes a guest one shared row. See Non-member identity.AttendanceChange: event (FK to AttendanceEvent, on_delete=CASCADE, related_name="attendance_changes"), nullable user (FK to AUTH_USER_MODEL, CASCADE), nullable non_member (FK to NonMemberAttendee, CASCADE), timestamp (default django.utils.timezone.now), and type (Type.ENTER = 0, Type.LEAVE = 1). A check constraint named foreign_keys_ok requires exactly one of user and non_member to be set: a change is about one attendee of one kind, never both and never neither. Meta.get_latest_by = "timestamp" and Meta.ordering = ["-timestamp"] put the newest change first in every list: the standalone change list, the “Närvaroändringar” log on the event page, and the feed on the staff overview page. attendee_label(attendee) in attendance/models.py is the one definition of an attendee’s name, and it is deliberately untranslated: a member’s get_full_name(), and for a guest the bare name. AttendanceChange.attendee_name returns it, which is the label the websocket payload carries and the overview page displays; the identity the page matches on is attendee_key, described next. A guest’s get_full_name() is NonMemberAttendee.__str__, the name plus a translated “(icke-medlem)” marker, and that is what the event page’s Närvarande list renders, because the page is drawn once and in the reader’s own language. The change log, the feed and the report render attendee_name, which is the bare label, so the marker stays out of anything that has to match between a participant’s request and a staff member’s browser.AttendancePoll: question (a OneToOneField to polls.Question, on_delete=CASCADE, related_name="attendance_poll") and event (a ForeignKey to AttendanceEvent, on_delete=PROTECT, related_name="polls"). It is the per-poll opt-in for room-only voting: a poll with a row here accepts a vote only from somebody is_attendee_present finds in the room, and a poll without one is an ordinary poll with the vote rules it always had. The one-to-one says a poll belongs to at most one meeting, while one meeting can carry several polls. PROTECT prevents deleting a meeting from silently removing its poll’s room requirement; an editor must explicitly remove the link first, which deliberately returns the poll to ordinary voting. The field is declared with the string target 'polls.Question', not an import, for the reason the class docstring gives: every association installs polls and only date installs this app, so the reference has to stay lazy and one-directional. polls reads the link back through the accessor the field creates, Question.attendance_poll, behind apps.is_installed('attendance'); the vote path itself is in docs/dev/polls.md.attendee_key(attendee) is the identity beside the displayed name: user-<pk> for a member and non-member-<pk> for a guest. The two kinds are namespaced because they are different tables, so their primary keys are not comparable. The name cannot serve as the identity: two members can share one, and a guest can type one that a member already has. attendee_entry(attendee) returns {"key": <key>, "name": <label>} for one row of the staff list, and AttendanceChange.attendee_key exposes the same key for a change. Every broadcast also carries attendance_change_token(change): an ISO UTC timestamp with microseconds and the change primary key. The snapshot includes the latest state for every attendee, including LEAVE rows as absent-state tombstones. That combination lets the browser compare versions per attendee and merge a live message with a snapshot even when either arrives first.
Presence is never stored. AttendanceEvent.latest_attendance_changes(timestamp) uses ROW_NUMBER() partitioned by the member and guest foreign keys and ordered by timestamp descending, then pk descending, returning one latest row per attendee. This window query runs on the repository’s SQLite test database and PostgreSQL, and includes absent attendees for the websocket snapshot. present_attendees(timestamp) and present_count(timestamp) filter the same rows for ENTER, so the list and count agree. is_attendee_present(attendee, after_timestamp) reads the same (timestamp, pk) order for one attendee and ignores future-dated rows until their timestamp; with after_timestamp it asks for the newest change since that time rather than a snapshot at that time. polls/vote.py asks AttendancePoll.event.is_attendee_present(user) with no timestamp before counting an attached poll vote. was_attendee_present asks whether somebody ever entered. The report timeline also uses (timestamp, pk), so all state readers resolve ties the same way and no migration or schema change is needed.
has_ended is a property that is true only when end_datetime is set and already past, and has_started is true once start_datetime has passed. The report page reads both, to label a meeting as not started, ongoing or ended; the report labels its meeting-end headcount with them, and the admin’s meeting list colours its status column with them. What filters the public list is the queryset in attendance/views.py: an event with an end is listed while end_datetime >= now(), and an event with no end only while start_datetime > now() - NO_END_LISTING_HOURS, which is 24 hours. A meeting that recorded an end therefore leaves /attendance/ as soon as that moment has passed, and one whose end nobody filled in leaves it a day after it started, so a forgotten end cannot pin yesterday’s meeting to the public page for ever. The bound is on the list and nothing else: the detail view consults neither the filter nor has_ended, so an event’s page, its code and its QR code keep working after the end and after that day for anyone who still has the URL or the QR code. The filter is presentation, not a lock.
attendance/forms.py is one form, and it renders nothing: AttendanceChangeForm carries non_member_name (optional, max 255), type (a typed choice field) and code (optional IntegerField). It exists so the POST is validated; the template owns the markup.
The views subscript a generic at class-definition time (SingleObjectMixin[AttendanceEvent]), which is a runtime expression and not only a type annotation. core/settings/common.py calls django_stubs_ext.monkeypatch() at import time for exactly that, and django-stubs-ext is a runtime dependency in pyproject.toml. Dropping either one breaks the module at import.
attendance/urls.py declares three names, and core/urls/common.py mounts the app under attendance/:
attendance-index at /attendance/ (AttendanceEventsView): public, no login. It lists the events the queryset above leaves, soonest first, each linking to its slug, so the meeting that is running now is at the top. Nothing in the code puts this page in the site’s menus: the menu is editor content, so a link is added through the admin’s navigation (a Static Page Nav category or a Static URL entry) like any other link, and it disappears again if an editor removes it.attendance-event-view at /attendance/<slug>/ (AttendanceEventDetailView): public when allow_non_members is true, and that field defaults to False, so a new meeting is members-only until an editor ticks the switch. When it is false the view runs behind UserPassesTestMixin, so an anonymous visitor is redirected to the login page (LOGIN_URL = "members:login") while any signed-in member gets in. The mixin redirects with self.request.get_full_path(), so the ?code= the visitor arrived with rides along in next and logging in lands back on the check-in page with the room’s code filled in. An unknown slug is a 404.attendance-event-overview at /attendance/<slug>/overview (AttendanceEventOverview, note the missing trailing slash): for readers who hold attendance.view_attendanceevent, through UserPassesTestMixin and is_user_allowed. Anonymous is redirected to the login page, and a signed-in member without the permission gets 403 whether or not they are staff. The detail page renders its links and its log from the same test, in can_see_attendance.The detail POST answers with four status codes:
is_code_valid rejects the code. An anonymous POST with an empty name is 403 too, with “Namn måste anges om du inte är inloggad”, although the template marks that input required and a browser never sends it empty. A malformed type is also 403: it is the one form error for a field the page does not draw, so the view maps type_error onto generic_error, which the template renders, rather than failing silently.ENTER while the attendee is already present, and “Du kan inte gå ut ur ett evenemang var du inte är närvarande” for a LEAVE while they are not.next parameter when it names a safe local URL and to request.path (the same page without the ?code= the QR code added) otherwise. The response uses the local HttpResponseSeeOther, because HttpResponseRedirect is 302.next parameterA participant can arrive here from a room-only poll, which refuses a vote until they are in the room and links to this page with ?next=/polls/<id>/ (see docs/dev/polls.md). The detail view reads that value on GET from the query string and on POST from the form, and _safe_next() accepts it only when django.utils.http.url_has_allowed_host_and_scheme allows it against request.get_host(): a value with another host, including the //evil.example shape and a full https://evil.example/polls/7/ URL, is dropped and the 303 falls back to request.path. That check is the open-redirect guard, and the value is dropped rather than reflected, so the page never renders a hostile URL. A safe value is rendered into the check-in form as a hidden input, and that is what carries it through a refused attempt: the 403 for a wrong code, the 429 for a lockout and the 409 for a conflicting change all render the page again through get_ctx(), and the hidden input is still there for the attempt that succeeds. The field is absent when there is no next, and the QR flow’s ?code= is read from the query as before, so the two parameters coexist in one URL.
The write is deliberately not atomic. A comment in views.py accepts that two simultaneous submissions can both pass the presence check and write two rows, “but it wouldn’t really matter in the end”: presence is read from the newest row, so the answer is unchanged. Unlike booking, there is no select_for_update here, and the duplicate stays in the log.
After the row is saved, the view calls websocket.send_attendance_change(...). The sender owns the failure handling, because the row is already the record by then: attendance/websocket.py catches anything the channel layer raises, logs it under the attendance logger, and returns. A Redis outage therefore costs the live update and not the write, and the participant is not told their check-in failed when it did not. Retrying after such a failure is still answered 409, because the row exists; the next page load draws it.
A guest who has no account types their name on every visit, so the view keeps the last one in a cookie. A successful anonymous change, meaning one that wrote an AttendanceChange, sets attendance_non_member_name on the 303 response to the name that was typed, with HttpOnly, SameSite=Lax, path="/", secure when the request is secure, and a max_age of one year. The two values are NON_MEMBER_NAME_COOKIE and NON_MEMBER_NAME_COOKIE_MAX_AGE in attendance/views.py. The name is percent-encoded into the value and decoded again on the way in. That is not decoration: a Set-Cookie header is latin-1, and http.cookies escapes a name like “Gäst” into G\344st, which a browser hands back verbatim and Django then drops as an unparseable cookie, so an unencoded cookie would silently lose exactly the Swedish names this site is made of, and a name outside latin-1 would break the header outright. A refused attempt sets nothing: the 403 for a wrong code, a missing name or a malformed form, the 429 for a lockout and the 409 for a conflict all return before the cookie is written. A member’s successful change writes nothing either, because a member is identified by the account and not by a name box.
On a GET the view reads the cookie back. It prefills the name input with the value in prefilled_name, and it resolves the name with NonMemberAttendee.objects.filter(name__iexact=...), so a remembered guest gets the treatment a member gets: the line that says whether they are present, and the button that makes no sense disabled. The match is case-insensitive for the same reason the check-in is: the row keeps the spelling used first while the cookie keeps the spelling typed last, so an exact lookup would lose a guest who typed their name differently the second time. Nothing is created on a GET. A cookie is a claim about a name, not a guest, and get_or_create stays where it was, on a submitted check-in, so a cookie naming nobody prefills the box and shows no state.
The cookie is convenience only, and the log does not depend on it. A guest’s identity is the name they submit, exactly as it was before this cookie existed: two people who type the same name still share one row, a different name is a different attendee, and clearing cookies costs a guest nothing but retyping. Capitalisation and leading or trailing whitespace are folded by the check-in (see Non-member identity), so a name that differs in either of those is not a different attendee either. HttpOnly keeps the name away from the page’s scripts, and secure is read from request.is_secure(), which the deployment’s forwarded-protocol handling makes true on an HTTPS request.
AttendanceEvent.totp builds a django_otp.oath.TOTP over code_secret.encode() with step=code_validity_time: the library’s standard six-digit SHA1 TOTP. get_current_code() returns totp.token(), and is_code_valid(code) accepts the current period’s token or the one before it: django_otp verifies the current step only, so the previous period is reached by asking the same secret to count one step back with drift=-1. A code that rotates while somebody is typing it therefore still works for one more period, a code two steps old is refused, and the next period’s code is deliberately not accepted early. That matters now that a handful of wrong codes starts a lockout, because the participant who is a little slow is exactly the one the grace protects. time_until_next_code() is step - totp.time % step, the seconds left in the window, which is the value the websocket returns to the overview page.
The code is rendered in one place: AttendanceEventOverview.get_ctx puts get_current_code() into the context of templates/common/attendance/overview.html, and the websocket behind the overview page serves the same value (see Websocket). No other view or template renders it. That is the anti-abuse control behind issue #398, the requirement that the attendance list cannot be used to inflate a meeting’s membership: checking somebody in needs a code that is visible only to the readers who hold the permission, on the spot. Its strength is the display and nothing else. The code is six digits, it is shown to everyone in the room, and it rotates every 30 seconds by default, so it is proof of presence at the meeting rather than a credential that identifies a person. Both code_secret and code_validity_time are editable in the admin, and changing the validity time also changes the step, so the code in force moves at once. The period has a floor of one second: django_otp divides by the step, so a zero or negative period breaks the calculation outright, and a MinValueValidator(1) on the field is what stops the admin form from saving one.
The code is the only thing that stands between the public page and the log, and it is a low bar on purpose. attendance/limits.py counts wrong codes in the session: after ATTEMPT_LIMIT (5) of them the session is locked out for LOCKOUT_SECONDS (60), every submission in that window answers 429 with “För många felaktiga koder. Försök igen om N sekunder.”, and a correct code clears both the counter and the lockout. The limit is checked before the code is, so a lockout also refuses the right code, which is the point: otherwise an attacker keeps guessing while a participant’s correct attempt slips through. The lockout is deliberately short, because the code is on a screen in the room and the likeliest person holding a wrong one is a participant who mistyped it.
What that bounds is worth stating plainly. The counter is in the session, so a client that discards its cookie starts over, and this is a speed bump rather than a wall. Moving it server-side needs a key the client cannot throw away, and the obvious candidate, the client address, is rewritten by the ingress, so every visitor would share one bucket and a low threshold would lock out the whole meeting. docs/dev/booking.md describes the same trade-off for the booking code.
The form is validated before the lockout is consulted, so a malformed submission is a 403 that costs no attempt.
templates/common/attendance/index.html: the event list under the page’s one h1, each entry a link to the event page resolved with the {% url 'attendance-event-view' e.slug %} tag.templates/common/attendance/detail.html: the check-in page. Its one h1 is the event title, the description is a <p class="h5"> rather than a heading, and “Till översiktsvyn” is resolved with the {% url 'attendance-event-overview' object.slug %} tag, so the link follows the URLconf rather than a hardcoded relative path. It prints “Börjar:” and “Slutar:” from the event, shows the name input and its explanatory sentence only to an anonymous visitor, with the input prefilled from the name cookie, tells a member and a remembered guest whether they are present (the context’s is_present is None only when the page cannot say, which is a visitor with no account and no remembered name, and then the page draws no state for them), and disables “Gå in” for whoever is present and “Gå ut” for whoever is not. The name input has a short visually hidden <label> and points at the explanation with aria-describedby; the code input has a visually hidden <label for="code"> because a placeholder is not a label. Both field errors get an id, role="alert" and an aria-describedby on their input, so a wrong code or a missing name is announced and tied to the control it belongs to. The decorative button icons are aria-hidden="true", and #qr-reader-error is aria-live="polite" because the script writes into it. “Närvarande” lists the current attendees from the present_attendees context, and the heading carries how many there are in a <span id="present-count">, the same element the overview page uses. The roster is paged the way the log below it is: the whole list is rendered, all but the first CHANGES_VISIBLE_ON_LOAD of it is hidden, and #roster-more reveals CHANGES_PAGE_SIZE more per click, through wireMorePager in static/common/attendance/js/changes-table.js. A noscript block shows every name when the script is off, because otherwise the roster would stop at its first page. The names in the list are inside {% if can_see_attendance %} and the number in the heading is not, so a participant reads how many are in the room and not who they are, and the closing line that links to the report is inside {% if can_see_report %} because that page is an admin view; the permission-gated “Närvaroändringar” log is the same table the overview feed uses, drawn from the changes context with select_related so the names cost one query rather than one per row. It is bounded the same way the feed is: the view loads the newest LOG_CHANGES_ON_EVENT_PAGE rows, the page hides all but the first CHANGES_VISIBLE_ON_LOAD, and the rest is one click away, with the closing line pointing at the report for the whole record. The view loads it only for a reader with the permission, so a participant’s request runs no log query at all. Its time column prints a short local date and the local time so a row says which day it belongs to, and it is a snapshot: the feed on the overview page is the live copy, this one redraws when the page is reloaded, which its line above the table says. The “Till översiktsvyn” link appears only when can_see_attendance is true, the same test the overview view makes. A next the view accepts is rendered as a hidden input in the check-in form and nowhere else, so a refused attempt keeps the return path; the input is absent when there is no next, and an off-site value is dropped rather than drawn. The two actions are separate buttons in a wrapping row, with a line under them saying which is for arriving and which for leaving, because a visitor whose presence the page cannot know gets both of them enabled. Both attendance templates load static/common/attendance/css/attendance.css, which carries that row, the list spacing and the two presence colours.templates/common/attendance/overview.html: the current code in large digits, a status line, the QR canvas and the Närvarande list from the present_attendees context. Its one h1 is the event title, and the code is a <p class="h1" id="current-code">, which looks the same without claiming to be the page heading. The Närvarande heading carries the number of attendees in that list inside a <span id="present-count">, which is the element overview.js writes the list’s length into. The list has the stable id present-attendees, one <li data-attendee="<key>"> per attendee displaying that entry’s name, and an empty-state element that shows when the list is empty. The status line and the feed carry aria-live="polite", because the script changes them without a page load. A live region announces additions rather than removals, so a list could never say that somebody left; the feed’s rows are the changes themselves, which is what the board follows to see that a person who stepped out came back, and the attendee list is deliberately silent so a change is announced once and not twice. The feed is a table inside a live wrapper: change-feed is its tbody, change-feed-heading its heading and no-changes its empty state, its columns are the time, the name and the change, and feed-more and feed-end are the paging button and the line that replaces it when the rendered rows run out. Each row carries attendance-changes__row--enter or --leave, and the change cell carries the matching --enter or --leave class, which is what gives an arrival and a departure different colours: the table and its cells are styled once under attendance-changes, and both the feed and the event page’s log use them, so the two read the same way. The feed’s own paging button and closing line stay under attendance-feed. AttendanceChange.type_name is the "ENTER" or "LEAVE" word that both the template and the websocket payload read, so neither compares the stored integer. Colour is never the only signal: the word in the last column carries the meaning. A visually hidden heading names the code for a screen reader, and the code sits inside an aria-live wrapper so a rotation is announced, while the QR canvas is labelled as an image with QR-kod som leder till incheckningssidan. Under the code, overview.js writes the seconds left in the window and turns that line into a warning when the next code does not arrive. The page’s own links are the feed’s closing line, which opens the participant page, so a reader either arrives from the event page or follows that line.The QR flow, end to end:
static/common/attendance/js/overview.js renders the event page’s address with ?code= appended into the canvas with the CDN qrcode library (QRCode.toCanvas). The address comes from the view, which asks request.build_absolute_uri() for the attendance-event-view route: the address this page is being served from, carried to the template in the data-event-url attribute of the #qrcode element. In production that is the association’s own domain, because the chart deploys with USE_X_FORWARDED_HOST and TRUST_X_FORWARDED_PROTO on by default (charts/date-website/values.yaml) and the ingress passes the public host and protocol through; everywhere else, a QA deployment or a laptop, it is that instance’s own address, which is the one a room standing in front of this screen can reach. A CONTENT_VARIABLES['SITE_URL'] value would name the same domain in production but the wrong instance in those other cases, and booking/emails.py reaches for it only because mail is composed where there is no request to ask. The one way to get this wrong is to open the overview page through an internal service name that bypasses the ingress, which would put that name in the code. It has to be absolute: a phone’s camera app only offers to open a link, and this page’s own scanner parses the value with new URL(), which throws on a bare path. Both routes to the page were broken while the value was a path. The URL the view builds is the event page itself, trailing slash included, so a scan does not take a redirect. The websocket URL is absolute: overview.js switches HTTP to WS or HTTPS to WSS, keeps window.location.host, and appends /ws to window.location.pathname with the last segment dropped, because the route matches without a trailing slash./attendance/<slug>/?code=<code>.detail.html writes the ?code= value into the code input, so the box arrives prefilled. A ?next= in the same URL becomes the hidden input beside it, so a code that arrives from a poll link survives a mistyped first attempt.static/common/attendance/js/detail.js drives the on-screen scanner with the CDN qr-scanner library. The “Skanna QR-kod” button starts disabled in the template, and the script enables it only after it has constructed the scanner, so a failed CDN load or a browser with JavaScript off leaves a disabled button and a typable code field rather than a dead control. A failed construction is not silent: the script writes “QR-skannern kunde inte laddas” into #qr-reader-error, which is why that element is aria-live. A device without a camera is a separate case: the button works, and pressing it shows “Ingen kamera hittades”. The template’s noscript block says “Tillåt JavaScript för att skanna QR-koder”. A scan parses the URL with new URL(value, window.location.origin) inside a try/catch, so a value that is a bare path rather than a link, which is what a code generated before that fix holds, still resolves against this page and fills the box in (URL.parse is a 2024 API and throws on older browsers, which would leave the code empty and the camera running), reads its code parameter, writes it into the input and stops the camera; a QR code without that parameter, or one that is not a URL at all, shows “QR-koden innehöll ingen kod”. The code field and the two submit buttons are ordinary form controls, so check-in by typing the code still works with JavaScript disabled, and only the camera is lost.overview.js keeps the display live: on open it sends {"type": "get_code"}, and each reply carries until_next, the seconds left in the window, which it uses to schedule the next request. The delay is validated before it is used, because a renamed or missing field would make it NaN and setTimeout treats NaN as zero, which would turn the countdown into a tight request loop; JSON.parse is wrapped for the same reason, so one unreadable frame does not stop the rotation. A URL or WebSocket constructor error sets the broken-connection status and retries after five seconds, just like a socket that closes. The last code it received stays on the screen while the socket is down, and that status line is the signal that it may be out of date. The countdown under the code is the second signal: it is written from each reply’s until_next, and once that window has passed without a new code it says that the code is not updating. A reply that carries no usable until_next starts no countdown at all: the page says it is fetching a code rather than promise a rotation it cannot know. A stalled code is dimmed and its QR code is hidden until a fresh one arrives, because the QR carries the same expired value and a scan of it would fill the form with a code the server refuses. The warning is written into the live status line as well, because the countdown itself is hidden from a screen reader. A close the server meant as final shows “Sidan kan inte längre uppdateras. Ladda om sidan.” without retrying. The same attendance_change messages are also written into the feed, one table row per change carrying the time in the reader’s own clock and the matching direction class, matched on the change id so a repeated frame is not shown twice and a row the server already rendered is left alone. The server renders the newest RECENT_CHANGES_ON_OVERVIEW rows and hides all but the first CHANGES_VISIBLE_ON_LOAD of them, so each table opens short and the page carries no more history than that. The Visa fler button reveals the next CHANGES_PAGE_SIZE hidden rows per click, and once none are left the button hides and the closing line appears: the feed’s line points at the meeting page, and the event page’s log points at the report, which carries the whole record as CSV and as a printed page. The pager itself is in static/common/attendance/js/changes-table.js, which both pages load, because the two tables behave the same way. Rows the socket appends are visible as they arrive, and the table keeps at most FEED_MAX_ENTRIES rows and drops the oldest, so a meeting that runs for hours cannot grow the page without bound. Drawing the QR code is guarded as well, because a CDN load that leaves QRCode undefined must not stop the socket from opening.
The server’s attendance_change message carries a version made from the change timestamp in UTC ISO format with microseconds and its primary key. overview.js compares that (timestamp, change_id) pair separately for each attendee and ignores any state that is not newer. The connect snapshot includes one latest state per attendee with a change, including absent attendees as LEAVE tombstones. The browser merges the snapshot instead of clearing blindly: it applies each state only when its version is newer and removes an initial HTML row missing from the snapshot only when no newer live change for that key has already arrived. This handles both sides of the connect race and delayed out-of-order messages. An ENTER adds or updates a row, a LEAVE removes it, and each applied state refreshes the count from attendeeList.children.length; the page’s headcount is always derived from its displayed list. Matching on keys keeps equal names separate, and document.createElement plus innerText prevents socket data from becoming HTML.
There is no JavaScript test runner in this repository. Browser verification for a lead: open the overview page over HTTPS, confirm DevTools shows a wss://<host>/ws/attendance/<slug> connection, then check in and out from another session and confirm the code, attendee rows, and headcount update. Repeat over HTTP in a local test setup and confirm the URL uses ws://.
Both scripts call their strings through the gettext global injected by the page’s {% url 'javascript-catalog' %} script tag, and every user-visible string in them is wrapped. They are the only entries in the locale/sv|en|fi/LC_MESSAGES/djangojs.po catalogs: “Ingen kamera hittades”, “Ett oväntat fel uppstod medan QR-skannern startades”, “QR-koden innehöll ingen kod”, “QR-skannern kunde inte laddas”, “Ansluter till servern…”, “Anslutningen till servern bröts”, “Sidan kan inte längre uppdateras. Ladda om sidan.”, “Ny kod om %(seconds)s s”, “Hämtar en ny kod…” and “Koden uppdateras inte. Kontrollera anslutningen.”. The template and model strings are in the ordinary django.po catalogs for the same three languages.
attendance/routing.py exposes websocket_urlpatterns with re_path(r"ws/attendance/(?P<slug>[-\w]+)/?$", consumers.AttendanceConsumer.as_asgi()). The trailing slash is optional, so a path variant that ends in one still reaches the consumer. core/routing.py imports an app’s routes only when it is installed, preserving the WEBSOCKET_ROUTES map across association variants, and wraps AuthMiddlewareStack(URLRouter(...)) in Channels AllowedHostsOriginValidator. The validator checks the origin against ALLOWED_HOSTS before session authentication, so a cross-origin page cannot use an authenticated browser session to read attendee names or the rotating code.
AttendanceConsumer is permission-gated because it also serves the current code: connect reloads the request’s session through settings.SESSION_ENGINE, checks active status and attendance.view_attendanceevent on the freshly loaded user, and closes before accepting anyone else. Each accepted client receives {"type": "attendance_snapshot", "data": [{"key": ..., "name": ..., "type": "ENTER"|"LEAVE", "present": <bool>, "timestamp": <UTC ISO timestamp>, "change_id": <pk>}, ...]} after accept. It includes one latest state per attendee with a change, including absent attendees. Snapshot and broadcast versions use the same (timestamp, change_id) order, which lets the client reject an older update for the same attendee whether it arrives before or after the snapshot. connect also looks up the event, so an unknown or deleted slug closes cleanly instead of raising and provoking endless retries. The lookup is repeated per message, so a code-secret or period change reaches an open page at once, and a deleted event closes the socket. On each inbound message and forwarded attendance_change or attendance_invalidate, authorization reloads the server-side session through settings.SESSION_ENGINE, then checks active status and the permission on a freshly loaded user. A deleted, expired or rotated session closes the socket instead of leaving it able to read codes or names. Taking the permission away also closes the connection, at the next message the page sends rather than on a timer. The close codes are 4003 for lost permission and 4004 for a missing event (AttendanceConsumer.NOT_ALLOWED and .EVENT_GONE), which overview.js treats as final. It joins attendance_<slug> and answers {"type": "get_code"} with {"type": "code", "code": <int>, "until_next": <seconds>}. Other client messages are ignored.
attendance/websocket.py broadcasts every saved change to the same group as an attendance_change event. Its payload includes the attendee key and label, ENTER or LEAVE, the timestamp token and the saved change primary key. The consumer forwards that state to overview.js, which updates the attendee list and derives the displayed count from the rows. detail.js opens no socket, so the public event page keeps its server-rendered snapshot and still has to be reloaded. The module also exports send_attendance_invalidate, which the change admin calls after a correction or a deletion: it sends {"type": "attendance_invalidate"} to the same group, the consumer revalidates the session and forwards it, and overview.js reloads the page. A reload rather than a repair, because the feed only appends and the attendee list is built from a snapshot, so neither can drop or rewrite the row that changed.
attendance/admin.py registers AttendanceEvent and AttendanceChange on their own, and deliberately carries no inline of the changes on the event form: Django does not paginate an inline, so a meeting with a few hundred check-ins would draw one editable form per row and the page would stop loading (the booking admin documents the same rule). The event form links to the change list filtered to the meeting instead. NonMemberAttendee is registered with a page that holds one thing: the name. There is no add, because a guest row comes from the public form and an editor adding one would invent an attendee, and no delete, because deleting the row cascades through every change recorded under the name. The list annotates each name with its change count and searches on it. AttendancePoll is not registered either: the editor meets it as the Närvarokrav inline on the poll page, which polls/admin.py adds behind apps.is_installed('attendance') together with the Närvaroevenemang column in the poll list (docs/admin/polls.md). That block makes the AttendancePollInline permissions follow the parent question: polls.add_question on the add page and polls.change_question on an existing poll, without requiring AttendancePoll-specific or event-management permissions. It also calls present_count() for the attached poll’s read-only room count. That count includes everyone in the room, not just eligible voters, and is context rather than an exact poll-time denominator. The event changelist lists the title, the status, the times and the guest switch, with a code link and the report link on each row, filters on the switch and offers a start-date drill-down, searches title, description and slug, and opens on the newest event, because the workflow is to find one event and open its overview page. The status column is has_ended and has_started behind three words: a running meeting is green, one that has not started is amber and an ended one is grey, through admin-colours.css. It also carries a Rapport button on every row, added to list_display and dropped again by get_list_display() for a reader without the permission, which is the per-row pattern events/admin.py and billing/admin.py already follow (docs/dev/events.md, docs/dev/billing.md); the button opens the report described next. The change changelist is only shown for one meeting: changelist_view reads event__id__exact and redirects to the event changelist when it is absent, so there is no all-events list, and its title names the meeting rather than saying “select something to change”. It lists the time, one attendee column and the coloured type, filters on the type, searches the attendee names, and opens newest first. response_add, response_change and response_delete all return to that meeting’s list, and delete_view keeps the meeting on the request before the row is gone, which is the only way response_delete can know where to go. get_changeform_initial_data reads the meeting out of _changelist_filters, so a row added from a meeting’s list opens with that meeting already chosen. has_module_permission() is false: the app index and the classic sidebar do not offer the list, while its changelist still answers for a meeting’s URL. The meeting is named above the table by a list_before_template include, because Unfold builds its changelist header from the model and ignores the title the view sets; the classic admin has no such hook, so for it the page title carries the meeting. A member is shown by get_full_name() and linked to their own admin page, with the username only as the fallback when neither name is set, and the direction carries the same green and amber as the event page’s table through admin-colours.css. The correction form picks a member by name as well, by overriding label_from_instance on its user field, so a username is not the thing an editor has to recognize. A correction or a deletion there fires websocket.send_attendance_invalidate for the meetings it touched, from save_model, delete_model and delete_queryset, so an overview page that is open reloads instead of keeping the row as it was. core/admin_ui.py carries an Attendance group for the Unfold sidebar holding the event list and the guest list, so an editor on a site with USE_UNFOLD on can reach both from the sidebar; each link drops out when its permission or URL name does not resolve, which is what keeps the group off an association that does not install the app. The change list is deliberately not a sidebar entry: it belongs to a meeting and is opened from one. The event change form groups its fields for the board member who fills it in: Mötet holds title, description, both times, the guest switch and the slug; a small unnamed section holds the three links an operator would otherwise assemble from the slug, Öppna incheckningen to the public page, Visa möteskoden to the overview page and Visa ändringarna to the change list filtered to this meeting, all opened in a new tab so a half-filled form is not left behind, and all rendered as nothing on the add page where there is no slug yet; and Koden holds code_secret and code_validity_time collapsed under classes = ("collapse",), with a description saying what a new secret does to the code in force. The form’s slug and end-time fields carry help text set in AttendanceEventAdminForm, because the ASCII rule for the slug and the meaning of an empty end both surprise an editor and a model help_text would be a schema change. The two times, and a change row’s timestamp, use core.admin_widgets.FlatpickrDateTimeAdminMixin, so each is one field with the shared calendar picker the events and polls forms use rather than the split date and hour boxes, and both admins declare the mixin’s Media because admin media is not inherited. Both admins also subclass core.admin_base.ModelAdmin and spread UNFOLD_FORMFIELD_OVERRIDES, as the other apps’ admins do: without that every text field renders as a bare vTextField with no border or padding under Unfold, which is what made the attendance form look unfinished beside the rest of the admin. Nothing is hidden and every field stays editable. AttendanceEvent.get_absolute_url() returns the public check-in page, which is what the admin’s own View on site button reads.
AttendanceEventAdmin carries one page that answers what happened in a room: who came and went, how many were there, the polls held there and how they turned out, and a way to take all of it away as a CSV or on paper. The editor-facing side of that page is docs/admin/attendance.md. It is the app’s only get_urls(), and its three routes sit under admin/attendance/attendanceevent/<id>/report/, each wrapped in self.admin_site.admin_view(...) and each re-checking its own permission, following billing/admin.py and events/admin.py (docs/dev/billing.md, docs/dev/events.md).
| URL name | Path | What it serves |
|---|---|---|
attendance_event_report |
<id>/report/ |
the page |
attendance_event_report_timeline_csv |
<id>/report/timeline.csv |
the timeline, one row per change |
attendance_event_report_polls_csv |
<id>/report/polls.csv |
one row per poll per choice |
An unknown id is a 404 on all three, and admin_view has already established that the reader is staff before any of them runs. That is why the event page asks can_see_report as well as can_see_attendance before it draws its link to the report: the admin would only bounce a permitted member who is not staff. The page itself requires attendance.view_attendanceevent, asked through self.has_view_permission(request) so a reader holding the change permission gets in too, and a reader without it gets a 403 rather than a link. The polls are behind a second and separate permission, polls.view_question. Without it the page prints one muted line where the polls would be, but only when the meeting actually has polls, and the poll CSV answers 403. The rest of the page, the timeline download included, needs no poll permission.
The report computes four numbers. present_at_end(event) answers how many were present at the event’s recorded end, using present_count(end_datetime), or after the latest recorded change when no end is stored. The latter is stable even if the report is opened later. present_at_end_label(event) supplies the matching Närvarande vid slutet or Närvarande vid sista ändringen label in the page and poll CSV. present_count() and present_attendees() now use the same portable latest-state window query, so the count and attendee list resolve timestamp ties by the same primary key order as the report timeline. peak_present_count() replays the already-loaded changes; when an end is recorded it excludes changes after that time, and without an end it includes all changes. The unique-attendee total counts distinct attendee keys among all ENTER rows, and the change total is the number of rows in the timeline.
The page has one h1, the meeting’s title, and the view deliberately leaves title out of the context: the admin base template renders that key as a heading of its own above the content, so setting it would print a second one. The page’s own name is in the template’s title block and in the subtitle under the h1.
The timeline is the whole log, oldest first, as local time, attendee name, arrival or departure, and member or guest. The page draws at most REPORT_TIMELINE_ROWS (1000) of it: the numbers above still read every change, the CSV still exports every change, and a longer meeting gets a line under the heading saying how many of how many it is showing. It costs one query: select_related("user", "non_member") for the names and order_by("timestamp", "pk") to give equal timestamps the same order as every presence reader. A name comes from AttendanceChange.attendee_name, which is attendee_label() and matches the overview list. The arrival or departure is get_type_display() translated again with the left/entered in general context; the kind is a separate column rather than the (icke-medlem) marker from NonMemberAttendee.__str__.
Each attached poll gets a section with its question, status, choice votes and percentages. Röstsedlar is get_total_votes(). Röstande is voters.count(), the members who voted; anonymous votes are not included. The last value is the meeting headcount from above and carries the same end or last-change label. It is contextual, not an exact denominator at the time of each poll: it can include guests and members who are not eligible to vote, and the data does not store the room population when each vote was cast. Choice percentages are guarded against zero ballots because get_vote_percentage() otherwise raises ZeroDivisionError; choices are ordered by primary key.
The two downloads follow billing/admin.py: an HttpResponse with content_type="text/csv" and a Content-Disposition header, written with csv.DictWriter. They use a semicolon delimiter and a UTF-8 BOM so Swedish Excel reads the columns and names correctly. String data cells beginning with =, +, - or @ get a leading apostrophe to prevent Excel from evaluating them as formulas, and the check runs on the value after its leading spaces, tabs and line breaks, so a formula cannot hide behind one. Headers and numeric values are not sanitized. Both files have explicit row order. The timeline header is Tid;Deltagare;Ändring;Typ; the poll CSV header’s final column uses the same end or last-change label as the report. Filenames use the slug and local meeting start date. The ballot count, voter count and headcount repeat on every choice row, and a poll with no choices contributes no row.
static/common/attendance/css/report.css carries both the screen layout of the report page and its print rules. On screen it draws the page as a header plus one bordered section per part, keeps the three actions in a wrapping row of buttons, and gives every table padded cells inside a wrapper that scrolls sideways when the window is too narrow for its columns. None of it reads a theme variable: borders and backgrounds are mixed from currentColor with plain rgba fallbacks, so the page looks the same under the stock admin and under Unfold, in a light and in a dark scheme. Its @media print block hides the stock admin chrome, the Unfold header and the sidebar with its toggle control, forces black text on a white page, keeps a table row, a list item and a heading together with a heading’s break-after: avoid, and repeats long table headers. The Unfold selector #main > .bg-white.border-b matches the direct header wrapper in the installed Unfold admin/base.html and unfold/helpers/header.html templates. The stylesheet does not paginate the report, add a running header or footer, or produce a PDF. The template links it from the admin base template’s extrastyle block, which both the stock admin and Unfold define.
The branch has one 0001_initial, regenerated from the complete current model state while the app is still unreleased. It creates all four tables (AttendanceEvent, AttendanceChange, NonMemberAttendee and AttendancePoll), the foreign_keys_ok check constraint and the case-insensitive unique_non_member_name_ci constraint, with the current allow_non_members=False default and the code-period validator. Because AttendancePoll.question points at polls.Question, Django makes the migration depend on polls.0006_remove_question_published_question_published_time; on an association without attendance, this migration is never loaded.
The migration does not copy, merge or otherwise move existing rows. In particular, a database that still contains separate guest rows whose names differ only by case cannot apply the new unique constraint until those rows are reconciled. The app has not been released, so this branch does not attempt an automatic merge that could rewrite attendance history.
A local database that ran an earlier revision of this branch has migration records for files that no longer exist. Dropping tables alone is not enough: migrate sees the old attendance rows in django_migrations and will not run the new initial migration. For disposable development data, remove the attendance tables and their django_migrations rows together, or use date-cleaninit, which recreates the database. Back up any data you need first. Once this flattened history is released, new behaviour gets ordinary forward migrations; do not rewrite it.
uv run python manage.py test attendance
Through the Docker helper, after source env.sh:
date-test attendance
Both run with core.settings.test, which installs the DaTe app set, uses in-memory SQLite and an in-memory channel layer. attendance/tests.py covers the models (including the poll-to-meeting link, its cascades and the one-to-one), the attendee keys and entries, the TOTP code and its rotation, portable presence readers and timestamp tie ordering (PresentAttendeesTests, PresentCountTests and AttendeePresenceTests), the report’s number with and without an end (PresentAtEndTests, which pins that a meeting without one is read at its last change whatever the clock says), the check constraint, the case-insensitive guest identity and the constraint behind it, the form, every view status code and access rule, the report page and its two downloads, the broadcast and the consumer. Later cases pin what this branch fixed: a channel layer that refuses cannot fail a saved check-in or check-out, the change admin’s invalidation reaches the consumer and a demoted socket is closed by it, a meeting that ends before it starts is refused on the end field, the attendee names and the report link follow the permission, the noscript reveal is in both tables, and the lockout tests run against a frozen clock. The overview tests also assert that the page carries the attendees it is given, that the heading carries their number in the element the script updates, that two attendees with the same name are two rows, that a guest whose name matches a member’s is a row of its own, that the key in the broadcast is the key the page renders, that a guest label does not move with the active language, and that the new markup (one heading, the hidden labels, the aria-live elements, the {% url %} links) is present; the detail tests pin that the change log does not cost a query per row, that the roster opens on its first page and renders the rest for the button, that the attendance heading carries the number of attendees the page was given, that the two field errors are tied to their inputs, and that next is rendered into the form only when it is given and only when it is local, that it survives a wrong code, a lockout and a conflict, and that a successful check-in lands on it; the index tests also pin the day-long listing of a meeting without an end and that the meeting’s own page outlives it; and the detail tests pin the name cookie, meaning the attributes it is set with on a successful guest change, that the value is ASCII enough for a Set-Cookie header and survives a name outside latin-1, that neither a member’s change nor a refused attempt writes one, that a GET prefills the box from it and reads presence for the remembered name, that a name with no row creates nothing, and the login redirect a members-only meeting gives an anonymous visitor with its ?code= preserved in next; the consumer tests pin the connect snapshot, the rejected connection for a slug that does not exist, the trailing-slash route, and that a staff member whose group membership is removed mid-connection gets no code and a closed socket; three admin tests pin the changelist an editor works from; and the thirteen report tests pin the rendered numbers, the label that matches whether the meeting has an end, the number moving when a later change is added to a meeting without one, the chronological timeline, the poll results, the peak over an overlapping log and its zero case, the 403 and the 404, the polls line a reader without polls.view_question gets, the changelist button with and without the permission, a poll with no votes at all, and both downloads (status, content type, filename, BOM, header row, row count and row order). Routing is covered separately by core/tests/test_url_route_parity.py, which asserts that date exposes the attendance/ prefix and that attendance-index reverses for date; that is the test to extend when another variant mounts the app.
AttendanceEvent.latest_attendance_changes() ranks rows by attendee and by (timestamp, pk), using a window query supported by SQLite and PostgreSQL. present_attendees(), present_count() and is_attendee_present() now agree on the same tie order. PresentAttendeesTests runs on the repository’s SQLite test settings and covers list/count agreement, equal-timestamp ordering and the one-query name loading. The websocket snapshot uses the same latest rows, including LEAVE tombstones, and its change-token tests pin the timestamp and primary key sent to the browser.
The file is written for core.settings.test. The current websocket tests cannot run together in a PostgreSQL TestCase transaction: channels’ AsyncConsumer calls close_old_connections() before dispatching each message, and Django may close a connection whose autocommit is off. SQLite does not take that branch, so the repository’s configured test suite remains the supported full run. If PostgreSQL testing is needed, run the websocket cases outside a TestCase transaction or in a separate process; changing production code to work around this test harness limitation is not appropriate.
There is no JavaScript test runner in this repository. Python tests cover version tokens and snapshot state, but the browser’s DOM merge and WebSocket constructor behavior are verified manually. The browser check above documents the lead’s steps: confirm the absolute ws:// or wss:// URL in DevTools, then check in and out from another session and verify the code, attendee rows and list-derived count update. The view tests separately cover the absolute event URL supplied to the QR code and the markup consumed by the script.
'attendance' to that variant’s installed apps (get_installed_apps([...]) in core/settings/<variant>.py). core/settings/date.py is the worked example.attendance route key to that variant’s build_urlpatterns(...) call. core/urls/date.py is the worked example. Unlike booking, the key is not gated on a capability, so a variant that wants an off switch has to add one.EXPECTED_PREFIXES and EXPECTED_URL_NAMES in core/tests/test_url_route_parity.py, or the parity test fails.The templates under templates/common/attendance/ and the scripts under static/common/attendance/js/ are shared, so nothing else is required; an association-specific override follows the usual templates/<association>/ rules in docs/dev/templates.md. No module under attendance/ outside its tests reads PROJECT_NAME, STAFF_GROUPS or a content variable, so the app itself needs no configuration beyond being installed and routed. One requirement is new: AttendancePoll.question points at polls.Question, so polls has to be installed as well. Every association installs polls today, so this only binds a future one.
NonMemberAttendee.name is unique across the whole table. An anonymous ENTER resolves a trimmed typed name case-insensitively with get_or_create(name__iexact=<trimmed name>, defaults={"name": <trimmed name>}); an anonymous LEAVE only looks up an existing guest case-insensitively and never creates one. A refused guest checkout never creates a guest identity row. A guest who types david dahl after somebody typed David Dahl therefore lands on the same row, and ` David Dahl ` does too. The first spelling stays what the row holds, and it is what the change log, the list and the report show, because the create only runs when nothing matches and the value it stores is the trimmed one. Case and leading or trailing whitespace are all that is folded. A nickname or a different surname is still a new row, so two different people who type the same name share one row for every event, and the change log cannot tell them apart. That is accepted because the app is a list of who was in the room, not an identity system: a guest has no account to key on, and splitting the name into first and last name would not change anything, since the collision is on the name either way. A real fix would take something that distinguishes people, an address or a per-event record, which is more than this list needs. The practical consequence is in the admin guide: ask guests for a name that is theirs alone.
unique_non_member_name_ci in the model’s Meta.constraints is the same fold in the database, and it is there because the lookup alone cannot stop two devices racing: both look, both find nothing, both insert. unique=True on the field stays and is the exact-name constraint that already existed, so the table carries both. The two backends fold differently and that difference is worth knowing. PostgreSQL folds Unicode case, so Åsa and åsa are one attendee in production. SQLite folds ASCII only, so the suite’s database treats those two as different names, and the constraint test therefore uses an ASCII pair, David Dahl and david dahl. The check-in lookup follows the same split, because on SQLite Django implements iexact with an ASCII-only LIKE, so the suite cannot pin the Unicode case in either half.
The detail view’s name cookie changes none of this: it prefills the name box with the name typed last time and looks that name up case-insensitively, so the guest who typed david dahl still gets the presence line of the David Dahl row.
AttendanceChange.user is on_delete=CASCADE on purpose, and it matches polls.Vote.user and members.SubscriptionPayment.member: deleting a member removes their own attendance changes with their account. An erasure request should not leave a trail of “this person attended these meetings” in a table nobody thought about. What outlives a member is the minutes, not this table. Nothing marks a row as belonging to a deleted account, and no report survives the deletion, so attendance history that has to outlive an account must be exported or recorded elsewhere first. Deleting an event cascades in the other direction: the event’s changes go with it, while a guest’s name row stays, because it is shared between events.
attendance.view_attendanceevent, not staff status, so the overview page, the code, the feed and the log reach exactly the groups the association grants it to. The report is the exception: it is an admin view, so it also needs staff status, and a permitted member who is not staff has no route to the complete record from the event page.attendance.view_attendanceevent like the log, so who is in the room is not public. There is still no per-meeting scope: the permission is global, so a reader who holds it reads every meeting.has_ended, and the same is true of a meeting without an end a day after it started, which the public list no longer shows. Anyone who still has the link or the QR code can check in.overview.js is the only client that consumes the broadcast.PresentAttendeesTests cases run there: the ranked window query keeps present_attendees() and present_count() portable while resolving timestamp ties the same way. PostgreSQL uses the same window query. The separate PostgreSQL test-harness limitation above applies to the websocket TestCase tests, not to these presence readers.attendance_change broadcast from detail.js too, so the event page’s own list is live as well; the overview page already does, and both would read the same payload.close_old_connections() closes, so the whole file can run against PostgreSQL and not only on SQLite.AttendanceEventsView if the event list grows.