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
2 changes: 1 addition & 1 deletion buzz/api/booking/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def summarize_booking(booking, titles: dict[str, str], add_ons: dict[str, list])
def build_lines(booking, titles: dict[str, str], add_ons: dict[str, list]) -> list[BookingLine]:
"""One line per ticket type, with the add-ons bought against it beneath.

ponytail: line amounts are the stored attendee amounts. A Free Tickets coupon zeroes
Line amounts are the stored attendee amounts. A Free Tickets coupon zeroes
those after the subtotal has already counted them, so under that coupon the lines sum
to less than `net_amount` and the discount line makes up the difference.
"""
Expand Down
4 changes: 2 additions & 2 deletions buzz/api/booking/test_booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from buzz.api.booking.exceptions import AddOnNotForEvent, InvalidAddOnValue, RegistrationsClosed
from buzz.api.booking.schemas import BookingRequest
from buzz.api.forms.test_forms import ensure_prompt_named_record
from buzz.api.forms.test_forms import ensure_event_host, ensure_prompt_named_record
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user

BOOKER = "booking-owner@example.com"
Expand Down Expand Up @@ -94,7 +94,7 @@ class BookingTestCase(IntegrationTestCase):
def setUpClass(cls):
super().setUpClass()
category = ensure_prompt_named_record("Event Category", "Test Booking Category")
host = ensure_prompt_named_record("Event Host", "Test Booking Host")
host = ensure_event_host("Test Booking Host")
owner = create_user("booking-team-owner@example.com", "Booking")
cls.team = create_owned_team(f"Booking Test Team {frappe.generate_hash(length=6)}", owner)
cls.event = frappe.get_doc(
Expand Down
7 changes: 4 additions & 3 deletions buzz/api/communications/test_communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ class CommunicationsTestCase(IntegrationTestCase):
def setUpClass(cls):
super().setUpClass()
frappe.set_user("Administrator")
for doctype, name in (("Event Category", "Test Category"), ("Event Host", "Test Host")):
if not frappe.db.exists(doctype, name):
frappe.get_doc({"doctype": doctype, "name": name}).insert(ignore_permissions=True)
if not frappe.db.exists("Event Category", "Test Category"):
frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert(
ignore_permissions=True
)
cls.owner = create_user("comms-owner@example.com", "Owner")
cls.viewer = create_user("comms-viewer@example.com", "Viewer")
cls.manager = create_user("comms-manager@example.com", "Manager")
Expand Down
19 changes: 19 additions & 0 deletions buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CreatedEvent,
EventDetail,
EventGuestsResponse,
EventHostRef,
MyEventFilters,
MyEventsResponse,
NewEvent,
Expand Down Expand Up @@ -69,3 +70,21 @@ def check_event_route(route: str, event: str | None = None) -> RouteAvailability
@frappe.whitelist(methods=["POST"])
def create_event(event: NewEvent) -> CreatedEvent:
return services.create_event(event)


@frappe.whitelist(methods=["POST"])
def add_co_host(
event: str,
host_name: str,
logo: str | None = None,
by_line: str | None = None,
about: str | None = None,
) -> EventHostRef:
"""Add an organisation that has no team here as a co-host of the event."""
return services.create_co_host(event, host_name, logo, by_line, about)


@frappe.whitelist(methods=["POST"])
def remove_co_host(event: str, host: str) -> None:
"""Drop a co-host from the event."""
services.remove_co_host(event, host)
10 changes: 10 additions & 0 deletions buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ class EventVenue(APIResponse):
address: str | None = None


class EventHostRef(APIResponse):
"""One name under "Hosted by" — the event's team, or one of its co-hosts."""

host: str
label: str
logo: str | None = None


class EventDetail(APIResponse):
"""One event, with everything the manage page edits or shows."""

Expand All @@ -67,6 +75,8 @@ class EventDetail(APIResponse):
# The organiser's own link, or the one Zoom issued when the meeting was booked.
meeting_link: str | None = None
is_published: bool
primary_host: EventHostRef | None = None
co_hosts: list[EventHostRef] = Field(default_factory=list)


class GuestAddOn(APIResponse):
Expand Down
118 changes: 88 additions & 30 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import frappe
from frappe import _
from frappe.model.naming import append_number_if_name_exists
from frappe.query_builder import Case
from frappe.query_builder.functions import Count, Date
from frappe.utils import add_days, get_datetime_in_timezone, get_system_timezone, getdate
Expand All @@ -18,6 +17,7 @@
EventDetail,
EventGuest,
EventGuestsResponse,
EventHostRef,
EventVenue,
GuestAddOn,
GuestTicketType,
Expand All @@ -30,7 +30,7 @@
RouteAvailability,
TicketTypeTotal,
)
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, BuzzEvent
from buzz.permissions import has_team_access, my_teams
from buzz.utils import is_app_installed

