diff --git a/.gitignore b/.gitignore index 8f05b25f..086ba65b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ jspm_packages/ .aider* buzz/public/dashboard/ +buzz/public/islands/ buzz/public/node_modules buzz/www/dashboard.html diff --git a/buzz/api/communities/__init__.py b/buzz/api/communities/__init__.py index b3d56f09..b7e334d9 100644 --- a/buzz/api/communities/__init__.py +++ b/buzz/api/communities/__init__.py @@ -1,8 +1,9 @@ import frappe +from buzz.api.communities.queries import find_upcoming_event from buzz.api.communities.reviews import CommunityReview -from buzz.api.communities.schemas import CommunityQueue, EventOption, EventRequests -from buzz.api.communities.submissions import EventSubmissions, submittable_events +from buzz.api.communities.schemas import CommunityQueue, EventOption, EventRequests, ExternalEvent +from buzz.api.communities.submissions import EventSubmissions, submit_external_request, submittable_events @frappe.whitelist() @@ -58,3 +59,18 @@ def remove_event(request: str) -> None: @frappe.whitelist(methods=["POST"]) def add_event(community: str, event: str) -> None: CommunityReview(community).add(event) + + +@frappe.whitelist() +def find_event(url: str) -> EventOption: + return find_upcoming_event(url) + + +@frappe.whitelist(methods=["POST"]) +def add_external_event(community: str, event: ExternalEvent) -> None: + CommunityReview(community).add_external_event(event) + + +@frappe.whitelist(methods=["POST"]) +def submit_external_event(community: str, event: ExternalEvent) -> None: + submit_external_request(community, event) diff --git a/buzz/api/communities/exceptions.py b/buzz/api/communities/exceptions.py index a5b3004d..f397c38e 100644 --- a/buzz/api/communities/exceptions.py +++ b/buzz/api/communities/exceptions.py @@ -1,6 +1,6 @@ from frappe import _lt -from buzz.api.exceptions import Conflict, NotPermitted +from buzz.api.exceptions import Conflict, NotPermitted, ResourceNotFound class CannotSubmitEvent(NotPermitted): @@ -21,3 +21,7 @@ class RequestNotRejected(Conflict): class RequestNotApproved(Conflict): message = _lt("Only an approved event can be removed from the community.") + + +class EventNotFound(ResourceNotFound): + message = _lt("No upcoming published event lives at this link.") diff --git a/buzz/api/communities/notifications.py b/buzz/api/communities/notifications.py index 40f5b4e7..ba7c6eaa 100644 --- a/buzz/api/communities/notifications.py +++ b/buzz/api/communities/notifications.py @@ -1,7 +1,8 @@ import frappe from frappe import _ -from frappe.utils import get_url +from frappe.utils import format_datetime, get_url +from buzz.events.doctype.buzz_team_settings.buzz_team_settings import get_team_settings from buzz.permissions import WRITE_ROLES @@ -10,31 +11,80 @@ def community_managers(community: str) -> list[str]: return frappe.get_all("Buzz Team Membership", filters=filters, pluck="user") +def community_contact(community: str) -> str | None: + """Where a submitter's reply goes: the community's support email, else its owner.""" + owner = {"team": community, "enabled": 1, "team_role": "Owner"} + return get_team_settings(community).support_email or frappe.db.get_value( + "Buzz Team Membership", owner, "user" + ) + + def team_name(team: str) -> str: return frappe.db.get_value("Buzz Team", team, "team_name") -def send_email(request, recipients: list[str], subject: str, template: str, **args) -> None: +def submitting_team(request) -> str: + # An external event has no Buzz team; its host stands in. + return team_name(request.event_team) if request.event_team else request.host + + +def external_event(request) -> dict | None: + """What the email shows for an event hosted elsewhere; a Buzz event gets the base header.""" + if not request.is_external_event: + return None + return frappe._dict( + title=request.event_title, + when=format_datetime(request.start_datetime, "EEE, d MMM yyyy, h:mm a"), + place=request.event_location, + host=request.host, + url=request.event_url, + ) + + +def send_email( + request, recipients: list[str], subject: str, template: str, reply_to: str | None = None, **args +) -> None: + # Administrator and Guest have no inbox; mail to them only fails. + recipients = [user for user in recipients if user not in frappe.STANDARD_USERS] + if not recipients: + return frappe.sendmail( recipients=recipients, + reply_to=None if reply_to in frappe.STANDARD_USERS else reply_to, subject=subject, template=template, raw_html=True, add_css=False, - args={"event_title": request.event_title, "community_name": team_name(request.community), **args}, + args={ + "event_title": request.event_title, + "community_name": team_name(request.community), + "event_doc": frappe.get_cached_doc("Buzz Event", request.event) if request.event else None, + "external_event": external_event(request), + **args, + }, reference_doctype=request.doctype, reference_name=request.name, ) def notify_submitted(request) -> None: - submitting_team = team_name(request.event_team) + """Tells the submitter it arrived, and the community's managers it needs a review.""" + if request.submitted_by: + send_email( + request, + [request.submitted_by], + _("{0} was sent to {1}").format(request.event_title, team_name(request.community)), + "community_event_received", + reply_to=community_contact(request.community), + ) send_email( request, community_managers(request.community), - _("{0} submitted {1}").format(submitting_team, request.event_title), + _("{0} submitted {1}").format(submitting_team(request), request.event_title), "community_event_submitted", - team_name=submitting_team, + reply_to=request.submitted_by, + team_name=submitting_team(request), + review_url=get_url(f"/b/manage/communities/{request.community}/calendar"), ) @@ -49,6 +99,7 @@ def notify_reviewed(request) -> None: [request.submitted_by], (_("{0} was approved") if approved else _("{0} was not approved")).format(request.event_title), "community_event_approved" if approved else "community_event_rejected", + reply_to=community_contact(request.community), community_url=get_url(f"/{community_route}"), review_note=request.review_note, ) diff --git a/buzz/api/communities/queries.py b/buzz/api/communities/queries.py index 24abbe8b..9c4c430b 100644 --- a/buzz/api/communities/queries.py +++ b/buzz/api/communities/queries.py @@ -1,5 +1,12 @@ +from datetime import datetime, timedelta +from urllib.parse import urlparse + import frappe +from frappe import _ +from frappe.query_builder.functions import Coalesce +from frappe.utils import get_datetime +from buzz.api.communities.exceptions import EventNotFound from buzz.api.communities.schemas import CommunityRequest, EventOption from buzz.www.events import TIME_FIELDS, exclude_ended_events, upcoming_filters @@ -7,27 +14,42 @@ def request_rows(**conditions) -> list[CommunityRequest]: - """Requests matching `conditions`, each with its event and both teams spelled out.""" - request, event, user = ( - frappe.qb.DocType(name) for name in ("Community Event Request", "Buzz Event", "User") + """Requests matching `conditions`, each with its event and both teams spelled out. + + An external event has no Buzz Event or team: its row comes from the request's own fields. + """ + request, event, user, venue = ( + frappe.qb.DocType(name) for name in ("Community Event Request", "Buzz Event", "User", "Event Venue") ) event_team, community = frappe.qb.DocType("Buzz Team").as_("event_team"), frappe.qb.DocType("Buzz Team") query = ( frappe.qb.from_(request) - .join(event) + .left_join(event) .on(event.name == request.event) - .join(event_team) + .left_join(event_team) .on(event_team.name == request.event_team) .join(community) .on(community.name == request.community) .left_join(user) .on(user.name == request.submitted_by) + .left_join(venue) + .on(venue.name == event.venue) .select( request.name, + request.creation, request.event, - event.title.as_("event_title"), + request.event_title, + request.is_external_event, + request.event_url, + request.host, + request.event_location, + request.start_datetime, event.route.as_("event_route"), event.start_date, + event.start_time, + event.medium, + event.banner_image, + venue.venue_name, request.event_team, event_team.team_name.as_("event_team_name"), event_team.logo.as_("event_team_logo"), @@ -38,11 +60,32 @@ def request_rows(**conditions) -> list[CommunityRequest]: user.full_name.as_("submitter_name"), request.review_note, ) - .orderby(event.start_date) + .orderby(Coalesce(event.start_date, request.start_datetime)) ) for field, value in conditions.items(): query = query.where(request[field] == value) - return [CommunityRequest(**row) for row in query.run(as_dict=True)] + return [CommunityRequest(**request_row(row)) for row in query.run(as_dict=True)] + + +def request_row(row) -> dict: + """Fills an external event's date, time, place and host from the request itself.""" + medium, venue = row.pop("medium"), row.pop("venue_name") + starts_at, location, host = row.pop("start_datetime"), row.pop("event_location"), row.pop("host") + row.place = _("Online") if medium == "Online" else venue + if row.is_external_event: + starts_at = get_datetime(starts_at) + row.update( + start_date=starts_at.date(), + start_time=time_of_day(starts_at), + place=location, + event_team_name=host, + ) + return row + + +def time_of_day(moment: datetime) -> timedelta: + """A datetime's time as the timedelta a Time field reads back as.""" + return moment - moment.replace(hour=0, minute=0, second=0, microsecond=0) def upcoming_event_options(filters: dict) -> list[EventOption]: @@ -60,3 +103,12 @@ def upcoming_event_options(filters: dict) -> list[EventOption]: ) for event in exclude_ended_events(events) ] + + +def find_upcoming_event(url: str) -> EventOption: + """The upcoming published event at a pasted link, or just its route.""" + route = urlparse(url).path.rstrip("/").rpartition("/")[2] + events = upcoming_event_options({"route": route}) if route else [] + if not events: + EventNotFound.throw() + return events[0] diff --git a/buzz/api/communities/reviews.py b/buzz/api/communities/reviews.py index f87b8b02..a3a123e3 100644 --- a/buzz/api/communities/reviews.py +++ b/buzz/api/communities/reviews.py @@ -3,7 +3,8 @@ from buzz.api.communities.exceptions import CannotReviewRequests, RequestNotApproved, RequestNotPending from buzz.api.communities.notifications import notify_reviewed from buzz.api.communities.queries import request_rows -from buzz.api.communities.schemas import CommunityQueue +from buzz.api.communities.schemas import CommunityQueue, ExternalEvent +from buzz.api.communities.submissions import external_request from buzz.permissions import has_team_access REQUEST = "Community Event Request" @@ -24,7 +25,11 @@ def from_request(cls, request: str) -> tuple["CommunityReview", "frappe.model.do def queue(self) -> CommunityQueue: return CommunityQueue( - pending=request_rows(community=self.community, status="Pending"), + pending=sorted( + request_rows(community=self.community, status="Pending"), + key=lambda row: row.creation, + reverse=True, + ), approved=request_rows(community=self.community, status="Approved"), ) @@ -47,6 +52,9 @@ def add(self, event: str) -> None: request.submitted_by = request.submitted_by or frappe.session.user self.set_status(request, "Approved") + def add_external_event(self, event: ExternalEvent) -> None: + self.set_status(external_request(self.community, event), "Approved") + def set_status(self, request, status: str, note: str | None = None) -> None: request.update({"status": status, "reviewed_by": frappe.session.user, "review_note": note}) request.save(ignore_permissions=True) diff --git a/buzz/api/communities/schemas.py b/buzz/api/communities/schemas.py index de072dc9..640dd2e8 100644 --- a/buzz/api/communities/schemas.py +++ b/buzz/api/communities/schemas.py @@ -1,6 +1,9 @@ -from datetime import date +from datetime import date, datetime, timedelta +from typing import Annotated -from buzz.api.schemas import APIResponse +from pydantic import Field + +from buzz.api.schemas import APIRequest, APIResponse class CommunityOption(APIResponse): @@ -18,12 +21,21 @@ class EventOption(APIResponse): class CommunityRequest(APIResponse): name: str - event: str + creation: datetime + # Unset for an external event, which lives on another platform at `event_url`. + event: str | None + is_external_event: bool + event_url: str | None event_title: str event_route: str | None start_date: date - event_team: str - event_team_name: str + start_time: timedelta | None + # The venue, "Online", or for an external event its location. + place: str | None + banner_image: str | None + event_team: str | None + # The event's team, or for an external event its host. + event_team_name: str | None event_team_logo: str | None community: str community_name: str @@ -42,3 +54,20 @@ class EventRequests(APIResponse): class CommunityQueue(APIResponse): pending: list[CommunityRequest] approved: list[CommunityRequest] + + +# Data columns hold 140 characters; a shared link with tracking parameters runs longer. +Text140 = Annotated[str, Field(max_length=140)] +Url = Annotated[str, Field(max_length=500)] + + +class ExternalEvent(APIRequest): + """An event hosted on another platform, such as Luma or KonfHub.""" + + event_title: Text140 + host: Text140 + event_location: Text140 + google_place_id: str | None = None + start_datetime: datetime + end_datetime: datetime + event_url: Url diff --git a/buzz/api/communities/submissions.py b/buzz/api/communities/submissions.py index b9f16f47..4f9304ff 100644 --- a/buzz/api/communities/submissions.py +++ b/buzz/api/communities/submissions.py @@ -1,9 +1,10 @@ import frappe +from frappe.rate_limiter import rate_limit from buzz.api.communities.exceptions import CannotSubmitEvent, RequestNotRejected from buzz.api.communities.notifications import notify_submitted from buzz.api.communities.queries import request_rows, upcoming_event_options -from buzz.api.communities.schemas import CommunityOption, EventOption, EventRequests +from buzz.api.communities.schemas import CommunityOption, EventOption, EventRequests, ExternalEvent from buzz.permissions import WRITE_ROLES, has_team_access REQUEST = "Community Event Request" @@ -28,7 +29,11 @@ def overview(self) -> EventRequests: def open_communities(self) -> list[CommunityOption]: asked = frappe.get_all(REQUEST, filters={"event": self.event}, pluck="community") - filters = {"is_a_community": 1, "is_published": 1, "name": ["not in", [*asked, self.team]]} + filters = { + "accept_community_submissions": 1, + "is_published": 1, + "name": ["not in", [*asked, self.team]], + } rows = frappe.get_all( "Buzz Team", filters=filters, fields=["name", "team_name", "logo"], order_by="team_name" ) @@ -77,3 +82,21 @@ def submittable_events(community: str) -> list[EventOption]: return [] asked = frappe.get_all(REQUEST, filters={"community": community}, pluck="event") return upcoming_event_options({"team": ["in", teams], "name": ["not in", asked]}) + + +def external_request(community: str, event: ExternalEvent): + """An unsaved request for an event hosted on another platform.""" + request = frappe.new_doc(REQUEST) + request.update(event.model_dump()) + request.update({"is_external_event": 1, "community": community, "submitted_by": frappe.session.user}) + return request + + +# Here, not on the endpoint: stacked under whitelist it hides the ExternalEvent annotation. +# Open to anyone signed in, and each call emails the community's managers. +@rate_limit(limit=10, seconds=60 * 60) +def submit_external_request(community: str, event: ExternalEvent) -> None: + """Anyone signed in may suggest an external event; the community reviews it.""" + request = external_request(community, event) + request.insert(ignore_permissions=True) + notify_submitted(request) diff --git a/buzz/api/communities/test_communities.py b/buzz/api/communities/test_communities.py index 320478f9..8ec501e8 100644 --- a/buzz/api/communities/test_communities.py +++ b/buzz/api/communities/test_communities.py @@ -6,7 +6,9 @@ from buzz.api.communities import ( add_event, + add_external_event, approve_request, + find_event, get_event_requests, get_requests, get_submittable_events, @@ -14,14 +16,17 @@ remove_event, resubmit_request, submit_event, + submit_external_event, withdraw_request, ) from buzz.api.communities.exceptions import ( CannotReviewRequests, CannotSubmitEvent, + EventNotFound, RequestNotPending, RequestNotRejected, ) +from buzz.api.teams import get_team_events from buzz.events.doctype.buzz_team.team_page import TeamPage from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory @@ -69,6 +74,9 @@ def submit(self, event=None, community=None) -> str: {"event": event or self.event, "community": community or self.community}, ) + def emails(self) -> list[tuple[list[str], str]]: + return [(call.kwargs["recipients"], call.kwargs["template"]) for call in self.sendmail.call_args_list] + def status_of(self, request: str) -> str: return frappe.db.get_value("Community Event Request", request, "status") @@ -107,10 +115,39 @@ def test_an_unpublished_event_is_refused(self): with self.assertRaises(frappe.ValidationError): self.submit(event=draft) - def test_the_community_managers_are_emailed(self): + def test_the_submitter_and_the_community_managers_are_emailed(self): self.submit() - self.assertEqual(self.sendmail.call_args.kwargs["recipients"], [self.curator]) + self.assertEqual( + self.emails(), + [([self.organiser], "community_event_received"), ([self.curator], "community_event_submitted")], + ) + + def test_replies_reach_the_other_side(self): + self.submit() + + received, submitted = (call.kwargs["reply_to"] for call in self.sendmail.call_args_list) + self.assertEqual((received, submitted), (self.curator, self.organiser)) + + def test_administrator_is_never_emailed(self): + BuzzTeamMembershipFactory.create(team=self.community, user="Administrator", team_role="Manager") + + self.submit() + + self.assertNotIn("Administrator", self.sendmail.call_args.kwargs["recipients"]) + + def set_support_email(self, email: str | None) -> None: + frappe.db.set_value("Buzz Team Settings", self.community, "support_email", email) + frappe.clear_document_cache("Buzz Team Settings", self.community) + + def test_a_community_support_email_takes_replies_over_its_owner(self): + self.set_support_email("hello@builders.example.com") + # Rollback is per class, so the next test must see the community without it. + self.addCleanup(self.set_support_email, None) + + self.submit() + + self.assertEqual(self.sendmail.call_args_list[0].kwargs["reply_to"], "hello@builders.example.com") def test_communities_already_asked_are_not_offered_again(self): self.submit() @@ -221,6 +258,113 @@ def test_a_curator_adds_an_event_directly(self): self.assertEqual([request.event for request in queue.approved], [self.event]) self.assertEqual(queue.pending, []) + def test_pending_requests_list_the_newest_submission_first(self): + # The older submission starts sooner, so start-date order would list it first. + sooner = add_days(today(), 5) + older = self.submit(self.create_event("Older", start_date=sooner, end_date=sooner)) + newer = self.submit() + frappe.db.set_value("Community Event Request", older, "creation", "2026-01-01 09:00:00") + frappe.set_user(self.curator) + + pending = [row.name for row in get_requests(self.community).pending] + + self.assertLess(pending.index(newer), pending.index(older)) + + def test_a_curator_finds_an_event_by_its_link(self): + route = frappe.db.get_value("Buzz Event", self.event, "route") + frappe.set_user(self.curator) + + found = find_event(f"https://buzz.example.com/events/{route}/") + + self.assertEqual((found.name, found.title), (self.event, self.event_title)) + with self.assertRaises(EventNotFound): + find_event("https://buzz.example.com/events/no-such-event") + + def test_anyone_signed_in_submits_an_external_event_for_review(self): + frappe.set_user(self.viewer) + + submit_external_event(self.community, self.external_event()) + + frappe.set_user(self.curator) + pending = [row for row in get_requests(self.community).pending if row.is_external_event] + self.assertEqual([row.event_title for row in pending], ["Rust Meetup"]) + self.assertEqual( + self.emails(), + [([self.viewer], "community_event_received"), ([self.curator], "community_event_submitted")], + ) + + def test_an_external_event_link_must_be_http(self): + frappe.set_user(self.viewer) + + with self.assertRaises(frappe.ValidationError): + submit_external_event( + self.community, self.external_event() | {"event_url": "javascript:alert(1)"} + ) + + def test_an_external_event_keeps_a_long_tracking_link_but_not_a_long_name(self): + frappe.set_user(self.viewer) + link = "https://lu.ma/rust?" + "utm_source=newsletter&utm_campaign=october-community-roundup&" * 4 + + # Rollback is per class, so this goes to the other community and stays out of its tests. + submit_external_event(self.other_community, self.external_event() | {"event_url": link}) + + self.assertTrue(frappe.db.exists("Community Event Request", {"event_url": link})) + with self.assertRaises(frappe.FrappeTypeError): + submit_external_event(self.other_community, self.external_event() | {"event_title": "Rust " * 30}) + + def test_a_curator_adds_an_external_event(self): + event = { + "event_title": "Rust Meetup", + "host": "Rustaceans", + "event_location": "WeWork, Bengaluru", + "start_datetime": "2026-12-01 18:00:00", + "end_datetime": "2026-12-01 20:00:00", + "event_url": "https://lu.ma/rust", + } + self.review(add_external_event, self.community, event) + frappe.set_user(self.curator) + + approved = get_requests(self.community).approved + + external = [request for request in approved if request.is_external_event] + self.assertEqual( + [(row.event_title, row.event_team_name) for row in external], [("Rust Meetup", "Rustaceans")] + ) + + def test_the_calendar_has_own_featured_and_external_events(self): + own = self.create_event("Curators Meetup", team=self.community) + self.review(add_event, self.community, self.event) + self.review(add_external_event, self.community, self.external_event()) + frappe.set_user(self.viewer) + + upcoming = get_team_events(self.community).upcoming + + flags = {row.title: (row.is_community_request, row.is_external) for row in upcoming} + self.assertEqual(flags["Curators Meetup"], (False, False)) + self.assertEqual(flags[self.event_title], (True, False)) + self.assertEqual(flags["Rust Meetup"], (True, True)) + self.assertIn(own, [row.name for row in upcoming]) + + def test_only_curators_are_organizers_on_the_community_page(self): + page = TeamPage(frappe.get_doc("Buzz Team", self.community)) + frappe.set_user(self.curator) + self.assertTrue(page.is_organizer()) + frappe.set_user(self.organiser) + self.assertFalse(page.is_organizer()) + frappe.set_user("Administrator") + self.assertFalse(page.is_organizer()) + + def external_event(self) -> dict: + day = add_days(today(), 20) + return { + "event_title": "Rust Meetup", + "host": "Rustaceans", + "event_location": "WeWork, Bengaluru", + "start_datetime": f"{day} 18:00:00", + "end_datetime": f"{day} 20:00:00", + "event_url": "https://lu.ma/rust", + } + def page_titles(self) -> list[str]: context = TeamPage(frappe.get_doc("Buzz Team", self.community)).as_context() return [event["title"] for day in context["upcoming_days"] for event in day["events"]] diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 220cf4b4..21260aac 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -1,8 +1,8 @@ import frappe from buzz.api.events.schemas import RouteAvailability -from buzz.api.teams import invitations, public_page, services -from buzz.api.teams.schemas import InviteOutcome, TeamOption, TeamOverview +from buzz.api.teams import calendar, invitations, public_page, services +from buzz.api.teams.schemas import InviteOutcome, TeamEvents, TeamOption, TeamOverview from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags @@ -27,7 +27,7 @@ def get_my_teams() -> list[TeamOption]: team.logo, team.slug, team.is_published, - team.is_a_community, + team.accept_community_submissions, team.short_description, membership.team_role, ) @@ -46,6 +46,11 @@ def team_option(my_team: dict, upcoming_event_count: int) -> TeamOption: ) +@frappe.whitelist() +def get_team_events(team: str) -> TeamEvents: + return calendar.team_events(team) + + @frappe.whitelist() def get_team_overview(team: str) -> TeamOverview: return services.team_overview(team) @@ -78,10 +83,12 @@ def update_public_page( links: list[dict], short_description: str | None = None, about: str | None = None, - is_a_community: bool = False, + accept_community_submissions: bool = False, slug: str | None = None, ) -> None: - services.update_public_page(team, is_published, links, short_description, about, is_a_community, slug) + services.update_public_page( + team, is_published, links, short_description, about, accept_community_submissions, slug + ) @frappe.whitelist() diff --git a/buzz/api/teams/calendar.py b/buzz/api/teams/calendar.py new file mode 100644 index 00000000..b7cb1f0b --- /dev/null +++ b/buzz/api/teams/calendar.py @@ -0,0 +1,99 @@ +from datetime import timedelta + +import frappe +from frappe.query_builder import Case +from frappe.utils import get_datetime + +from buzz.api.communities.queries import time_of_day +from buzz.api.events.services import split_by_date +from buzz.api.teams.schemas import TeamEvent, TeamEvents +from buzz.permissions import has_team_access + +REQUEST = "Community Event Request" + + +def team_events(team: str) -> TeamEvents: + """A team's own events, plus the Buzz and external events its community approved.""" + events = sorted( + buzz_events(team) + external_events(team), + key=lambda event: (event.start_date, event.start_time or timedelta(0)), + ) + upcoming, past = split_by_date(events) + return TeamEvents(upcoming=upcoming, past=past) + + +def buzz_events(team: str) -> list[TeamEvent]: + event, request = frappe.qb.DocType("Buzz Event"), frappe.qb.DocType(REQUEST) + host, venue = frappe.qb.DocType("Buzz Team"), frappe.qb.DocType("Event Venue") + own = event.team == team + approved = event.name.isin( + frappe.qb.from_(request) + .select(request.event) + .where((request.community == team) & (request.status == "Approved")) + ) + published = event.is_published == 1 + # Members also see their team's drafts; anyone else, published events only. + visible = own if has_team_access(team, "read", frappe.session.user) else own & published + rows = ( + frappe.qb.from_(event) + .left_join(host) + .on(host.name == event.team) + .left_join(venue) + .on(venue.name == event.venue) + .select( + event.name, + event.title, + event.route, + event.start_date, + event.end_date, + event.start_time, + event.end_time, + venue.venue_name.as_("venue"), + event.medium, + event.banner_image, + event.team, + host.team_name, + host.logo.as_("team_logo"), + Case().when(own, 1).else_(0).as_("is_host"), + Case().when(own, 0).else_(1).as_("is_community_request"), + ) + .where(visible | (approved & published)) + ).run(as_dict=True) + # Buzz Event autonames to integers, while every link to it travels as a string. + return [TeamEvent(**row | {"name": str(row.name), "is_attendee": False}) for row in rows] + + +def external_events(team: str) -> list[TeamEvent]: + rows = frappe.get_all( + REQUEST, + filters={"community": team, "status": "Approved", "is_external_event": 1}, + fields=[ + "name", + "event_title", + "event_url", + "host", + "event_location", + "start_datetime", + "end_datetime", + ], + ) + return [external_event(row) for row in rows] + + +def external_event(row) -> TeamEvent: + starts_at, ends_at = get_datetime(row.start_datetime), get_datetime(row.end_datetime) + return TeamEvent( + name=row.name, + title=row.event_title, + start_date=starts_at.date(), + end_date=ends_at.date(), + start_time=time_of_day(starts_at), + end_time=time_of_day(ends_at), + venue=row.event_location, + team_name=row.host, + event_url=row.event_url, + is_host=False, + is_attendee=False, + is_community_request=True, + is_external=True, + ) diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index 0f0f0c9a..1d013399 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -1,4 +1,4 @@ -from buzz.api.events.schemas import EventExternalLink +from buzz.api.events.schemas import EventExternalLink, MyEvent from buzz.api.schemas import APIResponse @@ -16,7 +16,7 @@ class TeamOption(APIResponse): # Every team page header links to the public page while the team is published. slug: str | None is_published: bool - is_a_community: bool + accept_community_submissions: bool short_description: str | None upcoming_event_count: int team_role: str @@ -49,7 +49,7 @@ class TeamOverview(APIResponse): slug: str | None logo: str | None is_published: bool - is_a_community: bool + accept_community_submissions: bool # Absolute page URL while published; None otherwise. public_url: str | None short_description: str | None @@ -61,3 +61,17 @@ class TeamOverview(APIResponse): invites: list[TeamInvite] # Every member reads them; only Owner/Admin can change them. tax_details: TeamTaxDetails + + +class TeamEvent(MyEvent): + """A calendar row: the team's own event, or one its community approved.""" + + is_community_request: bool = False + is_external: bool = False + # Only an external event has one; a Buzz event lives at its route. + event_url: str | None = None + + +class TeamEvents(APIResponse): + upcoming: list[TeamEvent] + past: list[TeamEvent] diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 73a9296c..ed78b406 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -22,7 +22,7 @@ "slug", "logo", "is_published", - "is_a_community", + "accept_community_submissions", "route", "short_description", "about", @@ -227,7 +227,7 @@ def update_public_page( links: list[dict], short_description: str | None, about: str | None, - is_a_community: bool = False, + accept_community_submissions: bool = False, slug: str | None = None, ) -> None: """Publish or edit a team's public page. @@ -241,7 +241,7 @@ def update_public_page( doc = frappe.get_doc("Buzz Team", team) doc.is_published = int(is_published) - doc.is_a_community = int(is_a_community) + doc.accept_community_submissions = int(accept_community_submissions) public_page.set_slug(doc, slug) doc.short_description = (short_description or "").strip() or None doc.about = about or None diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index affaed93..ed77b42c 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -86,7 +86,7 @@ def test_flags_communities(self): self.create_team(user) with self.set_user(user): - flags = {option.name: option.is_a_community for option in get_my_teams()} + flags = {option.name: option.accept_community_submissions for option in get_my_teams()} self.assertEqual(sorted(flags.values()), [False, True]) self.assertTrue(flags[community]) @@ -243,9 +243,9 @@ def test_a_viewer_cannot_edit_the_page(self): self.assertNotEqual(frappe.db.get_value("Buzz Team", self.team, "short_description"), "Hijacked") def test_a_manager_makes_the_team_a_community(self): - self.publish_as(self.manager, is_a_community=True) + self.publish_as(self.manager, accept_community_submissions=True) - self.assertTrue(self.overview().is_a_community) + self.assertTrue(self.overview().accept_community_submissions) def test_an_unpublished_page_has_no_public_url(self): self.publish_as(self.owner, is_published=False) diff --git a/buzz/events/doctype/buzz_team/buzz_team.json b/buzz/events/doctype/buzz_team/buzz_team.json index e351f110..abeaceb4 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.json +++ b/buzz/events/doctype/buzz_team/buzz_team.json @@ -8,18 +8,23 @@ "engine": "InnoDB", "field_order": [ "team_name", - "slug", "section_break_hvmt", "logo", "public_page_tab", + "publishing_section", "is_published", + "column_break_route", "route", - "is_a_community", - "column_break_public", + "column_break_slug", + "slug", + "description_section", "short_description", - "section_break_about", + "column_break_about", "about", - "links" + "links_section", + "links", + "community_section", + "accept_community_submissions" ], "fields": [ { @@ -30,14 +35,6 @@ "label": "Team Name", "reqd": 1 }, - { - "description": "Used in URLs. Lowercase letters, numbers and hyphens.", - "fieldname": "slug", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Slug", - "unique": 1 - }, { "fieldname": "section_break_hvmt", "fieldtype": "Section Break" @@ -52,12 +49,20 @@ "fieldtype": "Tab Break", "label": "Public Page" }, + { + "fieldname": "publishing_section", + "fieldtype": "Section Break" + }, { "default": "0", "fieldname": "is_published", "fieldtype": "Check", "label": "Published" }, + { + "fieldname": "column_break_route", + "fieldtype": "Column Break" + }, { "description": "Where the public page lives. Filled as community/ on first publish.", "fieldname": "route", @@ -68,36 +73,57 @@ "unique": 1 }, { - "fieldname": "column_break_public", + "fieldname": "column_break_slug", "fieldtype": "Column Break" }, + { + "description": "Used in URLs. Lowercase letters, numbers and hyphens.", + "fieldname": "slug", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Slug", + "unique": 1 + }, + { + "fieldname": "description_section", + "fieldtype": "Section Break" + }, { "fieldname": "short_description", "fieldtype": "Small Text", "label": "Short Description" }, { - "fieldname": "section_break_about", - "fieldtype": "Section Break" + "fieldname": "column_break_about", + "fieldtype": "Column Break" }, { "fieldname": "about", "fieldtype": "Text Editor", "label": "About" }, + { + "fieldname": "links_section", + "fieldtype": "Section Break" + }, { "fieldname": "links", "fieldtype": "Table", "label": "Links", "options": "Event External Link" }, + { + "fieldname": "community_section", + "fieldtype": "Section Break", + "label": "Community" + }, { "default": "0", "depends_on": "is_published", - "description": "Also lists approved events from other teams.", - "fieldname": "is_a_community", + "description": "Other teams can submit their events. Approved ones are listed on the public page.", + "fieldname": "accept_community_submissions", "fieldtype": "Check", - "label": "Is a Community" + "label": "Accept Community Submissions" } ], "grid_page_length": 50, @@ -117,7 +143,7 @@ "link_fieldname": "team" } ], - "modified": "2026-10-06 14:00:00.000000", + "modified": "2026-10-08 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Team", diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index 45db7bbd..fb042b71 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.py +++ b/buzz/events/doctype/buzz_team/buzz_team.py @@ -77,7 +77,7 @@ def validate(self): if not self.slug: self.set_slug() # Submissions are listed on the public page, so a private team has nowhere to show them. - if self.is_a_community and not self.is_published: + if self.accept_community_submissions and not self.is_published: frappe.throw(_("Only a public team can accept community event submissions.")) # After the slug: the first publish builds the route from it. super().validate() diff --git a/buzz/events/doctype/buzz_team/team_page.py b/buzz/events/doctype/buzz_team/team_page.py index 09cd1481..5a098ff6 100644 --- a/buzz/events/doctype/buzz_team/team_page.py +++ b/buzz/events/doctype/buzz_team/team_page.py @@ -8,7 +8,9 @@ from frappe.utils import date_diff, flt, format_date, get_url, getdate, today from buzz.api.booking.services import are_registrations_closed +from buzz.api.maps.services import place_search_enabled from buzz.events.doctype.buzz_team.team_map import TeamMap +from buzz.permissions import role_allows, team_role_of from buzz.www.event.date_range import format_time from buzz.www.event.index import public_links from buzz.www.event.meta import DESCRIPTION_LENGTH, plain_text @@ -81,6 +83,9 @@ def as_context(self) -> dict: "upcoming_days": group_by_day(upcoming), "past_days": group_by_day(past), "meta": self.meta(), + "is_guest": frappe.session.user == "Guest", + "is_organizer": self.is_organizer(), + "place_search_enabled": place_search_enabled(), **TeamMap(upcoming, past).as_context(), } @@ -125,7 +130,7 @@ def attending_events(self) -> set[str]: def event_filters(self) -> dict: """The team's own events, plus those its community approved.""" filters = {"team": self.team.name} - if self.team.is_a_community: + if self.team.accept_community_submissions: approved = {"community": self.team.name, "status": "Approved"} filters["name"] = [ "in", @@ -157,6 +162,14 @@ def card(self, event) -> dict: "longitude": flt(event.longitude) if has_location else None, } + def is_organizer(self) -> bool: + """The community's own curators add events straight away; anyone else submits them. + + Membership, not access: a System Manager outside the team still submits from here. + """ + team_role = team_role_of(frappe.session.user, self.team.name) + return bool(team_role) and role_allows(team_role, "write") + def meta(self) -> dict: text = self.team.short_description or plain_text(self.team.about) return { diff --git a/buzz/events/doctype/buzz_team/templates/buzz_team.html b/buzz/events/doctype/buzz_team/templates/buzz_team.html index d342ec46..fe3d78ff 100644 --- a/buzz/events/doctype/buzz_team/templates/buzz_team.html +++ b/buzz/events/doctype/buzz_team/templates/buzz_team.html @@ -76,6 +76,14 @@

