From 4a33e7ec895ffdcf6d0ba944749678c351cabcac Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 30 Sep 2026 18:28:22 +0530 Subject: [PATCH 01/20] feat(venues): name venues randomly and carry the label in venue_name Event Venue was autonamed by prompt, so its docname doubled as its label and two venues could not share a name. It is now autonamed by hash, with venue_name as the title field. Existing venues keep their docname; a patch copies it into venue_name, so no link is rewritten. Buzz Event gains a hidden venue_name fetched from the venue. Most readers print the venue straight off the event row or over a link hop in a list query, so the label has to live on the event. Relabelling a venue pushes the new label to its events without moving their modified timestamp, which the manage page uses as its conflict check. Payload changes: ConfirmationVenue.name is now venue_name, and the guest event payload gains a venue_name key. Every other key keeps its name and now carries the label. --- buzz/api/booking/details.py | 2 +- buzz/api/booking/event_data.py | 1 + buzz/api/booking/schemas.py | 2 +- buzz/api/booking/test_booking.py | 1 + buzz/api/checkin/services.py | 2 +- buzz/api/events/services.py | 4 +- buzz/api/events/test_events.py | 2 +- buzz/api/forms/services.py | 2 +- buzz/api/proposals/services.py | 2 +- buzz/api/sponsorships/services.py | 2 +- buzz/emails.py | 2 +- .../events/doctype/buzz_event/buzz_event.json | 15 ++++- buzz/events/doctype/buzz_event/buzz_event.py | 1 + .../doctype/buzz_event/test_buzz_event.py | 3 +- .../doctype/buzz_team/test_buzz_team.py | 2 +- .../doctype/event_venue/event_venue.json | 19 ++++-- .../events/doctype/event_venue/event_venue.py | 8 +++ .../doctype/event_venue/test_event_venue.py | 58 +++++++++++++++---- buzz/install.py | 13 +++-- buzz/patches.txt | 1 + buzz/patches/set_event_venue_name.py | 15 +++++ .../doctype/event_booking/event_booking.py | 2 +- .../doctype/event_ticket/event_ticket.py | 2 +- buzz/www/event/index.py | 6 +- buzz/www/events.py | 4 +- dashboard/src/components/BookingEventInfo.vue | 2 +- .../src/components/EventDetailsHeader.vue | 4 +- .../dashboard/events/AddVenueDialog.vue | 19 +++--- .../dashboard/events/EventLocation.vue | 9 +-- dashboard/src/data/tickets.ts | 2 +- dashboard/src/data/venues.ts | 5 +- dashboard/src/pages/BookingsList.vue | 2 +- dashboard/src/pages/TicketDetails.vue | 2 +- dashboard/src/types/Events/EventVenue.ts | 2 + e2e/tests/manage-event.spec.ts | 8 +-- 35 files changed, 159 insertions(+), 67 deletions(-) create mode 100644 buzz/patches/set_event_venue_name.py diff --git a/buzz/api/booking/details.py b/buzz/api/booking/details.py index 577f6ca2..c562a592 100644 --- a/buzz/api/booking/details.py +++ b/buzz/api/booking/details.py @@ -71,7 +71,7 @@ def get_confirmation_venue(venue_id: str | None) -> ConfirmationVenue | None: if not venue_id: return None venue_doc = frappe.get_cached_doc("Event Venue", venue_id) - return ConfirmationVenue(name=venue_doc.name, address=venue_doc.get("address")) + return ConfirmationVenue(venue_name=venue_doc.venue_name, address=venue_doc.get("address")) def build_booking_details(booking_id: str) -> BookingDetailsResponse: diff --git a/buzz/api/booking/event_data.py b/buzz/api/booking/event_data.py index 1766f8f0..4b1dd30c 100644 --- a/buzz/api/booking/event_data.py +++ b/buzz/api/booking/event_data.py @@ -54,6 +54,7 @@ def get_event_details(event_doc: "BuzzEvent"): "time_zone": event_doc.time_zone, "time_zone_label": event_doc.time_zone_label, "venue": event_doc.venue, + "venue_name": event_doc.venue_name, "medium": event_doc.medium, "category": event_doc.category, "banner_image": event_doc.banner_image, diff --git a/buzz/api/booking/schemas.py b/buzz/api/booking/schemas.py index bffb2974..b2b489ae 100644 --- a/buzz/api/booking/schemas.py +++ b/buzz/api/booking/schemas.py @@ -73,7 +73,7 @@ class ConfirmationEvent(APIResponse): class ConfirmationVenue(APIResponse): - name: Any + venue_name: str | None address: str | None diff --git a/buzz/api/booking/test_booking.py b/buzz/api/booking/test_booking.py index 7c760978..019b9aa0 100644 --- a/buzz/api/booking/test_booking.py +++ b/buzz/api/booking/test_booking.py @@ -43,6 +43,7 @@ "time_zone", "time_zone_label", "venue", + "venue_name", "medium", "category", "banner_image", diff --git a/buzz/api/checkin/services.py b/buzz/api/checkin/services.py index 1f68c30d..b4fa724f 100644 --- a/buzz/api/checkin/services.py +++ b/buzz/api/checkin/services.py @@ -109,7 +109,7 @@ def ticket_details(self, **checkin_state) -> TicketCheckinDetails: attendee_email=self.ticket.attendee_email, event_title=self.event.title, ticket_type=ticket_type or self.ticket.ticket_type, - venue=self.event.venue, + venue=self.event.venue_name, start_date=self.event.start_date, start_time=self.event.start_time, end_date=self.event.end_date, diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index 3f7b9d31..f174dcea 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -77,7 +77,7 @@ def events_for(user: str, filters: MyEventFilters | None = None) -> list[MyEvent event.end_date, event.start_time, event.end_time, - event.venue, + event.venue_name.as_("venue"), event.medium, event.banner_image, event.team, @@ -379,7 +379,7 @@ def event_guests( start_date=doc.start_date, start_time=doc.start_time, end_date=doc.end_date, - venue=doc.venue, + venue=doc.venue_name, total=total, matched=matched, registrations_closed=are_registrations_closed(doc), diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 06dd4f73..c10103c5 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -357,7 +357,7 @@ def test_resolves_the_venue_with_its_address(self): venue = frappe.get_doc( { "doctype": "Event Venue", - "name": "Get Event Venue", + "venue_name": "Get Event Venue", "address": "12 Example Street", "team": self.team, } diff --git a/buzz/api/forms/services.py b/buzz/api/forms/services.py index 9bab540d..23e859a1 100644 --- a/buzz/api/forms/services.py +++ b/buzz/api/forms/services.py @@ -118,7 +118,7 @@ def event_summary(self) -> FormEventSummary: end_time=event.end_time, time_zone=event.time_zone, time_zone_label=event.time_zone_label, - venue=event.venue, + venue=event.venue_name, medium=event.medium, short_description=event.short_description, ) diff --git a/buzz/api/proposals/services.py b/buzz/api/proposals/services.py index aa0a87b6..66b02da3 100644 --- a/buzz/api/proposals/services.py +++ b/buzz/api/proposals/services.py @@ -28,7 +28,7 @@ "event.start_date", "event.start_time", "event.end_date", - "event.venue", + "event.venue_name as venue", "event.banner_image", "event.allow_editing_talks_after_acceptance", "status", diff --git a/buzz/api/sponsorships/services.py b/buzz/api/sponsorships/services.py index 3aac4462..43b40ceb 100644 --- a/buzz/api/sponsorships/services.py +++ b/buzz/api/sponsorships/services.py @@ -99,7 +99,7 @@ def event_summary(self) -> EventSummary: about=event.about, start_date=event.start_date, end_date=event.end_date, - venue=event.venue, + venue=event.venue_name, route=event.route, ) diff --git a/buzz/emails.py b/buzz/emails.py index 67ec8017..a9948897 100644 --- a/buzz/emails.py +++ b/buzz/emails.py @@ -16,7 +16,7 @@ def email_event_header(event) -> dict: day=start.day, title=event.title, start_time=f"{start.strftime('%-I:%M %p')} {label}".strip(), - venue=event.venue, + venue=event.venue_name, venue_map_url=venue_map_url(event.venue), url=get_url(f"/events/{event.route}") if event.is_published and event.route else None, banner_url=get_url(event.banner_image) if event.banner_image else None, diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 782e937c..945500ac 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -15,6 +15,7 @@ "banner_image", "host", "venue", + "venue_name", "meeting_link", "hosted_by_section", "co_hosts", @@ -505,11 +506,11 @@ "label": "Card Image" }, { + "description": "Look of this event's public page. Leave empty to use the default from Buzz Settings.", "fieldname": "theme", "fieldtype": "Link", "label": "Theme", - "options": "Buzz Theme", - "description": "Look of this event's public page. Leave empty to use the default from Buzz Settings." + "options": "Buzz Theme" }, { "default": "1", @@ -590,6 +591,14 @@ { "fieldname": "column_break_rgkz", "fieldtype": "Column Break" + }, + { + "fetch_from": "venue.venue_name", + "fieldname": "venue_name", + "fieldtype": "Data", + "hidden": 1, + "label": "Venue Name", + "read_only": 1 } ], "grid_page_length": 50, @@ -667,7 +676,7 @@ "link_fieldname": "event" } ], - "modified": "2026-09-25 12:00:00.000000", + "modified": "2026-09-30 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 c23b70dc..ebecd7d6 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -102,6 +102,7 @@ class BuzzEvent(Document): time_zone_label: DF.Data | None title: DF.Data venue: DF.Link | None + venue_name: DF.Data | None # end: auto-generated types def before_insert(self): diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index 7f67f4c1..fbd546f6 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -185,11 +185,10 @@ def _make_team(self, team_name: str) -> str: return create_owned_team(team_name, owner) def _make_venue(self, name: str, team: str | None) -> str: - """Event Venue is autonamed by prompt, so the docname is the venue's own name.""" venue = frappe.get_doc( { "doctype": "Event Venue", - "__newname": name, + "venue_name": name, "address": "1 Test Street", "team": team, } diff --git a/buzz/events/doctype/buzz_team/test_buzz_team.py b/buzz/events/doctype/buzz_team/test_buzz_team.py index 57d0e136..a2554a67 100644 --- a/buzz/events/doctype/buzz_team/test_buzz_team.py +++ b/buzz/events/doctype/buzz_team/test_buzz_team.py @@ -136,7 +136,7 @@ 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 Venue": {"venue_name": f"Venue {suffix}", "address": "somewhere"}, "Event Host": {"host_name": f"Host {suffix}"}, "Event Template": {"template_name": f"Template {suffix}"}, "Buzz Campaign": {"name": f"Campaign {suffix}", "title": suffix, "description": "why"}, diff --git a/buzz/events/doctype/event_venue/event_venue.json b/buzz/events/doctype/event_venue/event_venue.json index f9b461bc..051c58be 100644 --- a/buzz/events/doctype/event_venue/event_venue.json +++ b/buzz/events/doctype/event_venue/event_venue.json @@ -1,11 +1,12 @@ { "actions": [], "allow_rename": 1, - "autoname": "prompt", + "autoname": "hash", "creation": "2025-07-19 11:35:15.182035", "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "venue_name", "team", "address", "column_break_kqaw", @@ -18,6 +19,13 @@ "location" ], "fields": [ + { + "fieldname": "venue_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Venue Name", + "reqd": 1 + }, { "fieldname": "address", "fieldtype": "Small Text", @@ -82,11 +90,11 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-09-08 12:00:00.000000", + "modified": "2026-09-30 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Event Venue", - "naming_rule": "Set by user", + "naming_rule": "Random", "owner": "Administrator", "permissions": [ { @@ -117,7 +125,10 @@ ], "quick_entry": 1, "row_format": "Dynamic", + "search_fields": "venue_name, address", + "show_title_field_in_link": 1, "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "venue_name" } diff --git a/buzz/events/doctype/event_venue/event_venue.py b/buzz/events/doctype/event_venue/event_venue.py index 6e79c24c..afcc467e 100644 --- a/buzz/events/doctype/event_venue/event_venue.py +++ b/buzz/events/doctype/event_venue/event_venue.py @@ -21,6 +21,7 @@ class EventVenue(Document): latitude: DF.Float longitude: DF.Float team: DF.Link | None + venue_name: DF.Data type: DF.Literal["Embed Google Maps", "Open Street Map"] # end: auto-generated types @@ -28,6 +29,13 @@ def validate(self): self.set_geojson_for_location() self.remove_fixed_dimensions_from_google_map_embed() + def on_update(self): + if self.get_doc_before_save() and self.has_value_changed("venue_name"): + # `modified` is the manage page's conflict check, so a relabel must not move it + frappe.db.set_value( + "Buzz Event", {"venue": self.name}, "venue_name", self.venue_name, update_modified=False + ) + def remove_fixed_dimensions_from_google_map_embed(self): if not self.google_maps_embed_code: return diff --git a/buzz/events/doctype/event_venue/test_event_venue.py b/buzz/events/doctype/event_venue/test_event_venue.py index 938e0864..e41c2311 100644 --- a/buzz/events/doctype/event_venue/test_event_venue.py +++ b/buzz/events/doctype/event_venue/test_event_venue.py @@ -1,20 +1,56 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -# import frappe +import frappe from frappe.tests import IntegrationTestCase -# 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 -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +from buzz.api.events.test_events import create_event +from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user +from buzz.patches.set_event_venue_name import execute as set_event_venue_name class IntegrationTestEventVenue(IntegrationTestCase): - """ - Integration tests for EventVenue. - Use this class for testing interactions between multiple components. - """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.team = create_owned_team("Venue Naming Team", create_user("venue-naming@example.com", "Owner")) - pass + def create_venue(self, venue_name: str): + return frappe.get_doc( + { + "doctype": "Event Venue", + "venue_name": venue_name, + "address": "1 Test Street", + "team": self.team, + } + ).insert(ignore_permissions=True) + + def test_name_is_random_and_venue_name_is_the_label(self): + venue = self.create_venue("Town Hall") + + self.assertNotEqual(venue.name, "Town Hall") + self.assertEqual(venue.get_title(), "Town Hall") + + def test_two_venues_can_share_a_venue_name(self): + self.assertNotEqual(self.create_venue("Shared Hall").name, self.create_venue("Shared Hall").name) + + def test_event_carries_the_venue_name_and_follows_a_change(self): + venue = self.create_venue("Old Hall") + event = create_event("Venue Name Event", self.team, venue=venue.name) + self.assertEqual(frappe.db.get_value("Buzz Event", event, "venue_name"), "Old Hall") + + venue.venue_name = "New Hall" + venue.save(ignore_permissions=True) + + self.assertEqual(frappe.db.get_value("Buzz Event", event, "venue_name"), "New Hall") + + def test_patch_copies_the_old_docname_into_venue_name(self): + venue = self.create_venue("Legacy Hall") + event = create_event("Legacy Venue Event", self.team, venue=venue.name) + frappe.db.set_value("Event Venue", venue.name, "venue_name", "", update_modified=False) + frappe.db.set_value("Buzz Event", event, "venue_name", "", update_modified=False) + + set_event_venue_name() + + self.assertEqual(frappe.db.get_value("Event Venue", venue.name, "venue_name"), venue.name) + self.assertEqual(frappe.db.get_value("Buzz Event", event, "venue_name"), venue.name) diff --git a/buzz/install.py b/buzz/install.py index 335e6090..f16cb13b 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -135,9 +135,14 @@ def setup_test_records(): test_category = frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert( ignore_if_duplicate=True ) - test_venue = frappe.get_doc( - {"doctype": "Event Venue", "name": "Test Venue", "address": "test", "team": admin_team} - ).insert(ignore_if_duplicate=True) + test_venue = ( + frappe.db.exists("Event Venue", {"venue_name": "Test Venue"}) + or frappe.get_doc( + {"doctype": "Event Venue", "venue_name": "Test Venue", "address": "test", "team": admin_team} + ) + .insert() + .name + ) test_host = frappe.get_doc( {"doctype": "Event Host", "host_name": "Test Host", "team": admin_team} ).insert(ignore_if_duplicate=True) @@ -150,7 +155,7 @@ def setup_test_records(): "doctype": "Buzz Event", "team": admin_team, "category": test_category.name, - "venue": test_venue.name, + "venue": test_venue, "host": test_host.name, "title": "Test Event", "route": "test-route", diff --git a/buzz/patches.txt b/buzz/patches.txt index 9c98aaa8..049e4b23 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -25,3 +25,4 @@ buzz.patches.migrate_sponsor_enquiry_forms buzz.patches.generate_event_share_images #30-09-2026 buzz.patches.make_ticket_qr_codes_private buzz.patches.make_sponsor_logos_public +buzz.patches.set_event_venue_name diff --git a/buzz/patches/set_event_venue_name.py b/buzz/patches/set_event_venue_name.py new file mode 100644 index 00000000..fb235926 --- /dev/null +++ b/buzz/patches/set_event_venue_name.py @@ -0,0 +1,15 @@ +import frappe + + +def execute(): + """Event Venue names used to be the docname. `venue_name` now carries the label.""" + venue = frappe.qb.DocType("Event Venue") + frappe.qb.update(venue).set(venue.venue_name, venue.name).where( + (venue.venue_name.isnull()) | (venue.venue_name == "") + ).run() + + event = frappe.qb.DocType("Buzz Event") + # Every venue still carries its label as the docname here, so the link value is the label. + frappe.qb.update(event).set(event.venue_name, event.venue).where( + (event.venue_name.isnull()) | (event.venue_name == "") + ).run() diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index cc3116db..66c6e0bd 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -278,7 +278,7 @@ def get_booking_email_args(self) -> dict: "doc": self, "event_doc": event_doc, "event_title": event_doc.title, - "venue": event_doc.venue, + "venue": event_doc.venue_name, "attendee_rows": self.get_attendee_email_rows(), "support_email": get_event_team_settings(self.event).support_email, } diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index d4fce9f7..7887ce00 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py @@ -112,7 +112,7 @@ def send_ticket_email(self, now: bool = False): return event_title, ticket_template, ticket_print_format, venue = frappe.get_cached_value( - "Buzz Event", self.event, ["title", "ticket_email_template", "ticket_print_format", "venue"] + "Buzz Event", self.event, ["title", "ticket_email_template", "ticket_print_format", "venue_name"] ) team_settings = get_event_team_settings(self.event) diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index 8777e926..603b8514 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -246,9 +246,11 @@ def venue(self) -> dict | None: venue = frappe.db.get_value( "Event Venue", self.event.venue, - ["name", "address", "type", "google_maps_embed_code", "latitude", "longitude"], + ["venue_name", "address", "type", "google_maps_embed_code", "latitude", "longitude"], as_dict=True, ) return ( - {"name": venue.name, "address": venue.address, "map_url": venue_map_url(venue)} if venue else None + {"name": venue.venue_name, "address": venue.address, "map_url": venue_map_url(venue)} + if venue + else None ) diff --git a/buzz/www/events.py b/buzz/www/events.py index f441040c..625a886b 100644 --- a/buzz/www/events.py +++ b/buzz/www/events.py @@ -23,7 +23,7 @@ LISTING_LIMIT = 60 FEATURED_LIMIT = 3 POPULAR_LIMIT = 8 -CARD_FIELDS = ["title", "route", "start_date", "medium", "venue", "card_image", "banner_image"] +CARD_FIELDS = ["title", "route", "start_date", "medium", "venue_name", "card_image", "banner_image"] TIME_FIELDS = ["start_time", "end_date", "end_time", "time_zone"] @@ -84,7 +84,7 @@ def event_card(event) -> dict: "url": f"/events/{event.route}", "date": format_day(event.start_date), "is_live": has_started(event) and not has_ended(event), - "place": _("Online") if event.medium == "Online" else event.venue, + "place": _("Online") if event.medium == "Online" else event.venue_name, "image": event.card_image or event.banner_image, } diff --git a/dashboard/src/components/BookingEventInfo.vue b/dashboard/src/components/BookingEventInfo.vue index 0e7ea550..a075f68d 100644 --- a/dashboard/src/components/BookingEventInfo.vue +++ b/dashboard/src/components/BookingEventInfo.vue @@ -41,7 +41,7 @@ {{ __("Venue") }} -