Expand Down Expand Up @@ -149,10 +149,92 @@ def event_detail(event: str) -> EventDetail:
CannotManageEvent.throw()

return EventDetail(
**row | {"name": str(row.name), "venue": venue_of(row.venue), "meeting_link": meeting_link_of(row)}
**row
| {
"name": str(row.name),
"venue": venue_of(row.venue),
Comment on lines +154 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Legacy Proposal Hosts Disappear

Existing events created from proposals store the proposal organization only in Buzz Event.host and have no rows in the new co_hosts table. This response now ignores the legacy field, so those events show only their team under “Hosted by” after migration. Add a migration or fallback that preserves legacy proposal hosts.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: buzz/api/events/services.py
Line: 154-155

Comment:
**Legacy Proposal Hosts Disappear**

Existing events created from proposals store the proposal organization only in `Buzz Event.host` and have no rows in the new `co_hosts` table. This response now ignores the legacy field, so those events show only their team under “Hosted by” after migration. Add a migration or fallback that preserves legacy proposal hosts.

**Knowledge Base Used:**
- [Proposals and sponsorships](https://app.greptile.com/bwh-tech/-/custom-context/knowledge-base/bwhtech/buzz/-/docs/proposals-and-sponsorships.md)
- [Frappe application integration](https://app.greptile.com/bwh-tech/-/custom-context/knowledge-base/bwhtech/buzz/-/docs/frappe-application-integration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f66ae0e with a new patch, backfill_event_co_hosts.

The discriminator is Event Host.host_name != Buzz Team.team_name, not the team: the proposal flow stamps the host with the event's own team, so team equality cannot separate a real proposal host from an auto-minted one. An auto-minted host is named after its team, which is exactly the "would render twice" case. Two tests cover both directions.

"meeting_link": meeting_link_of(row),
"primary_host": primary_host_of(row.team),
"co_hosts": co_hosts_of(event),
}
)


def primary_host_of(team: str | None) -> EventHostRef | None:
"""The team hosting the event."""
if not team:
return None
row = frappe.db.get_value("Buzz Team", team, ["team_name", "logo"], as_dict=True)
return EventHostRef(host=team, label=row.team_name or team, logo=row.logo) if row else None


def co_hosts_of(event: str) -> list[EventHostRef]:
"""Co-hosts in table order.

Read as SQL rather than through `get_list`: `Event Host` is filtered to the reader's
own teams, and a co-host may belong to another.
"""
co_host, host = frappe.qb.DocType("Event CoHost"), frappe.qb.DocType("Event Host")
rows = (
frappe.qb.from_(co_host)
.join(host)
.on(host.name == co_host.host)
.select(host.name, host.host_name, host.logo)
.where((co_host.parenttype == "Buzz Event") & (co_host.parent == str(event)))
.orderby(co_host.idx)
).run(as_dict=True)
return [EventHostRef(host=row.name, label=row.host_name or row.name, logo=row.logo) for row in rows]


def create_co_host(
event: str,
host_name: str,
logo: str | None = None,
by_line: str | None = None,
about: str | None = None,
) -> EventHostRef:
"""Add an organisation with no team here as a co-host of the event.

The team's own host of that name is reused rather than minted twice: names are no
longer docnames, so a second record would list the same organisation twice.
"""
doc = manageable_event(event)
existing = frappe.db.get_value("Event Host", {"host_name": host_name, "team": doc.team}, "name")
if existing:
host = frappe.get_doc("Event Host", existing)
else:
host = frappe.get_doc(
{
"doctype": "Event Host",
"host_name": host_name,
"team": doc.team,
"logo": logo,
"by_line": by_line,
"about": about,
}
)
# Event Host is Event Manager-writable; the team access check above is the authorisation.
host.insert(ignore_permissions=True)

doc.append("co_hosts", {"host": host.name})
doc.save()
return EventHostRef(host=host.name, label=host.host_name, logo=host.logo)


def remove_co_host(event: str, host: str) -> None:
"""Drop a co-host from the event. The Event Host record itself is left alone."""
doc = manageable_event(event)
doc.co_hosts = [row for row in doc.co_hosts if row.host != host]
doc.save()


def manageable_event(event: str) -> BuzzEvent:
doc = frappe.get_doc("Buzz Event", event)
if not has_team_access(doc.team, "write", frappe.session.user):
CannotManageEvent.throw()
return doc


def venue_of(venue: str | None) -> EventVenue | None:
if not venue:
return None
Expand Down Expand Up @@ -209,10 +291,7 @@ def set_registration_state(event: str, closed: bool) -> RegistrationState:
wall clock and opening clears the cutoff. Opening cannot beat the event's end date,
which closes registrations on its own — hence the state rather than an acknowledgement.
"""
doc = frappe.get_doc("Buzz Event", event)
if not has_team_access(doc.team, "write", frappe.session.user):
CannotManageEvent.throw()

doc = manageable_event(event)
timezone = doc.time_zone or get_system_timezone()
doc.registrations_close_at = get_datetime_in_timezone(timezone).replace(tzinfo=None) if closed else None
doc.save()
Expand Down Expand Up @@ -438,8 +517,8 @@ def route_availability(route: str, event: str | None = None) -> RouteAvailabilit
return RouteAvailability(available=True, message=_("Available"))


# Buzz Event demands a category and a host, neither of which the create form asks for.
# These are the defaults it fills in; the organiser changes them on the event afterwards.
# Buzz Event demands a category, which the create form does not ask for. This is the default
# it fills in; the organiser changes it on the event afterwards.
DEFAULT_CATEGORY = "Meetups"
# Zoom-backed, so the meeting the organiser asked for is the one the event gets.
ZOOM_CATEGORY = "Zoom Meeting"
Expand Down Expand Up @@ -470,7 +549,6 @@ def create_event(new: NewEvent) -> CreatedEvent:
"venue": new.venue,
"medium": "Online" if new.zoom_meeting else "In Person",
"category": ZOOM_CATEGORY if new.zoom_meeting else DEFAULT_CATEGORY,
"host": host_for(new.team),
}
).insert()

Expand All @@ -497,23 +575,3 @@ def book_zoom_meeting(event) -> None:
title=_("Zoom Meeting Not Created"),
indicator="orange",
)


def host_for(team: str) -> str:
"""The team's own Event Host, made on first use.

Event Host is required on every event but absent from the create form, and a new team
has none. Host names are docnames and therefore global, so an existing name is given a
suffix rather than joined.
"""
existing = frappe.db.get_value("Event Host", {"team": team}, "name")
if existing:
return existing

team_name = frappe.db.get_value("Buzz Team", team, "team_name") or team
host = frappe.get_doc({"doctype": "Event Host", "name": team_name, "team": team})
host.name = append_number_if_name_exists("Event Host", team_name)
# Event Host is readable by the team but writable by Event Manager only, and creating
# an event is what mints it — the team check above is the authorisation.
host.insert(ignore_permissions=True)
return host.name
77 changes: 67 additions & 10 deletions buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from pydantic import ValidationError

from buzz.api.events import (
add_co_host,
check_event_route,
get_event,
get_event_guests,
get_event_registration_trend,
get_my_events,
remove_co_host,
set_registration_state,
)
from buzz.api.events import create_event as create_event_endpoint
Expand Down Expand Up @@ -50,8 +52,6 @@ def setUpClass(cls):
frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert(
ignore_permissions=True
)
if not frappe.db.exists("Event Host", "Test Host"):
frappe.get_doc({"doctype": "Event Host", "name": "Test Host"}).insert(ignore_permissions=True)

cls.host_user = create_user("events-host@example.com", "Host")
cls.attendee = create_user("events-attendee@example.com", "Attendee")
Expand Down Expand Up @@ -278,14 +278,6 @@ def test_creates_an_event_the_team_owns(self):
self.assertEqual(event.medium, "In Person")
self.assertEqual(event.category, "Meetups")

def test_mints_one_host_per_team_and_reuses_it(self):
first = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload()).name)
second = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload(title="Second")).name)

self.assertTrue(first.host)
self.assertEqual(first.host, second.host)
self.assertEqual(frappe.db.get_value("Event Host", first.host, "team"), self.team)

def test_carries_the_optional_fields_through(self):
created = create_event_endpoint(
self.payload(
Expand Down Expand Up @@ -418,6 +410,71 @@ def test_an_unknown_event_is_not_found(self):
get_event("999999999")


class TestEventCoHosts(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.set_user("Administrator")

cls.owner = create_user("co-host-owner@example.com", "Owner")
cls.viewer = create_user("co-host-viewer@example.com", "Viewer")
cls.team = create_owned_team("Co-host Team", cls.owner)
add_member(cls.team, cls.viewer, "Viewer")

def setUp(self):
frappe.set_user("Administrator")
self.event = create_event("Co-hosted Event", self.team)
frappe.set_user(self.owner)
self.addCleanup(frappe.set_user, "Administrator")

def test_the_team_is_the_primary_host(self):
detail = get_event(self.event).__json__()

self.assertEqual(detail["primary_host"]["host"], self.team)
self.assertEqual(detail["primary_host"]["label"], "Co-host Team")
self.assertEqual(detail["co_hosts"], [])

def test_adding_and_removing_a_co_host_round_trips(self):
added = add_co_host(self.event, "Acme Corp", by_line="We make things")

self.assertEqual(get_event(self.event).__json__()["co_hosts"][0]["label"], "Acme Corp")
self.assertEqual(frappe.db.get_value("Event Host", added.host, "team"), self.team)

remove_co_host(self.event, added.host)
self.assertEqual(get_event(self.event).__json__()["co_hosts"], [])

def test_the_same_organisation_cannot_be_added_twice(self):
added = add_co_host(self.event, "Acme Corp")
event = frappe.get_doc("Buzz Event", self.event)
event.append("co_hosts", {"host": added.host})

with self.assertRaises(frappe.ValidationError):
event.save()

def test_the_same_name_is_not_added_twice(self):
added = add_co_host(self.event, "Acme Corp")

with self.assertRaises(frappe.ValidationError):
add_co_host(self.event, "Acme Corp")

# The second call reuses the team's host rather than minting a second record.
self.assertEqual(frappe.db.count("Event Host", {"host_name": "Acme Corp", "team": self.team}), 1)
self.assertEqual(get_event(self.event).__json__()["co_hosts"][0]["host"], added.host)

def test_a_viewer_cannot_add_a_co_host(self):
frappe.set_user(self.viewer)

with self.assertRaises(CannotManageEvent):
add_co_host(self.event, "Acme Corp")

def test_a_viewer_cannot_remove_a_co_host(self):
added = add_co_host(self.event, "Acme Corp")
frappe.set_user(self.viewer)

with self.assertRaises(CannotManageEvent):
remove_co_host(self.event, added.host)


class TestCheckEventRoute(IntegrationTestCase):
@classmethod
def setUpClass(cls):
Expand Down
Loading
Loading