diff --git a/buzz/api/checkin/__init__.py b/buzz/api/checkin/__init__.py
index 54be4d61..976327d1 100644
--- a/buzz/api/checkin/__init__.py
+++ b/buzz/api/checkin/__init__.py
@@ -1,7 +1,9 @@
import frappe
-from buzz.api.checkin.schemas import CheckinResponse
-from buzz.api.checkin.services import CheckinService
+from buzz.api.checkin.schemas import CheckinEvent, CheckinResponse
+from buzz.api.checkin.services import CheckinService, checkin_events, ensure_can_check_in
+from buzz.api.events.schemas import EventGuestsResponse
+from buzz.api.events.services import GUESTS_PAGE_SIZE, event_guests
@frappe.whitelist()
@@ -9,6 +11,29 @@ def validate_ticket_for_checkin(ticket_id: str) -> CheckinResponse:
return CheckinService(ticket_id).validate()
-@frappe.whitelist()
+@frappe.whitelist(methods=["POST"])
def checkin_ticket(ticket_id: str) -> CheckinResponse:
return CheckinService(ticket_id).checkin()
+
+
+@frappe.whitelist(methods=["POST"])
+def undo_checkin(ticket_id: str) -> CheckinResponse:
+ return CheckinService(ticket_id).undo_checkin()
+
+
+@frappe.whitelist()
+def get_checkin_events() -> list[CheckinEvent]:
+ return checkin_events()
+
+
+@frappe.whitelist()
+def get_checkin_guests(
+ event: str,
+ search: str | None = None,
+ checked_in: str | None = None,
+ start: int = 0,
+ limit: int = GUESTS_PAGE_SIZE,
+) -> EventGuestsResponse:
+ """The guest list, for someone who can check its guests in."""
+ ensure_can_check_in(event)
+ return event_guests(event, search, start=start, limit=limit, checked_in=checked_in)
diff --git a/buzz/api/checkin/exceptions.py b/buzz/api/checkin/exceptions.py
index ffa278bd..32c8aab7 100644
--- a/buzz/api/checkin/exceptions.py
+++ b/buzz/api/checkin/exceptions.py
@@ -21,3 +21,8 @@ class TicketRefunded(Conflict):
class AlreadyCheckedIn(Conflict):
title = _lt("Already Checked In")
message = _lt("This ticket was already checked in today, at {checked_in_at}.")
+
+
+class NotCheckedIn(Conflict):
+ title = _lt("Not Checked In")
+ message = _lt("This ticket has not been checked in today.")
diff --git a/buzz/api/checkin/schemas.py b/buzz/api/checkin/schemas.py
index e875bc41..128f4049 100644
--- a/buzz/api/checkin/schemas.py
+++ b/buzz/api/checkin/schemas.py
@@ -40,3 +40,12 @@ class CheckinResponse(APIResponse):
message: str
ticket: TicketCheckinDetails
payment_details: PaymentDetails | None = None
+
+
+class CheckinEvent(APIResponse):
+ name: str
+ title: str
+ start_date: date | None
+ start_time: timedelta | None
+ end_date: date | None
+ end_time: timedelta | None
diff --git a/buzz/api/checkin/services.py b/buzz/api/checkin/services.py
index 1bce4694..c7db20e5 100644
--- a/buzz/api/checkin/services.py
+++ b/buzz/api/checkin/services.py
@@ -6,18 +6,20 @@
from buzz.api.checkin.exceptions import (
AlreadyCheckedIn,
+ NotCheckedIn,
TicketCancelled,
TicketNotFound,
TicketRefunded,
)
from buzz.api.checkin.schemas import (
+ CheckinEvent,
CheckinResponse,
PaymentDetails,
TicketAddOn,
TicketCheckinDetails,
)
from buzz.api.exceptions import NotPermitted
-from buzz.permissions import has_team_access
+from buzz.permissions import CHECKIN_ROLES, can_check_in, is_unrestricted
from buzz.ticketing.doctype.event_booking_refund.event_booking_refund import get_committed_tickets
if TYPE_CHECKING:
@@ -28,19 +30,45 @@
CANCELLED = 2
+def ensure_can_check_in(event: str) -> None:
+ team = frappe.db.get_value("Buzz Event", event, "team")
+ if not can_check_in(team, frappe.session.user):
+ NotPermitted.throw()
+
+
+def checkin_events() -> list[CheckinEvent]:
+ """Events not yet over, on the teams the user works the front desk for."""
+ filters = {"is_published": 1, "end_date": [">=", today()]}
+ user = frappe.session.user
+ if not is_unrestricted(user):
+ teams = frappe.get_all(
+ "Buzz Team Membership",
+ filters={"user": user, "enabled": 1, "team_role": ["in", list(CHECKIN_ROLES)]},
+ pluck="team",
+ )
+ filters["team"] = ["in", teams or [""]]
+
+ rows = frappe.get_all(
+ "Buzz Event",
+ filters=filters,
+ fields=["name", "title", "start_date", "start_time", "end_date", "end_time"],
+ order_by="start_date asc",
+ ignore_permissions=True,
+ )
+ return [CheckinEvent(**{**row, "name": str(row.name)}) for row in rows]
+
+
class CheckinService:
- """Front-desk check-in for a single ticket. Restricted to Frontdesk Manager."""
+ """Front-desk check-in for a single ticket, by a team member at Frontdesk or above."""
def __init__(self, ticket_id: str):
- frappe.only_for("Frontdesk Manager", True)
if not frappe.db.exists("Event Ticket", ticket_id):
TicketNotFound.throw()
self.ticket_id = ticket_id
self.date = today()
# Both endpoints route through here, so one team check covers them.
- if not has_team_access(self.event.team, "read", frappe.session.user):
- NotPermitted.throw()
+ ensure_can_check_in(self.ticket.event)
@property
def ticket(self) -> "EventTicket":
@@ -83,11 +111,32 @@ def ensure_checkin_allowed(self) -> None:
if self.ticket.booking and self.ticket_id in get_committed_tickets(self.ticket.booking):
TicketRefunded.throw()
- existing = frappe.db.exists("Event Check In", {"ticket": self.ticket_id, "date": self.date})
+ existing = self.todays_checkin()
if existing:
creation = frappe.db.get_value("Event Check In", existing, "creation")
AlreadyCheckedIn.throw(checked_in_at=f"{format_date(creation)} at {format_time(creation)}")
+ def undo_checkin(self) -> CheckinResponse:
+ existing = self.todays_checkin()
+ if not existing:
+ NotCheckedIn.throw()
+
+ # Cancelled rather than deleted, so the record of the mistaken check-in stays.
+ checkin_doc = frappe.get_doc("Event Check In", existing)
+ checkin_doc.flags.ignore_permissions = True
+ checkin_doc.cancel()
+ return CheckinResponse(
+ message=_("Undid the check-in for {attendee_name}").format(
+ attendee_name=self.ticket.attendee_name
+ ),
+ ticket=self.ticket_details(),
+ )
+
+ def todays_checkin(self) -> str | None:
+ return frappe.db.exists(
+ "Event Check In", {"ticket": self.ticket_id, "date": self.date, "docstatus": 1}
+ )
+
def record_checkin(self) -> "EventCheckIn":
checkin_doc = frappe.new_doc("Event Check In")
checkin_doc.ticket = self.ticket_id
diff --git a/buzz/api/checkin/test_checkin.py b/buzz/api/checkin/test_checkin.py
index dcefacde..13848004 100644
--- a/buzz/api/checkin/test_checkin.py
+++ b/buzz/api/checkin/test_checkin.py
@@ -1,13 +1,22 @@
import frappe
from frappe.tests import IntegrationTestCase
-from buzz.api.checkin import checkin_ticket, validate_ticket_for_checkin
+from buzz.api.checkin import (
+ checkin_ticket,
+ get_checkin_events,
+ get_checkin_guests,
+ validate_ticket_for_checkin,
+)
from buzz.api.checkin.exceptions import AlreadyCheckedIn, TicketCancelled, TicketNotFound
+from buzz.api.exceptions import NotPermitted
from buzz.tests.factories import (
BuzzEventFactory,
+ BuzzTeamFactory,
+ BuzzTeamMembershipFactory,
EventBookingFactory,
EventPaymentFactory,
EventTicketFactory,
+ UserFactory,
)
@@ -86,3 +95,74 @@ def test_validation_rejects_an_already_checked_in_ticket(self):
with self.assertRaises(AlreadyCheckedIn):
validate_ticket_for_checkin(self.ticket)
+
+
+class TestCheckinAccess(IntegrationTestCase):
+ """Front-desk work is for the event's own team, at Frontdesk or above."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ owner = UserFactory.create_once("checkin-owner@example.com").name
+ other_owner = UserFactory.create_once("checkin-other-owner@example.com").name
+ cls.team = BuzzTeamFactory.create_owned_by(owner).name
+ cls.other_team = BuzzTeamFactory.create_owned_by(other_owner).name
+ cls.event = str(BuzzEventFactory.create(team=cls.team).name)
+ cls.other_event = str(BuzzEventFactory.create(team=cls.other_team).name)
+
+ cls.frontdesk = cls.member("checkin-frontdesk@example.com", "Frontdesk")
+ cls.manager = cls.member("checkin-manager@example.com", "Manager")
+ cls.viewer = cls.member("checkin-viewer@example.com", "Viewer")
+ # Front desk on one team, only a Viewer on the other.
+ BuzzTeamMembershipFactory.create(team=cls.other_team, user=cls.frontdesk, team_role="Viewer")
+
+ @classmethod
+ def member(cls, email: str, team_role: str) -> str:
+ user = UserFactory.create_once(email).name
+ BuzzTeamMembershipFactory.create(team=cls.team, user=user, team_role=team_role)
+ return user
+
+ def setUp(self):
+ frappe.clear_messages()
+
+ def test_frontdesk_and_manager_can_check_in(self):
+ for user in (self.frontdesk, self.manager):
+ ticket = EventTicketFactory.create(event=self.event).name
+ with self.set_user(user):
+ self.assertTrue(checkin_ticket(ticket).ticket.is_checked_in)
+
+ def test_viewer_cannot_check_in(self):
+ ticket = EventTicketFactory.create(event=self.event).name
+ with self.set_user(self.viewer), self.assertRaises(NotPermitted):
+ validate_ticket_for_checkin(ticket)
+
+ def test_frontdesk_role_does_not_carry_to_another_team(self):
+ ticket = EventTicketFactory.create(event=self.other_event).name
+ with self.set_user(self.frontdesk), self.assertRaises(NotPermitted):
+ checkin_ticket(ticket)
+
+ def test_events_list_only_the_front_desk_teams(self):
+ with self.set_user(self.frontdesk):
+ events = {event.name for event in get_checkin_events()}
+
+ self.assertIn(self.event, events)
+ self.assertNotIn(self.other_event, events)
+
+ def test_viewer_cannot_open_the_guest_list(self):
+ with self.set_user(self.viewer), self.assertRaises(NotPermitted):
+ get_checkin_guests(self.event)
+
+ def test_guest_list_splits_on_todays_check_in(self):
+ event = str(BuzzEventFactory.create(team=self.team).name)
+ arrived, waiting = (EventTicketFactory.create("submitted", event=event).name for _ in range(2))
+ with self.set_user(self.frontdesk):
+ checkin_ticket(arrived)
+ everyone = get_checkin_guests(event).__json__()
+ checked_in = get_checkin_guests(event, checked_in="1").__json__()
+ not_checked_in = get_checkin_guests(event, checked_in="0").__json__()
+
+ self.assertEqual(everyone["checked_in"], 1)
+ self.assertEqual([guest["name"] for guest in checked_in["guests"]], [arrived])
+ self.assertIsNotNone(checked_in["guests"][0]["checked_in_at"])
+ self.assertEqual([guest["name"] for guest in not_checked_in["guests"]], [waiting])
+ self.assertEqual(not_checked_in["matched"], 1)
diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py
index 17bf7880..c0429f94 100644
--- a/buzz/api/events/schemas.py
+++ b/buzz/api/events/schemas.py
@@ -103,6 +103,7 @@ class EventGuest(APIResponse):
# When the ticket was raised, which is when this guest registered.
registered_at: datetime | None = None
add_ons: list[GuestAddOn] = Field(default_factory=list)
+ checked_in_at: datetime | None = None
class GuestTicketType(APIResponse):
@@ -119,10 +120,13 @@ class EventGuestsResponse(APIResponse):
start_time: timedelta | None = None
end_date: date | None = None
venue: str | None = None
+ route: str | None = None
# Everyone registered, then everyone the current search matches — the second is the
# first when nothing is being searched for.
total: int
matched: int
+ # Guests checked in today, which is what the front desk counts against.
+ checked_in: int = 0
guests: list[EventGuest]
has_next_page: bool = False
filter_fields: list[FilterField]
diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py
index b2870a2f..151316eb 100644
--- a/buzz/api/events/services.py
+++ b/buzz/api/events/services.py
@@ -1,8 +1,10 @@
+from datetime import datetime
+
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.query_builder.functions import Count, Date
-from frappe.utils import add_days, getdate
+from frappe.utils import add_days, getdate, today
from buzz.api.booking.guests import email_otp_available, phone_otp_available
from buzz.api.booking.services import are_registrations_closed
@@ -280,7 +282,7 @@ def meeting_link_of(row) -> str | None:
def guest_search_filters(search: str | None) -> list[list] | None:
- """Name or email, matched loosely — the two things a manager reads off the row."""
+ """Name, email or ticket ID, matched loosely."""
term = (search or "").strip()
if not term:
return None
@@ -288,6 +290,7 @@ def guest_search_filters(search: str | None) -> list[list] | None:
return [
["attendee_name", "like", f"%{term}%"],
["attendee_email", "like", f"%{term}%"],
+ ["name", "like", f"%{term}%"],
]
@@ -346,6 +349,7 @@ def event_guests(
order: str = "desc",
start: int = 0,
limit: int = GUESTS_PAGE_SIZE,
+ checked_in: str | None = None,
) -> EventGuestsResponse:
"""One page of the people holding a ticket to an event.
@@ -353,12 +357,17 @@ def event_guests(
access is the bar: this shows the team what it already sees through the doctype.
Ordered by when the ticket was registered, newest first by default, so a page is a
- stable slice as the caller walks down the list.
+ stable slice as the caller walks down the list. `checked_in` is "1" or "0" to keep only
+ the guests who have, or have not, checked in today.
"""
ensure_event_team_access(event)
filter_fields, answer_parents = guest_filter_fields(event)
conditions = ListConditions("Event Ticket", filter_fields, answer_parents).frappe_filters(filters)
+ arrivals = checked_in_today(event)
+ if checked_in in ("0", "1"):
+ operator = "in" if checked_in == "1" else "not in"
+ conditions.append(["name", operator, list(arrivals) or [""]])
query_filters = [["event", "=", event], ["docstatus", "=", 1], *conditions]
or_filters = guest_search_filters(search)
limit = max(1, min(int(limit), 100))
@@ -385,7 +394,10 @@ def event_guests(
ticket["registered_at"] = ticket.pop("creation")
ticket["ticket_type"] = type_titles.get(ticket.ticket_type) or ticket.ticket_type
- guests = [EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, [])) for ticket in tickets]
+ guests = [
+ EventGuest(**ticket, add_ons=by_ticket.get(ticket.name, []), checked_in_at=arrivals.get(ticket.name))
+ for ticket in tickets
+ ]
doc = frappe.get_cached_doc("Buzz Event", event)
total = count_tickets({"event": event, "docstatus": 1})
matched = count_tickets(query_filters, or_filters) if or_filters or conditions else total
@@ -395,14 +407,35 @@ def event_guests(
start_time=doc.start_time,
end_date=doc.end_date,
venue=doc.get_venue_name(),
+ route=doc.route if doc.is_published else None,
total=total,
matched=matched,
+ checked_in=len(arrivals),
guests=guests,
has_next_page=start + len(guests) < matched,
filter_fields=filter_fields,
)
+def checked_in_today(event: str) -> dict[str, datetime]:
+ """When each of the event's submitted tickets checked in today, keyed by ticket."""
+ # Query builder: the team check in the caller is the authorization.
+ checkin, ticket = frappe.qb.DocType("Event Check In"), frappe.qb.DocType("Event Ticket")
+ rows = (
+ frappe.qb.from_(checkin)
+ .join(ticket)
+ .on(ticket.name == checkin.ticket)
+ .select(checkin.ticket, checkin.creation)
+ .where(
+ (checkin.event == event)
+ & (checkin.date == today())
+ & (checkin.docstatus == 1)
+ & (ticket.docstatus == 1)
+ )
+ ).run(as_dict=True)
+ return {row.ticket: row.creation for row in rows}
+
+
def guest_filter_fields(event: str) -> tuple[list[FilterField], dict]:
"""Ticket type, then the event's ticket and booking questions.
diff --git a/buzz/permissions.py b/buzz/permissions.py
index 9110ce97..3e19ec7e 100644
--- a/buzz/permissions.py
+++ b/buzz/permissions.py
@@ -7,6 +7,7 @@
WRITE_ROLES = frozenset({"Owner", "Admin", "Manager"})
ADMIN_ROLES = frozenset({"Owner", "Admin"})
+CHECKIN_ROLES = frozenset({"Owner", "Admin", "Manager", "Frontdesk"})
WRITE_PTYPES = frozenset({"write", "create", "submit", "cancel", "amend", "delete"})
# Attendees, sponsors and speakers reach their own rows without any membership.
@@ -130,6 +131,11 @@ def has_team_access(team: str | None, ptype: str, user: str) -> bool:
return bool(team_role) and role_allows(team_role, ptype)
+def can_check_in(team: str | None, user: str) -> bool:
+ """Front-desk work on a team's events. Unlike has_team_access, an unstamped event fails closed."""
+ return is_unrestricted(user) or team_role_of(user, team) in CHECKIN_ROLES
+
+
def team_query_conditions(user: str | None = None, doctype: str | None = None, **kwargs) -> str | None:
user = user or frappe.session.user
if is_unrestricted(user):
diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts
index f9917f1c..4f50ea17 100644
--- a/dashboard/components.d.ts
+++ b/dashboard/components.d.ts
@@ -33,6 +33,8 @@ declare module 'vue' {
CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default']
CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default']
ChangeRoleDialog: typeof import('./src/components/dashboard/teams/ChangeRoleDialog.vue')['default']
+ CheckInGuestList: typeof import('./src/components/checkin/CheckInGuestList.vue')['default']
+ CheckInGuestRow: typeof import('./src/components/checkin/CheckInGuestRow.vue')['default']
CheckInQrDialog: typeof import('./src/components/dashboard/tickets/CheckInQrDialog.vue')['default']
ChosenLocation: typeof import('./src/components/dashboard/events/location/ChosenLocation.vue')['default']
CommunicationActions: typeof import('./src/components/dashboard/communications/CommunicationActions.vue')['default']
@@ -82,6 +84,7 @@ declare module 'vue' {
FilterChip: typeof import('./src/components/common/filters/FilterChip.vue')['default']
FloatingCreateEventButton: typeof import('./src/components/dashboard/FloatingCreateEventButton.vue')['default']
FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default']
+ GuestCheckInDialog: typeof import('./src/components/checkin/GuestCheckInDialog.vue')['default']
GuestInfoDrawer: typeof import('./src/components/dashboard/events/GuestInfoDrawer.vue')['default']
GuestListExport: typeof import('./src/components/dashboard/events/GuestListExport.vue')['default']
GuestRegistrationDialog: typeof import('./src/components/dashboard/events/GuestRegistrationDialog.vue')['default']
@@ -136,6 +139,7 @@ declare module 'vue' {
SaveActions: typeof import('./src/components/dashboard/teams/SaveActions.vue')['default']
SectionHeader: typeof import('./src/components/common/SectionHeader.vue')['default']
SelectionBar: typeof import('./src/components/common/SelectionBar.vue')['default']
+ SiteHeader: typeof import('./src/components/SiteHeader.vue')['default']
SortableList: typeof import('./src/components/dashboard/sponsorships/SortableList.vue')['default']
SponsorCard: typeof import('./src/components/dashboard/sponsorships/SponsorCard.vue')['default']
SponsorDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorDrawer.vue')['default']
@@ -161,7 +165,6 @@ declare module 'vue' {
ThemeSwitcher: typeof import('./src/components/settings/ThemeSwitcher.vue')['default']
TicketBookingDetails: typeof import('./src/components/dashboard/tickets/TicketBookingDetails.vue')['default']
TicketCard: typeof import('./src/components/TicketCard.vue')['default']
- TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default']
TicketsSection: typeof import('./src/components/TicketsSection.vue')['default']
TicketTransferDialog: typeof import('./src/components/TicketTransferDialog.vue')['default']
TicketTypeDrawer: typeof import('./src/components/dashboard/ticket-types/TicketTypeDrawer.vue')['default']
diff --git a/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 b/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2
new file mode 100644
index 00000000..64216964
Binary files /dev/null and b/dashboard/src/assets/LeagueGothic/LeagueGothic-Regular.woff2 differ
diff --git a/dashboard/src/assets/LeagueGothic/league-gothic.css b/dashboard/src/assets/LeagueGothic/league-gothic.css
new file mode 100644
index 00000000..b7ffcc3c
--- /dev/null
+++ b/dashboard/src/assets/LeagueGothic/league-gothic.css
@@ -0,0 +1,8 @@
+/* League Gothic (SIL Open Font License), Latin subset. Display headings only. */
+@font-face {
+ font-family: "League Gothic";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("LeagueGothic-Regular.woff2") format("woff2");
+}
diff --git a/dashboard/src/components/EventSelector.vue b/dashboard/src/components/EventSelector.vue
index 89a11f7f..e1f1c080 100644
--- a/dashboard/src/components/EventSelector.vue
+++ b/dashboard/src/components/EventSelector.vue
@@ -48,18 +48,11 @@
diff --git a/dashboard/src/components/SiteHeader.vue b/dashboard/src/components/SiteHeader.vue
new file mode 100644
index 00000000..dbc27043
--- /dev/null
+++ b/dashboard/src/components/SiteHeader.vue
@@ -0,0 +1,61 @@
+
+
+
+ Check-in
+ {{ [startsAt, page.data?.venue].filter(Boolean).join(" · ") }}
+
+ Tap Check in as a guest arrives, or tap
+ their name to see their ticket. Scan
+ reads the QR code on a ticket.
+ {{ name }}
+ {{ guest.attendee_email }}
+
+
+ {{ page.data.title }}
+
+
+
+
+
+
+
+
+
+ Add-ons
+
{{ description }}
+- {{ __("You don't have the required permissions to access the ticket scanner.") }} -
-- {{ validationResult.message }} -
-- {{ __("Ticket ID") }}: {{ validationResult.ticket.id }} -
-