Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions buzz/api/checkin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
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()
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)
5 changes: 5 additions & 0 deletions buzz/api/checkin/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
9 changes: 9 additions & 0 deletions buzz/api/checkin/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 55 additions & 6 deletions buzz/api/checkin/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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":
Expand Down Expand Up @@ -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
Expand Down
82 changes: 81 additions & 1 deletion buzz/api/checkin/test_checkin.py
Original file line number Diff line number Diff line change
@@ -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,
)


Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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]
Expand Down
41 changes: 37 additions & 4 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -280,14 +282,15 @@ 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

return [
["attendee_name", "like", f"%{term}%"],
["attendee_email", "like", f"%{term}%"],
["name", "like", f"%{term}%"],
]


Expand Down Expand Up @@ -346,19 +349,25 @@ 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.

A submitted ticket only — a draft belongs to a booking still being paid for. Read
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))
Expand All @@ -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
Expand All @@ -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.

Expand Down
Loading
Loading