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 @@

{{ event.title }}

{{ event_date.full_date }} {{ event_date.time_range }} - {% if venue %}{{ venue.name }}{% elif event.medium == "Online" %}{{ _("Online") }}{% endif %} + {% if venue %}{{ venue.name }}{% elif online_label %}{{ online_label }}{% endif %}
@@ -148,13 +148,27 @@ + {% if online_label %} +
+

{{ _("Where") }}

+

{{ online_label }}

+ {% if register_url %}

{{ _("Registered guests get the link to join.") }}

{% endif %} +
+ {% endif %} + {% if venue %}

{{ _("Where") }}

{% if venue.map_url %} - + {% endif %} -

{{ venue.name }}

+

+ {%- if venue.link -%} + {{ venue.name }} + {%- else -%} + {{ venue.name }} + {%- endif -%} +

{% if venue.address %}

{{ venue.address }}

{% endif %}
{% endif %} diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index 8777e926..5b96b39f 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -16,6 +16,7 @@ from buzz.api.booking.services import are_registrations_closed from buzz.api.events.services import co_hosts_of, primary_host_of, registration_link +from buzz.emails import venue_map_url as venue_link from buzz.utils import get_time_zone_label from buzz.www.event.meta import EventMeta from buzz.www.event.venue_map import venue_map_url @@ -23,6 +24,12 @@ no_cache = 1 RANGE_SEPARATOR = " \u2013 " +MEETING_PLATFORMS = { + "zoom.us": "Zoom", + "meet.google.com": "Google Meet", + "teams.microsoft.com": "Microsoft Teams", + "teams.live.com": "Microsoft Teams", +} def get_context(context): @@ -98,12 +105,33 @@ def as_context(self) -> dict: "speakers": self.speakers, "sponsor_tiers": self.sponsor_tiers, "venue": self.venue(), + "online_label": self.online_label(), "register_url": registration_link(self.event), "registrations_closed": are_registrations_closed(self.event), } meta = EventMeta(self.event, self.page, context) return context | {"meta": meta.as_dict(), "structured_data": meta.structured_data()} + def online_label(self) -> str | None: + if self.event.medium != "Online": + return None + platform = self.meeting_platform() + return _("Online on {0}").format(platform) if platform else _("Online") + + def meeting_platform(self) -> str | None: + # Only the service is named; the link itself goes to guests once they register. + if self.event.get("zoom_meeting") or self.event.get("zoom_webinar"): + return "Zoom" + host = urlparse(self.event.meeting_link or "").hostname or "" + return next( + ( + name + for domain, name in MEETING_PLATFORMS.items() + if host == domain or host.endswith(f".{domain}") + ), + None, + ) + def tabs(self) -> list[dict]: sections = [ ("about", _("About"), True), @@ -246,9 +274,22 @@ 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", + "google_place_id", + "latitude", + "longitude", + ], as_dict=True, ) - return ( - {"name": venue.name, "address": venue.address, "map_url": venue_map_url(venue)} if venue else None - ) + if not venue: + return None + return { + "name": venue.venue_name, + "address": venue.address, + "map_url": venue_map_url(venue), + "link": venue_link(self.event.venue), + } diff --git a/buzz/www/event/meta.py b/buzz/www/event/meta.py index 617b0727..7eb39f1e 100644 --- a/buzz/www/event/meta.py +++ b/buzz/www/event/meta.py @@ -93,7 +93,9 @@ def location(self) -> dict | None: if self.is_online(): return {"@type": "VirtualLocation", "url": self.url()} venue = self.context["venue"] - return {"@type": "Place", "name": venue["name"], "address": venue["address"]} if venue else None + if not venue: + return None + return {"@type": "Place", "name": venue["name"], "address": venue["address"] or venue["name"]} def organizer(self) -> dict | None: hosts = self.context["hosts"] diff --git a/buzz/www/event/test_event.py b/buzz/www/event/test_event.py index 5453ff18..f13f239c 100644 --- a/buzz/www/event/test_event.py +++ b/buzz/www/event/test_event.py @@ -9,9 +9,10 @@ from buzz.api.events.test_events import create_event from buzz.api.forms.test_forms import ensure_event_host +from buzz.emails import venue_map_url as email_venue_map_url from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.www.event.index import EventPage -from buzz.www.event.venue_map import google_maps_url, open_street_map_url +from buzz.www.event.venue_map import google_maps_url, open_street_map_url, venue_map_url from buzz.www.site_header import DEFAULT_FAVICON @@ -267,6 +268,16 @@ def test_online_event_links_the_page_not_the_join_link(self): data = EventPage("public-page-event").as_context()["structured_data"] self.assertEqual(data["location"], {"@type": "VirtualLocation", "url": data["url"]}) + def test_online_event_names_the_platform_but_never_shows_the_link(self): + link = "https://us02web.zoom.us/j/123?pwd=secret" + frappe.db.set_value("Buzz Event", self.event, {"medium": "Online", "meeting_link": link}) + + self.assertEqual(EventPage("public-page-event").as_context()["online_label"], "Online on Zoom") + self.assertNotIn("zoom.us", render("public-page-event")) + + frappe.db.set_value("Buzz Event", self.event, "meeting_link", "https://notzoom.us.example/j/1") + self.assertEqual(EventPage("public-page-event").as_context()["online_label"], "Online") + def test_no_offer_when_registrations_are_closed(self): frappe.db.set_value("Buzz Event", self.event, "registrations_close_at", "2000-01-01 00:00:00") self.assertNotIn("offers", EventPage("public-page-event").as_context()["structured_data"]) @@ -285,3 +296,49 @@ def test_google_embed_rejects_other_hosts(self): def test_open_street_map_needs_coordinates(self): self.assertIsNone(open_street_map_url(0, 0)) self.assertIn("marker=19.0%2C72.8", open_street_map_url(19.0, 72.8)) + + +class TestGooglePlaceMap(IntegrationTestCase): + PLACE = frappe._dict(google_place_id="place-1", type=None, latitude=0, longitude=0) + + def setUp(self): + self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + + def configure(self, enabled: int, embed_key: str | None) -> None: + settings = frappe.get_doc("Buzz Settings") + settings.update({"google_maps_enabled": enabled, "google_maps_embed_api_key": embed_key}) + settings.save() + + def test_place_with_an_embed_key_gets_the_google_embed(self): + self.configure(1, "embed-key") + self.assertEqual( + venue_map_url(self.PLACE), + "https://www.google.com/maps/embed/v1/place?key=embed-key&q=place_id%3Aplace-1", + ) + + def test_place_without_an_embed_key_has_no_map(self): + self.configure(1, None) + self.assertIsNone(venue_map_url(self.PLACE)) + + def test_place_with_the_switch_off_has_no_map(self): + self.configure(0, "embed-key") + self.assertIsNone(venue_map_url(self.PLACE)) + + def test_venue_without_a_place_keeps_its_own_map(self): + self.configure(1, "embed-key") + venue = frappe._dict(google_place_id=None, type="Open Street Map", latitude=19.0, longitude=72.8) + self.assertIn("openstreetmap.org", venue_map_url(venue)) + + def test_email_link_points_at_the_place(self): + venue = frappe.get_doc( + { + "doctype": "Event Venue", + "venue_name": "Nehru Centre", + "address": "Worli", + "google_place_id": "place-1", + } + ).insert(ignore_permissions=True) + self.assertEqual( + email_venue_map_url(venue.name), + "https://www.google.com/maps/search/?api=1&query=Nehru+Centre&query_place_id=place-1", + ) diff --git a/buzz/www/event/venue_map.py b/buzz/www/event/venue_map.py index 1dbd8c8e..6886c463 100644 --- a/buzz/www/event/venue_map.py +++ b/buzz/www/event/venue_map.py @@ -2,17 +2,34 @@ import re from urllib.parse import urlencode, urlparse +import frappe + GOOGLE_MAPS_HOSTS = {"www.google.com", "maps.google.com"} IFRAME_SOURCE = re.compile(r"""src\s*=\s*["']([^"']+)["']""", re.IGNORECASE) +GOOGLE_PLACE_EMBED_URL = "https://www.google.com/maps/embed/v1/place" MAP_SPAN_DEGREES = 0.01 def venue_map_url(venue) -> str | None: + if place_url := google_place_url(venue.google_place_id): + return place_url if venue.type == "Embed Google Maps": return google_maps_url(venue.google_maps_embed_code) return open_street_map_url(venue.latitude, venue.longitude) +def google_maps_embed_api_key() -> str | None: + settings = frappe.get_cached_doc("Buzz Settings") + return (settings.google_maps_enabled and settings.google_maps_embed_api_key) or None + + +def google_place_url(place_id: str | None) -> str | None: + key = google_maps_embed_api_key() + if not (place_id and key): + return None + return f"{GOOGLE_PLACE_EMBED_URL}?{urlencode({'key': key, 'q': f'place_id:{place_id}'})}" + + def google_maps_url(embed_code: str | None) -> str | None: # The embed code is raw markup; only a Google Maps embed URL survives, never the markup itself match = IFRAME_SOURCE.search(embed_code or "") diff --git a/buzz/www/events.py b/buzz/www/events.py index f441040c..3930f8a7 100644 --- a/buzz/www/events.py +++ b/buzz/www/events.py @@ -23,7 +23,15 @@ 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.venue_name as venue_name", + "card_image", + "banner_image", +] TIME_FIELDS = ["start_time", "end_date", "end_time", "time_zone"] @@ -55,8 +63,9 @@ def has_started(event) -> bool: def has_ended(event) -> bool: + # A one-day event leaves `end_date` blank, and getdate(None) would read it as today. return datetime.combine( - getdate(event.end_date), get_time(event.end_time) + getdate(event.end_date or event.start_date), get_time(event.end_time) ) < current_time_in_event_timezone(event) @@ -84,7 +93,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 deleted file mode 100644 index ccf8e090..00000000 --- a/dashboard/src/components/dashboard/events/AddVenueDialog.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/dashboard/src/components/dashboard/events/EventLocation.vue b/dashboard/src/components/dashboard/events/EventLocation.vue deleted file mode 100644 index 6a77f980..00000000 --- a/dashboard/src/components/dashboard/events/EventLocation.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - diff --git a/dashboard/src/components/dashboard/events/EventMedium.vue b/dashboard/src/components/dashboard/events/EventMedium.vue index ce7045c8..81dc7d3c 100644 --- a/dashboard/src/components/dashboard/events/EventMedium.vue +++ b/dashboard/src/components/dashboard/events/EventMedium.vue @@ -1,42 +1,82 @@ + + diff --git a/dashboard/src/components/dashboard/events/location/ChosenLocation.vue b/dashboard/src/components/dashboard/events/location/ChosenLocation.vue new file mode 100644 index 00000000..bffaf6c2 --- /dev/null +++ b/dashboard/src/components/dashboard/events/location/ChosenLocation.vue @@ -0,0 +1,53 @@ + + + diff --git a/dashboard/src/components/dashboard/events/location/EventLocationField.vue b/dashboard/src/components/dashboard/events/location/EventLocationField.vue new file mode 100644 index 00000000..2daf9391 --- /dev/null +++ b/dashboard/src/components/dashboard/events/location/EventLocationField.vue @@ -0,0 +1,76 @@ + + + diff --git a/dashboard/src/components/dashboard/events/location/LocationDialog.vue b/dashboard/src/components/dashboard/events/location/LocationDialog.vue new file mode 100644 index 00000000..8e957fad --- /dev/null +++ b/dashboard/src/components/dashboard/events/location/LocationDialog.vue @@ -0,0 +1,224 @@ + + + diff --git a/dashboard/src/components/dashboard/events/location/LocationMap.vue b/dashboard/src/components/dashboard/events/location/LocationMap.vue new file mode 100644 index 00000000..e10705a6 --- /dev/null +++ b/dashboard/src/components/dashboard/events/location/LocationMap.vue @@ -0,0 +1,67 @@ + + +