diff --git a/buzz/api/events/__init__.py b/buzz/api/events/__init__.py index 6888b211..dc1b6c32 100644 --- a/buzz/api/events/__init__.py +++ b/buzz/api/events/__init__.py @@ -4,6 +4,7 @@ from buzz.api.events.schemas import ( CreatedEvent, EventDetail, + EventGuestsResponse, MyEventsResponse, NewEvent, RouteAvailability, @@ -22,6 +23,12 @@ def get_event(event: str) -> EventDetail: return services.event_detail(event) +@frappe.whitelist() +def get_event_guests(event: str) -> EventGuestsResponse: + """Everyone holding a submitted ticket to an event, with their add-ons.""" + return services.event_guests(event) + + @frappe.whitelist() def check_event_route(route: str, event: str | None = None) -> RouteAvailability: """Whether an event can take this route. `event` is the one being edited, if any.""" diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index e15bb666..f7ea5f6c 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -1,5 +1,7 @@ from datetime import date, timedelta +from pydantic import Field + from buzz.api.schemas import APIRequest, APIResponse @@ -50,6 +52,28 @@ class EventDetail(APIResponse): is_published: bool +class GuestAddOn(APIResponse): + title: str + # The option the attendee picked, for an add-on that offers a choice. + value: str | None = None + + +class EventGuest(APIResponse): + """One ticket. A person who booked twice holds two, and appears twice.""" + + name: str + attendee_name: str | None = None + attendee_email: str | None = None + ticket_type: str | None = None + add_ons: list[GuestAddOn] = Field(default_factory=list) + + +class EventGuestsResponse(APIResponse): + total: int + registrations_closed: bool + guests: list[EventGuest] + + class RouteAvailability(APIResponse): available: bool message: str diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index e268d8ec..214bd1f0 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -4,6 +4,7 @@ from frappe.query_builder import Case from frappe.utils import getdate +from buzz.api.booking.services import are_registrations_closed from buzz.api.events.exceptions import ( CannotCreateEvents, CannotManageEvent, @@ -13,7 +14,10 @@ from buzz.api.events.schemas import ( CreatedEvent, EventDetail, + EventGuest, + EventGuestsResponse, EventVenue, + GuestAddOn, MyEvent, MyEventsResponse, NewEvent, @@ -140,6 +144,78 @@ def meeting_link_of(row) -> str | None: return frappe.db.get_value("Zoom Meeting", meeting, "zoom_link") if meeting else None +def event_guests(event: str) -> EventGuestsResponse: + """Everyone 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. + """ + team = frappe.db.get_value("Buzz Event", event, "team") + if not frappe.db.exists("Buzz Event", event): + EventNotFound.throw() + + if not has_team_access(team, "read", frappe.session.user): + CannotManageEvent.throw() + + tickets = frappe.get_all( + "Event Ticket", + filters={"event": event, "docstatus": 1}, + fields=["name", "attendee_name", "attendee_email", "ticket_type"], + order_by="attendee_name asc", + ) + + by_ticket = add_ons_by_ticket([ticket.name for ticket in tickets]) + # Event Ticket Type is autonamed, so the link value is a number nobody recognises. + type_titles = titles_of("Event Ticket Type", {ticket.ticket_type for ticket in tickets}) + + guests = [ + EventGuest( + **ticket | {"ticket_type": type_titles.get(ticket.ticket_type) or ticket.ticket_type}, + add_ons=by_ticket.get(ticket.name, []), + ) + for ticket in tickets + ] + return EventGuestsResponse( + total=len(guests), + registrations_closed=are_registrations_closed(frappe.get_cached_doc("Buzz Event", event)), + guests=guests, + ) + + +def titles_of(doctype: str, names: set[str | None]) -> dict[str, str]: + """Name-to-title map for a set of links, in one query.""" + wanted = [name for name in names if name] + if not wanted: + return {} + + rows = frappe.get_all(doctype, filters={"name": ("in", wanted)}, fields=["name", "title"], as_list=True) + # An autonamed doctype comes back with integer names, while every link to it travels + # as a string — without this the map never matches. + return {str(name): title for name, title in rows} + + +def add_ons_by_ticket(tickets: list[str]) -> dict[str, list[GuestAddOn]]: + """Add-ons for every ticket at once, rather than a query per row.""" + if not tickets: + return {} + + # Query builder rather than get_all: a child doctype has no standalone permission of + # its own, and the team check above is the authorization. + value = frappe.qb.DocType("Ticket Add-on Value") + rows = ( + frappe.qb.from_(value) + .select(value.parent, value.add_on, value.value) + .where((value.parenttype == "Event Ticket") & value.parent.isin(tickets)) + ).run(as_dict=True) + titles = titles_of("Ticket Add-on", {row.add_on for row in rows}) + + by_ticket: dict[str, list[GuestAddOn]] = {} + for row in rows: + add_on = GuestAddOn(title=titles.get(row.add_on) or row.add_on, value=row.value) + by_ticket.setdefault(row.parent, []).append(add_on) + return by_ticket + + def route_availability(route: str, event: str | None = None) -> RouteAvailability: """Whether an event can take this route. diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 51251076..0000e2aa 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -2,7 +2,7 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today -from buzz.api.events import check_event_route, get_event, get_my_events +from buzz.api.events import check_event_route, get_event, get_event_guests, get_my_events from buzz.api.events import create_event as create_event_endpoint from buzz.api.events.exceptions import ( CannotCreateEvents, @@ -387,3 +387,110 @@ def test_a_blank_route_is_not_available(self): frappe.set_user(self.owner) self.assertFalse(check_event_route(" ").available) + + +class TestGetEventGuests(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + + cls.owner = create_user("guests-owner@example.com", "Owner") + cls.stranger = create_user("guests-stranger@example.com", "Stranger") + cls.team = create_owned_team("Guests Team", cls.owner) + + def setUp(self): + frappe.set_user("Administrator") + self.addCleanup(frappe.set_user, "Administrator") + + def test_counts_and_lists_the_submitted_tickets(self): + event = create_event("Guested Event", self.team) + issue_ticket(event, "guest-one@example.com") + issue_ticket(event, "guest-two@example.com") + frappe.set_user(self.owner) + + guests = get_event_guests(event).__json__() + + self.assertEqual(guests["total"], 2) + self.assertEqual(len(guests["guests"]), 2) + emails = {guest["attendee_email"] for guest in guests["guests"]} + self.assertEqual(emails, {"guest-one@example.com", "guest-two@example.com"}) + + def test_leaves_out_a_ticket_that_was_never_submitted(self): + event = create_event("Draft Ticket Event", self.team) + create_ticket(event, "draft-guest@example.com") + frappe.set_user(self.owner) + + self.assertEqual(get_event_guests(event).total, 0) + + def test_carries_the_add_ons_a_ticket_holds(self): + event = create_event("Add-on Event", self.team) + ticket = issue_ticket(event, "addon-guest@example.com") + add_on = frappe.get_doc( + {"doctype": "Ticket Add-on", "event": event, "title": "T-Shirt", "price": 0} + ).insert(ignore_permissions=True) + # Submitted, so the row goes on through db_insert rather than a save. + frappe.get_doc( + { + "doctype": "Ticket Add-on Value", + "parenttype": "Event Ticket", + "parentfield": "add_ons", + "parent": ticket, + "add_on": add_on.name, + "value": "Large", + } + ).db_insert() + frappe.set_user(self.owner) + + guest = get_event_guests(event).guests[0] + + self.assertEqual(len(guest.add_ons), 1) + self.assertEqual(guest.add_ons[0].title, "T-Shirt") + self.assertEqual(guest.add_ons[0].value, "Large") + + def test_names_the_ticket_type_rather_than_its_docname(self): + event = create_event("Typed Ticket Event", self.team) + ticket = issue_ticket(event, "typed-guest@example.com") + ticket_type = frappe.db.get_value("Event Ticket", ticket, "ticket_type") + title = frappe.db.get_value("Event Ticket Type", ticket_type, "title") + frappe.set_user(self.owner) + + guest = get_event_guests(event).guests[0] + + self.assertEqual(guest.ticket_type, title) + self.assertNotEqual(guest.ticket_type, ticket_type) + + def test_an_event_with_no_guests_is_empty_rather_than_an_error(self): + event = create_event("Quiet Event", self.team) + frappe.set_user(self.owner) + + guests = get_event_guests(event) + + self.assertEqual(guests.total, 0) + self.assertEqual(guests.guests, []) + + def test_reports_registrations_closed_once_the_cutoff_has_passed(self): + event = create_event("Closed Event", self.team, registrations_close_at="2020-01-01 00:00:00") + frappe.set_user(self.owner) + + self.assertTrue(get_event_guests(event).registrations_closed) + + def test_reports_registrations_open_before_the_event_ends(self): + event = create_event("Open Event", self.team) + frappe.set_user(self.owner) + + self.assertFalse(get_event_guests(event).registrations_closed) + + def test_a_non_member_cannot_read_the_guest_list(self): + event = create_event("Private Guests", self.team) + issue_ticket(event, "private-guest@example.com") + frappe.set_user(self.stranger) + + with self.assertRaises(CannotManageEvent): + get_event_guests(event) + + def test_an_unknown_event_is_not_found(self): + frappe.set_user(self.owner) + + with self.assertRaises(EventNotFound): + get_event_guests("999999999") diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 2fe320a1..3f0948eb 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -31,8 +31,10 @@ declare module 'vue' { EventBanner: typeof import('./src/components/dashboard/events/EventBanner.vue')['default'] EventCard: typeof import('./src/components/dashboard/events/EventCard.vue')['default'] EventDetailsHeader: typeof import('./src/components/EventDetailsHeader.vue')['default'] + EventGuestItem: typeof import('./src/components/dashboard/events/EventGuestItem.vue')['default'] EventLocation: typeof import('./src/components/dashboard/events/EventLocation.vue')['default'] EventMedium: typeof import('./src/components/dashboard/events/EventMedium.vue')['default'] + EventPageHeader: typeof import('./src/components/dashboard/events/EventPageHeader.vue')['default'] EventRoute: typeof import('./src/components/dashboard/events/EventRoute.vue')['default'] EventSchedule: typeof import('./src/components/dashboard/events/EventSchedule.vue')['default'] EventSelector: typeof import('./src/components/EventSelector.vue')['default'] diff --git a/dashboard/src/components/dashboard/events/EventGuestItem.vue b/dashboard/src/components/dashboard/events/EventGuestItem.vue new file mode 100644 index 00000000..e45cdfd1 --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventGuestItem.vue @@ -0,0 +1,36 @@ + + + diff --git a/dashboard/src/components/dashboard/events/EventPageHeader.vue b/dashboard/src/components/dashboard/events/EventPageHeader.vue new file mode 100644 index 00000000..bd78af28 --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventPageHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts index 973e238b..a49d3921 100644 --- a/dashboard/src/data/events.ts +++ b/dashboard/src/data/events.ts @@ -1,4 +1,4 @@ -import type { EventDetail, MyEvents } from "@/types" +import type { EventDetail, EventGuests, MyEvents } from "@/types" import { createResource, useCall } from "frappe-ui" // v2 path: useCall reads the payload from `data`, which /api/method names `message`. @@ -33,3 +33,12 @@ export const updateEvent = createResource({ url: "frappe.client.set_value" }) /** Whether an event can take a route. Routes are the public URL namespace, so they are unique. */ export const checkEventRoute = createResource({ url: "buzz.api.events.check_event_route" }) + +/** Everyone holding a submitted ticket to an event, with their add-ons and the total. */ +export function eventGuests(event: string) { + return createResource({ + url: "buzz.api.events.get_event_guests", + params: { event }, + auto: true, + }) +} diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index 346927c4..c19483ef 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -43,9 +43,9 @@ const eventItems = computed(() => [ to: `/manage/events/${eventId.value}/details`, }, { - label: "Attendees", + label: "Guests", icon: "lucide-users-round", - to: `/manage/events/${eventId.value}/attendees`, + to: `/manage/events/${eventId.value}/guests`, }, { label: "Talks", diff --git a/dashboard/src/pages/manage/events/EventDetails.vue b/dashboard/src/pages/manage/events/EventDetails.vue index f10231a3..19236103 100644 --- a/dashboard/src/pages/manage/events/EventDetails.vue +++ b/dashboard/src/pages/manage/events/EventDetails.vue @@ -1,11 +1,12 @@