diff --git a/buzz/api/events/__init__.py b/buzz/api/events/__init__.py index 35e0dc1c..6888b211 100644 --- a/buzz/api/events/__init__.py +++ b/buzz/api/events/__init__.py @@ -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() @@ -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) diff --git a/buzz/api/events/exceptions.py b/buzz/api/events/exceptions.py index acb362ad..2b950f75 100644 --- a/buzz/api/events/exceptions.py +++ b/buzz/api/events/exceptions.py @@ -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): diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 539a94b7..e15bb666 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -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 diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index 52bdcda0..e268d8ec 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -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 @@ -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" diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 4ad6fc4e..6378dd14 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -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 @@ -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="

A long one

", + 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"], "

A long one

") + 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) diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index f461c142..47661cc0 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -15,6 +15,7 @@ "banner_image", "host", "venue", + "meeting_link", "section_break_naqe", "start_date", "start_time", @@ -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", @@ -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", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index c59cbe45..7da572d0 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -99,9 +99,22 @@ def validate(self): self.validate_tax_settings() self.validate_guest_verification_config() self.validate_custom_forms() + self.clear_unused_location() self.validate_venue_team() self.set_time_zone_label() + def clear_unused_location(self): + """Only the medium in force keeps its location. + + `generate_ics_file` and the booking page both read `venue` without consulting + `medium`, so a venue left behind by a switch to Online publishes an address the + event no longer has. + """ + if self.medium == "Online": + self.venue = None + else: + self.meeting_link = None + def validate_venue_team(self): """A venue may only be linked by the team that owns it. diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index c8bbb617..b3112910 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -226,6 +226,44 @@ def test_a_venue_without_a_team_is_accepted(self): event.validate_venue_team() + def test_turning_an_event_online_drops_its_venue(self): + """The venue outlives the medium otherwise. + + `generate_ics_file` and the booking page both read `venue` without consulting + `medium`, so a leftover venue puts a physical address on an online event. + """ + team = self._make_team("Venue Test Team") + event = self._make_event_with_venue(self._make_venue("Venue Test Hall", team), team) + event.medium = "Online" + event.meeting_link = "https://example.com/room" + + event.clear_unused_location() + + self.assertIsNone(event.venue) + self.assertEqual(event.meeting_link, "https://example.com/room") + + def test_turning_an_event_in_person_drops_its_meeting_link(self): + team = self._make_team("Venue Test Team") + venue = self._make_venue("Venue Test Hall", team) + event = self._make_event_with_venue(venue, team) + event.medium = "In Person" + event.meeting_link = "https://example.com/room" + + event.clear_unused_location() + + self.assertEqual(event.venue, venue) + self.assertIsNone(event.meeting_link) + + def test_an_online_event_keeps_a_venue_it_never_had(self): + """Clearing must not invent a change on an event that was always online.""" + team = self._make_team("Venue Test Team") + event = self._make_event_with_venue(None, team) + event.medium = "Online" + + event.clear_unused_location() + + self.assertIsNone(event.venue) + # ==================== Create from Template Tests ==================== def test_create_from_template_copies_direct_fields(self): diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 14fb93cf..2fe320a1 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -28,9 +28,12 @@ declare module 'vue' { CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default'] CustomFieldInput: typeof import('./src/components/CustomFieldInput.vue')['default'] CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default'] + EventBanner: typeof import('./src/components/dashboard/events/EventBanner.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'] + EventMedium: typeof import('./src/components/dashboard/events/EventMedium.vue')['default'] + EventRoute: typeof import('./src/components/dashboard/events/EventRoute.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'] diff --git a/dashboard/src/components/dashboard/events/EventBanner.vue b/dashboard/src/components/dashboard/events/EventBanner.vue new file mode 100644 index 00000000..c689e825 --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventBanner.vue @@ -0,0 +1,105 @@ + + + diff --git a/dashboard/src/components/dashboard/events/EventCard.vue b/dashboard/src/components/dashboard/events/EventCard.vue index 2dd4e254..f357a7ce 100644 --- a/dashboard/src/components/dashboard/events/EventCard.vue +++ b/dashboard/src/components/dashboard/events/EventCard.vue @@ -4,16 +4,27 @@ import { dayLabel } from "@/utils/dateLabels"; import { bannerPattern } from "@/utils/eventBanner"; import { Avatar, Button } from "frappe-ui"; import { computed } from "vue"; +import { RouterLink } from "vue-router"; // The Events page files cards under a date heading; a standalone list has to // carry the date on the card itself. const props = withDefaults( - defineProps<{ event: MyEvent; showDate?: boolean; showManage?: boolean }>(), + defineProps<{ + event: MyEvent; + showDate?: boolean; + showManage?: boolean; + // A list where every card is manageable — a team's own events — can drop the + // per-card button and make the whole card the way in. + routeToManage?: boolean; + }>(), { showManage: true } ); -// Two gates: the caller has to want the button, and only a host has anything to manage. -const canManage = computed(() => props.showManage && props.event.is_host); +// Only a host has anything to manage, whichever way in the caller asked for. +const linksToManage = computed(() => props.routeToManage && props.event.is_host); + +// The button would be a second link inside the first, so the card link wins. +const canManage = computed(() => props.showManage && !linksToManage.value && props.event.is_host); // Times arrive as a serialized timedelta ("9:00:00"), so the hour needs padding. const startTime = computed((): string => { @@ -38,8 +49,9 @@ const venue = computed(() => { diff --git a/dashboard/src/components/dashboard/events/EventLocation.vue b/dashboard/src/components/dashboard/events/EventLocation.vue index 42e2e68a..fc966397 100644 --- a/dashboard/src/components/dashboard/events/EventLocation.vue +++ b/dashboard/src/components/dashboard/events/EventLocation.vue @@ -8,7 +8,12 @@ import { computed, ref, watch } from "vue"; // venue really could be called "Zoom" — the sentinel keeps the two apart. const ZOOM = "__zoom__"; -const props = defineProps<{ team: string; disabled?: boolean }>(); +// A page that asks for the medium separately picks a venue and nothing else, so the +// Zoom option would be a second way to answer a question already answered. +const props = withDefaults( + defineProps<{ team: string; disabled?: boolean; showVirtual?: boolean }>(), + { showVirtual: true } +); const venue = defineModel("venue", { default: "" }); // Zoom cannot be booked until the event exists, so this is the intent to act on at save. @@ -32,37 +37,41 @@ const selected = computed({ }, }); +const addVenue = { + type: "custom" as const, + key: "add-venue", + label: "Add venue", + icon: "lucide-plus", + // Only worth offering once they have typed a name nothing answers to. + condition: ({ query }: { query: string }) => + Boolean(query.trim()) && + !(venues.data ?? []).some((row) => row.name.toLowerCase() === query.trim().toLowerCase()), + onClick: ({ query }: { query: string }) => { + suggestedName.value = query.trim(); + isAdding.value = true; + }, +}; + const options = computed(() => [ { group: "Venues", - options: (venues.data ?? []).map((row) => ({ - label: row.name, - description: row.address, - value: row.name, - })), - }, - { - group: "Virtual", options: [ - { label: "Create Zoom meeting", value: ZOOM, icon: "lucide-video" }, - { - type: "custom" as const, - key: "add-venue", - label: "Add venue", - icon: "lucide-plus", - // Only worth offering once they have typed a name nothing answers to. - condition: ({ query }: { query: string }) => - Boolean(query.trim()) && - !(venues.data ?? []).some( - (row) => row.name.toLowerCase() === query.trim().toLowerCase() - ), - onClick: ({ query }: { query: string }) => { - suggestedName.value = query.trim(); - isAdding.value = true; - }, - }, + ...(venues.data ?? []).map((row) => ({ + label: row.name, + description: row.address, + value: row.name, + })), + addVenue, ], }, + ...(props.showVirtual + ? [ + { + group: "Virtual", + options: [{ label: "Create Zoom meeting", value: ZOOM, icon: "lucide-video" }], + }, + ] + : []), ]); async function onVenueCreated(name: string) { diff --git a/dashboard/src/components/dashboard/events/EventMedium.vue b/dashboard/src/components/dashboard/events/EventMedium.vue new file mode 100644 index 00000000..a7653388 --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventMedium.vue @@ -0,0 +1,84 @@ + + + diff --git a/dashboard/src/components/dashboard/events/EventRoute.vue b/dashboard/src/components/dashboard/events/EventRoute.vue new file mode 100644 index 00000000..b7a19a18 --- /dev/null +++ b/dashboard/src/components/dashboard/events/EventRoute.vue @@ -0,0 +1,102 @@ + + + diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts index 9af852c0..973e238b 100644 --- a/dashboard/src/data/events.ts +++ b/dashboard/src/data/events.ts @@ -1,4 +1,4 @@ -import type { MyEvents } from "@/types" +import type { EventDetail, MyEvents } from "@/types" import { createResource, useCall } from "frappe-ui" // v2 path: useCall reads the payload from `data`, which /api/method names `message`. @@ -12,3 +12,24 @@ export function useMyEvents() { export const createEvent = createResource<{ name: string; title: string }>({ url: "buzz.api.events.create_event", }) + +/** One event with everything its manage page edits. Per page, so it is not a singleton. */ +export function eventDetail(event: string) { + return createResource({ + url: "buzz.api.events.get_event", + params: { event }, + auto: true, + }) +} + +/** + * Save edits back onto an event. + * + * `set_value` takes a fieldname-to-value map, so the whole form travels as one write — + * and the team permission hooks guard Buzz Event, which is why this needs no endpoint of + * its own. + */ +export const updateEvent = createResource({ url: "frappe.client.set_value" }) + +/** Whether an event can take a route. Routes are the public URL namespace, so they are unique. */ +export const checkEventRoute = createResource({ url: "buzz.api.events.check_event_route" }) diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index 8a2a3236..346927c4 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -4,7 +4,7 @@ import UserMenu from "@/components/UserMenu.vue"; import { useTeamAccess } from "@/composables/useTeamAccess"; import NotFound from "@/pages/NotFound.vue"; import { DesktopShell, PageHeaderTarget, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui"; -import { ref } from "vue"; +import { computed, ref } from "vue"; import { useRoute } from "vue-router"; const collapsed = ref(false); @@ -30,6 +30,29 @@ const teamItems = [ { label: "Events", icon: "lucide-calendar-days", to: "/manage/team/events" }, { label: "Members", icon: "lucide-users-round", to: "/manage/team/members" }, ]; + +// An event opens into the same shell with its own destinations in place of the +// personal and team ones. +const eventId = computed(() => route.params.eventId as string | undefined); + +const eventItems = computed(() => [ + { label: "Back", icon: "lucide-arrow-left", to: "/" }, + { + label: "Details", + icon: "lucide-receipt-text", + to: `/manage/events/${eventId.value}/details`, + }, + { + label: "Attendees", + icon: "lucide-users-round", + to: `/manage/events/${eventId.value}/attendees`, + }, + { + label: "Talks", + icon: "lucide-presentation", + to: `/manage/events/${eventId.value}/talks`, + }, +]);