diff --git a/buzz/api/booking/__init__.py b/buzz/api/booking/__init__.py
index cc2eaad9..0a56ffc3 100644
--- a/buzz/api/booking/__init__.py
+++ b/buzz/api/booking/__init__.py
@@ -1,7 +1,7 @@
import frappe
from frappe.rate_limiter import rate_limit
-from buzz.api.booking import coupons, details, event_data, guests
+from buzz.api.booking import coupons, details, event_data, guests, my_bookings
from buzz.api.booking.schemas import (
BookingConfirmationResponse,
BookingDetailsResponse,
@@ -9,6 +9,7 @@
BookingSummary,
EventBookingDataResponse,
FreeBookingResponse,
+ MyBooking,
OfflineBookingResponse,
PaymentLinkResponse,
)
@@ -45,6 +46,11 @@ def get_booking_details(booking_id: str) -> BookingDetailsResponse:
return details.build_booking_details(booking_id)
+@frappe.whitelist()
+def get_my_bookings() -> list[MyBooking]:
+ return my_bookings.my_bookings()
+
+
@frappe.whitelist()
def get_booking_summary(booking_id: str) -> BookingSummary:
"""One booking as a receipt, for anyone allowed to read it."""
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..63c12f3e 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.get_venue_name(),
"medium": event_doc.medium,
"category": event_doc.category,
"banner_image": event_doc.banner_image,
diff --git a/buzz/api/booking/my_bookings.py b/buzz/api/booking/my_bookings.py
new file mode 100644
index 00000000..e3fd5185
--- /dev/null
+++ b/buzz/api/booking/my_bookings.py
@@ -0,0 +1,33 @@
+import frappe
+
+from buzz.api.booking.schemas import MyBooking
+from buzz.events.doctype.event_venue.event_venue import set_venue_names
+
+MY_BOOKING_FIELDS = [
+ "name",
+ "event",
+ "event.title as event_title",
+ "event.start_date",
+ "event.venue as venue",
+ "docstatus",
+ "total_amount",
+ "currency",
+ "creation",
+ "status",
+ {"attendees": ["ticket_type"]},
+]
+# The list has no paging; this is the page the dashboard showed before it moved here.
+MY_BOOKINGS_LIMIT = 20
+
+
+def my_bookings() -> list[MyBooking]:
+ """The session user's submitted and cancelled bookings, newest first."""
+ rows = frappe.get_list(
+ "Event Booking",
+ fields=MY_BOOKING_FIELDS,
+ filters={"user": frappe.session.user, "docstatus": ["!=", 0]},
+ order_by="creation desc",
+ limit=MY_BOOKINGS_LIMIT,
+ )
+ set_venue_names(rows)
+ return [MyBooking(**row) for row in rows]
diff --git a/buzz/api/booking/schemas.py b/buzz/api/booking/schemas.py
index bffb2974..835103ba 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
@@ -177,3 +177,18 @@ class BookingSummary(APIResponse):
tax_label: str | None
tax_percentage: float
total_amount: float
+
+
+class MyBooking(APIResponse):
+ name: str
+ event: str | None
+ event_title: str | None
+ start_date: date | None
+ venue: str | None
+ docstatus: int
+ total_amount: float
+ currency: str | None
+ creation: datetime
+ status: str | None
+ # Only counted by the list; each row is a plain ticket_type pair.
+ attendees: list
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..1bce4694 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.get_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/__init__.py b/buzz/api/events/__init__.py
index 0b62a1e6..56559343 100644
--- a/buzz/api/events/__init__.py
+++ b/buzz/api/events/__init__.py
@@ -1,6 +1,6 @@
import frappe
-from buzz.api.events import services
+from buzz.api.events import services, zoom
from buzz.api.events.schemas import (
CreatedEvent,
EventDetail,
@@ -85,6 +85,11 @@ def create_event(event: NewEvent) -> CreatedEvent:
return services.create_event(event)
+@frappe.whitelist(methods=["POST"])
+def convert_to_zoom_meeting(event: str) -> None:
+ zoom.convert_to_zoom_meeting(event)
+
+
@frappe.whitelist(methods=["POST"])
def add_co_host(
event: str,
diff --git a/buzz/api/events/exceptions.py b/buzz/api/events/exceptions.py
index 2b950f75..067ec63f 100644
--- a/buzz/api/events/exceptions.py
+++ b/buzz/api/events/exceptions.py
@@ -21,3 +21,8 @@ class CannotCreateEvents(NotPermitted):
class ZoomNotAvailable(BuzzAPIError):
title = _lt("Zoom Not Available")
message = _lt("Zoom is not set up on this site, so a Zoom meeting cannot be created.")
+
+
+class EventEnded(BuzzAPIError):
+ title = _lt("Event Has Ended")
+ message = _lt("You cannot convert events that have ended to Zoom meetings.")
diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py
index 3f7b9d31..63f108b4 100644
--- a/buzz/api/events/services.py
+++ b/buzz/api/events/services.py
@@ -56,6 +56,7 @@ def events_for(user: str, filters: MyEventFilters | None = None) -> list[MyEvent
event = frappe.qb.DocType("Buzz Event")
ticket = frappe.qb.DocType("Event Ticket")
team = frappe.qb.DocType("Buzz Team")
+ venue = frappe.qb.DocType("Event Venue")
hosted = event.team.isin(my_teams(user))
ticketed = event.name.isin(
@@ -69,6 +70,8 @@ def events_for(user: str, filters: MyEventFilters | None = None) -> list[MyEvent
# Left join: an event predating the team backfill still belongs in the feed.
.left_join(team)
.on(team.name == event.team)
+ .left_join(venue)
+ .on(venue.name == event.venue)
.select(
event.name,
event.title,
@@ -77,7 +80,7 @@ def events_for(user: str, filters: MyEventFilters | None = None) -> list[MyEvent
event.end_date,
event.start_time,
event.end_time,
- event.venue,
+ venue.venue_name.as_("venue"),
event.medium,
event.banner_image,
event.team,
@@ -379,7 +382,7 @@ def event_guests(
start_date=doc.start_date,
start_time=doc.start_time,
end_date=doc.end_date,
- venue=doc.venue,
+ venue=doc.get_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/events/test_zoom.py b/buzz/api/events/test_zoom.py
new file mode 100644
index 00000000..776227e0
--- /dev/null
+++ b/buzz/api/events/test_zoom.py
@@ -0,0 +1,100 @@
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+from frappe.utils import add_days, today
+
+from buzz.api.events import convert_to_zoom_meeting
+from buzz.api.events.exceptions import CannotManageEvent, EventEnded
+from buzz.api.events.test_events import create_event
+from buzz.events.doctype.buzz_event.buzz_event import BuzzEvent
+from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user
+from buzz.test_permissions import add_member
+from buzz.utils import is_app_installed
+
+
+def book_meeting(event: BuzzEvent):
+ """The real booking calls Zoom on insert, so the row goes straight to the table."""
+ meeting = frappe.get_doc({"doctype": "Zoom Meeting", "name": "ZOOM-TEST", "title": event.title})
+ meeting.db_insert()
+ event.db_set("zoom_meeting", meeting.name)
+
+
+class TestConvertToZoomMeeting(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ frappe.set_user("Administrator")
+ cls.owner = create_user("zoom-owner@example.com", "Owner")
+ cls.viewer = create_user("zoom-viewer@example.com", "Viewer")
+ cls.team = create_owned_team("Zoom Team", cls.owner)
+ add_member(cls.team, cls.viewer, "Viewer")
+
+ def setUp(self):
+ if not is_app_installed("zoom_integration"):
+ self.skipTest("zoom_integration is not installed on this site")
+ frappe.set_user("Administrator")
+ venue = frappe.get_doc(
+ {
+ "doctype": "Event Venue",
+ "venue_name": "Zoom Hall",
+ "address": "1 Zoom Street",
+ "team": self.team,
+ }
+ ).insert(ignore_permissions=True)
+ self.event = create_event("Zoom Event", self.team, medium="In Person", venue=venue.name)
+ frappe.set_user(self.owner)
+ self.addCleanup(frappe.set_user, "Administrator")
+
+ def test_converts_and_reuses_the_linked_meeting(self):
+ with patch.object(
+ BuzzEvent, "create_meeting_on_zoom", autospec=True, side_effect=book_meeting
+ ) as book:
+ convert_to_zoom_meeting(self.event)
+ frappe.db.set_value("Buzz Event", self.event, "medium", "In Person")
+ convert_to_zoom_meeting(self.event)
+
+ event = frappe.get_doc("Buzz Event", self.event)
+ self.assertEqual(event.medium, "Online")
+ self.assertFalse(event.venue)
+ self.assertEqual(event.zoom_meeting, "ZOOM-TEST")
+ book.assert_called_once()
+
+ def test_a_zoom_failure_changes_nothing(self):
+ # The request rolls back on the error; the savepoint stands in for it.
+ frappe.db.savepoint("zoom_conversion")
+ with patch.object(BuzzEvent, "create_meeting_on_zoom", side_effect=Exception("Zoom is down")):
+ with self.assertRaises(Exception):
+ convert_to_zoom_meeting(self.event)
+ frappe.db.rollback(save_point="zoom_conversion")
+
+ event = frappe.get_doc("Buzz Event", self.event)
+ self.assertEqual(event.medium, "In Person")
+ self.assertTrue(event.venue)
+
+ def test_a_failed_save_books_no_meeting(self):
+ with (
+ patch.object(BuzzEvent, "validate", side_effect=frappe.ValidationError),
+ patch.object(BuzzEvent, "create_meeting_on_zoom") as book,
+ ):
+ with self.assertRaises(frappe.ValidationError):
+ convert_to_zoom_meeting(self.event)
+
+ book.assert_not_called()
+
+ def test_a_viewer_cannot_convert(self):
+ frappe.set_user(self.viewer)
+
+ with self.assertRaises(CannotManageEvent):
+ convert_to_zoom_meeting(self.event)
+
+ def test_an_ended_event_cannot_be_converted(self):
+ yesterday = add_days(today(), -1)
+ frappe.db.set_value("Buzz Event", self.event, {"start_date": yesterday, "end_date": None})
+
+ with patch.object(BuzzEvent, "create_meeting_on_zoom") as book:
+ with self.assertRaises(EventEnded):
+ convert_to_zoom_meeting(self.event)
+
+ book.assert_not_called()
+ self.assertEqual(frappe.db.get_value("Buzz Event", self.event, "medium"), "In Person")
diff --git a/buzz/api/events/zoom.py b/buzz/api/events/zoom.py
new file mode 100644
index 00000000..c0bd4d25
--- /dev/null
+++ b/buzz/api/events/zoom.py
@@ -0,0 +1,27 @@
+from buzz.api.events.exceptions import EventEnded, ZoomNotAvailable
+from buzz.api.events.services import manageable_event
+from buzz.utils import is_app_installed
+
+
+def convert_to_zoom_meeting(event: str) -> None:
+ """Make the event virtual on a Zoom meeting, or leave it as it was.
+
+ The event is saved before the meeting is booked, so a failed save never leaves a meeting
+ behind on Zoom, and a Zoom failure rolls the save back with the request.
+ A meeting still linked from an earlier conversion is reused rather than booked twice.
+ """
+ # buzz.www.events imports the event page, which imports this package.
+ from buzz.www.events import has_ended
+
+ doc = manageable_event(event)
+ if not is_app_installed("zoom_integration"):
+ ZoomNotAvailable.throw()
+ if has_ended(doc):
+ EventEnded.throw()
+
+ doc.medium = "Online"
+ # The organiser's own link outranks Zoom's in `meeting_link_of`.
+ doc.meeting_link = None
+ doc.save()
+ if not doc.zoom_meeting:
+ doc.create_meeting_on_zoom()
diff --git a/buzz/api/forms/services.py b/buzz/api/forms/services.py
index 9bab540d..bead3c65 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.get_venue_name(),
medium=event.medium,
short_description=event.short_description,
)
diff --git a/buzz/api/maps/__init__.py b/buzz/api/maps/__init__.py
new file mode 100644
index 00000000..824d1d7f
--- /dev/null
+++ b/buzz/api/maps/__init__.py
@@ -0,0 +1,26 @@
+import frappe
+from frappe.rate_limiter import rate_limit
+
+from buzz.api.maps import services
+from buzz.api.maps.schemas import MapLinkLocation, 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)
+
+
+@frappe.whitelist(methods=["POST"])
+@rate_limit(limit=30, seconds=60)
+def add_place_as_venue(team: str, place_id: str, name: str, session_token: str) -> str:
+ """Save a place picked from the search as one of the team's venues, answering with its name."""
+ return services.GooglePlaces().save_as_venue(team, place_id, name, session_token)
+
+
+@frappe.whitelist()
+@rate_limit(limit=30, seconds=60)
+def locate_map_link(link: str) -> MapLinkLocation:
+ """Where a pasted Google Maps or OpenStreetMap link points, for the add-venue preview."""
+ return services.locate_map_link(link)
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..8846a89e
--- /dev/null
+++ b/buzz/api/maps/schemas.py
@@ -0,0 +1,17 @@
+from buzz.api.schemas import APIResponse
+
+
+class MapLinkLocation(APIResponse):
+ """What a pasted map link says about a place; every part may be missing."""
+
+ latitude: float | None = None
+ longitude: float | None = None
+ name: str | None = None
+ address: str | None = None
+ embed_url: str | None = None
+
+
+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..63144008
--- /dev/null
+++ b/buzz/api/maps/services.py
@@ -0,0 +1,100 @@
+from urllib.parse import quote
+
+import frappe
+import requests
+
+from buzz.api.maps.exceptions import CannotAddVenues, PlaceSearchFailed, PlaceSearchNotEnabled
+from buzz.api.maps.schemas import MapLinkLocation, PlacePrediction
+from buzz.events.doctype.event_venue.map_link import read_map_link
+from buzz.www.event.venue_map import google_maps_url
+
+AUTOCOMPLETE_URL = "https://places.googleapis.com/v1/places:autocomplete"
+DETAILS_URL = "https://places.googleapis.com/v1/places/{place_id}"
+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)
+
+
+def locate_map_link(link: str) -> MapLinkLocation:
+ """Read a pasted map link the way saving a venue would, without saving anything."""
+ if not frappe.has_permission("Event Venue", "create"):
+ CannotAddVenues.throw()
+ if embed_url := google_maps_url(link):
+ return MapLinkLocation(embed_url=embed_url)
+ place = read_map_link(link)
+ latitude, longitude = place.coordinates or (None, None)
+ return MapLinkLocation(latitude=latitude, longitude=longitude, name=place.name, address=place.address)
+
+
+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.request("POST", AUTOCOMPLETE_URL, json={"input": query, "sessionToken": session_token})
+ return [
+ prediction_of(suggestion["placePrediction"])
+ for suggestion in data.get("suggestions", [])
+ if "placePrediction" in suggestion
+ ]
+
+ def save_as_venue(self, team: str, place_id: str, name: str, session_token: str) -> str:
+ existing = frappe.db.exists("Event Venue", {"team": team, "google_place_id": place_id})
+ if existing:
+ frappe.has_permission("Event Venue", "read", existing, throw=True)
+ return existing
+ venue = frappe.get_doc(
+ {"doctype": "Event Venue", "team": team, "venue_name": name, "google_place_id": place_id}
+ )
+ # Checked before the billed Place Details call, not left to insert().
+ venue.check_permission("create")
+ venue.address = self.address_of(place_id, session_token) or name
+ return venue.insert().name
+
+ def address_of(self, place_id: str, session_token: str) -> str | None:
+ # Only the address is asked for: the name came with the prediction, and the place id
+ # is the one thing Google lets a site keep for good.
+ place = self.request(
+ "GET",
+ DETAILS_URL.format(place_id=quote(place_id, safe="")),
+ params={"sessionToken": session_token},
+ headers={"X-Goog-FieldMask": "id,formattedAddress"},
+ )
+ return place.get("formattedAddress")
+
+ def request(self, method: str, url: str, headers: dict | None = None, **options) -> dict:
+ try:
+ response = requests.request(
+ method,
+ url,
+ headers={"X-Goog-Api-Key": self.api_key, **(headers or {})},
+ timeout=REQUEST_TIMEOUT_SECONDS,
+ **options,
+ )
+ 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..6bb23e70
--- /dev/null
+++ b/buzz/api/maps/test_maps.py
@@ -0,0 +1,192 @@
+from unittest.mock import Mock, patch
+
+import frappe
+import requests
+from frappe.tests import IntegrationTestCase
+
+from buzz.api.maps import add_place_as_venue, locate_map_link, 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_owned_team, create_user
+from buzz.events.doctype.event_venue.test_event_venue import clear_map_link_cache
+
+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.request")
+ 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.args, ("POST", "https://places.googleapis.com/v1/places:autocomplete")
+ )
+ 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.request")
+ 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.request", 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")
+
+
+class TestAddPlaceAsVenue(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.owner = create_user("place-venue-owner@example.com", "Owner")
+ cls.team = create_owned_team("Place Venue Team", cls.owner)
+
+ 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()
+ frappe.set_user(self.owner)
+
+ @patch("buzz.api.maps.services.requests.request")
+ def test_saves_the_place_with_its_address_and_place_id(self, request):
+ request.return_value = google_answers(
+ {"id": "place-1", "formattedAddress": "Dr Annie Besant Rd, Worli"}
+ )
+
+ venue = frappe.get_doc(
+ "Event Venue", add_place_as_venue(self.team, "place-1", "Nehru Centre", "token-1")
+ )
+
+ self.assertEqual(
+ (venue.venue_name, venue.address, venue.google_place_id, venue.team),
+ ("Nehru Centre", "Dr Annie Besant Rd, Worli", "place-1", self.team),
+ )
+ self.assertFalse(venue.latitude or venue.longitude)
+ self.assertEqual(request.call_args.args, ("GET", "https://places.googleapis.com/v1/places/place-1"))
+ self.assertEqual(request.call_args.kwargs["params"], {"sessionToken": "token-1"})
+ self.assertEqual(request.call_args.kwargs["headers"]["X-Goog-FieldMask"], "id,formattedAddress")
+
+ @patch("buzz.api.maps.services.requests.request")
+ def test_a_place_the_team_already_has_is_reused_without_calling_google(self, request):
+ request.return_value = google_answers({"formattedAddress": "Somewhere"})
+ first = add_place_as_venue(self.team, "place-2", "Town Hall", "token-1")
+
+ self.assertEqual(add_place_as_venue(self.team, "place-2", "Town Hall", "token-2"), first)
+ request.assert_called_once()
+
+ @patch("buzz.api.maps.services.requests.request")
+ def test_place_id_cannot_redirect_the_google_call(self, request):
+ request.return_value = google_answers({})
+
+ add_place_as_venue(self.team, "../other?x=1", "Odd Place", "token-1")
+
+ self.assertEqual(
+ request.call_args.args[1], "https://places.googleapis.com/v1/places/..%2Fother%3Fx%3D1"
+ )
+
+ @patch("buzz.api.maps.services.requests.request")
+ def test_another_teams_member_cannot_add_to_the_team(self, request):
+ request.return_value = google_answers({"formattedAddress": "Somewhere"})
+ outsider = create_user("place-venue-outsider@example.com", "Outsider")
+ create_owned_team("Place Venue Other Team", outsider)
+ frappe.set_user(outsider)
+
+ with self.assertRaises(frappe.PermissionError):
+ add_place_as_venue(self.team, "place-3", "Not Mine", "token-1")
+ request.assert_not_called()
+
+
+class TestLocateMapLink(IntegrationTestCase):
+ def setUp(self):
+ clear_map_link_cache()
+ frappe.set_user("Administrator")
+ self.addCleanup(frappe.set_user, "Administrator")
+
+ def test_google_link_gives_coordinates_and_the_place_name(self):
+ link = "https://www.google.com/maps/place/Nehru+Centre/@18.99,72.81,17z/data=!3d18.9903!4d72.8174"
+
+ self.assertEqual(
+ locate_map_link(link).__json__(),
+ {
+ "latitude": 18.9903,
+ "longitude": 72.8174,
+ "name": "Nehru Centre",
+ "address": None,
+ "embed_url": None,
+ },
+ )
+
+ def test_embed_code_gives_the_embed_url(self):
+ embed = ''
+
+ self.assertEqual(locate_map_link(embed).embed_url, "https://www.google.com/maps/embed?pb=abc")
+
+ def test_unreadable_link_gives_nothing(self):
+ self.assertEqual(
+ locate_map_link("https://example.com/somewhere").__json__(),
+ {"latitude": None, "longitude": None, "name": None, "address": None, "embed_url": None},
+ )
+
+ def test_someone_who_cannot_add_venues_is_refused(self):
+ frappe.set_user(create_user("no-map-links@example.com", "Attendee"))
+
+ with self.assertRaises(CannotAddVenues):
+ locate_map_link("https://www.openstreetmap.org/#map=17/12.9/77.5")
diff --git a/buzz/api/proposals/services.py b/buzz/api/proposals/services.py
index aa0a87b6..0370375d 100644
--- a/buzz/api/proposals/services.py
+++ b/buzz/api/proposals/services.py
@@ -16,6 +16,7 @@
ProposalTrend,
StatusTotal,
)
+from buzz.events.doctype.event_venue.event_venue import set_venue_names
from buzz.permissions import derived_has_permission, has_team_access
# The columns behind one proposal card. The event ones come over a link hop, so a
@@ -28,7 +29,7 @@
"event.start_date",
"event.start_time",
"event.end_date",
- "event.venue",
+ "event.venue as venue",
"event.banner_image",
"event.allow_editing_talks_after_acceptance",
"status",
@@ -62,6 +63,7 @@ def my_proposals() -> list[ProposalListItem]:
)
speakers = speakers_by_proposal([row.name for row in rows])
+ set_venue_names(rows)
return [ProposalListItem(**row, speakers=speakers.get(row.name, [])) for row in rows]
diff --git a/buzz/api/sponsorships/services.py b/buzz/api/sponsorships/services.py
index 3aac4462..06468fa7 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.get_venue_name(),
route=event.route,
)
diff --git a/buzz/api/tickets/schemas.py b/buzz/api/tickets/schemas.py
index 23230788..cf741b2a 100644
--- a/buzz/api/tickets/schemas.py
+++ b/buzz/api/tickets/schemas.py
@@ -20,6 +20,7 @@ class TicketDetailsResponse(APIResponse):
doc: Any
add_ons: list[TicketAddOnDetail]
event: Any
+ venue: str | None
booking: Any | None
ticket_type: Any
can_transfer_ticket: bool
diff --git a/buzz/api/tickets/services.py b/buzz/api/tickets/services.py
index ed9c9a2e..6f4b214c 100644
--- a/buzz/api/tickets/services.py
+++ b/buzz/api/tickets/services.py
@@ -40,11 +40,13 @@ def details(self) -> TicketDetailsResponse:
TicketNotAccessible.throw()
event_id = ticket.event
+ event = frappe.get_cached_doc("Buzz Event", event_id)
zoom = self.zoom_registration()
return TicketDetailsResponse(
doc=ticket,
add_ons=self.add_on_details(event_id),
- event=frappe.get_cached_doc("Buzz Event", event_id),
+ event=event,
+ venue=event.get_venue_name(),
booking=self.booking_owned_by_user(),
ticket_type=frappe.get_cached_doc("Event Ticket Type", ticket.ticket_type),
can_transfer_ticket=windows.is_window_open(event_id, windows.TRANSFER),
diff --git a/buzz/api/tickets/test_tickets.py b/buzz/api/tickets/test_tickets.py
index 23debdda..08b179b2 100644
--- a/buzz/api/tickets/test_tickets.py
+++ b/buzz/api/tickets/test_tickets.py
@@ -39,6 +39,7 @@ def set_team_cutoffs(team: str, days: int):
"doc",
"add_ons",
"event",
+ "venue",
"booking",
"ticket_type",
"can_transfer_ticket",
diff --git a/buzz/emails.py b/buzz/emails.py
index 67ec8017..b8117099 100644
--- a/buzz/emails.py
+++ b/buzz/emails.py
@@ -1,4 +1,4 @@
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
import frappe
from frappe.utils import get_url
@@ -16,19 +16,28 @@ 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.get_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,
)
-def venue_map_url(venue_name: str | None) -> str | None:
- venue = venue_name and frappe.db.get_value(
- "Event Venue", venue_name, ["address", "latitude", "longitude"], as_dict=True
+def venue_map_url(venue: str | None) -> str | None:
+ venue = venue and frappe.db.get_value(
+ "Event Venue",
+ venue,
+ ["venue_name", "address", "latitude", "longitude", "google_place_id", "map_link"],
+ as_dict=True,
)
if not venue:
return None
+ # The organiser's own link wins. Pasted embed code is markup, not somewhere to go.
+ if urlparse(venue.map_link or "").scheme in ("http", "https"):
+ return venue.map_link
+ if venue.google_place_id:
+ place = {"api": 1, "query": venue.venue_name, "query_place_id": venue.google_place_id}
+ return f"https://www.google.com/maps/search/?{urlencode(place)}"
query = f"{venue.latitude},{venue.longitude}" if venue.latitude and venue.longitude else venue.address
return f"https://www.google.com/maps/search/?{urlencode({'api': 1, 'query': query})}" if query else None
diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json
index 782e937c..766fb93c 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.json
+++ b/buzz/events/doctype/buzz_event/buzz_event.json
@@ -505,11 +505,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",
@@ -667,7 +667,7 @@
"link_fieldname": "event"
}
],
- "modified": "2026-09-25 12:00:00.000000",
+ "modified": "2026-10-01 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..022c48bf 100644
--- a/buzz/events/doctype/buzz_event/buzz_event.py
+++ b/buzz/events/doctype/buzz_event/buzz_event.py
@@ -132,6 +132,9 @@ def clear_unused_location(self):
else:
self.meeting_link = None
+ def get_venue_name(self) -> str | None:
+ return frappe.get_cached_value("Event Venue", self.venue, "venue_name") if self.venue else None
+
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)
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_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/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..1f28a247 100644
--- a/buzz/events/doctype/event_venue/event_venue.json
+++ b/buzz/events/doctype/event_venue/event_venue.json
@@ -1,16 +1,19 @@
{
"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",
+ "map_link",
"address",
"column_break_kqaw",
"type",
"google_maps_embed_code",
+ "google_place_id",
"section_break_cexp",
"longitude",
"latitude",
@@ -18,12 +21,19 @@
"location"
],
"fields": [
+ {
+ "fieldname": "venue_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Venue Name",
+ "reqd": 1
+ },
{
"fieldname": "address",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Address",
- "reqd": 1
+ "mandatory_depends_on": "eval:!doc.map_link"
},
{
"fieldname": "location",
@@ -77,16 +87,31 @@
"label": "Team",
"options": "Buzz Team",
"search_index": 1
+ },
+ {
+ "description": "Set when the venue is added from Google Maps search.",
+ "fieldname": "google_place_id",
+ "fieldtype": "Data",
+ "label": "Google Place ID",
+ "no_copy": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "description": "A Google Maps or OpenStreetMap link. The venue's location is read from it when the venue is saved.",
+ "fieldname": "map_link",
+ "fieldtype": "Small Text",
+ "label": "Map Link"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2026-09-08 12:00:00.000000",
+ "modified": "2026-09-30 15:00:00.000000",
"modified_by": "Administrator",
"module": "Events",
"name": "Event Venue",
- "naming_rule": "Set by user",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
@@ -117,7 +142,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..0ac6ee32 100644
--- a/buzz/events/doctype/event_venue/event_venue.py
+++ b/buzz/events/doctype/event_venue/event_venue.py
@@ -4,8 +4,12 @@
import re
import frappe
+from frappe import _
from frappe.model.document import Document
+from buzz.events.doctype.event_venue.map_link import read_map_link
+from buzz.www.event.venue_map import google_maps_url
+
class EventVenue(Document):
# begin: auto-generated types
@@ -16,18 +20,41 @@ class EventVenue(Document):
if TYPE_CHECKING:
from frappe.types import DF
- address: DF.SmallText
+ address: DF.SmallText | None
google_maps_embed_code: DF.Code | None
+ google_place_id: DF.Data | None
latitude: DF.Float
longitude: DF.Float
+ map_link: DF.SmallText | None
team: DF.Link | None
+ venue_name: DF.Data
type: DF.Literal["Embed Google Maps", "Open Street Map"]
# end: auto-generated types
def validate(self):
+ self.set_location_from_map_link()
+ self.validate_address()
self.set_geojson_for_location()
self.remove_fixed_dimensions_from_google_map_embed()
+ def set_location_from_map_link(self):
+ if not self.map_link or not self.has_value_changed("map_link"):
+ return
+ if google_maps_url(self.map_link):
+ self.type = "Embed Google Maps"
+ self.google_maps_embed_code = self.map_link
+ return
+ place = read_map_link(self.map_link)
+ if place.coordinates:
+ self.type = "Open Street Map"
+ self.latitude, self.longitude = place.coordinates
+ self.address = self.address or place.address
+
+ def validate_address(self):
+ has_location = (self.latitude and self.longitude) or self.google_maps_embed_code
+ if not self.address and not has_location:
+ frappe.throw(_("Add an address, or a map link that shows where the venue is."))
+
def remove_fixed_dimensions_from_google_map_embed(self):
if not self.google_maps_embed_code:
return
@@ -54,3 +81,17 @@ def set_geojson_for_location(self):
],
}
self.location = frappe.as_json(self.location)
+
+
+def set_venue_names(rows: list) -> None:
+ """Swap each row's `venue` from the venue's random name to its label."""
+ venues = [row.venue for row in rows if row.venue]
+ names = {}
+ if venues:
+ names = dict(
+ frappe.get_all(
+ "Event Venue", filters={"name": ["in", venues]}, fields=["name", "venue_name"], as_list=True
+ )
+ )
+ for row in rows:
+ row.venue = names.get(row.venue)
diff --git a/buzz/events/doctype/event_venue/map_link.py b/buzz/events/doctype/event_venue/map_link.py
new file mode 100644
index 00000000..cb424312
--- /dev/null
+++ b/buzz/events/doctype/event_venue/map_link.py
@@ -0,0 +1,160 @@
+import re
+from typing import NamedTuple
+from urllib.parse import ParseResult, parse_qs, unquote_plus, urlparse
+
+import frappe
+import requests
+from frappe.utils import get_url
+
+GOOGLE_HOST = re.compile(r"(^|\.)google\.[a-z.]+$")
+GOOGLE_SHORT_LINK_HOSTS = {"maps.app.goo.gl", "goo.gl"}
+OPEN_STREET_MAP_HOSTS = {"www.openstreetmap.org", "openstreetmap.org", "www.osm.org", "osm.org"}
+NUMBER = r"(-?\d+(?:\.\d+)?)"
+# `!3d…!4d…` is the place itself; `@…` is only where the map happened to be centred.
+GOOGLE_PLACE = re.compile(rf"!3d{NUMBER}!4d{NUMBER}")
+GOOGLE_VIEW = re.compile(rf"@{NUMBER},{NUMBER}")
+GOOGLE_PLACE_NAME = re.compile(r"/maps/place/([^/@]+)")
+OPEN_STREET_MAP_VIEW = re.compile(rf"map=\d+/{NUMBER}/{NUMBER}")
+OPEN_STREET_MAP_OBJECT = re.compile(r"^/(node|way|relation)/(\d+)")
+OPEN_STREET_MAP_SHORT_LINK = re.compile(r"^/go/([A-Za-z0-9_~@-]+)")
+SHORT_LINK_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_~"
+NOMINATIM_LOOKUP_URL = "https://nominatim.openstreetmap.org/lookup"
+REQUEST_TIMEOUT_SECONDS = 5
+MAP_LINK_CACHE_PREFIX = "buzz_map_link_place:"
+MAP_LINK_CACHE_SECONDS = 600
+
+Coordinates = tuple[float, float]
+
+
+class MapLinkPlace(NamedTuple):
+ """What a pasted map link says about a place; every part may be missing."""
+
+ coordinates: Coordinates | None = None
+ name: str | None = None
+ address: str | None = None
+
+
+def coordinates_of(link: str | None) -> Coordinates | None:
+ """Latitude and longitude carried by a Google Maps or OpenStreetMap link, if any."""
+ return read_map_link(link).coordinates
+
+
+def read_map_link(link: str | None) -> MapLinkPlace:
+ # The picker's preview and the save that follows read the same link; only a place that
+ # was found is kept, so a failed lookup is tried again.
+ cache_key = f"{MAP_LINK_CACHE_PREFIX}{link}"
+ if cached := frappe.cache.get_value(cache_key):
+ return cached
+ place = parse_map_link(link)
+ if any(place):
+ frappe.cache.set_value(cache_key, place, expires_in_sec=MAP_LINK_CACHE_SECONDS)
+ return place
+
+
+def parse_map_link(link: str | None) -> MapLinkPlace:
+ url = resolved_url(link)
+ host = url.hostname or ""
+ if host in OPEN_STREET_MAP_HOSTS:
+ return open_street_map_place(url)
+ if GOOGLE_HOST.search(host):
+ return MapLinkPlace(google_coordinates(url), google_place_name(url))
+ return MapLinkPlace()
+
+
+def resolved_url(link: str | None) -> ParseResult:
+ """The link as a URL, with a Google short link swapped for the full one it stands for."""
+ url = urlparse((link or "").strip())
+ if url.hostname in GOOGLE_SHORT_LINK_HOSTS:
+ url = urlparse(full_google_link(url.geturl()) or "")
+ return url
+
+
+def full_google_link(short_link: str) -> str | None:
+ # One hop, to a host named above, and only the redirect target is read: a pasted link
+ # must not be able to point the server at an address of its choosing.
+ try:
+ response = requests.get(
+ short_link.replace("http://", "https://", 1),
+ allow_redirects=False,
+ timeout=REQUEST_TIMEOUT_SECONDS,
+ )
+ except requests.RequestException:
+ return None
+ return response.headers.get("Location")
+
+
+def google_coordinates(url: ParseResult) -> Coordinates | None:
+ link = url.geturl()
+ match = GOOGLE_PLACE.search(link) or GOOGLE_VIEW.search(link)
+ return valid_coordinates(*match.groups()) if match else None
+
+
+def google_place_name(url: ParseResult) -> str | None:
+ match = GOOGLE_PLACE_NAME.search(url.path)
+ return unquote_plus(match.group(1)) if match else None
+
+
+def open_street_map_place(url: ParseResult) -> MapLinkPlace:
+ if map_object := OPEN_STREET_MAP_OBJECT.match(url.path):
+ return open_street_map_object(*map_object.groups())
+ if short_link := OPEN_STREET_MAP_SHORT_LINK.match(url.path):
+ return MapLinkPlace(short_link_coordinates(short_link.group(1)))
+ return MapLinkPlace(open_street_map_coordinates(url))
+
+
+def open_street_map_coordinates(url: ParseResult) -> Coordinates | None:
+ query = parse_qs(url.query)
+ if "mlat" in query and "mlon" in query:
+ return valid_coordinates(query["mlat"][0], query["mlon"][0])
+ match = OPEN_STREET_MAP_VIEW.search(url.fragment)
+ return valid_coordinates(*match.groups()) if match else None
+
+
+def short_link_coordinates(code: str) -> Coordinates | None:
+ """Undo OpenStreetMap's `/go/` code: each character holds 3 bits of longitude and 3 of latitude."""
+ x = y = bits = 0
+ for character in code.replace("@", "~"):
+ digit = SHORT_LINK_DIGITS.find(character)
+ if digit < 0:
+ continue # "-" only refines the zoom
+ for shift in (5, 3, 1):
+ x = (x << 1) | ((digit >> shift) & 1)
+ y = (y << 1) | ((digit >> (shift - 1)) & 1)
+ bits += 3
+ if not bits or bits > 32:
+ return None
+ longitude = (x << (32 - bits)) * 360 / 2**32 - 180
+ latitude = (y << (32 - bits)) * 180 / 2**32 - 90
+ return round(latitude, 6), round(longitude, 6)
+
+
+def open_street_map_object(kind: str, number: str) -> MapLinkPlace:
+ """A node, way or relation link names a map object; Nominatim says where and what it is."""
+ try:
+ response = requests.get(
+ NOMINATIM_LOOKUP_URL,
+ params={"osm_ids": f"{kind[0].upper()}{number}", "format": "jsonv2"},
+ # Nominatim's usage policy asks each application to say who it is
+ headers={"User-Agent": f"Buzz event venues ({get_url()})"},
+ timeout=REQUEST_TIMEOUT_SECONDS,
+ )
+ response.raise_for_status()
+ found = response.json()
+ except (requests.RequestException, ValueError):
+ return MapLinkPlace()
+ if not found:
+ return MapLinkPlace()
+ place = found[0]
+ return MapLinkPlace(
+ valid_coordinates(place.get("lat"), place.get("lon")),
+ place.get("name") or None,
+ place.get("display_name") or None,
+ )
+
+
+def valid_coordinates(latitude: str | None, longitude: str | None) -> Coordinates | None:
+ try:
+ point = float(latitude), float(longitude)
+ except (TypeError, ValueError):
+ return None
+ return point if abs(point[0]) <= 90 and abs(point[1]) <= 180 else None
diff --git a/buzz/events/doctype/event_venue/test_event_venue.py b/buzz/events/doctype/event_venue/test_event_venue.py
index 938e0864..a10b98f2 100644
--- a/buzz/events/doctype/event_venue/test_event_venue.py
+++ b/buzz/events/doctype/event_venue/test_event_venue.py
@@ -1,20 +1,203 @@
# Copyright (c) 2025, BWH Studios and Contributors
# See license.txt
-# import frappe
+from unittest.mock import Mock, patch
+
+import frappe
+import requests
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.events.doctype.event_venue.map_link import (
+ MAP_LINK_CACHE_PREFIX,
+ SHORT_LINK_DIGITS,
+ coordinates_of,
+ read_map_link,
+)
+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"))
+
+ 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_reads_the_current_venue_name(self):
+ venue = self.create_venue("Old Hall")
+ event = frappe.get_doc("Buzz Event", create_event("Venue Name Event", self.team, venue=venue.name))
+ self.assertEqual(event.get_venue_name(), "Old Hall")
+
+ venue.venue_name = "New Hall"
+ venue.save(ignore_permissions=True)
+
+ self.assertEqual(event.get_venue_name(), "New Hall")
+
+ def test_patch_copies_the_old_docname_into_venue_name(self):
+ venue = self.create_venue("Legacy Hall")
+ frappe.db.set_value("Event Venue", venue.name, "venue_name", "", update_modified=False)
+
+ set_event_venue_name()
+
+ self.assertEqual(frappe.db.get_value("Event Venue", venue.name, "venue_name"), venue.name)
+
+
+GOOGLE_PLACE_LINK = (
+ "https://www.google.com/maps/place/Nehru+Centre/@18.99,72.81,17z/data=!3m1!4b1!4m6!3m5"
+ "!1s0x3be7ce8d!8m2!3d18.9903!4d72.8174!16zL20vMGZf"
+)
+
+
+def open_street_map_short_code(latitude: float, longitude: float, zoom: int) -> str:
+ """OpenStreetMap's own encoding, kept here so the decoder is checked against the real scheme."""
+ x = int((longitude + 180) * 2**32 / 360)
+ y = int((latitude + 90) * 2**32 / 180)
+ interleaved = 0
+ for bit in range(31, -1, -1):
+ interleaved = (interleaved << 2) | (((x >> bit) & 1) << 1) | ((y >> bit) & 1)
+ length = -(-(zoom + 8) // 3)
+ code = "".join(SHORT_LINK_DIGITS[(interleaved >> (58 - 6 * index)) & 0x3F] for index in range(length))
+ return code + "-" * ((zoom + 8) % 3)
+
+
+def clear_map_link_cache():
+ frappe.cache.delete_keys(MAP_LINK_CACHE_PREFIX)
+
+
+class TestMapLinkCoordinates(IntegrationTestCase):
+ def setUp(self):
+ clear_map_link_cache()
+
+ def test_google_link_gives_the_place_not_the_map_centre(self):
+ self.assertEqual(coordinates_of(GOOGLE_PLACE_LINK), (18.9903, 72.8174))
+
+ def test_google_link_without_a_place_gives_the_map_centre(self):
+ self.assertEqual(
+ coordinates_of("https://www.google.co.in/maps/@12.9716,77.5946,15z"), (12.9716, 77.5946)
+ )
+
+ def test_open_street_map_marker_and_view(self):
+ marker = "https://www.openstreetmap.org/?mlat=12.5&mlon=77.5#map=17/1.0/2.0"
+ self.assertEqual(coordinates_of(marker), (12.5, 77.5))
+ self.assertEqual(
+ coordinates_of("https://www.openstreetmap.org/#map=17/12.9716/77.5946"), (12.9716, 77.5946)
+ )
+
+ def test_links_without_coordinates_give_nothing(self):
+ for link in (
+ "https://www.openstreetmap.org/user/someone",
+ "https://example.com/@12.9,77.5",
+ "https://www.google.com/maps/@999,77.5,15z",
+ "not a link",
+ None,
+ ):
+ with self.subTest(link=link):
+ self.assertIsNone(coordinates_of(link))
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get")
+ def test_google_short_link_is_followed_one_hop(self, get):
+ get.return_value = Mock(headers={"Location": GOOGLE_PLACE_LINK})
+
+ self.assertEqual(coordinates_of("https://maps.app.goo.gl/abc123"), (18.9903, 72.8174))
+ get.assert_called_once()
+ self.assertFalse(get.call_args.kwargs["allow_redirects"])
+
+ def test_open_street_map_short_link_is_decoded_without_a_request(self):
+ code = open_street_map_short_code(latitude=12.9716, longitude=77.5946, zoom=17)
+
+ latitude, longitude = coordinates_of(f"https://osm.org/go/{code}?m=")
+
+ self.assertAlmostEqual(latitude, 12.9716, places=3)
+ self.assertAlmostEqual(longitude, 77.5946, places=3)
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get")
+ def test_open_street_map_object_is_looked_up_by_id(self, get):
+ found = [
+ {
+ "lat": "18.9903",
+ "lon": "72.8174",
+ "name": "Nehru Centre",
+ "display_name": "Nehru Centre, Worli",
+ }
+ ]
+ get.return_value = Mock(json=Mock(return_value=found), raise_for_status=Mock())
+
+ place = read_map_link("https://www.openstreetmap.org/way/4567#map=18/1/2")
+
+ self.assertEqual(place, ((18.9903, 72.8174), "Nehru Centre", "Nehru Centre, Worli"))
+ self.assertEqual(get.call_args.args, ("https://nominatim.openstreetmap.org/lookup",))
+ self.assertEqual(get.call_args.kwargs["params"], {"osm_ids": "W4567", "format": "jsonv2"})
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get")
+ def test_a_found_place_is_looked_up_once(self, get):
+ get.return_value = Mock(headers={"Location": GOOGLE_PLACE_LINK})
+
+ coordinates_of("https://maps.app.goo.gl/cached")
+ coordinates_of("https://maps.app.goo.gl/cached")
+
+ get.assert_called_once()
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get", side_effect=requests.ConnectionError)
+ def test_a_failed_lookup_is_tried_again(self, get):
+ coordinates_of("https://maps.app.goo.gl/down")
+ coordinates_of("https://maps.app.goo.gl/down")
+
+ self.assertEqual(get.call_count, 2)
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get")
+ def test_unknown_open_street_map_object_gives_nothing(self, get):
+ get.return_value = Mock(json=Mock(return_value=[]), raise_for_status=Mock())
+
+ self.assertIsNone(coordinates_of("https://www.openstreetmap.org/node/1"))
+
+ @patch("buzz.events.doctype.event_venue.map_link.requests.get")
+ def test_other_hosts_are_never_requested(self, get):
+ self.assertIsNone(coordinates_of("https://internal.example/maps/abc"))
+ get.assert_not_called()
+
+
+class TestVenueMapLink(IntegrationTestCase):
+ def setUp(self):
+ clear_map_link_cache()
+
+ def venue(self, **fields):
+ return frappe.get_doc({"doctype": "Event Venue", "venue_name": "Linked Hall", **fields})
+
+ def test_map_link_sets_the_location_and_makes_address_optional(self):
+ venue = self.venue(map_link=GOOGLE_PLACE_LINK).insert(ignore_permissions=True)
+
+ self.assertEqual((venue.type, venue.latitude, venue.longitude), ("Open Street Map", 18.9903, 72.8174))
+ self.assertFalse(venue.address)
+
+ def test_pasted_embed_code_becomes_the_google_embed(self):
+ embed = ''
+ venue = self.venue(map_link=embed).insert(ignore_permissions=True)
+
+ self.assertEqual(venue.type, "Embed Google Maps")
+ self.assertIn("maps/embed?pb=abc", venue.google_maps_embed_code)
- pass
+ def test_address_is_required_when_the_link_shows_no_location(self):
+ with self.assertRaises(frappe.ValidationError):
+ self.venue(map_link="https://www.openstreetmap.org/user/someone").insert(ignore_permissions=True)
+ with self.assertRaises(frappe.ValidationError):
+ self.venue().insert(ignore_permissions=True)
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..b363601f
--- /dev/null
+++ b/buzz/patches/set_event_venue_name.py
@@ -0,0 +1,9 @@
+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()
diff --git a/buzz/public/themes/base.css b/buzz/public/themes/base.css
index 5a322dda..8deff575 100644
--- a/buzz/public/themes/base.css
+++ b/buzz/public/themes/base.css
@@ -553,11 +553,49 @@ body {
font-weight: 500;
}
+.venue-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.venue-link:hover {
+ text-decoration: underline;
+}
+
+/* The arrow only fills the middle of Lucide's 24px box, so the markup crops the box to the
+ glyph. Centred on the line it sits above the lowercase letters; 1px brings it level. */
+.venue-link .icon {
+ position: relative;
+ top: 1px;
+ flex-shrink: 0;
+ width: 12px;
+ height: 12px;
+ color: var(--ink-muted);
+}
+
.aside-note {
margin-top: 4px;
color: var(--ink-muted);
font-size: 13px;
white-space: pre-line;
+ text-wrap: pretty;
+}
+
+.online-place {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.online-place .icon {
+ flex-shrink: 0;
+ color: var(--ink-muted);
+}
+
+/* Hangs the note under the label text, clear of the icon (16px) and the gap (8px). */
+.online-place + .aside-note {
+ padding-left: 24px;
}
.date-row {
diff --git a/buzz/templates/emails/base.html b/buzz/templates/emails/base.html
index 8cf906c5..6b37a462 100644
--- a/buzz/templates/emails/base.html
+++ b/buzz/templates/emails/base.html
@@ -42,7 +42,7 @@
{{ event_header.start_time }}
{%- if event_header.venue %} ·
{% if event_header.venue_map_url -%}
- {{ event_header.venue }}
+ {{ event_header.venue }}
{%- else %}{{ event_header.venue }}{% endif %}
{%- endif %}
diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py
index cc3116db..63b6ce7b 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.get_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..26cecd63 100644
--- a/buzz/ticketing/doctype/event_ticket/event_ticket.py
+++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py
@@ -111,8 +111,8 @@ def send_ticket_email(self, now: bool = False):
if not send_ticket_email:
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"]
+ event_title, ticket_template, ticket_print_format = frappe.get_cached_value(
+ "Buzz Event", self.event, ["title", "ticket_email_template", "ticket_print_format"]
)
team_settings = get_event_team_settings(self.event)
@@ -126,7 +126,7 @@ def send_ticket_email(self, now: bool = False):
"doc": self,
"event_doc": event_doc,
"event_title": event_title,
- "venue": venue,
+ "venue": event_doc.get_venue_name(),
"support_email": team_settings.support_email,
}
diff --git a/buzz/utils.py b/buzz/utils.py
index acecb1cc..a95cfd84 100644
--- a/buzz/utils.py
+++ b/buzz/utils.py
@@ -186,9 +186,11 @@ def generate_ics_file(event_doc, attendee_email: str):
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "email_id"
)
- venue_address = ""
- if event_doc.venue:
- venue_address = frappe.db.get_value("Event Venue", event_doc.venue, "address") or ""
+ # A venue added from a map link may have no address; its name still says where.
+ venue = event_doc.venue and frappe.db.get_value(
+ "Event Venue", event_doc.venue, ["venue_name", "address"], as_dict=True
+ )
+ venue_address = (venue.address or venue.venue_name) if venue else ""
context = {
"uid": uuid4(),
diff --git a/buzz/www/dashboard.py b/buzz/www/dashboard.py
index 7e09ee30..6caa5454 100644
--- a/buzz/www/dashboard.py
+++ b/buzz/www/dashboard.py
@@ -6,6 +6,9 @@
from frappe.utils import get_system_timezone
from buzz import telemetry
+from buzz.api.maps.services import place_search_enabled
+from buzz.utils import is_app_installed
+from buzz.www.event.venue_map import google_maps_embed_api_key
no_cache = 1
@@ -36,5 +39,8 @@ 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(),
+ "google_maps_embed_api_key": google_maps_embed_api_key(),
+ "zoom_available": is_app_installed("zoom_integration"),
}
)
diff --git a/buzz/www/event/index.html b/buzz/www/event/index.html
index fc333be7..f469b0d4 100644
--- a/buzz/www/event/index.html
+++ b/buzz/www/event/index.html
@@ -31,7 +31,7 @@
{{ online_label }}
+ {% if register_url %}{{ _("Registered guests get the link to join.") }}
{% endif %} +{{ venue.name }}
++ {%- if venue.link -%} + {{ venue.name }} + {%- else -%} + {{ venue.name }} + {%- endif -%} +
{% if venue.address %}{{ venue.address }}
{% endif %}{{ 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 }}
{{ title }}
+{{ subtitle }}
++ No venues yet. Search for a place, or type a name to add one. +
+