{{ venue.name }}

+

{{ venue.venue_name }}

{{ venue.address }}

diff --git a/dashboard/src/components/EventDetailsHeader.vue b/dashboard/src/components/EventDetailsHeader.vue index 41f01bf3..61509c9b 100644 --- a/dashboard/src/components/EventDetailsHeader.vue +++ b/dashboard/src/components/EventDetailsHeader.vue @@ -48,13 +48,13 @@ -
+

{{ __("Venue") }}

-

{{ eventDetails.venue }}

+

{{ eventDetails.venue_name }}

diff --git a/dashboard/src/components/dashboard/events/AddVenueDialog.vue b/dashboard/src/components/dashboard/events/AddVenueDialog.vue index ccf8e090..2ed0a3e5 100644 --- a/dashboard/src/components/dashboard/events/AddVenueDialog.vue +++ b/dashboard/src/components/dashboard/events/AddVenueDialog.vue @@ -32,19 +32,20 @@ async function submit() { if (invalid.value) return errorMessage.value = "" - try { - await createVenue.submit({ + const venue = await createVenue + .submit({ team: props.team, - name: name.value.trim(), + venue_name: name.value.trim(), address: address.value.trim(), }) - } catch (error) { - errorMessage.value = serverErrorMessage(error) - return - } + .catch((error) => { + errorMessage.value = serverErrorMessage(error) + return null + }) + if (!venue) return - toast.success(`${name.value.trim()} added`) - emit("created", name.value.trim()) + toast.success(`${venue.venue_name} added`) + emit("created", venue.name) isOpen.value = false } diff --git a/dashboard/src/components/dashboard/events/EventLocation.vue b/dashboard/src/components/dashboard/events/EventLocation.vue index 6a77f980..1bd99fc6 100644 --- a/dashboard/src/components/dashboard/events/EventLocation.vue +++ b/dashboard/src/components/dashboard/events/EventLocation.vue @@ -6,8 +6,7 @@ import ZoomLogo from "@/components/common/ZoomLogo.vue" import AddVenueDialog from "@/components/dashboard/events/AddVenueDialog.vue" import { loadVenues, venues } from "@/data/venues" -// A reserved value rather than a venue name. Event Venue is autonamed by prompt, so a -// venue really could be called "Zoom" — the sentinel keeps the two apart. +// A reserved value that no venue's `name` can take. const ZOOM = "__zoom__" // A page that asks for the medium separately picks a venue and nothing else, so the @@ -47,7 +46,9 @@ const selected = computed({ const unmatched = computed(() => { if (venues.loading) return false const text = query.value.trim().toLowerCase() - return Boolean(text) && !(venues.data ?? []).some((row) => row.name.toLowerCase().includes(text)) + return ( + Boolean(text) && !(venues.data ?? []).some((row) => row.venue_name.toLowerCase().includes(text)) + ) }) function addManually() { @@ -69,7 +70,7 @@ const options = computed(() => [ { group: "Venues", options: (venues.data ?? []).map((row) => ({ - label: row.name, + label: row.venue_name, description: row.address, value: row.name, })), diff --git a/dashboard/src/data/tickets.ts b/dashboard/src/data/tickets.ts index 25e9a4af..33d16d71 100644 --- a/dashboard/src/data/tickets.ts +++ b/dashboard/src/data/tickets.ts @@ -20,7 +20,7 @@ export function useMyTickets(event: () => string) { "event.start_date", "event.end_date", "event.start_time", - "event.venue", + "event.venue_name as venue", ], // Tickets are held by email address, which is the session id for everyone // except Administrator — hence the account email first. diff --git a/dashboard/src/data/venues.ts b/dashboard/src/data/venues.ts index de38d4c0..2a80ef2f 100644 --- a/dashboard/src/data/venues.ts +++ b/dashboard/src/data/venues.ts @@ -3,6 +3,7 @@ import { ref } from "vue" export interface Venue { name: string + venue_name: string address: string } @@ -14,7 +15,7 @@ const team = ref("") export const venues = useList({ doctype: "Event Venue", filters: () => ({ team: team.value }), - fields: ["name", "address"], + fields: ["name", "venue_name", "address"], orderBy: "modified desc", // One page holds every venue a team has; the picker does not page. limit: 1000, @@ -27,5 +28,5 @@ export function loadVenues(name: string) { return venues.reload() } -// Event Venue is autonamed by prompt, so the `name` sent is the venue's own. +// The server names the venue, so the insert answers with the `name` to select. export const createVenue = useDoctype("Event Venue").insert diff --git a/dashboard/src/pages/BookingsList.vue b/dashboard/src/pages/BookingsList.vue index ea59a7ce..3324a242 100644 --- a/dashboard/src/pages/BookingsList.vue +++ b/dashboard/src/pages/BookingsList.vue @@ -64,7 +64,7 @@ const bookings = useList({ "event", "event.title as event_title", "event.start_date", - "event.venue", + "event.venue_name as venue", "docstatus", "total_amount", "currency", diff --git a/dashboard/src/pages/TicketDetails.vue b/dashboard/src/pages/TicketDetails.vue index 487231d9..5460b7be 100644 --- a/dashboard/src/pages/TicketDetails.vue +++ b/dashboard/src/pages/TicketDetails.vue @@ -373,7 +373,7 @@ const ticketDetails = useCall({ : null, formatted_creation: dayjsLocal(data.doc.creation).format("MMMM DD, YYYY [at] h:mm A"), event_title: data.event?.title || "", - venue: data.event?.venue || "", + venue: data.event?.venue_name || "", description: data.event?.description || "", ticket_type_title: data.ticket_type?.title || data.doc.ticket_type, }, diff --git a/dashboard/src/types/Events/EventVenue.ts b/dashboard/src/types/Events/EventVenue.ts index 22b5a576..da2e66d3 100644 --- a/dashboard/src/types/Events/EventVenue.ts +++ b/dashboard/src/types/Events/EventVenue.ts @@ -9,6 +9,8 @@ export interface EventVenue { parentfield?: string parenttype?: string idx?: number + /** Venue Name : Data */ + venue_name: string /** Address : Small Text */ address: string /** Type : Select */ diff --git a/e2e/tests/manage-event.spec.ts b/e2e/tests/manage-event.spec.ts index 43b82d64..7425b7fe 100644 --- a/e2e/tests/manage-event.spec.ts +++ b/e2e/tests/manage-event.spec.ts @@ -98,10 +98,8 @@ test.describe("Switching an event's medium", () => { test.beforeEach(async ({ page, request }) => { const team = await ensureTestTeam(request) - // Event Venue is autonamed by prompt, so the docname is the venue's own name. - const venue = `E2E Venue ${Date.now()}` - await createDoc(request, "Event Venue", { - __newname: venue, + const venue = await createDoc<{ name: string }>(request, "Event Venue", { + venue_name: `E2E Venue ${Date.now()}`, address: "1 Test Street", team, }) @@ -112,7 +110,7 @@ test.describe("Switching an event's medium", () => { start_date: "2030-01-01", start_time: "09:00:00", end_time: "17:00:00", - venue, + venue: venue.name, }, }) eventId = String(event.name) From b105e7f8ef9569061889e08de7d1d459b3698245 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 30 Sep 2026 19:04:25 +0530 Subject: [PATCH 02/20] feat(maps): search Google Maps places from the venue picker Buzz Settings gains a Map Integration tab with a Google Maps switch and two keys: a secret Places key used from the server, and a public embed key for the event page map, which nothing reads yet. The tab sits before Communications so custom fields anchored to the end of the form stay where they were. buzz.api.maps.search_places proxies Places Autocomplete for anyone who can add venues, so the Places key never reaches the browser. The venue picker shows the results in a Google Maps group under the saved venues once three characters are typed. Picking a result does nothing yet. The switch reaches the dashboard as google_place_search_enabled in the boot data. The Vite dev server renders no boot data, so in development the dashboard now fetches it from get_context_for_dev. With the switch off or no Places key, the picker sends no request and looks as it did. --- buzz/api/maps/__init__.py | 12 +++ buzz/api/maps/exceptions.py | 18 ++++ buzz/api/maps/schemas.py | 7 ++ buzz/api/maps/services.py | 56 ++++++++++++ buzz/api/maps/test_maps.py | 87 +++++++++++++++++++ .../doctype/buzz_settings/buzz_settings.json | 43 ++++++++- .../doctype/buzz_settings/buzz_settings.py | 3 + buzz/www/dashboard.py | 2 + .../dashboard/events/EventLocation.vue | 21 ++++- dashboard/src/data/venues.ts | 46 +++++++++- dashboard/src/global.d.ts | 1 + dashboard/src/main.ts | 8 ++ 12 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 buzz/api/maps/__init__.py create mode 100644 buzz/api/maps/exceptions.py create mode 100644 buzz/api/maps/schemas.py create mode 100644 buzz/api/maps/services.py create mode 100644 buzz/api/maps/test_maps.py diff --git a/buzz/api/maps/__init__.py b/buzz/api/maps/__init__.py new file mode 100644 index 00000000..20833068 --- /dev/null +++ b/buzz/api/maps/__init__.py @@ -0,0 +1,12 @@ +import frappe +from frappe.rate_limiter import rate_limit + +from buzz.api.maps import services +from buzz.api.maps.schemas import PlacePrediction + + +@frappe.whitelist() +@rate_limit(limit=120, seconds=60) +def search_places(query: str, session_token: str) -> list[PlacePrediction]: + """Google Maps places matching what the organiser typed into the venue picker.""" + return services.GooglePlaces().search(query, session_token) diff --git a/buzz/api/maps/exceptions.py b/buzz/api/maps/exceptions.py new file mode 100644 index 00000000..43873b7e --- /dev/null +++ b/buzz/api/maps/exceptions.py @@ -0,0 +1,18 @@ +from frappe import _lt + +from buzz.api.exceptions import BuzzAPIError, NotPermitted + + +class PlaceSearchNotEnabled(BuzzAPIError): + title = _lt("Place Search Not Available") + message = _lt("Google Maps place search is not set up on this site.") + + +class PlaceSearchFailed(BuzzAPIError): + title = _lt("Place Search Failed") + message = _lt("Google Maps could not be reached. Add the venue manually instead.") + + +class CannotAddVenues(NotPermitted): + title = _lt("Not Permitted") + message = _lt("You cannot add venues.") diff --git a/buzz/api/maps/schemas.py b/buzz/api/maps/schemas.py new file mode 100644 index 00000000..b4f7b209 --- /dev/null +++ b/buzz/api/maps/schemas.py @@ -0,0 +1,7 @@ +from buzz.api.schemas import APIResponse + + +class PlacePrediction(APIResponse): + place_id: str + name: str + address: str | None = None diff --git a/buzz/api/maps/services.py b/buzz/api/maps/services.py new file mode 100644 index 00000000..7aaa3386 --- /dev/null +++ b/buzz/api/maps/services.py @@ -0,0 +1,56 @@ +import frappe +import requests + +from buzz.api.maps.exceptions import CannotAddVenues, PlaceSearchFailed, PlaceSearchNotEnabled +from buzz.api.maps.schemas import PlacePrediction + +AUTOCOMPLETE_URL = "https://places.googleapis.com/v1/places:autocomplete" +REQUEST_TIMEOUT_SECONDS = 5 + + +def place_search_enabled() -> bool: + settings = frappe.get_cached_doc("Buzz Settings") + return bool(settings.google_maps_enabled and settings.google_places_api_key) + + +class GooglePlaces: + """Google Places calls made with the site's secret key, for someone who can add venues.""" + + def __init__(self): + if not frappe.has_permission("Event Venue", "create"): + CannotAddVenues.throw() + if not place_search_enabled(): + PlaceSearchNotEnabled.throw() + self.api_key = frappe.get_cached_doc("Buzz Settings").get_password("google_places_api_key") + + def search(self, query: str, session_token: str) -> list[PlacePrediction]: + if not query.strip(): + return [] + data = self.post(AUTOCOMPLETE_URL, {"input": query, "sessionToken": session_token}) + return [ + prediction_of(suggestion["placePrediction"]) + for suggestion in data.get("suggestions", []) + if "placePrediction" in suggestion + ] + + def post(self, url: str, body: dict) -> dict: + try: + response = requests.post( + url, json=body, headers={"X-Goog-Api-Key": self.api_key}, timeout=REQUEST_TIMEOUT_SECONDS + ) + response.raise_for_status() + except requests.RequestException as error: + # Google's answer names the cause (disabled API, key restriction, billing); it never holds the key + answer = error.response.text if error.response is not None else str(error) + frappe.log_error("Google Places request failed", answer) + PlaceSearchFailed.throw() + return response.json() + + +def prediction_of(place: dict) -> PlacePrediction: + text = place.get("structuredFormat", {}) + return PlacePrediction( + place_id=place["placeId"], + name=text.get("mainText", {}).get("text") or place["text"]["text"], + address=text.get("secondaryText", {}).get("text"), + ) diff --git a/buzz/api/maps/test_maps.py b/buzz/api/maps/test_maps.py new file mode 100644 index 00000000..4ee754ab --- /dev/null +++ b/buzz/api/maps/test_maps.py @@ -0,0 +1,87 @@ +from unittest.mock import Mock, patch + +import frappe +import requests +from frappe.tests import IntegrationTestCase + +from buzz.api.maps import search_places +from buzz.api.maps.exceptions import CannotAddVenues, PlaceSearchFailed, PlaceSearchNotEnabled +from buzz.api.maps.services import place_search_enabled +from buzz.events.doctype.buzz_team.test_buzz_team import create_user + +SUGGESTIONS = { + "suggestions": [ + { + "placePrediction": { + "placeId": "place-1", + "text": {"text": "Nehru Centre, Worli, Mumbai"}, + "structuredFormat": { + "mainText": {"text": "Nehru Centre"}, + "secondaryText": {"text": "Worli, Mumbai"}, + }, + } + }, + {"queryPrediction": {"text": {"text": "nehru centre events"}}}, + ] +} + + +def configure_google_maps(enabled: int = 1, places_key: str | None = "secret-places-key") -> None: + settings = frappe.get_doc("Buzz Settings") + settings.google_maps_enabled = enabled + settings.google_places_api_key = places_key + settings.save() + + +def google_answers(payload: dict) -> Mock: + return Mock(json=Mock(return_value=payload), raise_for_status=Mock()) + + +class TestSearchPlaces(IntegrationTestCase): + def setUp(self): + frappe.set_user("Administrator") + self.addCleanup(frappe.set_user, "Administrator") + self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + configure_google_maps() + + @patch("buzz.api.maps.services.requests.post") + def test_maps_suggestions_to_predictions_with_the_secret_key(self, post): + post.return_value = google_answers(SUGGESTIONS) + + places = [place.__json__() for place in search_places("nehru", "token-1")] + + self.assertEqual( + places, [{"place_id": "place-1", "name": "Nehru Centre", "address": "Worli, Mumbai"}] + ) + self.assertEqual(post.call_args.kwargs["headers"], {"X-Goog-Api-Key": "secret-places-key"}) + self.assertEqual(post.call_args.kwargs["json"], {"input": "nehru", "sessionToken": "token-1"}) + + @patch("buzz.api.maps.services.requests.post") + def test_blank_query_does_not_call_google(self, post): + self.assertEqual(search_places(" ", "token-1"), []) + post.assert_not_called() + + def test_disabled_switch_turns_search_off(self): + configure_google_maps(enabled=0) + + self.assertFalse(place_search_enabled()) + with self.assertRaises(PlaceSearchNotEnabled): + search_places("nehru", "token-1") + + def test_missing_key_turns_search_off(self): + configure_google_maps(places_key=None) + + self.assertFalse(place_search_enabled()) + with self.assertRaises(PlaceSearchNotEnabled): + search_places("nehru", "token-1") + + @patch("buzz.api.maps.services.requests.post", side_effect=requests.ConnectionError) + def test_google_failure_is_a_named_error(self, post): + with self.assertRaises(PlaceSearchFailed): + search_places("nehru", "token-1") + + def test_someone_who_cannot_add_venues_is_refused(self): + frappe.set_user(create_user("no-venues@example.com", "Attendee")) + + with self.assertRaises(CannotAddVenues): + search_places("nehru", "token-1") diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.json b/buzz/events/doctype/buzz_settings/buzz_settings.json index c164ad70..761f4d93 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.json +++ b/buzz/events/doctype/buzz_settings/buzz_settings.json @@ -28,6 +28,12 @@ "login_tab", "login_banner_section", "login_banner", + "map_integration_tab", + "google_maps_section", + "google_maps_enabled", + "column_break_google_maps", + "google_places_api_key", + "google_maps_embed_api_key", "communications_tab", "ticketing_emails_section", "default_ticket_email_template", @@ -255,13 +261,48 @@ "fieldname": "success_section", "fieldtype": "Section Break", "label": "Success" + }, + { + "fieldname": "map_integration_tab", + "fieldtype": "Tab Break", + "label": "Map Integration" + }, + { + "fieldname": "google_maps_section", + "fieldtype": "Section Break", + "label": "Google Maps" + }, + { + "default": "0", + "description": "Search Google Maps when adding a venue, and show a Google map on event pages.", + "fieldname": "google_maps_enabled", + "fieldtype": "Check", + "label": "Enable Google Maps" + }, + { + "depends_on": "eval:doc.google_maps_enabled", + "description": "Secret key for place search. In Google Cloud Console enable Places API (New) and restrict this key to it. It is only used from the server.", + "fieldname": "google_places_api_key", + "fieldtype": "Password", + "label": "Places API Key" + }, + { + "fieldname": "column_break_google_maps", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.google_maps_enabled", + "description": "Public key for the map on event pages. In Google Cloud Console enable Maps Embed API and restrict this key to it and to this site's address (HTTP referrer).", + "fieldname": "google_maps_embed_api_key", + "fieldtype": "Data", + "label": "Maps Embed API Key" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-09-25 12:00:00.000000", + "modified": "2026-09-30 13:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Settings", diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.py b/buzz/events/doctype/buzz_settings/buzz_settings.py index 12134213..0cadad25 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.py +++ b/buzz/events/doctype/buzz_settings/buzz_settings.py @@ -29,6 +29,9 @@ class BuzzSettings(Document): event_proposal_banner_title: DF.Data | None event_proposal_success_message: DF.MarkdownEditor | None event_proposal_success_title: DF.Data | None + google_maps_embed_api_key: DF.Data | None + google_maps_enabled: DF.Check + google_places_api_key: DF.Password | None login_banner: DF.MarkdownEditor | None show_hosting_banner: DF.Check support_email: DF.Data | None diff --git a/buzz/www/dashboard.py b/buzz/www/dashboard.py index 7e09ee30..ab75980e 100644 --- a/buzz/www/dashboard.py +++ b/buzz/www/dashboard.py @@ -6,6 +6,7 @@ from frappe.utils import get_system_timezone from buzz import telemetry +from buzz.api.maps.services import place_search_enabled no_cache = 1 @@ -36,5 +37,6 @@ def get_boot(): "site_name": frappe.local.site, "read_only_mode": frappe.flags.read_only, "system_timezone": get_system_timezone(), + "google_place_search_enabled": place_search_enabled(), } ) diff --git a/dashboard/src/components/dashboard/events/EventLocation.vue b/dashboard/src/components/dashboard/events/EventLocation.vue index 1bd99fc6..0bb64073 100644 --- a/dashboard/src/components/dashboard/events/EventLocation.vue +++ b/dashboard/src/components/dashboard/events/EventLocation.vue @@ -4,7 +4,7 @@ import { computed, ref, watch } from "vue" import ZoomLogo from "@/components/common/ZoomLogo.vue" import AddVenueDialog from "@/components/dashboard/events/AddVenueDialog.vue" -import { loadVenues, venues } from "@/data/venues" +import { loadVenues, usePlaceSearch, venues } from "@/data/venues" // A reserved value that no venue's `name` can take. const ZOOM = "__zoom__" @@ -24,6 +24,7 @@ const isAdding = ref(false) const suggestedName = ref("") const isOpen = ref(false) const query = ref("") +const placeSearch = usePlaceSearch(query, isOpen) watch( () => props.team, @@ -66,6 +67,21 @@ function createZoomMeeting() { // selected, because the trigger reads its label from a matching option. const zoomOption = { label: "Create Zoom meeting", value: ZOOM, icon: "lucide-video" } +// Custom rows: Google already matched them to the query, and a place is not a value +// the picker can hold until it has been saved as a venue. +const placeOptions = computed(() => + placeSearch.places.map((place) => ({ + type: "custom" as const, + key: place.place_id, + label: place.name, + description: place.address ?? undefined, + icon: "lucide-map-pin", + condition: () => true, + keepOpen: true, + onClick: () => {}, + })), +) + const options = computed(() => [ { group: "Venues", @@ -75,6 +91,7 @@ const options = computed(() => [ value: row.name, })), }, + ...(placeSearch.places.length ? [{ group: "Google Maps", options: placeOptions.value }] : []), ...(zoomMeeting.value ? [{ group: "Virtual", options: [zoomOption] }] : []), ]) @@ -97,7 +114,7 @@ async function onVenueCreated(name: string) { class="w-full" :loading="venues.loading" :disabled="disabled" - :error="error" + :error="error || placeSearch.error" @keydown.enter.prevent="unmatched && addManually()" > +
+ + +
+ + + + + + + + +
+ +
+
+
+ +
+ diff --git a/dashboard/src/components/dashboard/events/location/LocationDialog.vue b/dashboard/src/components/dashboard/events/location/LocationDialog.vue index c6566d6a..36d3810f 100644 --- a/dashboard/src/components/dashboard/events/location/LocationDialog.vue +++ b/dashboard/src/components/dashboard/events/location/LocationDialog.vue @@ -20,8 +20,11 @@ interface Preview { use: () => Promise | string } -// Zoom is booked only when the event is created, so a page editing one leaves it out. -const props = withDefaults(defineProps<{ team: string; showZoom?: boolean }>(), { showZoom: true }) +// Pages that book Zoom another way, or cannot book it, leave the Zoom option out. +const props = withDefaults(defineProps<{ team: string; showZoom?: boolean; title?: string }>(), { + showZoom: true, + title: "Add location", +}) const isOpen = defineModel({ required: true }) const emit = defineEmits<{ picked: [venue: string]; zoom: [] }>() @@ -96,11 +99,7 @@ function pickZoom() {