diff --git a/buzz/api/events/__init__.py b/buzz/api/events/__init__.py index 6e424db1..35e0dc1c 100644 --- a/buzz/api/events/__init__.py +++ b/buzz/api/events/__init__.py @@ -1,10 +1,15 @@ import frappe from buzz.api.events import services -from buzz.api.events.schemas import MyEventsResponse +from buzz.api.events.schemas import CreatedEvent, MyEventsResponse, NewEvent @frappe.whitelist() def get_my_events() -> MyEventsResponse: """Events hosted by the session user's teams, plus events they hold a ticket to.""" return services.my_events() + + +@frappe.whitelist(methods=["POST"]) +def create_event(event: NewEvent) -> CreatedEvent: + return services.create_event(event) diff --git a/buzz/api/events/exceptions.py b/buzz/api/events/exceptions.py new file mode 100644 index 00000000..acb362ad --- /dev/null +++ b/buzz/api/events/exceptions.py @@ -0,0 +1,13 @@ +from frappe import _lt + +from buzz.api.exceptions import BuzzAPIError, NotPermitted + + +class CannotCreateEvents(NotPermitted): + title = _lt("Not Permitted") + message = _lt("You cannot create events for this team.") + + +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.") diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 2b2c85fd..539a94b7 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from buzz.api.schemas import APIResponse +from buzz.api.schemas import APIRequest, APIResponse class MyEvent(APIResponse): @@ -21,3 +21,24 @@ class MyEvent(APIResponse): class MyEventsResponse(APIResponse): upcoming: list[MyEvent] past: list[MyEvent] + + +class NewEvent(APIRequest): + team: str + title: str + start_date: date + start_time: timedelta + end_time: timedelta + end_date: date | None = None + about: str | None = None + banner_image: str | None = None + time_zone: str | None = None + venue: str | None = None + # Zoom cannot be booked before the event exists, so the dashboard asks for it here + # and the service books it once the event is saved. + zoom_meeting: bool = False + + +class CreatedEvent(APIResponse): + name: str + title: str diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index c37c004d..52bdcda0 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -1,9 +1,13 @@ import frappe +from frappe import _ +from frappe.model.naming import append_number_if_name_exists from frappe.query_builder import Case from frappe.utils import getdate -from buzz.api.events.schemas import MyEvent, MyEventsResponse -from buzz.permissions import my_teams +from buzz.api.events.exceptions import CannotCreateEvents, ZoomNotAvailable +from buzz.api.events.schemas import CreatedEvent, MyEvent, MyEventsResponse, NewEvent +from buzz.permissions import has_team_access, my_teams +from buzz.utils import is_app_installed def my_events() -> MyEventsResponse: @@ -63,3 +67,84 @@ def split_by_date(events: list[MyEvent]) -> tuple[list[MyEvent], list[MyEvent]]: is_over = (event.end_date or event.start_date) < getdate() (past if is_over else upcoming).append(event) return upcoming, list(reversed(past)) + + +# 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" +# Zoom-backed, so the meeting the organiser asked for is the one the event gets. +ZOOM_CATEGORY = "Zoom Meeting" + + +def create_event(new: NewEvent) -> CreatedEvent: + """Create an event for a team from the dashboard's create form.""" + if not has_team_access(new.team, "create", frappe.session.user): + CannotCreateEvents.throw() + + # Checked before the insert: a half-made event the organiser has to clean up is worse + # than a refusal. + if new.zoom_meeting and not is_app_installed("zoom_integration"): + ZoomNotAvailable.throw() + + event = frappe.get_doc( + { + "doctype": "Buzz Event", + "team": new.team, + "title": new.title, + "start_date": new.start_date, + "end_date": new.end_date, + "start_time": new.start_time, + "end_time": new.end_time, + "about": new.about, + "banner_image": new.banner_image, + "time_zone": new.time_zone, + "venue": new.venue, + "medium": "Online" if new.zoom_meeting else "In Person", + "category": ZOOM_CATEGORY if new.zoom_meeting else DEFAULT_CATEGORY, + "host": host_for(new.team), + } + ).insert() + + if new.zoom_meeting: + book_zoom_meeting(event) + + return CreatedEvent(name=str(event.name), title=event.title) + + +def book_zoom_meeting(event) -> None: + """Book the Zoom meeting the organiser asked for, and keep the event either way. + + `create_meeting_on_zoom` calls Zoom during the request and writes the meeting back + onto the event. Letting it raise would roll the insert back with it, so a Zoom outage + would cost the organiser the whole event rather than just the meeting; they can add + one from the event afterwards. + """ + try: + event.create_meeting_on_zoom() + except Exception: + frappe.log_error(title="Zoom meeting not created", reference_doctype="Buzz Event") + frappe.msgprint( + _("The event was created, but its Zoom meeting could not be. Add one from the event."), + title=_("Zoom Meeting Not Created"), + indicator="orange", + ) + + +def host_for(team: str) -> str: + """The team's own Event Host, made on first use. + + Event Host is required on every event but absent from the create form, and a new team + has none. Host names are docnames and therefore global, so an existing name is given a + suffix rather than joined. + """ + existing = frappe.db.get_value("Event Host", {"team": team}, "name") + if existing: + return existing + + team_name = frappe.db.get_value("Buzz Team", team, "team_name") or team + host = frappe.get_doc({"doctype": "Event Host", "name": team_name, "team": team}) + host.name = append_number_if_name_exists("Event Host", team_name) + # Event Host is readable by the team but writable by Event Manager only, and creating + # an event is what mints it — the team check above is the authorisation. + host.insert(ignore_permissions=True) + return host.name diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index b2847b64..4ad6fc4e 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -2,9 +2,13 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today +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.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 +from buzz.utils import is_app_installed def create_event(title: str, team: str, **overrides) -> str: @@ -174,3 +178,107 @@ def test_serializes_every_declared_field(self): "team_logo", }, ) + + +class TestCreateEvent(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + + if not frappe.db.exists("Event Category", "Meetups"): + frappe.get_doc({"doctype": "Event Category", "name": "Meetups"}).insert(ignore_permissions=True) + + cls.owner = create_user("create-event-owner@example.com", "Owner") + cls.viewer = create_user("create-event-viewer@example.com", "Viewer") + cls.team = create_owned_team("Create Event Team", cls.owner) + add_member(cls.team, cls.viewer, "Viewer") + + def setUp(self): + frappe.set_user(self.owner) + self.addCleanup(frappe.set_user, "Administrator") + + def payload(self, **overrides) -> NewEvent: + return NewEvent( + **{ + "team": self.team, + "title": "Frappeverse Mumbai", + "start_date": add_days(today(), 30), + "start_time": "09:00:00", + "end_time": "17:00:00", + **overrides, + } + ) + + def test_creates_an_event_the_team_owns(self): + created = create_event_endpoint(self.payload()) + + event = frappe.get_doc("Buzz Event", created.name) + self.assertEqual(created.title, "Frappeverse Mumbai") + self.assertEqual(event.team, self.team) + self.assertEqual(event.medium, "In Person") + self.assertEqual(event.category, "Meetups") + + def test_mints_one_host_per_team_and_reuses_it(self): + first = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload()).name) + second = frappe.get_doc("Buzz Event", create_event_endpoint(self.payload(title="Second")).name) + + self.assertTrue(first.host) + self.assertEqual(first.host, second.host) + self.assertEqual(frappe.db.get_value("Event Host", first.host, "team"), self.team) + + def test_carries_the_optional_fields_through(self): + created = create_event_endpoint( + self.payload( + end_date=add_days(today(), 31), + about="
Come along
", + time_zone="Asia/Kolkata", + ) + ) + + event = frappe.get_doc("Buzz Event", created.name) + self.assertEqual(event.about, "Come along
") + self.assertEqual(event.time_zone, "Asia/Kolkata") + # Derived on validate from the zone, so it proves the zone reached the document. + self.assertEqual(event.time_zone_label, "IST") + + def test_a_viewer_cannot_create_events(self): + frappe.set_user(self.viewer) + + with self.assertRaises(CannotCreateEvents): + create_event_endpoint(self.payload()) + + def test_a_non_member_cannot_create_events(self): + stranger = create_user("create-event-stranger@example.com", "Stranger") + frappe.set_user(stranger) + + with self.assertRaises(CannotCreateEvents): + create_event_endpoint(self.payload()) + + def test_a_venue_from_another_team_is_refused(self): + """The reported vector: a manager naming a venue that belongs to someone else. + + Event Venue is autonamed by prompt, so another team's venue name is guessable, + and the booking confirmation reads the linked venue's address without a + permission check. + """ + stranger = create_user("create-event-venue-stranger@example.com", "Stranger") + their_team = create_owned_team("Create Event Other Team", stranger) + theirs = frappe.get_doc( + { + "doctype": "Event Venue", + "__newname": "Create Event Other Team Hall", + "address": "1 Test Street", + "team": their_team, + } + ).insert(ignore_permissions=True) + + with self.assertRaises(frappe.exceptions.ValidationError): + create_event_endpoint(self.payload(venue=str(theirs.name))) + + def test_zoom_is_refused_when_the_app_is_missing(self): + if is_app_installed("zoom_integration"): + self.skipTest("zoom_integration is installed on this site") + + with self.assertRaises(ZoomNotAvailable): + create_event_endpoint(self.payload(zoom_meeting=True)) diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 363dc755..c59cbe45 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -99,8 +99,24 @@ def validate(self): self.validate_tax_settings() self.validate_guest_verification_config() self.validate_custom_forms() + self.validate_venue_team() self.set_time_zone_label() + def validate_venue_team(self): + """A venue may only be linked by the team that owns it. + + Nothing downstream re-checks this: the booking confirmation and the calendar + invite both read the linked venue's address without a permission check, so a + cross-team link publishes the other team's address. + """ + if not self.venue: + return + + venue_team = frappe.db.get_value("Event Venue", self.venue, "team") + # An unstamped venue predates the team backfill; role permissions still gate it. + if venue_team and venue_team != self.team: + frappe.throw(_("Venue {0} belongs to another team.").format(self.venue)) + def set_time_zone_label(self): # validate runs before the mandatory check, so dates may still be empty here if not (self.time_zone and self.start_date and self.start_time): diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index ceb9484d..c8bbb617 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -9,6 +9,7 @@ from buzz.api.booking.services import are_registrations_closed from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template +from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import ( create_webinar_template, set_team_settings, @@ -158,6 +159,73 @@ def test_unreserved_route_is_accepted(self): event.insert() self.assertEqual(event.route, "my-conference-2026") + # ==================== Venue Team Tests ==================== + + def _make_team(self, team_name: str) -> str: + """Per test, not per class: tearDown rolls back everything setUpClass inserts.""" + owner = create_user("buzz-event-venue-owner@example.com", "Owner") + 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, + "address": "1 Test Street", + "team": team, + } + ).insert(ignore_permissions=True) + return str(venue.name) + + def _make_event_with_venue(self, venue: str, team: str | None): + return frappe.get_doc( + { + "doctype": "Buzz Event", + "title": f"Venue Test Event {venue}", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "start_time": "09:00:00", + "end_time": "18:00:00", + "team": team, + "venue": venue, + } + ) + + def test_a_venue_from_another_team_is_rejected(self): + """A venue carries its team's address, so linking one across teams leaks it. + + Event Venue is autonamed by prompt, so the docname is the venue's own name and + therefore guessable; nothing else stops a manager naming another team's venue. + """ + team = self._make_team("Venue Test Team") + theirs = self._make_venue("Venue Test Other Team Hall", self._make_team("Venue Test Other Team")) + event = self._make_event_with_venue(theirs, team) + + with self.assertRaises(frappe.exceptions.ValidationError): + event.validate_venue_team() + + def test_the_teams_own_venue_is_accepted(self): + team = self._make_team("Venue Test Team") + ours = self._make_venue("Venue Test Own Hall", team) + event = self._make_event_with_venue(ours, team) + + event.validate_venue_team() + + def test_a_venue_without_a_team_is_accepted(self): + """An unstamped venue predates the team backfill; role permissions still gate it. + + Same convention as `has_team_access`, which abstains on an unstamped row rather + than refusing one. + """ + team = self._make_team("Venue Test Team") + unstamped = self._make_venue("Venue Test Unstamped Hall", team) + frappe.db.set_value("Event Venue", unstamped, "team", None) + event = self._make_event_with_venue(unstamped, team) + + event.validate_venue_team() + # ==================== Create from Template Tests ==================== def test_create_from_template_copies_direct_fields(self): diff --git a/buzz/install.py b/buzz/install.py index 16f74183..3e112323 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -130,15 +130,15 @@ def setup_test_records(): create_talk_proposal_statuses() # Administrator's only membership, so the team resolves for fixtures that omit one. - create_default_team_for("Administrator") + admin_team = create_default_team_for("Administrator").name 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"}).insert( - ignore_if_duplicate=True - ) - test_host = frappe.get_doc({"doctype": "Event Host", "name": "Test Host"}).insert( + test_venue = frappe.get_doc( + {"doctype": "Event Venue", "name": "Test Venue", "address": "test", "team": admin_team} + ).insert(ignore_if_duplicate=True) + test_host = frappe.get_doc({"doctype": "Event Host", "name": "Test Host", "team": admin_team}).insert( ignore_if_duplicate=True ) @@ -148,6 +148,7 @@ def setup_test_records(): frappe.get_doc( { "doctype": "Buzz Event", + "team": admin_team, "category": test_category.name, "venue": test_venue.name, "host": test_host.name, diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 05741af3..14fb93cf 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -13,6 +13,7 @@ declare module 'vue' { export interface GlobalComponents { AddMembersDialog: typeof import('./src/components/dashboard/teams/AddMembersDialog.vue')['default'] AddOnPreferenceDialog: typeof import('./src/components/AddOnPreferenceDialog.vue')['default'] + AddVenueDialog: typeof import('./src/components/dashboard/events/AddVenueDialog.vue')['default'] AttendeeFormControl: typeof import('./src/components/AttendeeFormControl.vue')['default'] BackButton: typeof import('./src/components/common/BackButton.vue')['default'] BaseCustomEventForm: typeof import('./src/components/BaseCustomEventForm.vue')['default'] @@ -29,6 +30,8 @@ declare module 'vue' { CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default'] EventCard: typeof import('./src/components/dashboard/events/EventCard.vue')['default'] EventDetailsHeader: typeof import('./src/components/EventDetailsHeader.vue')['default'] + EventLocation: typeof import('./src/components/dashboard/events/EventLocation.vue')['default'] + EventSchedule: typeof import('./src/components/dashboard/events/EventSchedule.vue')['default'] EventSelector: typeof import('./src/components/EventSelector.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default'] diff --git a/dashboard/src/components/dashboard/events/AddVenueDialog.vue b/dashboard/src/components/dashboard/events/AddVenueDialog.vue new file mode 100644 index 00000000..7546b2df --- /dev/null +++ b/dashboard/src/components/dashboard/events/AddVenueDialog.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/dashboard/src/components/dashboard/events/EventLocation.vue b/dashboard/src/components/dashboard/events/EventLocation.vue new file mode 100644 index 00000000..42e2e68a --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventLocation.vue @@ -0,0 +1,89 @@ + + + +