{{ day.label }}{{ day.weekday }}

{{ doc.team_name }}

{% if events_hosted == 1 %}{{ _("1 event hosted") }}{% else %}{{ _("{0} events hosted").format(events_hosted) }}{% endif %}

+ {% if doc.accept_community_submissions %} + {% set event_action = _("Add Event") if is_organizer else _("Submit Event") %} + {% if is_guest %} + {{ event_action }} + {% else %} + + {% endif %} + {% endif %} {% if countries %} @@ -139,6 +147,11 @@

{{ _("About") }}

{% endautoescape %}{% endblock %} {% block script %} + {% if doc.accept_community_submissions and not is_guest %} + {% set island = island_script() %} + + {% if island %}{% endif %} + {% endif %} {{ super() }} {% endblock %} diff --git a/buzz/events/doctype/community_event_request/community_event_request.json b/buzz/events/doctype/community_event_request/community_event_request.json index a1e7c2e4..1fc60616 100644 --- a/buzz/events/doctype/community_event_request/community_event_request.json +++ b/buzz/events/doctype/community_event_request/community_event_request.json @@ -8,12 +8,23 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "is_external_event", "event", "event_title", "event_team", + "event_url", "column_break_community", "community", "status", + "external_event_section", + "host", + "event_location", + "google_place_id", + "column_break_external", + "start_datetime", + "end_datetime", + "latitude", + "longitude", "section_break_review", "submitted_by", "column_break_review", @@ -21,21 +32,29 @@ "review_note" ], "fields": [ + { + "default": "0", + "description": "An event hosted on another platform, such as Luma or KonfHub.", + "fieldname": "is_external_event", + "fieldtype": "Check", + "label": "Is External Event" + }, { "fieldname": "event", "fieldtype": "Link", "in_list_view": 1, "in_standard_filter": 1, "label": "Event", - "options": "Buzz Event", - "reqd": 1 + "mandatory_depends_on": "eval:!doc.is_external_event", + "options": "Buzz Event" }, { "fetch_from": "event.title", "fieldname": "event_title", "fieldtype": "Data", - "label": "Event Title", - "read_only": 1 + "label": "Event Name", + "mandatory_depends_on": "eval:doc.is_external_event", + "read_only_depends_on": "eval:!doc.is_external_event" }, { "fetch_from": "event.team", @@ -45,6 +64,14 @@ "options": "Buzz Team", "read_only": 1 }, + { + "fieldname": "event_url", + "length": 500, + "fieldtype": "Data", + "label": "Event URL", + "options": "URL", + "mandatory_depends_on": "eval:doc.is_external_event" + }, { "fieldname": "column_break_community", "fieldtype": "Column Break" @@ -68,6 +95,68 @@ "options": "Pending\nApproved\nRejected", "reqd": 1 }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "external_event_section", + "fieldtype": "Section Break", + "label": "External Event" + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "host", + "fieldtype": "Data", + "label": "Host", + "mandatory_depends_on": "eval:doc.is_external_event" + }, + { + "depends_on": "eval:doc.is_external_event", + "description": "The place picked from the Google Maps search.", + "fieldname": "event_location", + "fieldtype": "Data", + "label": "Event Location", + "mandatory_depends_on": "eval:doc.is_external_event" + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "google_place_id", + "fieldtype": "Data", + "label": "Google Place ID", + "read_only": 1 + }, + { + "fieldname": "column_break_external", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "start_datetime", + "fieldtype": "Datetime", + "label": "Starts At", + "mandatory_depends_on": "eval:doc.is_external_event" + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "end_datetime", + "fieldtype": "Datetime", + "label": "Ends At", + "mandatory_depends_on": "eval:doc.is_external_event" + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "latitude", + "fieldtype": "Float", + "label": "Latitude", + "precision": "9", + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_external_event", + "fieldname": "longitude", + "fieldtype": "Float", + "label": "Longitude", + "precision": "9", + "read_only": 1 + }, { "fieldname": "section_break_review", "fieldtype": "Section Break", @@ -100,7 +189,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-10-06 16:57:41.747837", + "modified": "2026-10-09 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Community Event Request", diff --git a/buzz/events/doctype/community_event_request/community_event_request.py b/buzz/events/doctype/community_event_request/community_event_request.py index 860a7433..4550bbd7 100644 --- a/buzz/events/doctype/community_event_request/community_event_request.py +++ b/buzz/events/doctype/community_event_request/community_event_request.py @@ -4,9 +4,12 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import get_datetime, validate_url from buzz.permissions import WRITE_PTYPES, as_sql, is_unrestricted, my_teams, team_role_of +EXTERNAL_FIELDS = ("event_title", "event_url", "host", "event_location", "start_datetime", "end_datetime") + class CommunityEventRequest(Document): # begin: auto-generated types @@ -18,32 +21,60 @@ class CommunityEventRequest(Document): from frappe.types import DF community: DF.Link - event: DF.Link + end_datetime: DF.Datetime | None + event: DF.Link | None + event_location: DF.Data | None event_team: DF.Link | None event_title: DF.Data | None + event_url: DF.Data | None + google_place_id: DF.Data | None + host: DF.Data | None + is_external_event: DF.Check + latitude: DF.Float + longitude: DF.Float review_note: DF.SmallText | None reviewed_by: DF.Link | None + start_datetime: DF.Datetime | None status: DF.Literal["Pending", "Approved", "Rejected"] submitted_by: DF.Link | None # end: auto-generated types def validate(self): + if self.is_external_event: + self.validate_external_event() + else: + self.validate_buzz_event() + self.validate_community() + + def validate_buzz_event(self): + if not self.event: + frappe.throw(_("Pick the event to list.")) self.event_title, self.event_team = frappe.db.get_value("Buzz Event", self.event, ["title", "team"]) # An unpublished event may still be taken off the page. if self.status != "Rejected": self.validate_event() - self.validate_community() self.validate_unique_pair() + def validate_external_event(self): + # The form marks these mandatory, but only the form: the server checks them here. + labels = [self.meta.get_label(field) for field in EXTERNAL_FIELDS if not self.get(field)] + if labels: + frappe.throw(_("An external event needs: {0}").format(", ".join(labels))) + # Frappe's URL check takes any scheme, and this link renders as an href for curators. + validate_url(self.event_url, throw=True, valid_schemes=("http", "https")) + if get_datetime(self.end_datetime) < get_datetime(self.start_datetime): + frappe.throw(_("The event cannot end before it starts.")) + self.event = self.event_team = None + def validate_event(self): if not frappe.db.get_value("Buzz Event", self.event, "is_published"): frappe.throw(_("Only a published event can be listed by a community.")) def validate_community(self): community = frappe.db.get_value( - "Buzz Team", self.community, ["is_a_community", "is_published"], as_dict=True + "Buzz Team", self.community, ["accept_community_submissions", "is_published"], as_dict=True ) - if not (community and community.is_a_community and community.is_published): + if not (community and community.accept_community_submissions and community.is_published): frappe.throw(_("{0} is not a published community.").format(self.community)) if self.community == self.event_team: frappe.throw(_("A community lists its own events already.")) diff --git a/buzz/hooks.py b/buzz/hooks.py index 5c3bea3d..7b1652ff 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -170,7 +170,7 @@ # ---------- jinja = { - "methods": ["buzz.emails.email_event_header", "buzz.emails.email_brand"], + "methods": ["buzz.emails.email_event_header", "buzz.emails.email_brand", "buzz.islands.island_script"], } # Installation diff --git a/buzz/islands.py b/buzz/islands.py new file mode 100644 index 00000000..09610001 --- /dev/null +++ b/buzz/islands.py @@ -0,0 +1,17 @@ +import frappe +from frappe.utils import get_file_json + +MANIFEST = "public/islands/.vite/manifest.json" +ENTRY = "src/islands/main.ts" + + +def island_script() -> str | None: + """URL of the islands entry, which a Jinja page loads as a module script. + + None before `yarn build:islands` has run: an empty src would load the page itself. + """ + try: + manifest = get_file_json(frappe.get_app_path("buzz", MANIFEST)) + return f"/assets/buzz/islands/{manifest[ENTRY]['file']}" + except (FileNotFoundError, KeyError): + return None diff --git a/buzz/patches.txt b/buzz/patches.txt index ecde99fa..a83de640 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -30,3 +30,4 @@ buzz.patches.move_sponsorship_tier_price_to_prices buzz.patches.move_ticket_type_price_to_prices buzz.patches.create_administrator_team buzz.patches.geocode_event_venues +buzz.patches.rename_is_a_community_field diff --git a/buzz/patches/rename_is_a_community_field.py b/buzz/patches/rename_is_a_community_field.py new file mode 100644 index 00000000..96cbd900 --- /dev/null +++ b/buzz/patches/rename_is_a_community_field.py @@ -0,0 +1,7 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + if frappe.db.has_column("Buzz Team", "is_a_community"): + rename_field("Buzz Team", "is_a_community", "accept_community_submissions") diff --git a/buzz/public/themes/team.css b/buzz/public/themes/team.css index 73a79808..3485ef9f 100644 --- a/buzz/public/themes/team.css +++ b/buzz/public/themes/team.css @@ -25,6 +25,12 @@ object-fit: cover; } +/* Wider than a plain subtle button: it is the page's one call to action. */ +.team-event-action { + min-width: 9rem; + justify-content: center; +} + .team-identity-text { flex: 1; min-width: 240px; diff --git a/buzz/templates/emails/_button.html b/buzz/templates/emails/_button.html new file mode 100644 index 00000000..69fd2503 --- /dev/null +++ b/buzz/templates/emails/_button.html @@ -0,0 +1,7 @@ + + + + +
+ {{ button_label }} +
diff --git a/buzz/templates/emails/_community_event.html b/buzz/templates/emails/_community_event.html new file mode 100644 index 00000000..17ae02f9 --- /dev/null +++ b/buzz/templates/emails/_community_event.html @@ -0,0 +1,15 @@ +{#- An event hosted on another platform. A Buzz event shows in the base header instead. -#} +{% if external_event %} + + + + +
+
{{ external_event.title | e }}
+
+ {{ external_event.when }}{% if external_event.place %} · {{ external_event.place | e }}{% endif %}
+ Hosted by {{ external_event.host | e }} +
+ Open event link  +
+{% endif %} diff --git a/buzz/templates/emails/community_event_approved.html b/buzz/templates/emails/community_event_approved.html index 3e83c1dc..abd5c768 100644 --- a/buzz/templates/emails/community_event_approved.html +++ b/buzz/templates/emails/community_event_approved.html @@ -1,15 +1,12 @@ {% extends "buzz/templates/emails/base.html" %} -{% block preheader %}{{ event_title | e }} is now listed on {{ community_name | e }}.{% endblock %} +{% block preheader %}{{ event_title | e }} is now on {{ community_name | e }}.{% endblock %} {% block title %}Your event was approved{% endblock %} {% block content %} -

{{ event_title | e }} is now listed on {{ community_name | e }}.

- - - - -
- View the community -
+

{{ community_name | e }} approved {{ event_title | e }}. It now shows on the community's calendar and page.

+{% include "buzz/templates/emails/_community_event.html" %} +{% with button_url=community_url, button_label="View community page" %}{% include "buzz/templates/emails/_button.html" %}{% endwith %} {% endblock %} + +{% block reason %}You received this email because you submitted an event to {{ community_name | e }} on Buzz.{% endblock %} diff --git a/buzz/templates/emails/community_event_received.html b/buzz/templates/emails/community_event_received.html new file mode 100644 index 00000000..44d46873 --- /dev/null +++ b/buzz/templates/emails/community_event_received.html @@ -0,0 +1,11 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}{{ community_name | e }} will review {{ event_title | e }}.{% endblock %} +{% block title %}Your event was sent for review{% endblock %} + +{% block content %} +

We sent {{ event_title | e }} to {{ community_name | e }}. Its organizers will review it, and we will email you when they decide.

+{% include "buzz/templates/emails/_community_event.html" %} +{% endblock %} + +{% block reason %}You received this email because you submitted an event to {{ community_name | e }} on Buzz.{% endblock %} diff --git a/buzz/templates/emails/community_event_rejected.html b/buzz/templates/emails/community_event_rejected.html index 58c244a8..f6228cb4 100644 --- a/buzz/templates/emails/community_event_rejected.html +++ b/buzz/templates/emails/community_event_rejected.html @@ -4,8 +4,12 @@ {% block title %}Your event was not approved{% endblock %} {% block content %} -

{{ community_name | e }} did not approve {{ event_title | e }} for its page. You can submit it again from the event's details.

+

{{ community_name | e }} did not approve {{ event_title | e }} for its calendar.

+{% include "buzz/templates/emails/_community_event.html" %} {% if review_note %} -

{{ review_note | e }}

+

Note from the organizers{{ review_note | e }}

{% endif %} +

{% if external_event %}You can submit it again from the community page.{% else %}You can change the event and submit it again from its details page.{% endif %}

{% endblock %} + +{% block reason %}You received this email because you submitted an event to {{ community_name | e }} on Buzz.{% endblock %} diff --git a/buzz/templates/emails/community_event_submitted.html b/buzz/templates/emails/community_event_submitted.html index 69f83982..f47a465b 100644 --- a/buzz/templates/emails/community_event_submitted.html +++ b/buzz/templates/emails/community_event_submitted.html @@ -1,8 +1,12 @@ {% extends "buzz/templates/emails/base.html" %} -{% block preheader %}{{ team_name | e }} wants {{ event_title | e }} listed on {{ community_name | e }}.{% endblock %} +{% block preheader %}{{ team_name | e }} wants {{ event_title | e }} on {{ community_name | e }}.{% endblock %} {% block title %}An event is waiting for review{% endblock %} {% block content %} -

{{ team_name | e }} submitted {{ event_title | e }} to {{ community_name | e }}. It shows on the community page once you approve it.

+

{{ team_name | e }} submitted {{ event_title | e }} to {{ community_name | e }}. Approve it to show it on your community calendar and page.

+{% include "buzz/templates/emails/_community_event.html" %} +{% with button_url=review_url, button_label="Review submission" %}{% include "buzz/templates/emails/_button.html" %}{% endwith %} {% endblock %} + +{% block reason %}You received this email because you manage {{ community_name | e }} on Buzz.{% endblock %} diff --git a/buzz/templates/event_page/layout.html b/buzz/templates/event_page/layout.html index 30fb5b79..0e145806 100644 --- a/buzz/templates/event_page/layout.html +++ b/buzz/templates/event_page/layout.html @@ -22,6 +22,7 @@ + {{ (head_html or "") | safe }} diff --git a/buzz/tests/factories/buzz_team_factory.py b/buzz/tests/factories/buzz_team_factory.py index 1d63639e..c243325c 100644 --- a/buzz/tests/factories/buzz_team_factory.py +++ b/buzz/tests/factories/buzz_team_factory.py @@ -39,4 +39,4 @@ def default_attributes(self) -> dict[str, Any]: @property def community(self) -> dict[str, Any]: - return {"is_published": 1, "is_a_community": 1} + return {"is_published": 1, "accept_community_submissions": 1} diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 358ea084..76eac997 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -12,6 +12,8 @@ export {} declare module 'vue' { export interface GlobalComponents { AddCoHostDialog: typeof import('./src/components/dashboard/events/AddCoHostDialog.vue')['default'] + AddEventDialog: typeof import('./src/components/dashboard/teams/AddEventDialog.vue')['default'] + AddEventMenu: typeof import('./src/components/dashboard/teams/AddEventMenu.vue')['default'] AddMembersDialog: typeof import('./src/components/dashboard/teams/AddMembersDialog.vue')['default'] AddOnPreferenceDialog: typeof import('./src/components/AddOnPreferenceDialog.vue')['default'] AddPricedItemDialog: typeof import('./src/components/dashboard/sponsorships/AddPricedItemDialog.vue')['default'] @@ -75,6 +77,8 @@ declare module 'vue' { EventTalkActions: typeof import('./src/components/dashboard/proposals/EventTalkActions.vue')['default'] EventTalkProposalDrawer: typeof import('./src/components/dashboard/proposals/EventTalkProposalDrawer.vue')['default'] EventThemeSetting: typeof import('./src/components/dashboard/events/EventThemeSetting.vue')['default'] + ExistingEventForm: typeof import('./src/components/dashboard/teams/ExistingEventForm.vue')['default'] + ExternalEventForm: typeof import('./src/components/dashboard/teams/ExternalEventForm.vue')['default'] FilterChip: typeof import('./src/components/common/filters/FilterChip.vue')['default'] FloatingCreateEventButton: typeof import('./src/components/dashboard/FloatingCreateEventButton.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] @@ -103,6 +107,8 @@ declare module 'vue' { PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default'] PaymentGatewayLogo: typeof import('./src/components/PaymentGatewayLogo.vue')['default'] PaymentProviders: typeof import('./src/components/dashboard/ticket-types/PaymentProviders.vue')['default'] + PendingSubmission: typeof import('./src/components/dashboard/teams/PendingSubmission.vue')['default'] + PendingSubmissions: typeof import('./src/components/dashboard/teams/PendingSubmissions.vue')['default'] PerkList: typeof import('./src/components/dashboard/sponsorships/PerkList.vue')['default'] PhoneInput: typeof import('./src/components/PhoneInput.vue')['default'] PreferencesPanel: typeof import('./src/components/settings/PreferencesPanel.vue')['default'] @@ -112,6 +118,7 @@ declare module 'vue' { PriceList: typeof import('./src/components/dashboard/sponsorships/PriceList.vue')['default'] PrintedTicket: typeof import('./src/components/dashboard/tickets/PrintedTicket.vue')['default'] ProfileView: typeof import('./src/components/ProfileView.vue')['default'] + ProgressStatus: typeof import('./src/components/common/ProgressStatus.vue')['default'] ProposalCard: typeof import('./src/components/dashboard/proposals/ProposalCard.vue')['default'] ProposalDrawer: typeof import('./src/components/dashboard/proposals/ProposalDrawer.vue')['default'] ProposalEditDialog: typeof import('./src/components/ProposalEditDialog.vue')['default'] @@ -121,6 +128,7 @@ declare module 'vue' { QuickActionTile: typeof import('./src/components/dashboard/events/QuickActionTile.vue')['default'] RegistrationActions: typeof import('./src/components/dashboard/events/RegistrationActions.vue')['default'] RegistrationDialog: typeof import('./src/components/dashboard/events/RegistrationDialog.vue')['default'] + RejectRequestDialog: typeof import('./src/components/dashboard/teams/RejectRequestDialog.vue')['default'] RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default'] RevenueCards: typeof import('./src/components/dashboard/ticket-types/RevenueCards.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -133,6 +141,7 @@ declare module 'vue' { SponsorDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorDrawer.vue')['default'] SponsorshipDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorshipDrawer.vue')['default'] SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default'] + SubmissionThumbnail: typeof import('./src/components/dashboard/teams/SubmissionThumbnail.vue')['default'] SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default'] TaxSettings: typeof import('./src/components/dashboard/ticket-types/TaxSettings.vue')['default'] TeamCard: typeof import('./src/components/dashboard/teams/TeamCard.vue')['default'] @@ -163,6 +172,7 @@ declare module 'vue' { UserSettingsDialog: typeof import('./src/components/UserSettingsDialog.vue')['default'] VirtualMeetingOptions: typeof import('./src/components/dashboard/events/VirtualMeetingOptions.vue')['default'] WebsiteInput: typeof import('./src/components/dashboard/sponsorships/WebsiteInput.vue')['default'] + WorkspaceOpening: typeof import('./src/components/dashboard/WorkspaceOpening.vue')['default'] ZoomLogo: typeof import('./src/components/common/ZoomLogo.vue')['default'] } } diff --git a/dashboard/package.json b/dashboard/package.json index 83bb3716..40651c6d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build --base=/assets/buzz/dashboard/ && yarn copy-html-entry", + "build": "vite build --base=/assets/buzz/dashboard/ && yarn copy-html-entry && yarn build:islands", + "build:islands": "vite build --config vite.islands.config.ts && yarn build:icons", + "build:icons": "tailwindcss -c tailwind.icons.config.js -i src/islands/icons.css -o ../buzz/public/islands/icons.css --minify", "preview": "vite preview", "lint": "oxlint --deny-warnings", "typecheck": "./typecheck.sh", diff --git a/dashboard/src/components/common/EmptyState.vue b/dashboard/src/components/common/EmptyState.vue index f042f228..0a527cb7 100644 --- a/dashboard/src/components/common/EmptyState.vue +++ b/dashboard/src/components/common/EmptyState.vue @@ -1,14 +1,20 @@