Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4a33e7e
feat(venues): name venues randomly and carry the label in venue_name
harshtandiya Sep 30, 2026
b105e7f
feat(maps): search Google Maps places from the venue picker
harshtandiya Sep 30, 2026
2df65c6
fix(event-page): let venue map links open outside the iframe sandbox
harshtandiya Sep 30, 2026
c6c5e18
feat(maps): save a Google Maps place as a venue and embed its map
harshtandiya Sep 30, 2026
57fe0ae
feat(events): pick the event location in a dialog with a preview
harshtandiya Sep 30, 2026
9060925
feat(events): show the chosen location with its map
harshtandiya Sep 30, 2026
640408a
feat(venues): read a venue's location from a pasted map link
harshtandiya Sep 30, 2026
541dcf4
feat(event-page): link the venue name to its map
harshtandiya Sep 30, 2026
8bad7cb
feat(events): add a venue by hand with Enter when nothing matches
harshtandiya Sep 30, 2026
9bc20ab
fix(events): make the place search token without crypto.randomUUID
harshtandiya Sep 30, 2026
fdc937b
feat(events): pick the venue in the location dialog on the manage eve…
harshtandiya Sep 30, 2026
b55ea8c
fix(events): treat a blank end date as the start date when checking i…
harshtandiya Sep 30, 2026
d1bb16f
feat(events): convert an event between in person and virtual from its…
harshtandiya Sep 30, 2026
1cf22b5
feat(events): pick a venue before converting a virtual event to in pe…
harshtandiya Sep 30, 2026
70d8e94
refactor(events): read the venue name from Event Venue instead of a c…
harshtandiya Sep 30, 2026
0e17c2e
fix(events): save the event before booking its Zoom meeting
harshtandiya Sep 30, 2026
8f7315a
fix(venues): escape map links, cache map link lookups and guard place…
harshtandiya Sep 30, 2026
da7b0dc
fix(dashboard): fetch dev boot data on the v2 method path
harshtandiya Sep 30, 2026
07bfcaf
feat(events): show where an online event happens on its public page
harshtandiya Sep 30, 2026
211c6c9
test(tickets): expect venue in the ticket details payload
harshtandiya Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion buzz/api/booking/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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,
BookingRequest,
BookingSummary,
EventBookingDataResponse,
FreeBookingResponse,
MyBooking,
OfflineBookingResponse,
PaymentLinkResponse,
)
Expand Down Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion buzz/api/booking/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions buzz/api/booking/event_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions buzz/api/booking/my_bookings.py
Original file line number Diff line number Diff line change
@@ -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]
17 changes: 16 additions & 1 deletion buzz/api/booking/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class ConfirmationEvent(APIResponse):


class ConfirmationVenue(APIResponse):
name: Any
venue_name: str | None
address: str | None


Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions buzz/api/booking/test_booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"time_zone",
"time_zone_label",
"venue",
"venue_name",
"medium",
"category",
"banner_image",
Expand Down
2 changes: 1 addition & 1 deletion buzz/api/checkin/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions buzz/api/events/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
7 changes: 5 additions & 2 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
100 changes: 100 additions & 0 deletions buzz/api/events/test_zoom.py
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions buzz/api/events/zoom.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion buzz/api/forms/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
26 changes: 26 additions & 0 deletions buzz/api/maps/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions buzz/api/maps/exceptions.py
Original file line number Diff line number Diff line change
@@ -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.")
17 changes: 17 additions & 0 deletions buzz/api/maps/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading