Skip to content
Merged
20 changes: 19 additions & 1 deletion buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import frappe

from buzz.api.events import services
from buzz.api.events.schemas import CreatedEvent, MyEventsResponse, NewEvent
from buzz.api.events.schemas import (
CreatedEvent,
EventDetail,
MyEventsResponse,
NewEvent,
RouteAvailability,
)


@frappe.whitelist()
Expand All @@ -10,6 +16,18 @@ def get_my_events() -> MyEventsResponse:
return services.my_events()


@frappe.whitelist()
def get_event(event: str) -> EventDetail:
"""One event for the manage page, for someone who can edit it."""
return services.event_detail(event)


@frappe.whitelist()
def check_event_route(route: str, event: str | None = None) -> RouteAvailability:
"""Whether an event can take this route. `event` is the one being edited, if any."""
return services.route_availability(route, event)


@frappe.whitelist(methods=["POST"])
def create_event(event: NewEvent) -> CreatedEvent:
return services.create_event(event)
12 changes: 11 additions & 1 deletion buzz/api/events/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from frappe import _lt

from buzz.api.exceptions import BuzzAPIError, NotPermitted
from buzz.api.exceptions import BuzzAPIError, NotPermitted, ResourceNotFound


class EventNotFound(ResourceNotFound):
title = _lt("Event Not Found")
message = _lt("This event does not exist.")


class CannotManageEvent(NotPermitted):
title = _lt("Not Permitted")
message = _lt("You cannot manage this event.")


class CannotCreateEvents(NotPermitted):
Expand Down
32 changes: 32 additions & 0 deletions buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,38 @@ class MyEventsResponse(APIResponse):
past: list[MyEvent]


class EventVenue(APIResponse):
name: str
address: str | None = None


class EventDetail(APIResponse):
"""One event, with everything the manage page edits or shows."""

name: str
title: str
route: str | None = None
team: str | None = None
start_date: date
end_date: date | None = None
start_time: timedelta | None = None
end_time: timedelta | None = None
time_zone: str | None = None
short_description: str | None = None
about: str | None = None
banner_image: str | None = None
medium: str | None = None
venue: EventVenue | None = None
# The organiser's own link, or the one Zoom issued when the meeting was booked.
meeting_link: str | None = None
is_published: bool


class RouteAvailability(APIResponse):
available: bool
message: str


class NewEvent(APIRequest):
team: str
title: str
Expand Down
101 changes: 99 additions & 2 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@
from frappe.query_builder import Case
from frappe.utils import getdate

from buzz.api.events.exceptions import CannotCreateEvents, ZoomNotAvailable
from buzz.api.events.schemas import CreatedEvent, MyEvent, MyEventsResponse, NewEvent
from buzz.api.events.exceptions import (
CannotCreateEvents,
CannotManageEvent,
EventNotFound,
ZoomNotAvailable,
)
from buzz.api.events.schemas import (
CreatedEvent,
EventDetail,
EventVenue,
MyEvent,
MyEventsResponse,
NewEvent,
RouteAvailability,
)
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES
from buzz.permissions import has_team_access, my_teams
from buzz.utils import is_app_installed

Expand Down Expand Up @@ -69,6 +83,89 @@ def split_by_date(events: list[MyEvent]) -> tuple[list[MyEvent], list[MyEvent]]:
return upcoming, list(reversed(past))


DETAIL_FIELDS = (
"name",
"title",
"route",
"team",
"start_date",
"end_date",
"start_time",
"end_time",
"time_zone",
"short_description",
"about",
"banner_image",
"medium",
"venue",
"meeting_link",
"is_published",
)


def event_detail(event: str) -> EventDetail:
"""One event for its manage page, with the venue and meeting link resolved."""
row = frappe.db.get_value("Buzz Event", event, DETAIL_FIELDS, as_dict=True)
if not row:
EventNotFound.throw()

# Read is not enough: this payload backs the page that edits the event.
if not has_team_access(row.team, "write", frappe.session.user):
CannotManageEvent.throw()

return EventDetail(
**row | {"name": str(row.name), "venue": venue_of(row.venue), "meeting_link": meeting_link_of(row)}
)


def venue_of(venue: str | None) -> EventVenue | None:
if not venue:
return None
address = frappe.db.get_value("Event Venue", venue, "address")
return EventVenue(name=venue, address=address)


def meeting_link_of(row) -> str | None:
"""The organiser's own link, falling back to the one Zoom issued.

`zoom_meeting` is a custom field the zoom_integration app adds, so it is absent on a
site without it — and a booked meeting is the link even when nobody typed one in.
"""
if row.meeting_link:
return row.meeting_link
if not is_app_installed("zoom_integration"):
return None

meeting = frappe.db.get_value("Buzz Event", row.name, "zoom_meeting")
return frappe.db.get_value("Zoom Meeting", meeting, "zoom_link") if meeting else None


def route_availability(route: str, event: str | None = None) -> RouteAvailability:
"""Whether an event can take this route.

Routes are the public URL namespace, so this checks every event rather than only the
published ones: an unpublished event still holds its route, and publishing it later
would collide.
"""
route = (route or "").strip().lower()
if not route:
return RouteAvailability(available=False, message=_("Enter a route."))

if route in RESERVED_EVENT_ROUTES:
return RouteAvailability(
available=False, message=_("'{0}' is reserved and cannot be used.").format(route)
)

filters = {"route": route}
if event:
filters["name"] = ("!=", event)

if frappe.db.exists("Buzz Event", filters):
return RouteAvailability(available=False, message=_("This route is already taken."))

return RouteAvailability(available=True, message=_("This route is available."))


# Buzz Event demands a category and a host, neither of which the create form asks for.
# These are the defaults it fills in; the organiser changes them on the event afterwards.
DEFAULT_CATEGORY = "Meetups"
Expand Down
130 changes: 128 additions & 2 deletions buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, today

from buzz.api.events import check_event_route, get_event, get_my_events
from buzz.api.events import create_event as create_event_endpoint
from buzz.api.events import get_my_events
from buzz.api.events.exceptions import CannotCreateEvents, ZoomNotAvailable
from buzz.api.events.exceptions import (
CannotCreateEvents,
CannotManageEvent,
EventNotFound,
ZoomNotAvailable,
)
from buzz.api.events.schemas import NewEvent
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user, payload_for
from buzz.test_permissions import add_member, create_ticket
Expand Down Expand Up @@ -282,3 +287,124 @@ def test_zoom_is_refused_when_the_app_is_missing(self):

with self.assertRaises(ZoomNotAvailable):
create_event_endpoint(self.payload(zoom_meeting=True))


class TestGetEvent(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.set_user("Administrator")

cls.owner = create_user("get-event-owner@example.com", "Owner")
cls.viewer = create_user("get-event-viewer@example.com", "Viewer")
cls.stranger = create_user("get-event-stranger@example.com", "Stranger")
cls.team = create_owned_team("Get Event Team", cls.owner)
add_member(cls.team, cls.viewer, "Viewer")

def setUp(self):
frappe.set_user("Administrator")
self.addCleanup(frappe.set_user, "Administrator")

def test_returns_the_fields_the_manage_page_edits(self):
event = create_event(
"Detailed Event",
self.team,
short_description="A short one",
about="<p>A long one</p>",
medium="Online",
meeting_link="https://example.com/join",
)
frappe.set_user(self.owner)

detail = get_event(event).__json__()

self.assertEqual(detail["name"], event)
self.assertEqual(detail["short_description"], "A short one")
self.assertEqual(detail["about"], "<p>A long one</p>")
self.assertEqual(detail["medium"], "Online")
self.assertEqual(detail["meeting_link"], "https://example.com/join")
self.assertIsNone(detail["venue"])

def test_resolves_the_venue_with_its_address(self):
venue = frappe.get_doc(
{
"doctype": "Event Venue",
"name": "Get Event Venue",
"address": "12 Example Street",
"team": self.team,
}
).insert(ignore_permissions=True)
event = create_event("Venued Event", self.team, venue=venue.name)
frappe.set_user(self.owner)

detail = get_event(event).__json__()

self.assertEqual(detail["venue"]["name"], venue.name)
self.assertEqual(detail["venue"]["address"], "12 Example Street")

def test_a_viewer_cannot_open_the_manage_payload(self):
event = create_event("Viewer Event", self.team)
frappe.set_user(self.viewer)

with self.assertRaises(CannotManageEvent):
get_event(event)

def test_a_non_member_cannot_open_the_manage_payload(self):
event = create_event("Stranger Event", self.team)
frappe.set_user(self.stranger)

with self.assertRaises(CannotManageEvent):
get_event(event)

def test_an_unknown_event_is_not_found(self):
frappe.set_user(self.owner)

with self.assertRaises(EventNotFound):
get_event("999999999")


class TestCheckEventRoute(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.set_user("Administrator")

cls.owner = create_user("check-route-owner@example.com", "Owner")
cls.team = create_owned_team("Check Route Team", cls.owner)

def setUp(self):
frappe.set_user("Administrator")
self.addCleanup(frappe.set_user, "Administrator")

def test_an_unused_route_is_available(self):
frappe.set_user(self.owner)

self.assertTrue(check_event_route("a-route-nobody-has").available)

def test_a_route_another_event_holds_is_taken(self):
create_event("Route Holder", self.team, route="taken-route")
frappe.set_user(self.owner)

self.assertFalse(check_event_route("taken-route").available)

def test_an_unpublished_event_still_holds_its_route(self):
create_event("Draft Route Holder", self.team, route="draft-route", is_published=0)
frappe.set_user(self.owner)

self.assertFalse(check_event_route("draft-route").available)

def test_an_event_does_not_block_its_own_route(self):
event = create_event("Self Route", self.team, route="own-route")
frappe.set_user(self.owner)

self.assertTrue(check_event_route("own-route", event=event).available)

def test_a_reserved_route_is_refused(self):
frappe.set_user(self.owner)

self.assertFalse(check_event_route("account").available)

def test_a_blank_route_is_not_available(self):
frappe.set_user(self.owner)

self.assertFalse(check_event_route(" ").available)
11 changes: 10 additions & 1 deletion buzz/events/doctype/buzz_event/buzz_event.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"banner_image",
"host",
"venue",
"meeting_link",
"section_break_naqe",
"start_date",
"start_time",
Expand Down Expand Up @@ -118,6 +119,14 @@
"mandatory_depends_on": "eval:doc.medium!=\"Online\"",
"options": "Event Venue"
},
{
"depends_on": "eval:doc.medium==\"Online\"",
"description": "Where attendees join an online event.",
"fieldname": "meeting_link",
"fieldtype": "Data",
"label": "Meeting Link",
"options": "URL"
},
{
"default": "In Person",
"fieldname": "medium",
Expand Down Expand Up @@ -612,7 +621,7 @@
"link_fieldname": "event"
}
],
"modified": "2026-07-31 12:00:00.000000",
"modified": "2026-08-14 13:08:24.523151",
"modified_by": "Administrator",
"module": "Events",
"name": "Buzz Event",
Expand Down
Loading
Loading