Skip to content
Open
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
7 changes: 7 additions & 0 deletions buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from buzz.api.events.schemas import (
CreatedEvent,
EventDetail,
EventGuestsResponse,
MyEventsResponse,
NewEvent,
RouteAvailability,
Expand All @@ -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."""
Expand Down
24 changes: 24 additions & 0 deletions buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from datetime import date, timedelta

from pydantic import Field

from buzz.api.schemas import APIRequest, APIResponse


Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,7 +14,10 @@
from buzz.api.events.schemas import (
CreatedEvent,
EventDetail,
EventGuest,
EventGuestsResponse,
EventVenue,
GuestAddOn,
MyEvent,
MyEventsResponse,
NewEvent,
Expand Down Expand Up @@ -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.

Expand Down
109 changes: 108 additions & 1 deletion buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
2 changes: 2 additions & 0 deletions dashboard/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
36 changes: 36 additions & 0 deletions dashboard/src/components/dashboard/events/EventGuestItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { EventGuest } from "@/types";
import { Avatar, Badge } from "frappe-ui";

const props = defineProps<{ guest: EventGuest }>();

// A ticket can be issued before anyone is named on it, so the email is the fallback
// and the row never renders blank.
const name = props.guest.attendee_name || props.guest.attendee_email || "Unnamed guest";
</script>

<template>
<li class="flex items-center gap-3 px-4 py-3">
<Avatar :label="name" size="lg" />

<!-- Name and email on one line: the email is how you tell two Harshes apart, so it
belongs beside the name rather than under it. -->
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="shrink-0 truncate text-base font-medium text-ink-gray-8">{{ name }}</span>
<span v-if="guest.attendee_email" class="truncate text-base text-ink-gray-5">
{{ guest.attendee_email }}
</span>
</div>

<div class="flex shrink-0 items-center gap-1.5">
<Badge
v-for="addOn in guest.add_ons"
:key="addOn.title"
theme="blue"
variant="subtle"
:label="addOn.value ? `${addOn.title}: ${addOn.value}` : addOn.title"
/>
<Badge v-if="guest.ticket_type" variant="outline" :label="guest.ticket_type" />
</div>
</li>
</template>
17 changes: 17 additions & 0 deletions dashboard/src/components/dashboard/events/EventPageHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { Breadcrumbs, PageHeader } from "frappe-ui";
import { computed } from "vue";

// The event, then the section of it being looked at. Neither crumb is a link: the
// event on its own resolves to whichever section is open.
const props = defineProps<{ title: string | undefined; section: string }>();

const items = computed(() => [{ label: props.title || "Event" }, { label: props.section }]);
</script>

<template>
<PageHeader class="border-none pt-2 bg-surface-elevation-1">
<Breadcrumbs :items="items" />
<slot />
</PageHeader>
</template>
11 changes: 10 additions & 1 deletion dashboard/src/data/events.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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<EventGuests>({
url: "buzz.api.events.get_event_guests",
params: { event },
auto: true,
})
}
4 changes: 2 additions & 2 deletions dashboard/src/layouts/ManagerLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading