diff --git a/buzz/api/booking/details.py b/buzz/api/booking/details.py index eac0b9a8..72977066 100644 --- a/buzz/api/booking/details.py +++ b/buzz/api/booking/details.py @@ -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. """ diff --git a/buzz/api/booking/test_booking.py b/buzz/api/booking/test_booking.py index fbf3fcbe..7c760978 100644 --- a/buzz/api/booking/test_booking.py +++ b/buzz/api/booking/test_booking.py @@ -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" @@ -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( diff --git a/buzz/api/communications/test_communications.py b/buzz/api/communications/test_communications.py index 5266c5e5..88e28dd4 100644 --- a/buzz/api/communications/test_communications.py +++ b/buzz/api/communications/test_communications.py @@ -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") diff --git a/buzz/api/events/__init__.py b/buzz/api/events/__init__.py index 71ea8c78..cdef8957 100644 --- a/buzz/api/events/__init__.py +++ b/buzz/api/events/__init__.py @@ -5,6 +5,7 @@ CreatedEvent, EventDetail, EventGuestsResponse, + EventHostRef, MyEventFilters, MyEventsResponse, NewEvent, @@ -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) diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index d7e0ab31..92c9ef79 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -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.""" @@ -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): diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index 0a592980..91009e1b 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -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 @@ -18,6 +17,7 @@ EventDetail, EventGuest, EventGuestsResponse, + EventHostRef, EventVenue, GuestAddOn, GuestTicketType, @@ -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 @@ -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), + "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 @@ -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() @@ -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" @@ -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() @@ -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 diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 1ab1ab42..d3e690af 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -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 @@ -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") @@ -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( @@ -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): diff --git a/buzz/api/forms/test_forms.py b/buzz/api/forms/test_forms.py index fdc83561..2b8444a2 100644 --- a/buzz/api/forms/test_forms.py +++ b/buzz/api/forms/test_forms.py @@ -31,7 +31,7 @@ def ensure_prompt_named_record(doctype, name): - # Event Category / Event Host use autoname "prompt" -> name set explicitly. + # Event Category uses autoname "prompt" -> name set explicitly. if frappe.db.exists(doctype, name): return name doc = frappe.new_doc(doctype) @@ -40,12 +40,23 @@ def ensure_prompt_named_record(doctype, name): return doc.name +def ensure_event_host(host_name): + # Event Host autonames to a hash, so `host_name` is both the label and the lookup key. + existing = frappe.db.get_value("Event Host", {"host_name": host_name}, "name") + if existing: + return existing + doc = frappe.new_doc("Event Host") + doc.host_name = host_name + doc.insert(ignore_permissions=True) + return doc.name + + class FormsTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Test Forms Category") - cls.host = ensure_prompt_named_record("Event Host", "Test Forms Host") + cls.host = ensure_event_host("Test Forms Host") def setUp(self): frappe.set_user("Administrator") @@ -129,7 +140,7 @@ class TestGetLinkFieldOptions(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Test Forms Category") - cls.host = ensure_prompt_named_record("Event Host", "Test Forms Host") + cls.host = ensure_event_host("Test Forms Host") def make_tier(self, title="Gold Tier"): event = frappe.new_doc("Buzz Event") @@ -157,9 +168,9 @@ def test_options_have_value_label_shape(self): self.assertTrue(all(set(option) == {"value", "label"} for option in options)) def test_no_title_field_label_falls_back_to_name(self): - # Event Host has no title field -> label mirrors the name. - match = next(o for o in get_link_field_options("Event Host") if o["value"] == self.host) - self.assertEqual(match["label"], self.host) + # Event Category has no title field -> label mirrors the name. + match = next(o for o in get_link_field_options("Event Category") if o["value"] == self.category) + self.assertEqual(match["label"], self.category) def test_title_field_used_as_label(self): # Sponsorship Tier names are hashes; its title field is the readable label. diff --git a/buzz/api/proposals/test_proposals.py b/buzz/api/proposals/test_proposals.py index a17af36f..ad7671dc 100644 --- a/buzz/api/proposals/test_proposals.py +++ b/buzz/api/proposals/test_proposals.py @@ -4,7 +4,7 @@ from frappe.utils.response import json_handler from buzz.api.events.exceptions import CannotManageEvent, EventNotFound -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.api.proposals import ( accept_proposal, get_event_proposal_trend, @@ -29,7 +29,7 @@ class TestGetMyProposals(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "My Proposals Category") - cls.host = ensure_prompt_named_record("Event Host", "My Proposals Host") + cls.host = ensure_event_host("My Proposals Host") cls.event = make_test_event(cls.category, cls.host) cls.speaker_user = make_test_user("speaker-api@example.com") cls.other_user = make_test_user("other-api@example.com") @@ -144,7 +144,7 @@ class TestGetEventProposals(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Event Proposals Category") - cls.host = ensure_prompt_named_record("Event Host", "Event Proposals Host") + cls.host = ensure_event_host("Event Proposals Host") cls.manager = make_test_user("proposals-manager@example.com") cls.outsider = make_test_user("proposals-outsider@example.com") cls.team = create_owned_team(f"Proposals Team {frappe.generate_hash(length=6)}", cls.manager) @@ -244,7 +244,7 @@ class TestGetEventProposalTrend(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Proposal Trend Category") - cls.host = ensure_prompt_named_record("Event Host", "Proposal Trend Host") + cls.host = ensure_event_host("Proposal Trend Host") cls.manager = make_test_user("trend-manager@example.com") cls.outsider = make_test_user("trend-outsider@example.com") cls.team = create_owned_team(f"Trend Team {frappe.generate_hash(length=6)}", cls.manager) @@ -283,7 +283,7 @@ class TestAcceptProposal(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Accept Proposal Category") - cls.host = ensure_prompt_named_record("Event Host", "Accept Proposal Host") + cls.host = ensure_event_host("Accept Proposal Host") cls.manager = make_test_user("accept-manager@example.com") cls.outsider = make_test_user("accept-outsider@example.com") cls.team = create_owned_team(f"Accept Team {frappe.generate_hash(length=6)}", cls.manager) @@ -337,7 +337,7 @@ class TestSetProposalState(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Proposal State Category") - cls.host = ensure_prompt_named_record("Event Host", "Proposal State Host") + cls.host = ensure_event_host("Proposal State Host") cls.manager = make_test_user("proposal-state-manager@example.com") cls.outsider = make_test_user("proposal-state-outsider@example.com") cls.team = create_owned_team(f"Proposal State Team {frappe.generate_hash(length=6)}", cls.manager) diff --git a/buzz/api/tickets/test_tickets.py b/buzz/api/tickets/test_tickets.py index 3d6a37da..23debdda 100644 --- a/buzz/api/tickets/test_tickets.py +++ b/buzz/api/tickets/test_tickets.py @@ -4,7 +4,7 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today -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.api.tickets import ( change_add_on_preference, create_cancellation_request, @@ -58,7 +58,7 @@ def setUpClass(cls): # sharing test-route: IntegrationTestCase rolls the DB back but not the document # cache, which would leave a rolled-back date visible to later test modules. category = ensure_prompt_named_record("Event Category", "Test Tickets Category") - host = ensure_prompt_named_record("Event Host", "Test Tickets Host") + host = ensure_event_host("Test Tickets Host") cls.event = frappe.get_doc( { "doctype": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 543958aa..b560a790 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -16,6 +16,8 @@ "host", "venue", "meeting_link", + "hosted_by_section", + "co_hosts", "section_break_naqe", "start_date", "start_time", @@ -178,9 +180,21 @@ { "fieldname": "host", "fieldtype": "Link", + "hidden": 1, "label": "Host", "options": "Event Host", - "reqd": 1 + "read_only": 1 + }, + { + "fieldname": "hosted_by_section", + "fieldtype": "Section Break", + "label": "Hosted By" + }, + { + "fieldname": "co_hosts", + "fieldtype": "Table", + "label": "Co-hosts", + "options": "Event CoHost" }, { "fieldname": "time_zone", @@ -630,7 +644,7 @@ "link_fieldname": "event" } ], - "modified": "2026-08-27 12:00:00.000000", + "modified": "2026-09-08 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index d24f881a..220208b4 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -37,6 +37,7 @@ class BuzzEvent(Document): from frappe.types import DF from buzz.events.doctype.buzz_event_form.buzz_event_form import BuzzEventForm + from buzz.events.doctype.event_cohost.event_cohost import EventCoHost from buzz.events.doctype.event_featured_speaker.event_featured_speaker import EventFeaturedSpeaker from buzz.events.doctype.event_payment_gateway.event_payment_gateway import EventPaymentGateway from buzz.events.doctype.schedule_item.schedule_item import ScheduleItem @@ -53,6 +54,7 @@ class BuzzEvent(Document): booking_confirmation_email_template: DF.Link | None card_image: DF.AttachImage | None category: DF.Link + co_hosts: DF.Table[EventCoHost] custom_forms: DF.Table[BuzzEventForm] default_ticket_type: DF.Link | None end_date: DF.Date | None @@ -61,7 +63,7 @@ class BuzzEvent(Document): featured_speakers: DF.Table[EventFeaturedSpeaker] free_event: DF.Check guest_verification_method: DF.Literal["None", "Email OTP", "Phone OTP"] - host: DF.Link + host: DF.Link | None is_published: DF.Check medium: DF.Literal["In Person", "Online"] meeting_link: DF.Data | None @@ -105,6 +107,7 @@ def validate(self): self.validate_custom_forms() self.clear_unused_location() self.validate_venue_team() + self.validate_co_hosts() self.set_time_zone_label() def clear_unused_location(self): @@ -134,6 +137,13 @@ def validate_venue_team(self): if venue_team and venue_team != self.team: frappe.throw(_("Venue {0} belongs to another team.").format(self.venue)) + def validate_co_hosts(self): + hosts = [row.host for row in self.co_hosts] + duplicate = next((host for host in hosts if hosts.count(host) > 1), None) + if duplicate: + label = frappe.db.get_value("Event Host", duplicate, "host_name") or duplicate + frappe.throw(_("{0} is already a co-host of this event.").format(label)) + def set_time_zone_label(self): # validate runs before the mandatory check, so dates may still be empty here if not (self.time_zone and self.start_date and self.start_time): diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index b3112910..049a37f9 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -8,6 +8,7 @@ from frappe.tests.utils import FrappeTestCase from buzz.api.booking.services import are_registrations_closed +from buzz.api.forms.test_forms import ensure_event_host from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import ( @@ -37,11 +38,6 @@ def create_test_fixtures(cls): ignore_permissions=True ) - if not frappe.db.exists("Event Host", "Test Host"): - frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert( - ignore_permissions=True - ) - def tearDown(self): frappe.db.rollback() @@ -53,7 +49,7 @@ def _make_event_with_schedule(self, schedule_overrides, **event_overrides): "doctype": "Buzz Event", "title": "Schedule Test Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": "2026-03-05", "end_date": "2026-03-06", "start_time": "9:00:00", @@ -108,7 +104,7 @@ def _make_event_with_route(self, route): "doctype": "Buzz Event", "title": f"Route Test Event {route}", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -184,7 +180,7 @@ def _make_event_with_venue(self, venue: str, team: str | None): "doctype": "Buzz Event", "title": f"Venue Test Event {venue}", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -273,7 +269,7 @@ def test_create_from_template_copies_direct_fields(self): "doctype": "Event Template", "template_name": "Direct Fields Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "medium": "Online", "about": "About text", "short_description": "Short desc", @@ -307,7 +303,7 @@ def test_create_from_template_copies_direct_fields(self): event = frappe.get_doc("Buzz Event", event_name) self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, "Test Host") + self.assertEqual(event.host, ensure_event_host("Test Host")) self.assertEqual(event.medium, "Online") self.assertEqual(event.about, "About text") self.assertEqual(event.short_description, "Short desc") @@ -326,7 +322,7 @@ def test_create_from_template_respects_unselected_options(self): "doctype": "Event Template", "template_name": "Selective Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "medium": "In Person", "about": "Should not appear", "apply_tax": 1, @@ -341,7 +337,7 @@ def test_create_from_template_respects_unselected_options(self): event = frappe.get_doc("Buzz Event", event_name) self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, "Test Host") + self.assertEqual(event.host, ensure_event_host("Test Host")) self.assertFalse(event.about) self.assertFalse(event.apply_tax) @@ -352,7 +348,7 @@ def test_create_from_template_additional_fields_override(self): "doctype": "Event Template", "template_name": "Override Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), } ) template.insert() @@ -367,7 +363,7 @@ def test_create_from_template_additional_fields_override(self): event = frappe.get_doc("Buzz Event", event_name) self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, "Test Host") + self.assertEqual(event.host, ensure_event_host("Test Host")) def test_create_from_template_creates_ticket_types(self): """Test that ticket types are created as linked documents""" @@ -376,7 +372,7 @@ def test_create_from_template_creates_ticket_types(self): "doctype": "Event Template", "template_name": "Ticket Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_ticket_types": [ { "title": "Early Bird", @@ -414,7 +410,7 @@ def test_create_from_template_creates_add_ons(self): "doctype": "Event Template", "template_name": "AddOn Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_add_ons": [ { "title": "Workshop Access", @@ -450,7 +446,7 @@ def test_create_from_template_creates_custom_fields(self): "doctype": "Event Template", "template_name": "CustomField Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_custom_fields": [ { "label": "Company", @@ -487,7 +483,7 @@ def test_create_from_template_skips_linked_docs_when_unselected(self): "doctype": "Event Template", "template_name": "Skip Linked Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_ticket_types": [ {"title": "Skipped", "price": 100, "currency": "INR", "is_published": 1} ], @@ -523,7 +519,7 @@ def test_create_from_template_sets_default_title_and_date(self): "doctype": "Event Template", "template_name": "Defaults Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), } ) template.insert() @@ -542,7 +538,7 @@ def test_create_from_template_copies_sponsorship_settings(self): "doctype": "Event Template", "template_name": "Sponsor Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "auto_send_pitch_deck": 1, "sponsor_deck_reply_to": "test@example.com", "sponsor_deck_cc": "cc@example.com", @@ -574,7 +570,7 @@ def test_save_event_as_template_all_options(self): "doctype": "Buzz Event", "title": "Full Save Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -641,7 +637,7 @@ def test_save_event_as_template_all_options(self): template = frappe.get_doc("Event Template", template_name) self.assertEqual(template.category, "Test Category") - self.assertEqual(template.host, "Test Host") + self.assertEqual(template.host, ensure_event_host("Test Host")) self.assertEqual(template.medium, "Online") self.assertEqual(template.about, "Full event description") self.assertEqual(template.apply_tax, 1) @@ -665,7 +661,7 @@ def test_save_event_as_template_partial(self): "doctype": "Buzz Event", "title": "Partial Save Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -698,7 +694,7 @@ def test_round_trip_preserves_data(self): "doctype": "Buzz Event", "title": "Round Trip Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -767,7 +763,7 @@ def test_create_from_template_requires_template_read_permission(self): "doctype": "Event Template", "template_name": "Perm Test Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), } ) template.insert() @@ -787,7 +783,7 @@ def test_save_as_template_requires_create_permission(self): "doctype": "Buzz Event", "title": "Perm Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -1086,7 +1082,7 @@ def _make_event(self, **overrides): "doctype": "Buzz Event", "title": "TZ Label Test Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": "2026-03-05", "end_date": "2026-03-06", "start_time": "9:00:00", @@ -1153,10 +1149,6 @@ def setUpClass(cls): frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( ignore_permissions=True ) - if not frappe.db.exists("Event Host", "Test Host"): - frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert( - ignore_permissions=True - ) def tearDown(self): frappe.db.rollback() @@ -1167,7 +1159,7 @@ def _make_event(self): "doctype": "Buzz Event", "title": "Meeting Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": "2026-08-01", "end_date": "2026-08-01", "start_time": "10:00:00", diff --git a/buzz/events/doctype/buzz_team/test_buzz_team.py b/buzz/events/doctype/buzz_team/test_buzz_team.py index af921ed8..13452990 100644 --- a/buzz/events/doctype/buzz_team/test_buzz_team.py +++ b/buzz/events/doctype/buzz_team/test_buzz_team.py @@ -137,13 +137,12 @@ def create_owned_team(team_name: str, owner: str) -> str: def payload_for(doctype: str, suffix: str) -> dict: payloads = { "Event Venue": {"name": f"Venue {suffix}", "address": "somewhere"}, - "Event Host": {"name": f"Host {suffix}"}, + "Event Host": {"host_name": f"Host {suffix}"}, "Event Template": {"template_name": f"Template {suffix}"}, "Buzz Campaign": {"name": f"Campaign {suffix}", "title": suffix, "description": "why"}, "Buzz Event": { "title": f"Event {suffix}", "category": "Test Category", - "host": "Test Host", "start_date": "2026-03-05", "end_date": "2026-03-06", "start_time": "09:00:00", diff --git a/buzz/events/doctype/event_cohost/__init__.py b/buzz/events/doctype/event_cohost/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/events/doctype/event_cohost/event_cohost.json b/buzz/events/doctype/event_cohost/event_cohost.json new file mode 100644 index 00000000..09604bf0 --- /dev/null +++ b/buzz/events/doctype/event_cohost/event_cohost.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-09-08 12:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "host" + ], + "fields": [ + { + "fieldname": "host", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Host", + "options": "Event Host", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-09-08 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Event CoHost", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/events/doctype/event_cohost/event_cohost.py b/buzz/events/doctype/event_cohost/event_cohost.py new file mode 100644 index 00000000..16a828c1 --- /dev/null +++ b/buzz/events/doctype/event_cohost/event_cohost.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class EventCoHost(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + host: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/buzz/events/doctype/event_host/event_host.json b/buzz/events/doctype/event_host/event_host.json index 05d9934b..469a06d2 100644 --- a/buzz/events/doctype/event_host/event_host.json +++ b/buzz/events/doctype/event_host/event_host.json @@ -1,11 +1,12 @@ { "actions": [], "allow_rename": 1, - "autoname": "prompt", + "autoname": "hash", "creation": "2025-07-19 11:36:30.869780", "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "host_name", "team", "logo", "country", @@ -17,6 +18,14 @@ "about" ], "fields": [ + { + "allow_in_quick_entry": 1, + "fieldname": "host_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Host Name", + "reqd": 1 + }, { "allow_in_quick_entry": 1, "fieldname": "logo", @@ -65,7 +74,6 @@ "fieldtype": "Link", "label": "Team", "options": "Buzz Team", - "reqd": 1, "search_index": 1 } ], @@ -73,11 +81,11 @@ "image_field": "logo", "index_web_pages_for_search": 1, "links": [], - "modified": "2026-07-31 12:00:00.000000", + "modified": "2026-09-08 17:48:14.470171", "modified_by": "Administrator", "module": "Events", "name": "Event Host", - "naming_rule": "Set by user", + "naming_rule": "Random", "owner": "Administrator", "permissions": [ { @@ -108,7 +116,9 @@ ], "quick_entry": 1, "row_format": "Dynamic", + "show_title_field_in_link": 1, "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "host_name" } diff --git a/buzz/events/doctype/event_host/event_host.py b/buzz/events/doctype/event_host/event_host.py index 1c6d90e8..ac85c76d 100644 --- a/buzz/events/doctype/event_host/event_host.py +++ b/buzz/events/doctype/event_host/event_host.py @@ -20,9 +20,10 @@ class EventHost(Document): address: DF.SmallText | None by_line: DF.Data | None country: DF.Link | None + host_name: DF.Data logo: DF.AttachImage | None social_media_links: DF.Table[SocialMediaLink] - team: DF.Link + team: DF.Link | None # end: auto-generated types pass diff --git a/buzz/events/doctype/event_host/test_event_host.py b/buzz/events/doctype/event_host/test_event_host.py index 4aa7447f..10a768b4 100644 --- a/buzz/events/doctype/event_host/test_event_host.py +++ b/buzz/events/doctype/event_host/test_event_host.py @@ -1,9 +1,14 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -# import frappe +import frappe from frappe.tests import IntegrationTestCase +from buzz.api.forms.test_forms import ensure_prompt_named_record +from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team +from buzz.patches.backfill_event_co_hosts import execute as backfill_event_co_hosts +from buzz.patches.backfill_event_host_names import execute as backfill_event_host_names + # On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list @@ -17,4 +22,68 @@ class IntegrationTestEventHost(IntegrationTestCase): Use this class for testing interactions between multiple components. """ - pass + def make_legacy_host(self) -> str: + """A host from before `host_name` existed: the docname was the label.""" + host = frappe.get_doc({"doctype": "Event Host", "host_name": "Legacy Host"}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Event Host", host.name, "host_name", "", update_modified=False) + return host.name + + def test_backfill_names_a_host_that_has_none(self): + host = self.make_legacy_host() + + backfill_event_host_names() + + self.assertEqual(frappe.db.get_value("Event Host", host, "host_name"), host) + + def test_backfill_leaves_a_named_host_alone(self): + host = frappe.get_doc({"doctype": "Event Host", "host_name": "Acme Corp"}).insert( + ignore_permissions=True + ) + + backfill_event_host_names() + + self.assertEqual(frappe.db.get_value("Event Host", host.name, "host_name"), "Acme Corp") + + def make_event(self, team: str, host: str) -> str: + event = frappe.get_doc( + { + "doctype": "Buzz Event", + "title": f"Backfill Event {frappe.generate_hash(length=6)}", + "team": team, + "category": ensure_prompt_named_record("Event Category", "Test Category"), + "host": host, + "start_date": "2030-01-01", + "start_time": "09:00:00", + "end_time": "18:00:00", + "medium": "Online", + } + ) + event.insert(ignore_permissions=True) + return event.name + + def test_backfill_carries_a_legacy_host_into_the_co_host_table(self): + team = create_owned_team(f"Backfill Team {frappe.generate_hash(length=6)}", "Administrator") + host = frappe.get_doc({"doctype": "Event Host", "host_name": "Acme Corp", "team": team}).insert( + ignore_permissions=True + ) + event = self.make_event(team, host.name) + frappe.db.delete("Event CoHost", {"parent": event}) + + backfill_event_co_hosts() + + self.assertEqual(frappe.get_all("Event CoHost", filters={"parent": event}, pluck="host"), [host.name]) + + def test_backfill_skips_a_host_named_after_its_own_team(self): + name = f"Minted Team {frappe.generate_hash(length=6)}" + team = create_owned_team(name, "Administrator") + host = frappe.get_doc({"doctype": "Event Host", "host_name": name, "team": team}).insert( + ignore_permissions=True + ) + event = self.make_event(team, host.name) + frappe.db.delete("Event CoHost", {"parent": event}) + + backfill_event_co_hosts() + + self.assertEqual(frappe.get_all("Event CoHost", filters={"parent": event}, pluck="host"), []) diff --git a/buzz/events/doctype/event_template/test_event_template.py b/buzz/events/doctype/event_template/test_event_template.py index 6437c4e6..62b7e94f 100644 --- a/buzz/events/doctype/event_template/test_event_template.py +++ b/buzz/events/doctype/event_template/test_event_template.py @@ -4,6 +4,7 @@ import frappe from frappe.tests.utils import FrappeTestCase +from buzz.api.forms.test_forms import ensure_event_host from buzz.events.doctype.buzz_event.buzz_event import create_from_template from buzz.events.doctype.event_template.event_template import create_template_from_event @@ -23,12 +24,6 @@ def create_test_fixtures(cls): ignore_permissions=True ) - # Create Event Host if not exists - if not frappe.db.exists("Event Host", "Test Host"): - frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert( - ignore_permissions=True - ) - def tearDown(self): """Clean up test data after each test""" frappe.db.rollback() @@ -42,7 +37,7 @@ def test_create_template_basic(self): "doctype": "Event Template", "template_name": "Test Webinar Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "medium": "Online", "about": "Test description", } @@ -60,7 +55,7 @@ def test_create_template_with_ticket_types(self): "doctype": "Event Template", "template_name": "Template with Tickets", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_ticket_types": [ { "title": "Early Bird", @@ -86,7 +81,7 @@ def test_create_template_with_add_ons(self): "doctype": "Event Template", "template_name": "Template with Add-ons", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_add_ons": [ {"title": "T-Shirt", "price": 500, "currency": "INR", "enabled": 1}, { @@ -112,7 +107,7 @@ def test_create_template_with_custom_fields(self): "doctype": "Event Template", "template_name": "Template with Custom Fields", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_custom_fields": [ { "label": "Company Name", @@ -148,7 +143,7 @@ def test_create_event_from_template_all_options(self): "doctype": "Event Template", "template_name": "Full Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "medium": "Online", "about": "Template about text", "apply_tax": 1, @@ -190,7 +185,7 @@ def test_create_event_from_template_all_options(self): # Verify event fields self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, "Test Host") + self.assertEqual(event.host, ensure_event_host("Test Host")) self.assertEqual(event.medium, "Online") self.assertEqual(event.about, "Template about text") self.assertEqual(event.apply_tax, 1) @@ -222,7 +217,7 @@ def test_create_event_from_template_partial_options(self): "doctype": "Event Template", "template_name": "Partial Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "medium": "In Person", "about": "Should not be copied", "template_ticket_types": [ @@ -242,7 +237,7 @@ def test_create_event_from_template_partial_options(self): self.assertEqual(event.category, "Test Category") # Host should be copied (it's mandatory) - self.assertEqual(event.host, "Test Host") + self.assertEqual(event.host, ensure_event_host("Test Host")) # About should NOT be copied self.assertFalse(event.about) @@ -258,7 +253,7 @@ def test_create_event_from_template_no_linked_docs(self): "doctype": "Event Template", "template_name": "No Linked Docs Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "template_ticket_types": [ {"title": "General", "price": 100, "currency": "INR", "is_published": 1} ], @@ -289,7 +284,7 @@ def test_save_event_as_template(self): "doctype": "Buzz Event", "title": "Source Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -354,7 +349,7 @@ def test_save_event_as_template_partial(self): "doctype": "Buzz Event", "title": "Partial Source Event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -389,7 +384,7 @@ def test_round_trip_event_to_template_to_event(self): "doctype": "Buzz Event", "title": "Original Conference", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "09:00:00", "end_time": "18:00:00", @@ -476,7 +471,7 @@ def test_create_event_empty_template(self): "doctype": "Event Template", "template_name": "Empty Template", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), } ) template.insert() diff --git a/buzz/install.py b/buzz/install.py index 59e9ee4a..335e6090 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -138,9 +138,9 @@ def setup_test_records(): test_venue = frappe.get_doc( {"doctype": "Event Venue", "name": "Test Venue", "address": "test", "team": admin_team} ).insert(ignore_if_duplicate=True) - test_host = frappe.get_doc({"doctype": "Event Host", "name": "Test Host", "team": admin_team}).insert( - ignore_if_duplicate=True - ) + test_host = frappe.get_doc( + {"doctype": "Event Host", "host_name": "Test Host", "team": admin_team} + ).insert(ignore_if_duplicate=True) test_event_exists = frappe.db.exists("Buzz Event", {"route": "test-route"}) if test_event_exists: diff --git a/buzz/patches.txt b/buzz/patches.txt index 554fc338..126f83d7 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -19,3 +19,5 @@ buzz.patches.create_default_teams #2 buzz.patches.assign_default_team #2 buzz.patches.create_team_settings_for_existing_teams buzz.patches.create_withdrawn_proposal_status +buzz.patches.backfill_event_host_names +buzz.patches.backfill_event_co_hosts diff --git a/buzz/patches/backfill_event_co_hosts.py b/buzz/patches/backfill_event_co_hosts.py new file mode 100644 index 00000000..563abf5d --- /dev/null +++ b/buzz/patches/backfill_event_co_hosts.py @@ -0,0 +1,43 @@ +import frappe + + +def execute(): + """Carry a legacy `Buzz Event.host` into the co-host table so it stays on display. + + Hosts auto-minted per team are skipped: their `host_name` is the team's own name, so a + row would list the team twice under "Hosted by". + """ + event = frappe.qb.DocType("Buzz Event") + host = frappe.qb.DocType("Event Host") + team = frappe.qb.DocType("Buzz Team") + + rows = ( + frappe.qb.from_(event) + .join(host) + .on(host.name == event.host) + .left_join(team) + .on(team.name == event.team) + .select(event.name.as_("event"), event.host) + .where(host.host_name != team.team_name) + ).run(as_dict=True) + + co_host = frappe.qb.DocType("Event CoHost") + already = set( + (frappe.qb.from_(co_host).select(co_host.parent).where(co_host.parenttype == "Buzz Event")).run( + pluck=True + ) + ) + + for row in rows: + if str(row.event) in already: + continue + frappe.get_doc( + { + "doctype": "Event CoHost", + "parenttype": "Buzz Event", + "parentfield": "co_hosts", + "parent": row.event, + "host": row.host, + "idx": 1, + } + ).db_insert() diff --git a/buzz/patches/backfill_event_host_names.py b/buzz/patches/backfill_event_host_names.py new file mode 100644 index 00000000..4f78ed27 --- /dev/null +++ b/buzz/patches/backfill_event_host_names.py @@ -0,0 +1,9 @@ +import frappe + + +def execute(): + """Event Host names used to be the docname. `host_name` now carries the label.""" + host = frappe.qb.DocType("Event Host") + frappe.qb.update(host).set(host.host_name, host.name).where( + (host.host_name.isnull()) | (host.host_name == "") + ).run() diff --git a/buzz/proposals/doctype/event_proposal/event_proposal.py b/buzz/proposals/doctype/event_proposal/event_proposal.py index c121f9a8..0e0ae9a3 100644 --- a/buzz/proposals/doctype/event_proposal/event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/event_proposal.py @@ -78,8 +78,9 @@ def _create_host(self): if not self.host_company: frappe.throw(_("Please enter the Company Name before creating a Host.")) - if frappe.db.exists("Event Host", self.host_company): - host = frappe.get_doc("Event Host", self.host_company) + existing = frappe.db.get_value("Event Host", {"host_name": self.host_company}, "name") + if existing: + host = frappe.get_doc("Event Host", existing) updated = False if self.host_company_logo and not host.logo: host.logo = self.host_company_logo @@ -91,7 +92,7 @@ def _create_host(self): host.save(ignore_permissions=True) else: host = frappe.new_doc("Event Host") - host.name = self.host_company + host.host_name = self.host_company host.logo = self.host_company_logo host.about = self.about_the_company host.insert(ignore_permissions=True) @@ -114,6 +115,7 @@ def create_event(self): # host may have just been auto-created in-memory and is not yet persisted, # so the mapped doc (read from DB) would miss it. buzz_event.host = self.host + buzz_event.append("co_hosts", {"host": self.host}) buzz_event.insert() self.status = "Event Created" diff --git a/buzz/proposals/doctype/event_proposal/test_event_proposal.py b/buzz/proposals/doctype/event_proposal/test_event_proposal.py index 1bb95759..8d267bf1 100644 --- a/buzz/proposals/doctype/event_proposal/test_event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/test_event_proposal.py @@ -47,28 +47,27 @@ def test_create_host_creates_and_links_event_host(self): company = f"Acme {frappe.generate_hash(length=6)}" proposal = self.make_proposal(host_company=company, about_the_company="We host events.") - host_name = proposal.create_host() + host = proposal.create_host() - self.assertEqual(host_name, company) - self.assertEqual(proposal.host, company) - self.assertTrue(frappe.db.exists("Event Host", company)) - self.assertEqual(frappe.db.get_value("Event Host", company, "about"), "We host events.") + self.assertEqual(proposal.host, host) + self.assertEqual(frappe.db.get_value("Event Host", host, "host_name"), company) + self.assertEqual(frappe.db.get_value("Event Host", host, "about"), "We host events.") def test_create_host_reuses_existing_host(self): company = f"Existing {frappe.generate_hash(length=6)}" existing = frappe.new_doc("Event Host") - existing.name = company + existing.host_name = company existing.insert(ignore_permissions=True) proposal = self.make_proposal(host_company=company) proposal.create_host() - self.assertEqual(proposal.host, company) + self.assertEqual(proposal.host, existing.name) def test_reuse_fills_only_empty_host_fields(self): # Existing host with empty logo/about -> proposal values fill them in. company = f"Empty {frappe.generate_hash(length=6)}" - frappe.get_doc({"doctype": "Event Host", "__newname": company}).insert(ignore_permissions=True) + frappe.get_doc({"doctype": "Event Host", "host_name": company}).insert(ignore_permissions=True) proposal = self.make_proposal( host_company=company, @@ -77,7 +76,7 @@ def test_reuse_fills_only_empty_host_fields(self): ) proposal.create_host() - host = frappe.get_doc("Event Host", company) + host = frappe.get_doc("Event Host", proposal.host) self.assertEqual(host.logo, "/files/proposal-logo.png") self.assertEqual(host.about, "Proposal about.") @@ -87,7 +86,7 @@ def test_reuse_does_not_overwrite_populated_host_fields(self): frappe.get_doc( { "doctype": "Event Host", - "__newname": company, + "host_name": company, "logo": "/files/original-logo.png", "about": "Original about.", } @@ -100,7 +99,7 @@ def test_reuse_does_not_overwrite_populated_host_fields(self): ) proposal.create_host() - host = frappe.get_doc("Event Host", company) + host = frappe.get_doc("Event Host", proposal.host) self.assertEqual(host.logo, "/files/original-logo.png") self.assertEqual(host.about, "Original about.") @@ -122,8 +121,8 @@ def test_submit_auto_creates_host_from_company(self): proposal.submit() - self.assertEqual(proposal.host, company) - self.assertTrue(frappe.db.exists("Event Host", company)) + self.assertTrue(proposal.host) + self.assertEqual(frappe.db.get_value("Event Host", proposal.host, "host_name"), company) self.assertEqual(proposal.status, "Event Created") def test_start_and_end_time_are_mandatory(self): diff --git a/buzz/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py b/buzz/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py index 7616fb30..16ecafa6 100644 --- a/buzz/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py +++ b/buzz/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py @@ -115,7 +115,7 @@ def send_pitch_deck(self, now=False): def send_approval_notification(self): event = frappe.get_cached_doc("Buzz Event", self.event) - host_name = event.host or "The Event Team" + host_name = frappe.db.get_value("Buzz Team", event.team, "team_name") or "The Event Team" dashboard_link = get_url(f"/b/account/sponsorships/{self.name}") subject = f"[Payment Pending] Your Sponsorship for {event.title} has been Approved!" diff --git a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py index 734aa3f7..24cd9c7e 100644 --- a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py +++ b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py @@ -4,7 +4,7 @@ import frappe from frappe.tests import IntegrationTestCase -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 # On IntegrationTestCase, the doctype test records and all @@ -72,7 +72,7 @@ class TestTalkProposalSpeakerAccess(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_prompt_named_record("Event Host", "Proposal Perm Host") + cls.host = ensure_event_host("Proposal Perm Host") cls.speaker_user = make_test_user("speaker-perm@example.com") cls.other_user = make_test_user("other-perm@example.com") cls.manager_user = make_test_user("manager-perm@example.com", roles=["Event Manager"]) @@ -140,7 +140,7 @@ class TestCreateTalk(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_prompt_named_record("Event Host", "Proposal Perm Host") + cls.host = ensure_event_host("Proposal Perm Host") cls.speaker_user = make_test_user("create-talk-speaker@example.com") cls.owner_user = make_test_user("create-talk-owner@example.com", roles=["Event Manager"]) cls.event = make_test_event( @@ -210,7 +210,7 @@ class TestTalkProposalSpeakerChanges(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_prompt_named_record("Event Host", "Proposal Perm Host") + cls.host = ensure_event_host("Proposal Perm Host") cls.speaker_user = make_test_user("guard-speaker@example.com") cls.manager_user = make_test_user("guard-manager@example.com", roles=["Event Manager"]) cls.team = create_owned_team("Proposal Guard Team", cls.manager_user) diff --git a/buzz/ticketing/doctype/event_booking/test_event_eligibility.py b/buzz/ticketing/doctype/event_booking/test_event_eligibility.py index 0ddb1ec6..79b57208 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_eligibility.py +++ b/buzz/ticketing/doctype/event_booking/test_event_eligibility.py @@ -14,7 +14,7 @@ from buzz.api.booking import process_booking from buzz.api.booking.exceptions import 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 from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership @@ -28,7 +28,7 @@ class EligibilityTestCase(IntegrationTestCase): def setUpClass(cls): super().setUpClass() cls.category = ensure_prompt_named_record("Event Category", "Eligibility Category") - cls.host = ensure_prompt_named_record("Event Host", "Eligibility Host") + cls.host = ensure_event_host("Eligibility Host") owner = create_user("eligibility-team-owner@example.com", "Eligibility") cls.team = create_owned_team(f"Eligibility Team {frappe.generate_hash(length=6)}", owner) cls.event = cls.make_event() diff --git a/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py b/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py index 646a624b..f464fa43 100644 --- a/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py +++ b/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py @@ -4,6 +4,7 @@ import frappe from frappe.tests import IntegrationTestCase +from buzz.api.forms.test_forms import ensure_event_host from buzz.ticketing.report.detailed_event_registrations.detailed_event_registrations import ( execute, get_add_ons_for_event, @@ -41,16 +42,13 @@ def _create_test_event(cls): if not frappe.db.exists("Event Category", "Test Category"): frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert() - if not frappe.db.exists("Event Host", "Test Host"): - frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert() - event = frappe.get_doc( { "doctype": "Buzz Event", "title": "Test Report Event", "route": "test-report-event", "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "10:00:00", "end_time": "18:00:00", @@ -763,7 +761,7 @@ def test_report_with_no_tickets(self): "title": "Empty Event", "route": "empty-event-" + frappe.generate_hash(length=6), "category": "Test Category", - "host": "Test Host", + "host": ensure_event_host("Test Host"), "start_date": frappe.utils.today(), "start_time": "10:00:00", "end_time": "18:00:00", diff --git a/buzz/utils.py b/buzz/utils.py index 3a5780cf..fb366679 100644 --- a/buzz/utils.py +++ b/buzz/utils.py @@ -172,7 +172,7 @@ def generate_ics_file(event_doc, attendee_email: str): from frappe.utils import now_datetime start_dt, end_dt = build_event_datetimes(event_doc) - organizer_name = event_doc.host or event_doc.title + organizer_name = frappe.db.get_value("Buzz Team", event_doc.team, "team_name") or event_doc.title organizer_email = frappe.db.get_value( "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "email_id" ) @@ -202,8 +202,8 @@ def generate_ics_file(event_doc, attendee_email: str): # Curated abbreviations for zones where tzdata only provides a numeric offset # (tzdata dropped invented abbreviations in 2017). Zones with real tzdata # abbreviations (IST, EST, CET, ...) never reach this map. -# ponytail: DST-observing zones here (e.g. Chile) are pinned to their standard -# form; extend get_time_zone_label with per-date variants if that ever matters. +# DST-observing zones here (e.g. Chile) are pinned to their standard form; extend +# get_time_zone_label with per-date variants if that ever matters. TIMEZONE_ABBREVIATIONS = { "America/Araguaina": "BRT", "America/Argentina/Buenos_Aires": "ART", diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 6fc0ef47..53312c8d 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -11,6 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AddCoHostDialog: typeof import('./src/components/dashboard/events/AddCoHostDialog.vue')['default'] AddMembersDialog: typeof import('./src/components/dashboard/teams/AddMembersDialog.vue')['default'] AddOnPreferenceDialog: typeof import('./src/components/AddOnPreferenceDialog.vue')['default'] AddSpeakerDialog: typeof import('./src/components/dashboard/proposals/AddSpeakerDialog.vue')['default'] @@ -50,6 +51,7 @@ declare module 'vue' { EventGuestActions: typeof import('./src/components/dashboard/events/EventGuestActions.vue')['default'] EventGuestItem: typeof import('./src/components/dashboard/events/EventGuestItem.vue')['default'] EventGuestSkeleton: typeof import('./src/components/dashboard/events/EventGuestSkeleton.vue')['default'] + EventHosts: typeof import('./src/components/dashboard/events/EventHosts.vue')['default'] EventHoverCard: typeof import('./src/components/dashboard/events/EventHoverCard.vue')['default'] EventLocation: typeof import('./src/components/dashboard/events/EventLocation.vue')['default'] EventMedium: typeof import('./src/components/dashboard/events/EventMedium.vue')['default'] diff --git a/dashboard/src/components/dashboard/events/AddCoHostDialog.vue b/dashboard/src/components/dashboard/events/AddCoHostDialog.vue new file mode 100644 index 00000000..8f6eb839 --- /dev/null +++ b/dashboard/src/components/dashboard/events/AddCoHostDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/dashboard/src/components/dashboard/events/EventHosts.vue b/dashboard/src/components/dashboard/events/EventHosts.vue new file mode 100644 index 00000000..1b011adc --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventHosts.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts index 5c15979d..775a7836 100644 --- a/dashboard/src/data/events.ts +++ b/dashboard/src/data/events.ts @@ -1,6 +1,6 @@ import { createResource, useCall } from "frappe-ui" -import type { EventDetail, MyEvents, RegistrationTrend } from "@/types" +import type { EventDetail, EventHostRef, MyEvents, RegistrationTrend } from "@/types" // v2 path: useCall reads the payload from `data`, which /api/method names `message`. // Uncached: cacheKey would persist this user's feed to IndexedDB past a logout. @@ -46,3 +46,24 @@ export function useRegistrationTrend(event: string) { params: { event }, }) } + +/** Add an organisation that has no team here as a co-host of the event. */ +export function useAddCoHost() { + return useCall< + EventHostRef, + { event: string; host_name: string; logo?: string; by_line?: string; about?: string } + >({ + url: "/api/v2/method/buzz.api.events.add_co_host", + method: "POST", + immediate: false, + }) +} + +/** Drop a co-host from the event. The Event Host record itself is left alone. */ +export function useRemoveCoHost() { + return useCall({ + url: "/api/v2/method/buzz.api.events.remove_co_host", + method: "POST", + immediate: false, + }) +} diff --git a/dashboard/src/pages/manage/events/EventDetails.vue b/dashboard/src/pages/manage/events/EventDetails.vue index da051467..fac3fd18 100644 --- a/dashboard/src/pages/manage/events/EventDetails.vue +++ b/dashboard/src/pages/manage/events/EventDetails.vue @@ -7,6 +7,7 @@ import { useRoute } from "vue-router" import EventBanner from "@/components/dashboard/events/EventBanner.vue" import EventDetailsSkeleton from "@/components/dashboard/events/EventDetailsSkeleton.vue" +import EventHosts from "@/components/dashboard/events/EventHosts.vue" import EventMedium from "@/components/dashboard/events/EventMedium.vue" import EventPageHeader from "@/components/dashboard/events/EventPageHeader.vue" import EventRoute from "@/components/dashboard/events/EventRoute.vue" @@ -258,6 +259,13 @@ async function save() { :venue-address="event.data.venue?.address" /> + + diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index a961e730..a9d4814c 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -260,6 +260,13 @@ export interface EventVenueDetail { address: string | null } +// One name under "Hosted by": the event's team, or one of its co-hosts. +export interface EventHostRef { + host: string + label: string + logo: string | null +} + // buzz.api.events.get_event: one event with everything its manage page edits. export interface EventDetail { name: string @@ -280,6 +287,8 @@ export interface EventDetail { venue: EventVenueDetail | null meeting_link: string | null is_published: boolean + primary_host: EventHostRef | null + co_hosts: EventHostRef[] } // A ticket the user holds, flattened with the context its event carries. diff --git a/e2e/helpers/frappe.ts b/e2e/helpers/frappe.ts index 48fd016c..5d137608 100644 --- a/e2e/helpers/frappe.ts +++ b/e2e/helpers/frappe.ts @@ -247,3 +247,32 @@ export async function ensureTestTeam(request: APIRequestContext): Promise { + const [existing] = await getList<{ name: string }>(request, "Event Host", { + fields: ["name"], + filters: { host_name: hostName, team }, + limit: 1, + }) + + if (existing) { + return existing.name + } + + const host = await createDoc<{ name: string }>(request, "Event Host", { + host_name: hostName, + team, + }) + return host.name +} diff --git a/e2e/tests/check-in.setup.ts b/e2e/tests/check-in.setup.ts index 599a0d56..de58ff3a 100644 --- a/e2e/tests/check-in.setup.ts +++ b/e2e/tests/check-in.setup.ts @@ -16,6 +16,7 @@ import { createDoc, deleteDoc, docExists, + ensureEventHost, ensureTestTeam, getList, } from "../helpers/frappe" @@ -91,9 +92,7 @@ setup("seed check-in event, ticket type and front-desk users", async ({ request, const team = await ensureTestTeam(request) - if (!(await docExists(request, "Event Host", HOST))) { - await createDoc(request, "Event Host", { name: HOST, team }) - } + const host = await ensureEventHost(request, HOST, team) const startDate = new Date() startDate.setDate(startDate.getDate() + 7) @@ -102,7 +101,7 @@ setup("seed check-in event, ticket type and front-desk users", async ({ request, team, title: CHECK_IN_EVENT_TITLE, category: CATEGORY, - host: HOST, + host, route: CHECK_IN_EVENT_ROUTE, start_date: startDate.toISOString().split("T")[0], start_time: "09:00:00", diff --git a/e2e/tests/custom-forms.setup.ts b/e2e/tests/custom-forms.setup.ts index 6b6d2117..c982ec08 100644 --- a/e2e/tests/custom-forms.setup.ts +++ b/e2e/tests/custom-forms.setup.ts @@ -7,7 +7,15 @@ import { CUSTOM_FORMS_EVENT_ROUTE, MEMBERS_ONLY_FORM_ROUTE, } from "../data/custom-forms" -import { createDoc, docExists, ensureTestTeam, getDoc, getList, updateDoc } from "../helpers/frappe" +import { + createDoc, + docExists, + ensureEventHost, + ensureTestTeam, + getDoc, + getList, + updateDoc, +} from "../helpers/frappe" interface NamedDoc { name: string @@ -35,9 +43,7 @@ setup("setup custom forms on test event", async ({ request }) => { } const team = await ensureTestTeam(request) - if (!(await docExists(request, "Event Host", testHostName))) { - await createDoc(request, "Event Host", { name: testHostName, team }) - } + const host = await ensureEventHost(request, testHostName, team) const futureDate = new Date() futureDate.setMonth(futureDate.getMonth() + 1) @@ -47,7 +53,7 @@ setup("setup custom forms on test event", async ({ request }) => { team, title: "E2E Custom Forms Event", category: testCategoryName, - host: testHostName, + host, start_date: startDate, route: CUSTOM_FORMS_EVENT_ROUTE, is_published: 1, diff --git a/e2e/tests/event.setup.ts b/e2e/tests/event.setup.ts index a46c53b4..561b2b2f 100644 --- a/e2e/tests/event.setup.ts +++ b/e2e/tests/event.setup.ts @@ -1,6 +1,13 @@ import { test as setup } from "@playwright/test" -import { createDoc, deleteDoc, docExists, ensureTestTeam, getList } from "../helpers/frappe" +import { + createDoc, + deleteDoc, + docExists, + ensureEventHost, + ensureTestTeam, + getList, +} from "../helpers/frappe" interface NamedDoc { name: string @@ -66,14 +73,7 @@ setup("create test event for booking", async ({ request }) => { const team = await ensureTestTeam(request) - // Create Event Host if it doesn't exist - if (!(await docExists(request, "Event Host", testHostName))) { - await createDoc(request, "Event Host", { - name: testHostName, - team, - }) - console.log(`Created Event Host: ${testHostName}`) - } + const host = await ensureEventHost(request, testHostName, team) // Create Buzz Event const futureDate = new Date() @@ -84,7 +84,7 @@ setup("create test event for booking", async ({ request }) => { team, title: testEventTitle, category: testCategoryName, - host: testHostName, + host, start_date: startDate, route: testEventRoute, is_published: 1, diff --git a/e2e/tests/guest-event.setup.ts b/e2e/tests/guest-event.setup.ts index be9c4811..846888ac 100644 --- a/e2e/tests/guest-event.setup.ts +++ b/e2e/tests/guest-event.setup.ts @@ -1,6 +1,13 @@ import { test as setup } from "@playwright/test" -import { callMethod, createDoc, docExists, ensureTestTeam, getList } from "../helpers/frappe" +import { + callMethod, + createDoc, + docExists, + ensureEventHost, + ensureTestTeam, + getList, +} from "../helpers/frappe" interface NamedDoc { name: string @@ -106,12 +113,7 @@ setup("create guest booking test events", async ({ request }) => { const team = await ensureTestTeam(request) - if (!(await docExists(request, "Event Host", testHostName))) { - await createDoc(request, "Event Host", { - name: testHostName, - team, - }) - } + const host = await ensureEventHost(request, testHostName, team) const futureDate = new Date() futureDate.setMonth(futureDate.getMonth() + 1) @@ -123,7 +125,7 @@ setup("create guest booking test events", async ({ request }) => { team, title: evt.title, category: testCategoryName, - host: testHostName, + host, start_date: startDate, start_time: "09:00:00", end_time: "17:00:00", diff --git a/e2e/tests/offline-payment.setup.ts b/e2e/tests/offline-payment.setup.ts index b3d52aa3..39a1457a 100644 --- a/e2e/tests/offline-payment.setup.ts +++ b/e2e/tests/offline-payment.setup.ts @@ -1,6 +1,13 @@ import { test as setup } from "@playwright/test" -import { callMethod, createDoc, docExists, ensureTestTeam, getList } from "../helpers/frappe" +import { + callMethod, + createDoc, + docExists, + ensureEventHost, + ensureTestTeam, + getList, +} from "../helpers/frappe" interface NamedDoc { name: string @@ -85,12 +92,7 @@ setup("create offline payment test event", async ({ request }) => { const team = await ensureTestTeam(request) - if (!(await docExists(request, "Event Host", testHostName))) { - await createDoc(request, "Event Host", { - name: testHostName, - team, - }) - } + const host = await ensureEventHost(request, testHostName, team) const futureDate = new Date() futureDate.setMonth(futureDate.getMonth() + 1) @@ -101,7 +103,7 @@ setup("create offline payment test event", async ({ request }) => { team, title: offlinePaymentEvent.title, category: testCategoryName, - host: testHostName, + host, start_date: startDate, start_time: "09:00:00", end_time: "17:00:00", diff --git a/e2e/tests/tickets.setup.ts b/e2e/tests/tickets.setup.ts index bda0c9a3..90d4b3e1 100644 --- a/e2e/tests/tickets.setup.ts +++ b/e2e/tests/tickets.setup.ts @@ -15,6 +15,7 @@ import { createDoc, deleteDoc, docExists, + ensureEventHost, ensureTestTeam, getList, } from "../helpers/frappe" @@ -72,9 +73,7 @@ setup("seed a ticket owned by the attendee", async ({ request, baseURL }) => { } const team = await ensureTestTeam(request) - if (!(await docExists(request, "Event Host", HOST))) { - await createDoc(request, "Event Host", { name: HOST, team }) - } + const host = await ensureEventHost(request, HOST, team) // Far enough out that every action window (transfer, add-ons, cancellation) is open. const startDate = new Date() @@ -84,7 +83,7 @@ setup("seed a ticket owned by the attendee", async ({ request, baseURL }) => { team, title: TICKETS_EVENT_TITLE, category: CATEGORY, - host: HOST, + host, route: TICKETS_EVENT_ROUTE, start_date: startDate.toISOString().split("T")[0], start_time: "09:00:00",