diff --git a/buzz/api/communities/__init__.py b/buzz/api/communities/__init__.py new file mode 100644 index 00000000..b3d56f09 --- /dev/null +++ b/buzz/api/communities/__init__.py @@ -0,0 +1,60 @@ +import frappe + +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 + + +@frappe.whitelist() +def get_event_requests(event: str) -> EventRequests: + return EventSubmissions(event).overview() + + +@frappe.whitelist() +def get_submittable_events(community: str) -> list[EventOption]: + return submittable_events(community) + + +@frappe.whitelist(methods=["POST"]) +def submit_event(event: str, community: str) -> None: + EventSubmissions(event).submit(community) + + +@frappe.whitelist(methods=["POST"]) +def resubmit_request(request: str) -> None: + submissions, doc = EventSubmissions.from_request(request) + submissions.resubmit(doc) + + +@frappe.whitelist(methods=["POST"]) +def withdraw_request(request: str) -> None: + submissions, doc = EventSubmissions.from_request(request) + submissions.withdraw(doc) + + +@frappe.whitelist() +def get_requests(community: str) -> CommunityQueue: + return CommunityReview(community).queue() + + +@frappe.whitelist(methods=["POST"]) +def approve_request(request: str) -> None: + community_review, doc = CommunityReview.from_request(request) + community_review.review(doc, "Approved") + + +@frappe.whitelist(methods=["POST"]) +def reject_request(request: str, note: str | None = None) -> None: + community_review, doc = CommunityReview.from_request(request) + community_review.review(doc, "Rejected", note) + + +@frappe.whitelist(methods=["POST"]) +def remove_event(request: str) -> None: + community_review, doc = CommunityReview.from_request(request) + community_review.remove(doc) + + +@frappe.whitelist(methods=["POST"]) +def add_event(community: str, event: str) -> None: + CommunityReview(community).add(event) diff --git a/buzz/api/communities/exceptions.py b/buzz/api/communities/exceptions.py new file mode 100644 index 00000000..a5b3004d --- /dev/null +++ b/buzz/api/communities/exceptions.py @@ -0,0 +1,23 @@ +from frappe import _lt + +from buzz.api.exceptions import Conflict, NotPermitted + + +class CannotSubmitEvent(NotPermitted): + message = _lt("Only the event team's owners, admins and managers can send it to a community.") + + +class CannotReviewRequests(NotPermitted): + message = _lt("Only the community's owners, admins and managers can review its events.") + + +class RequestNotPending(Conflict): + message = _lt("This request has been reviewed already.") + + +class RequestNotRejected(Conflict): + message = _lt("Only a rejected request can be submitted again.") + + +class RequestNotApproved(Conflict): + message = _lt("Only an approved event can be removed from the community.") diff --git a/buzz/api/communities/notifications.py b/buzz/api/communities/notifications.py new file mode 100644 index 00000000..40f5b4e7 --- /dev/null +++ b/buzz/api/communities/notifications.py @@ -0,0 +1,54 @@ +import frappe +from frappe import _ +from frappe.utils import get_url + +from buzz.permissions import WRITE_ROLES + + +def community_managers(community: str) -> list[str]: + filters = {"team": community, "enabled": 1, "team_role": ["in", list(WRITE_ROLES)]} + return frappe.get_all("Buzz Team Membership", filters=filters, pluck="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: + frappe.sendmail( + recipients=recipients, + subject=subject, + template=template, + raw_html=True, + add_css=False, + args={"event_title": request.event_title, "community_name": team_name(request.community), **args}, + reference_doctype=request.doctype, + reference_name=request.name, + ) + + +def notify_submitted(request) -> None: + submitting_team = team_name(request.event_team) + send_email( + request, + community_managers(request.community), + _("{0} submitted {1}").format(submitting_team, request.event_title), + "community_event_submitted", + team_name=submitting_team, + ) + + +def notify_reviewed(request) -> None: + # A request made in Desk names no submitter, so there is nobody to tell. + if not request.submitted_by: + return + approved = request.status == "Approved" + community_route = frappe.db.get_value("Buzz Team", request.community, "route") + send_email( + request, + [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", + 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 new file mode 100644 index 00000000..24abbe8b --- /dev/null +++ b/buzz/api/communities/queries.py @@ -0,0 +1,62 @@ +import frappe + +from buzz.api.communities.schemas import CommunityRequest, EventOption +from buzz.www.events import TIME_FIELDS, exclude_ended_events, upcoming_filters + +EVENT_OPTION_LIMIT = 20 + + +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") + ) + event_team, community = frappe.qb.DocType("Buzz Team").as_("event_team"), frappe.qb.DocType("Buzz Team") + query = ( + frappe.qb.from_(request) + .join(event) + .on(event.name == request.event) + .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) + .select( + request.name, + request.event, + event.title.as_("event_title"), + event.route.as_("event_route"), + event.start_date, + request.event_team, + event_team.team_name.as_("event_team_name"), + event_team.logo.as_("event_team_logo"), + request.community, + community.team_name.as_("community_name"), + request.status, + request.submitted_by, + user.full_name.as_("submitter_name"), + request.review_note, + ) + .orderby(event.start_date) + ) + for field, value in conditions.items(): + query = query.where(request[field] == value) + return [CommunityRequest(**row) for row in query.run(as_dict=True)] + + +def upcoming_event_options(filters: dict) -> list[EventOption]: + """Published events that have not ended, as picker options.""" + events = frappe.get_all( + "Buzz Event", + filters=upcoming_filters() | filters, + fields=["name", "title", "start_date", "team.team_name as team_name", *TIME_FIELDS], + order_by="start_date asc", + limit=EVENT_OPTION_LIMIT, + ) + return [ + EventOption( + name=str(event.name), title=event.title, start_date=event.start_date, team_name=event.team_name + ) + for event in exclude_ended_events(events) + ] diff --git a/buzz/api/communities/reviews.py b/buzz/api/communities/reviews.py new file mode 100644 index 00000000..f87b8b02 --- /dev/null +++ b/buzz/api/communities/reviews.py @@ -0,0 +1,52 @@ +import frappe + +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.permissions import has_team_access + +REQUEST = "Community Event Request" + + +class CommunityReview: + """A community's queue: approve, reject or remove events from other teams, or add them.""" + + def __init__(self, community: str): + if not community or not has_team_access(community, "write", frappe.session.user): + CannotReviewRequests.throw() + self.community = community + + @classmethod + def from_request(cls, request: str) -> tuple["CommunityReview", "frappe.model.document.Document"]: + doc = frappe.get_doc(REQUEST, request) + return cls(doc.community), doc + + def queue(self) -> CommunityQueue: + return CommunityQueue( + pending=request_rows(community=self.community, status="Pending"), + approved=request_rows(community=self.community, status="Approved"), + ) + + def review(self, request, status: str, note: str | None = None) -> None: + if request.status != "Pending": + RequestNotPending.throw() + self.set_status(request, status, note) + notify_reviewed(request) + + def remove(self, request) -> None: + if request.status != "Approved": + RequestNotApproved.throw() + self.set_status(request, "Rejected") + + def add(self, event: str) -> None: + """Lists an event straight away, reusing the event's earlier request if it has one.""" + name = frappe.db.get_value(REQUEST, {"event": event, "community": self.community}) + request = frappe.get_doc(REQUEST, name) if name else frappe.new_doc(REQUEST) + request.update({"event": event, "community": self.community}) + request.submitted_by = request.submitted_by or frappe.session.user + self.set_status(request, "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 new file mode 100644 index 00000000..de072dc9 --- /dev/null +++ b/buzz/api/communities/schemas.py @@ -0,0 +1,44 @@ +from datetime import date + +from buzz.api.schemas import APIResponse + + +class CommunityOption(APIResponse): + name: str + team_name: str + logo: str | None + + +class EventOption(APIResponse): + name: str + title: str + start_date: date + team_name: str + + +class CommunityRequest(APIResponse): + name: str + event: str + event_title: str + event_route: str | None + start_date: date + event_team: str + event_team_name: str + event_team_logo: str | None + community: str + community_name: str + status: str + submitted_by: str | None + submitter_name: str | None + review_note: str | None + + +class EventRequests(APIResponse): + requests: list[CommunityRequest] + # Published communities this event has no request with yet. + communities: list[CommunityOption] + + +class CommunityQueue(APIResponse): + pending: list[CommunityRequest] + approved: list[CommunityRequest] diff --git a/buzz/api/communities/submissions.py b/buzz/api/communities/submissions.py new file mode 100644 index 00000000..b9f16f47 --- /dev/null +++ b/buzz/api/communities/submissions.py @@ -0,0 +1,79 @@ +import frappe + +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.permissions import WRITE_ROLES, has_team_access + +REQUEST = "Community Event Request" + + +class EventSubmissions: + """One event's requests to communities, made by the event's own team.""" + + def __init__(self, event: str): + self.event = str(event) + self.team = frappe.db.get_value("Buzz Event", self.event, "team") + if not self.team or not has_team_access(self.team, "write", frappe.session.user): + CannotSubmitEvent.throw() + + @classmethod + def from_request(cls, request: str) -> tuple["EventSubmissions", "frappe.model.document.Document"]: + doc = frappe.get_doc(REQUEST, request) + return cls(doc.event), doc + + def overview(self) -> EventRequests: + return EventRequests(requests=request_rows(event=self.event), communities=self.open_communities()) + + 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]]} + rows = frappe.get_all( + "Buzz Team", filters=filters, fields=["name", "team_name", "logo"], order_by="team_name" + ) + return [CommunityOption(**row) for row in rows] + + def submit(self, community: str) -> None: + request = frappe.get_doc( + { + "doctype": REQUEST, + "event": self.event, + "community": community, + "submitted_by": frappe.session.user, + } + ) + request.insert(ignore_permissions=True) + notify_submitted(request) + + def resubmit(self, request) -> None: + if request.status != "Rejected": + RequestNotRejected.throw() + request.update( + { + "status": "Pending", + "submitted_by": frappe.session.user, + "reviewed_by": None, + "review_note": None, + } + ) + request.save(ignore_permissions=True) + notify_submitted(request) + + def withdraw(self, request) -> None: + request.delete(ignore_permissions=True) + + +def submittable_events(community: str) -> list[EventOption]: + """The session user's upcoming events this community has not been asked about.""" + membership = { + "user": frappe.session.user, + "enabled": 1, + "team_role": ["in", list(WRITE_ROLES)], + "team": ["!=", community], + } + teams = frappe.get_all("Buzz Team Membership", filters=membership, pluck="team") + if not teams: + return [] + asked = frappe.get_all(REQUEST, filters={"community": community}, pluck="event") + return upcoming_event_options({"team": ["in", teams], "name": ["not in", asked]}) diff --git a/buzz/api/communities/test_communities.py b/buzz/api/communities/test_communities.py new file mode 100644 index 00000000..320478f9 --- /dev/null +++ b/buzz/api/communities/test_communities.py @@ -0,0 +1,226 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.communities import ( + add_event, + approve_request, + get_event_requests, + get_requests, + get_submittable_events, + reject_request, + remove_event, + resubmit_request, + submit_event, + withdraw_request, +) +from buzz.api.communities.exceptions import ( + CannotReviewRequests, + CannotSubmitEvent, + RequestNotPending, + RequestNotRejected, +) +from buzz.events.doctype.buzz_team.team_page import TeamPage +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory + +SENDMAIL = "buzz.api.communities.notifications.frappe.sendmail" + + +class CommunityTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.organiser = UserFactory.create_once("community-organiser@example.com").name + cls.viewer = UserFactory.create_once("community-viewer@example.com").name + cls.curator = UserFactory.create_once("community-curator@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.organiser, team_name="Chapter Builders").name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + cls.community = BuzzTeamFactory.create_owned_by( + cls.curator, "community", team_name="Builders United" + ).name + cls.other_community = BuzzTeamFactory.create_owned_by(cls.curator, "community").name + + @classmethod + def create_event(cls, title: str, **overrides): + day = add_days(today(), 10) + attributes = {"title": title, "team": cls.team, "start_date": day, "end_date": day} + return str(BuzzEventFactory.create(**(attributes | overrides)).name) + + def setUp(self): + # Rollback is per class, so each test gets an event no other test has submitted. + self.event_title = f"Chapter Meetup {frappe.generate_hash(length=6)}" + self.event = self.create_event(self.event_title) + patcher = patch(SENDMAIL) + self.sendmail = patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + frappe.set_user("Administrator") + + def submit(self, event=None, community=None) -> str: + frappe.set_user(self.organiser) + submit_event(event or self.event, community or self.community) + frappe.set_user("Administrator") + return frappe.db.get_value( + "Community Event Request", + {"event": event or self.event, "community": community or self.community}, + ) + + def status_of(self, request: str) -> str: + return frappe.db.get_value("Community Event Request", request, "status") + + +class TestSubmitting(CommunityTestCase): + def test_a_submitted_request_waits_for_review(self): + request = self.submit() + + self.assertEqual(self.status_of(request), "Pending") + self.assertEqual( + frappe.db.get_value("Community Event Request", request, "submitted_by"), self.organiser + ) + + def test_one_event_can_go_to_two_communities_but_once_each(self): + self.submit() + self.submit(community=self.other_community) + + with self.assertRaises(frappe.DuplicateEntryError): + self.submit() + + def test_a_viewer_cannot_submit(self): + frappe.set_user(self.viewer) + + with self.assertRaises(CannotSubmitEvent): + submit_event(self.event, self.community) + + def test_a_team_that_is_not_a_community_is_refused(self): + plain_team = BuzzTeamFactory.create_owned_by(self.curator, is_published=1).name + + with self.assertRaises(frappe.ValidationError): + self.submit(community=plain_team) + + def test_an_unpublished_event_is_refused(self): + draft = self.create_event("Draft Meetup", is_published=0) + + with self.assertRaises(frappe.ValidationError): + self.submit(event=draft) + + def test_the_community_managers_are_emailed(self): + self.submit() + + self.assertEqual(self.sendmail.call_args.kwargs["recipients"], [self.curator]) + + def test_communities_already_asked_are_not_offered_again(self): + self.submit() + frappe.set_user(self.organiser) + + offered = [community.name for community in get_event_requests(self.event).communities] + + self.assertNotIn(self.community, offered) + self.assertIn(self.other_community, offered) + + def test_the_page_offers_only_events_not_yet_submitted(self): + other = self.create_event("Second Meetup") + self.submit() + frappe.set_user(self.organiser) + + offered = [event.name for event in get_submittable_events(self.community)] + + self.assertIn(other, offered) + self.assertNotIn(self.event, offered) + + +class TestReviewing(CommunityTestCase): + def review(self, method, *args): + frappe.set_user(self.curator) + method(*args) + frappe.set_user("Administrator") + + def test_approving_lists_the_event_on_the_community_page(self): + request = self.submit() + + self.review(approve_request, request) + + self.assertEqual(self.status_of(request), "Approved") + self.assertIn(self.event_title, self.page_titles()) + self.assertEqual(self.sendmail.call_args.kwargs["recipients"], [self.organiser]) + + def test_pending_and_rejected_events_stay_off_the_page(self): + self.submit() + rejected = self.submit(community=self.other_community) + + self.review(reject_request, rejected, "Not a fit") + + self.assertNotIn(self.event_title, self.page_titles()) + self.assertEqual(frappe.db.get_value("Community Event Request", rejected, "review_note"), "Not a fit") + + def test_an_event_team_manager_cannot_review(self): + request = self.submit() + frappe.set_user(self.organiser) + + with self.assertRaises(CannotReviewRequests): + approve_request(request) + + def test_a_reviewed_request_cannot_be_reviewed_again(self): + request = self.submit() + self.review(approve_request, request) + + with self.assertRaises(RequestNotPending): + self.review(reject_request, request) + + def test_a_rejected_request_can_be_resubmitted(self): + request = self.submit() + self.review(reject_request, request) + frappe.set_user(self.organiser) + + resubmit_request(request) + + self.assertEqual(self.status_of(request), "Pending") + + def test_only_a_rejected_request_is_resubmitted(self): + request = self.submit() + frappe.set_user(self.organiser) + + with self.assertRaises(RequestNotRejected): + resubmit_request(request) + + def test_removing_an_approved_event_rejects_it(self): + request = self.submit() + self.review(approve_request, request) + + self.review(remove_event, request) + + self.assertEqual(self.status_of(request), "Rejected") + + def test_deleting_a_submitted_event_takes_its_requests_along(self): + request = self.submit() + # The defaults made with every event block a delete on their own links. + for doctype in ("Sponsorship Tier", "Event Ticket Type"): + frappe.db.delete(doctype, {"event": self.event}) + + frappe.delete_doc("Buzz Event", self.event) + + self.assertFalse(frappe.db.exists("Community Event Request", request)) + + def test_withdrawing_deletes_the_request(self): + request = self.submit() + frappe.set_user(self.organiser) + + withdraw_request(request) + + self.assertFalse(frappe.db.exists("Community Event Request", request)) + + def test_a_curator_adds_an_event_directly(self): + self.review(add_event, self.community, self.event) + frappe.set_user(self.curator) + + queue = get_requests(self.community) + + self.assertEqual([request.event for request in queue.approved], [self.event]) + self.assertEqual(queue.pending, []) + + 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 a3e464b7..0448b3bf 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -59,6 +59,18 @@ def update_team(team: str, team_name: str, logo: str | None = None) -> None: services.update_team(team, team_name, logo) +@frappe.whitelist(methods=["POST"]) +def update_public_page( + team: str, + is_published: bool, + links: list[dict], + short_description: str | None = None, + about: str | None = None, + is_a_community: bool = False, +) -> None: + services.update_public_page(team, is_published, links, short_description, about, is_a_community) + + @frappe.whitelist(methods=["POST"]) def resend_invite(team: str, email: str) -> None: invitations.resend_invite(team, email) diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index 584f7977..c22dcdcc 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -1,3 +1,4 @@ +from buzz.api.events.schemas import EventExternalLink from buzz.api.schemas import APIResponse @@ -35,6 +36,13 @@ class TeamOverview(APIResponse): team_name: str slug: str | None logo: str | None + is_published: bool + is_a_community: bool + # Absolute page URL while published; None otherwise. + public_url: str | None + short_description: str | None + about: str | None + links: list[EventExternalLink] my_role: str members: list[TeamMember] # Kept apart from members: these people cannot do anything on the team yet. diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 26d5398c..37225639 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -1,6 +1,8 @@ import frappe from frappe.query_builder import Case +from frappe.utils import get_url +from buzz.api.events.schemas import EventExternalLink from buzz.api.teams.exceptions import ( CannotEditTeam, CannotGrantOwnership, @@ -9,9 +11,19 @@ UnknownTeamRole, ) from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview -from buzz.permissions import can_manage_members, team_role_of - -TEAM_FIELDS = ("name", "team_name", "slug", "logo") +from buzz.permissions import WRITE_ROLES, can_manage_members, team_role_of + +TEAM_FIELDS = ( + "name", + "team_name", + "slug", + "logo", + "is_published", + "is_a_community", + "route", + "short_description", + "about", +) def validate_role(team_role: str) -> None: @@ -42,8 +54,11 @@ def team_overview(team: str) -> TeamOverview: if not details: NotATeamMember.throw() + route = details.pop("route") return TeamOverview( **details, + public_url=get_url(f"/{route}") if details.is_published and route else None, + links=links_of(team), my_role=role, members=members_of(team), invites=pending_invites_for(team), @@ -162,3 +177,39 @@ def update_team(team: str, team_name: str, logo: str | None) -> None: doc.team_name = team_name doc.logo = logo or None doc.save(ignore_permissions=True) + + +def links_of(team: str) -> list[EventExternalLink]: + rows = frappe.get_all( + "Event External Link", + filters={"parenttype": "Buzz Team", "parent": team, "parentfield": "links"}, + fields=["icon", "label", "url"], + order_by="idx", + ) + return [EventExternalLink(**row) for row in rows] + + +def update_public_page( + team: str, + is_published: bool, + links: list[dict], + short_description: str | None, + about: str | None, + is_a_community: bool = False, +) -> None: + """Publish or edit a team's public page. + + Owner/Admin/Manager, the roles that edit the team's events. Desk write on Buzz Team is + System Manager only, so the guard here is the authorization — the same shape as + `update_team`. The route is not editable here: it is filled on first publish. + """ + if team_role_of(frappe.session.user, team) not in WRITE_ROLES: + CannotEditTeam.throw() + + doc = frappe.get_doc("Buzz Team", team) + doc.is_published = int(is_published) + doc.is_a_community = int(is_a_community) + doc.short_description = (short_description or "").strip() or None + doc.about = about or None + doc.set("links", [{key: link.get(key) for key in ("icon", "label", "url")} for link in links]) + doc.save(ignore_permissions=True) diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index 6db30003..b1cf3c44 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -1,7 +1,7 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.teams import get_my_teams, get_team_overview, update_team +from buzz.api.teams import get_my_teams, get_team_overview, update_public_page, update_team from buzz.api.teams.exceptions import CannotEditTeam, NotATeamMember from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory @@ -167,3 +167,61 @@ def test_a_blank_name_is_refused(self): with self.set_user(owner), self.assertRaises(frappe.ValidationError): update_team(team, " ", None) + + +class TestUpdatePublicPage(TeamTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("public-page-owner@example.com").name + cls.manager = UserFactory.create_once("public-page-manager@example.com").name + cls.viewer = UserFactory.create_once("public-page-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner, team_name="Public Page Builders").name + for user, team_role in ((cls.manager, "Manager"), (cls.viewer, "Viewer")): + BuzzTeamMembershipFactory.create(team=cls.team, user=user, team_role=team_role) + + def overview(self): + with self.set_user(self.owner): + return get_team_overview(self.team) + + def publish_as(self, user: str, **overrides): + with self.set_user(user): + update_public_page(**({"team": self.team, "is_published": True, "links": []} | overrides)) + + def test_a_manager_publishes_the_page(self): + self.publish_as(self.manager, short_description="Builders in public.") + + overview = self.overview() + + self.assertTrue(overview.is_published) + self.assertEqual(overview.short_description, "Builders in public.") + self.assertTrue( + overview.public_url.endswith(f"/community/{frappe.db.get_value('Buzz Team', self.team, 'slug')}") + ) + + def test_links_are_saved_in_order(self): + links = [ + {"icon": "globe", "label": "Site", "url": "https://example.com"}, + {"icon": "message-circle", "label": "Forum", "url": "https://forum.example.com"}, + ] + self.publish_as(self.owner, links=links) + + overview = self.overview() + + self.assertEqual([link.label for link in overview.links], ["Site", "Forum"]) + + def test_a_viewer_cannot_edit_the_page(self): + with self.assertRaises(CannotEditTeam): + self.publish_as(self.viewer, short_description="Hijacked") + + 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.assertTrue(self.overview().is_a_community) + + def test_an_unpublished_page_has_no_public_url(self): + self.publish_as(self.owner, is_published=False) + + self.assertIsNone(self.overview().public_url) diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index f171119e..8ed71555 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -309,6 +309,8 @@ def on_trash(self): sponsorship_form_id = frappe.db.get_value("Sponsor Enquiry Form", {"event": self.name}) if sponsorship_form_id and not frappe.db.exists("Sponsorship Enquiry", {"event": self.name}): frappe.delete_doc("Sponsor Enquiry Form", sponsorship_form_id, ignore_permissions=True) + # A community's request to list the event means nothing once the event is gone. + frappe.db.delete("Community Event Request", {"event": self.name}) def create_default_records(self): records = [ diff --git a/buzz/events/doctype/buzz_team/buzz_team.json b/buzz/events/doctype/buzz_team/buzz_team.json index 1c6f5e30..e351f110 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.json +++ b/buzz/events/doctype/buzz_team/buzz_team.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_guest_to_view": 1, "allow_rename": 1, "autoname": "BTEAM.####", "creation": "2026-07-31 10:00:00", @@ -9,7 +10,16 @@ "team_name", "slug", "section_break_hvmt", - "logo" + "logo", + "public_page_tab", + "is_published", + "route", + "is_a_community", + "column_break_public", + "short_description", + "section_break_about", + "about", + "links" ], "fields": [ { @@ -36,11 +46,65 @@ "fieldname": "logo", "fieldtype": "Attach Image", "label": "Logo" + }, + { + "fieldname": "public_page_tab", + "fieldtype": "Tab Break", + "label": "Public Page" + }, + { + "default": "0", + "fieldname": "is_published", + "fieldtype": "Check", + "label": "Published" + }, + { + "description": "Where the public page lives. Filled as community/ on first publish.", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", + "no_copy": 1, + "permlevel": 1, + "unique": 1 + }, + { + "fieldname": "column_break_public", + "fieldtype": "Column Break" + }, + { + "fieldname": "short_description", + "fieldtype": "Small Text", + "label": "Short Description" + }, + { + "fieldname": "section_break_about", + "fieldtype": "Section Break" + }, + { + "fieldname": "about", + "fieldtype": "Text Editor", + "label": "About" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Event External Link" + }, + { + "default": "0", + "depends_on": "is_published", + "description": "Also lists approved events from other teams.", + "fieldname": "is_a_community", + "fieldtype": "Check", + "label": "Is a Community" } ], "grid_page_length": 50, + "has_web_view": 1, "image_field": "logo", "index_web_pages_for_search": 1, + "is_published_field": "is_published", "links": [ { "group": "Members", @@ -53,7 +117,7 @@ "link_fieldname": "team" } ], - "modified": "2026-08-04 17:38:48.007790", + "modified": "2026-10-06 14:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Team", @@ -81,6 +145,12 @@ "role": "Event Manager", "select": 1, "share": 1 + }, + { + "permlevel": 1, + "read": 1, + "role": "System Manager", + "write": 1 } ], "row_format": "Dynamic", diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index 7ff49962..4cc89e52 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.py +++ b/buzz/events/doctype/buzz_team/buzz_team.py @@ -3,12 +3,13 @@ import frappe from frappe import _ -from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists +from frappe.website.website_generator import WebsiteGenerator from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership from buzz.events.doctype.buzz_team_settings.buzz_team_settings import create_team_settings from buzz.permissions import can_manage_members, my_team_names +from buzz.www.site_header import apply_site_context SEARCH_LIMIT = 10 STANDARD_USERS = ("Administrator", "Guest") @@ -43,7 +44,7 @@ def set_team_from_sole_membership(doc, event=None): doc.team = teams[0] -class BuzzTeam(Document): +class BuzzTeam(WebsiteGenerator): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -52,17 +53,42 @@ class BuzzTeam(Document): if TYPE_CHECKING: from frappe.types import DF + from buzz.events.doctype.event_external_link.event_external_link import EventExternalLink + + about: DF.TextEditor | None + is_published: DF.Check + links: DF.Table[EventExternalLink] logo: DF.AttachImage | None + route: DF.Data | None + short_description: DF.SmallText | None slug: DF.Data | None team_name: DF.Data # end: auto-generated types + def autoname(self): + # WebsiteGenerator would name the team after its title; the BTEAM.#### series names it. + pass + def onload(self): + super().onload() self.set_onload("can_manage_members", can_manage_members(self.name)) def validate(self): if not self.slug: self.set_slug() + # After the slug: the first publish builds the route from it. + super().validate() + + def make_route(self) -> str: + return f"community/{self.slug}" + + def get_context(self, context): + from buzz.events.doctype.buzz_team.team_page import TeamPage + + apply_site_context(context) + # The header differs per visitor and the timeline changes by the hour. + context.no_cache = 1 + context.update(TeamPage(self).as_context()) def set_slug(self): slug = frappe.website.utils.cleanup_page_name(self.team_name).replace("_", "-") diff --git a/buzz/events/doctype/buzz_team/team_map.py b/buzz/events/doctype/buzz_team/team_map.py new file mode 100644 index 00000000..830f5da9 --- /dev/null +++ b/buzz/events/doctype/buzz_team/team_map.py @@ -0,0 +1,47 @@ +from collections import Counter + +import frappe +import pytz +from frappe.utils import today + + +def country_time_zones(country) -> list[str]: + # Frappe's list misses some zones, Svalbard's Arctic/Longyearbyen among them. + return (country.time_zones or "").split() or pytz.country_timezones.get((country.code or "").upper(), []) + + +class TeamMap: + """Map pins, region chips and calendar data for a team page, from its event cards.""" + + def __init__(self, upcoming: list[dict], past: list[dict]): + self.upcoming = upcoming + self.past = past + + def as_context(self) -> dict: + return { + "has_pins": any(event["latitude"] is not None for event in self.upcoming), + "countries": self.country_counts(), + "upcoming_count": len(self.upcoming), + "page_data": { + "upcoming": self.upcoming, + "past": self.past, + "today": today(), + "time_zone_countries": self.time_zone_countries(), + }, + } + + def countries(self) -> list[str]: + return [event["country"] for event in self.upcoming if event["country"]] + + def country_counts(self) -> list[dict]: + counts = sorted(Counter(self.countries()).items(), key=lambda item: (-item[1], item[0])) + return [{"name": name, "count": count} for name, count in counts] + + def time_zone_countries(self) -> dict[str, str]: + countries = set(self.countries()) + if not countries: + return {} + rows = frappe.get_all( + "Country", filters={"name": ["in", list(countries)]}, fields=["name", "code", "time_zones"] + ) + return {zone: row.name for row in rows for zone in country_time_zones(row)} diff --git a/buzz/events/doctype/buzz_team/team_page.py b/buzz/events/doctype/buzz_team/team_page.py new file mode 100644 index 00000000..09cd1481 --- /dev/null +++ b/buzz/events/doctype/buzz_team/team_page.py @@ -0,0 +1,166 @@ +import textwrap +from collections import defaultdict +from functools import cached_property +from itertools import groupby + +import frappe +from frappe import _ +from frappe.utils import date_diff, flt, format_date, get_url, getdate, today + +from buzz.api.booking.services import are_registrations_closed +from buzz.events.doctype.buzz_team.team_map import TeamMap +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 +from buzz.www.events import ( + CARD_FIELDS, + LISTING_LIMIT, + TIME_FIELDS, + exclude_ended_events, + has_ended, + has_started, + upcoming_filters, +) + +PAST_LIMIT = 30 +VENUE_FIELDS = [ + "venue.latitude as latitude", + "venue.longitude as longitude", + "venue.city as city", + "venue.venue_country as country", +] +# A community lists other teams' events too, so each card names its event's own team. +HOST_FIELDS = ["team.team_name as host_name", "team.logo as host_logo"] +EVENT_FIELDS = CARD_FIELDS + TIME_FIELDS + VENUE_FIELDS + HOST_FIELDS + ["name", "registrations_close_at"] + + +def day_labels(day) -> dict: + relative = {0: _("Today"), 1: _("Tomorrow")}.get(date_diff(day, today())) + return {"label": relative or format_date(day, "d MMM"), "weekday": format_date(day, "EEEE")} + + +def group_by_day(cards: list[dict]) -> list[dict]: + return [ + {"date": date, **day_labels(getdate(date)), "events": list(group)} + for date, group in groupby(cards, key=lambda card: card["date"]) + ] + + +def co_host_names_by_event(events: list[str]) -> dict[str, list[str]]: + """Co-host names per event, in table order, for every card in one query.""" + if not events: + return {} + co_host, host = frappe.qb.DocType("Event CoHost"), frappe.qb.DocType("Event Host") + rows = ( + frappe.qb.from_(co_host) + .join(host) + .on(host.name == co_host.host) + .select(co_host.parent, host.host_name) + .where((co_host.parenttype == "Buzz Event") & co_host.parent.isin(events)) + .orderby(co_host.idx) + ).run(as_dict=True) + names = defaultdict(list) + for row in rows: + names[row.parent].append(row.host_name) + return names + + +class TeamPage: + """Context for a team's public page: identity, links and its own event timeline.""" + + def __init__(self, team): + self.team = team + + def as_context(self) -> dict: + upcoming = [self.card(event) for event in self.upcoming_events] + past = [self.card(event) for event in self.past_events] + return { + "title": self.team.team_name, + "links": public_links(self.team.links), + "events_hosted": frappe.db.count("Buzz Event", {"team": self.team.name, "is_published": 1}), + "upcoming_days": group_by_day(upcoming), + "past_days": group_by_day(past), + "meta": self.meta(), + **TeamMap(upcoming, past).as_context(), + } + + @cached_property + def upcoming_events(self) -> list: + events = frappe.get_all( + "Buzz Event", + filters=upcoming_filters(), + or_filters=self.event_filters(), + fields=EVENT_FIELDS, + order_by="start_date asc, start_time asc", + limit=LISTING_LIMIT, + ) + return exclude_ended_events(events) + + @cached_property + def past_events(self) -> list: + # By start date: a one-day event leaves end_date blank, and has_ended makes the exact cut. + events = frappe.get_all( + "Buzz Event", + filters={"is_published": 1, "route": ["is", "set"], "start_date": ["<=", today()]}, + or_filters=self.event_filters(), + fields=EVENT_FIELDS, + order_by="start_date desc, start_time desc", + limit=PAST_LIMIT, + ) + return [event for event in events if has_ended(event)] + + @cached_property + def co_host_names(self) -> dict[str, list[str]]: + return co_host_names_by_event([str(event.name) for event in self.upcoming_events + self.past_events]) + + @cached_property + def attending_events(self) -> set[str]: + """Events the visitor holds a ticket for: the dashboard's Attending rule.""" + events = [str(event.name) for event in self.upcoming_events + self.past_events] + if frappe.session.user == "Guest" or not events: + return set() + tickets = {"attendee_email": frappe.session.user, "docstatus": 1, "event": ["in", events]} + return set(frappe.get_all("Event Ticket", filters=tickets, pluck="event")) + + 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: + approved = {"community": self.team.name, "status": "Approved"} + filters["name"] = [ + "in", + frappe.get_all("Community Event Request", filters=approved, pluck="event"), + ] + return filters + + def card(self, event) -> dict: + is_online = event.medium == "Online" + has_location = not is_online and bool(flt(event.latitude) and flt(event.longitude)) + return { + "route": event.route, + "date": str(getdate(event.start_date)), + "title": event.title, + "url": f"/events/{event.route}", + "image": event.card_image or event.banner_image, + "time": format_time(event.start_time), + "host_names": ", ".join([event.host_name, *self.co_host_names.get(str(event.name), [])]), + "host_logo": event.host_logo, + "place": _("Online") if is_online else event.venue_name, + "is_online": is_online, + "is_live": has_started(event) and not has_ended(event), + "is_attending": str(event.name) in self.attending_events, + # Past events have ended, so only upcoming cards can say this. + "registrations_closed": not has_ended(event) and are_registrations_closed(event), + "city": event.city, + "country": event.country, + "latitude": flt(event.latitude) if has_location else None, + "longitude": flt(event.longitude) if has_location else None, + } + + def meta(self) -> dict: + text = self.team.short_description or plain_text(self.team.about) + return { + "url": get_url(f"/{self.team.route}"), + "description": textwrap.shorten(text, DESCRIPTION_LENGTH, placeholder="…") if text else "", + "image": get_url(self.team.logo) if self.team.logo else "", + } diff --git a/buzz/events/doctype/buzz_team/templates/buzz_team.html b/buzz/events/doctype/buzz_team/templates/buzz_team.html new file mode 100644 index 00000000..d342ec46 --- /dev/null +++ b/buzz/events/doctype/buzz_team/templates/buzz_team.html @@ -0,0 +1,144 @@ +{% extends "buzz/templates/event_page/layout.html" %} + +{% block title %}{% autoescape true %}{{ title }}{% endautoescape %}{% endblock %} + +{% block meta_block %}{% autoescape true %} + + + + + + + {% if meta.image %}{% endif %} + + + + +{% endautoescape %}{% endblock %} + +{% block content %}{% autoescape true %} + {% macro event_card(event) %} + + {% if event.image %}{% else %}{% endif %} + + + {# Like the discover page: a live event says so instead of its start time. #} + {% if event.is_live %}{{ _("Live") }}{% else %}{{ event.time }}{% endif %} + {# An attendee already has their place, so closed registrations do not concern them. #} + {% if event.is_attending %}{{ _("Attending") }} + {% elif event.registrations_closed %}{{ _("Registrations closed") }}{% endif %} + + {{ event.title }} + + {% if event.host_logo %}{% else %}{% endif %} + {{ _("By {0}").format(event.host_names) }} + + {% if event.place %} + + + {{ event.place }} + + {% endif %} + + + {% endmacro %} + + {% macro timeline(days, empty_text) %} + {% for day in days %} +
+

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

+
{% for event in day.events %}{{ event_card(event) }}{% endfor %}
+
+ {% else %} +

{{ empty_text }}

+ {% endfor %} + + {% endmacro %} + + {% macro chevron(path) %}{% endmacro %} + + {% if has_pins %} +
+ +
+
+ + +
+

© OpenStreetMap

+
+ {% endif %} + +
+
+ {% if doc.logo %}{% else %}{% endif %} +
+

{{ doc.team_name }}

+

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

+
+
+ + {% if countries %} +
+ + {% for country in countries %} + + {% endfor %} +
+ {% endif %} + +
+
+
+ + +
+
{{ timeline(upcoming_days, _("No upcoming events.")) }}
+ +
+ + {% set has_events = upcoming_days or past_days %} + {% set has_about = doc.short_description or doc.about or links %} + {% if has_events or has_about %} + + {% endif %} +
+
+ + +{% endautoescape %}{% endblock %} + +{% block script %} + {{ super() }} + +{% endblock %} diff --git a/buzz/events/doctype/buzz_team/test_team_map.py b/buzz/events/doctype/buzz_team/test_team_map.py new file mode 100644 index 00000000..08131cf1 --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_map.py @@ -0,0 +1,62 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.events.doctype.buzz_team.team_page import TeamPage +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventVenueFactory + + +class TestTeamMap(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.team = BuzzTeamFactory.create_owned_by(team_name="Mapped Builders") + cls.create_event("Mumbai Meetup", "India", latitude=19.07, longitude=72.87, city="Mumbai") + cls.create_event("Pune Meetup", "India", latitude=18.52, longitude=73.86, city="Pune") + cls.create_event("Arctic Meetup", "Svalbard and Jan Mayen", latitude=78.22, longitude=15.65) + cls.create_event("Unmapped Meetup", "India") + BuzzEventFactory.create(title="Online Meetup", team=cls.team.name, **cls.dates()) + + @classmethod + def dates(cls) -> dict: + day = add_days(today(), 7) + return {"start_date": day, "end_date": day} + + @classmethod + def create_event(cls, title, country, **venue): + venue = EventVenueFactory.create(team=cls.team.name, venue_country=country, **venue) + BuzzEventFactory.create( + title=title, team=cls.team.name, medium="In Person", venue=venue.name, **cls.dates() + ) + + def context(self) -> dict: + return TeamPage(frappe.get_doc("Buzz Team", self.team.name)).as_context() + + def pinned_titles(self) -> set[str]: + events = self.context()["page_data"]["upcoming"] + return {event["title"] for event in events if event["latitude"] is not None} + + def test_only_venues_with_coordinates_are_pinned(self): + self.assertEqual(self.pinned_titles(), {"Mumbai Meetup", "Pune Meetup", "Arctic Meetup"}) + + def test_countries_count_upcoming_events(self): + countries = self.context()["countries"] + + self.assertEqual( + countries, [{"name": "India", "count": 3}, {"name": "Svalbard and Jan Mayen", "count": 1}] + ) + + def test_time_zones_cover_each_country_with_the_pytz_fallback(self): + zones = self.context()["page_data"]["time_zone_countries"] + + self.assertEqual(zones["Asia/Kolkata"], "India") + # Frappe's Country record for Svalbard lists no zones; pytz knows it. + self.assertEqual(zones["Arctic/Longyearbyen"], "Svalbard and Jan Mayen") + + def test_the_map_needs_a_pin(self): + team = BuzzTeamFactory.create_owned_by(team_name="Online Only Builders") + + context = TeamPage(team).as_context() + + self.assertFalse(context["has_pins"]) diff --git a/buzz/events/doctype/buzz_team/test_team_page.py b/buzz/events/doctype/buzz_team/test_team_page.py new file mode 100644 index 00000000..6ed78979 --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_page.py @@ -0,0 +1,89 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, format_date, getdate, today + +from buzz.events.doctype.buzz_team.team_page import TeamPage, day_labels +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + + +def event_titles(days: list[dict]) -> list[str]: + return [event["title"] for day in days for event in day["events"]] + + +class TestTeamPage(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.team = BuzzTeamFactory.create_owned_by(team_name="Timeline Builders") + cls.other_team = BuzzTeamFactory.create_owned_by(team_name="Elsewhere Builders") + cls.create_event("Next Week Meetup", 7, start_time="18:30:00", end_time="20:00:00") + cls.create_event("Last Month Meetup", -30) + cls.create_event("Draft Meetup", 7, is_published=0) + cls.create_event("Other Team Meetup", 7, team=cls.other_team.name) + cls.create_event("Ended This Morning", 0, start_time="00:00:00", end_time="00:00:01") + + @classmethod + def create_event(cls, title, offset, **overrides): + day = add_days(today(), offset) + attributes = {"title": title, "team": cls.team.name, "start_date": day, "end_date": day} + return BuzzEventFactory.create(**(attributes | overrides)) + + def context(self) -> dict: + return TeamPage(frappe.get_doc("Buzz Team", self.team.name)).as_context() + + def test_upcoming_lists_only_the_teams_published_upcoming_events(self): + titles = event_titles(self.context()["upcoming_days"]) + + self.assertIn("Next Week Meetup", titles) + self.assertNotIn("Draft Meetup", titles) + self.assertNotIn("Other Team Meetup", titles) + self.assertNotIn("Last Month Meetup", titles) + + def test_an_event_that_ended_today_moves_to_past(self): + context = self.context() + + self.assertNotIn("Ended This Morning", event_titles(context["upcoming_days"])) + self.assertIn("Ended This Morning", event_titles(context["past_days"])) + + def test_past_is_newest_first(self): + titles = event_titles(self.context()["past_days"]) + + self.assertLess(titles.index("Ended This Morning"), titles.index("Last Month Meetup")) + + def test_card_carries_time_host_and_place(self): + card = next( + event + for day in self.context()["upcoming_days"] + for event in day["events"] + if event["title"] == "Next Week Meetup" + ) + + self.assertEqual(card["time"], "18:30") + self.assertEqual(card["host_names"], "Timeline Builders") + self.assertTrue(card["is_online"]) + self.assertTrue(card["url"].startswith("/events/")) + + def test_day_labels_are_relative_then_dates(self): + self.assertEqual(day_labels(getdate(today()))["label"], "Today") + self.assertEqual(day_labels(getdate(add_days(today(), 1)))["label"], "Tomorrow") + later = getdate(add_days(today(), 5)) + self.assertEqual(day_labels(later)["label"], format_date(later, "d MMM")) + self.assertEqual(day_labels(later)["weekday"], format_date(later, "EEEE")) + + def test_events_hosted_counts_published_events(self): + self.assertEqual(self.context()["events_hosted"], 3) + + def test_links_drop_non_web_urls(self): + team = frappe.get_doc("Buzz Team", self.team.name) + team.set( + "links", + [ + {"icon": "globe", "label": "Site", "url": "https://example.com"}, + {"icon": "link", "label": "Sneaky", "url": "javascript:alert(1)"}, + ], + ) + + labels = [link["label"] for link in TeamPage(team).as_context()["links"]] + + self.assertEqual(labels, ["Site"]) diff --git a/buzz/events/doctype/buzz_team/test_team_page_render.py b/buzz/events/doctype/buzz_team/test_team_page_render.py new file mode 100644 index 00000000..9c240edc --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_page_render.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.website.serve import get_response, get_response_content + +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + + +class TestTeamPageRender(IntegrationTestCase): + def setUp(self): + # CI never runs bench build, so there is no assets.json for bundled_asset to read + assets_patch = patch("frappe.utils.get_assets_json", return_value={}) + assets_patch.start() + self.addCleanup(assets_patch.stop) + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + + def published_team(self, team_name: str, **overrides): + frappe.set_user("Administrator") + team = BuzzTeamFactory.create_owned_by(team_name=team_name, is_published=1, **overrides) + frappe.set_user("Guest") + return team + + def test_a_guest_sees_the_published_page(self): + team = self.published_team("Rendered Builders", short_description="We build in public.") + frappe.set_user("Administrator") + BuzzEventFactory.create(title="Rendered Meetup", team=team.name) + frappe.set_user("Guest") + + html = get_response_content(f"/{team.route}") + + self.assertIn("Rendered Builders", html) + self.assertIn("We build in public.", html) + self.assertIn("Rendered Meetup", html) + self.assertIn("es-tab-buttons", html) + + def test_a_signed_in_visitor_sees_the_page(self): + team = self.published_team("Signed In Builders") + frappe.set_user("Administrator") + + response = get_response(f"/{team.route}") + + self.assertEqual(response.status_code, 200) + + def test_a_team_with_no_events_still_renders(self): + team = self.published_team("Empty Builders") + + html = get_response_content(f"/{team.route}") + + self.assertIn("No upcoming events.", html) + self.assertIn("No past events.", html) + + def test_unpublishing_takes_the_page_down_at_once(self): + team = self.published_team("Fleeting Builders") + self.assertEqual(get_response(f"/{team.route}").status_code, 200) + + frappe.set_user("Administrator") + team.reload() + team.is_published = 0 + team.save() + frappe.set_user("Guest") + + self.assertEqual(get_response(f"/{team.route}").status_code, 404) + + def test_the_context_opts_out_of_the_page_cache(self): + # Developer mode never caches pages, so the flag itself is what can be checked. + team = self.published_team("Fresh Builders") + context = frappe._dict() + + frappe.get_doc("Buzz Team", team.name).get_context(context) + + self.assertEqual(context.no_cache, 1) diff --git a/buzz/events/doctype/buzz_team/test_team_route.py b/buzz/events/doctype/buzz_team/test_team_route.py new file mode 100644 index 00000000..95373aad --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_route.py @@ -0,0 +1,43 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.website.path_resolver import PathResolver + +from buzz.tests.factories import BuzzTeamFactory + + +class TestTeamRoute(IntegrationTestCase): + def setUp(self): + frappe.set_user("Administrator") + + def publish(self, team): + team.is_published = 1 + team.save() + return team + + def test_publishing_sets_the_community_route(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Route Builders")) + + self.assertEqual(team.route, f"community/{team.slug}") + + def test_an_unpublished_team_has_no_route(self): + team = BuzzTeamFactory.create_owned_by(team_name="Quiet Builders") + + self.assertFalse(team.route) + + def test_a_hand_set_top_level_route_resolves(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Featured Builders")) + route = f"featured-builders-{frappe.generate_hash(length=6)}" + team.route = route + team.save() + + _, renderer = PathResolver(route).resolve() + + self.assertEqual(renderer.docname, team.name) + + def test_a_slug_edit_keeps_the_route(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Stable Builders")) + route = team.route + team.slug = f"renamed-{frappe.generate_hash(length=6)}" + team.save() + + self.assertEqual(team.route, route) diff --git a/buzz/events/doctype/community_event_request/__init__.py b/buzz/events/doctype/community_event_request/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/events/doctype/community_event_request/community_event_request.js b/buzz/events/doctype/community_event_request/community_event_request.js new file mode 100644 index 00000000..70b9d208 --- /dev/null +++ b/buzz/events/doctype/community_event_request/community_event_request.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, BWH Studios and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Community Event Request", { +// refresh(frm) { + +// }, +// }); diff --git a/buzz/events/doctype/community_event_request/community_event_request.json b/buzz/events/doctype/community_event_request/community_event_request.json new file mode 100644 index 00000000..a1e7c2e4 --- /dev/null +++ b/buzz/events/doctype/community_event_request/community_event_request.json @@ -0,0 +1,141 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "autoname": "hash", + "creation": "2026-10-06 16:57:41.747837", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "event", + "event_title", + "event_team", + "column_break_community", + "community", + "status", + "section_break_review", + "submitted_by", + "column_break_review", + "reviewed_by", + "review_note" + ], + "fields": [ + { + "fieldname": "event", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Event", + "options": "Buzz Event", + "reqd": 1 + }, + { + "fetch_from": "event.title", + "fieldname": "event_title", + "fieldtype": "Data", + "label": "Event Title", + "read_only": 1 + }, + { + "fetch_from": "event.team", + "fieldname": "event_team", + "fieldtype": "Link", + "label": "Event Team", + "options": "Buzz Team", + "read_only": 1 + }, + { + "fieldname": "column_break_community", + "fieldtype": "Column Break" + }, + { + "fieldname": "community", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Community", + "options": "Buzz Team", + "reqd": 1 + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Pending\nApproved\nRejected", + "reqd": 1 + }, + { + "fieldname": "section_break_review", + "fieldtype": "Section Break", + "label": "Review" + }, + { + "fieldname": "submitted_by", + "fieldtype": "Link", + "label": "Submitted By", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "column_break_review", + "fieldtype": "Column Break" + }, + { + "fieldname": "reviewed_by", + "fieldtype": "Link", + "label": "Reviewed By", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "review_note", + "fieldtype": "Small Text", + "label": "Review Note" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-10-06 16:57:41.747837", + "modified_by": "Administrator", + "module": "Events", + "name": "Community Event Request", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Event Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "event_title", + "track_changes": 1 +} diff --git a/buzz/events/doctype/community_event_request/community_event_request.py b/buzz/events/doctype/community_event_request/community_event_request.py new file mode 100644 index 00000000..860a7433 --- /dev/null +++ b/buzz/events/doctype/community_event_request/community_event_request.py @@ -0,0 +1,79 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from buzz.permissions import WRITE_PTYPES, as_sql, is_unrestricted, my_teams, team_role_of + + +class CommunityEventRequest(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + community: DF.Link + event: DF.Link + event_team: DF.Link | None + event_title: DF.Data | None + review_note: DF.SmallText | None + reviewed_by: DF.Link | None + status: DF.Literal["Pending", "Approved", "Rejected"] + submitted_by: DF.Link | None + # end: auto-generated types + + def validate(self): + 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_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 + ) + if not (community and community.is_a_community 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.")) + + def validate_unique_pair(self): + pair = {"event": self.event, "community": self.community, "name": ["!=", self.name]} + if frappe.db.exists("Community Event Request", pair): + frappe.throw( + _("This event already has a request with this community."), frappe.DuplicateEntryError + ) + + +def on_doctype_update(): + # validate_unique_pair names the clash; the index stops two concurrent inserts. + frappe.db.add_unique("Community Event Request", ["event", "community"]) + + +def has_request_permission(doc, ptype: str = "read", user: str | None = None, **kwargs) -> bool: + """Members of either team read a request; changes go through buzz.api.communities.""" + user = user or frappe.session.user + if is_unrestricted(user): + return True + if ptype in WRITE_PTYPES: + return False + return bool(team_role_of(user, doc.event_team) or team_role_of(user, doc.community)) + + +def request_query_conditions(user: str | None = None, **kwargs) -> str | None: + user = user or frappe.session.user + if is_unrestricted(user): + return None + table = frappe.qb.DocType("Community Event Request") + return as_sql(table.event_team.isin(my_teams(user)) | table.community.isin(my_teams(user))) diff --git a/buzz/events/doctype/event_venue/event_venue.json b/buzz/events/doctype/event_venue/event_venue.json index 1f28a247..b62154f5 100644 --- a/buzz/events/doctype/event_venue/event_venue.json +++ b/buzz/events/doctype/event_venue/event_venue.json @@ -17,6 +17,8 @@ "section_break_cexp", "longitude", "latitude", + "city", + "venue_country", "column_break_xfsv", "location" ], @@ -102,12 +104,25 @@ "fieldname": "map_link", "fieldtype": "Small Text", "label": "Map Link" + }, + { + "description": "Filled from OpenStreetMap when empty.", + "fieldname": "city", + "fieldtype": "Data", + "label": "City" + }, + { + "description": "Filled from OpenStreetMap when empty.", + "fieldname": "venue_country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-09-30 15:00:00.000000", + "modified": "2026-10-06 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Event Venue", diff --git a/buzz/events/doctype/event_venue/event_venue.py b/buzz/events/doctype/event_venue/event_venue.py index 0ac6ee32..96c34890 100644 --- a/buzz/events/doctype/event_venue/event_venue.py +++ b/buzz/events/doctype/event_venue/event_venue.py @@ -7,6 +7,7 @@ from frappe import _ from frappe.model.document import Document +from buzz.events.doctype.event_venue.geocode import enqueue_geocode, needs_geocoding from buzz.events.doctype.event_venue.map_link import read_map_link from buzz.www.event.venue_map import google_maps_url @@ -21,12 +22,14 @@ class EventVenue(Document): from frappe.types import DF address: DF.SmallText | None + city: DF.Data | None google_maps_embed_code: DF.Code | None google_place_id: DF.Data | None latitude: DF.Float longitude: DF.Float map_link: DF.SmallText | None team: DF.Link | None + venue_country: DF.Link | None venue_name: DF.Data type: DF.Literal["Embed Google Maps", "Open Street Map"] # end: auto-generated types @@ -37,6 +40,13 @@ def validate(self): self.set_geojson_for_location() self.remove_fixed_dimensions_from_google_map_embed() + def on_update(self): + # A live worker would call Nominatim mid-test. + if frappe.in_test or self.flags.geocoded or not needs_geocoding(self): + return + if any(self.has_value_changed(field) for field in ("address", "latitude", "longitude")): + enqueue_geocode(self.name) + def set_location_from_map_link(self): if not self.map_link or not self.has_value_changed("map_link"): return diff --git a/buzz/events/doctype/event_venue/geocode.py b/buzz/events/doctype/event_venue/geocode.py new file mode 100644 index 00000000..9217e309 --- /dev/null +++ b/buzz/events/doctype/event_venue/geocode.py @@ -0,0 +1,124 @@ +import time + +import frappe +import requests +from frappe.utils import flt, get_url + +from buzz.events.doctype.event_venue.map_link import REQUEST_TIMEOUT_SECONDS + +NOMINATIM_URL = "https://nominatim.openstreetmap.org" +# Nominatim's usage policy allows one request a second. +REQUEST_INTERVAL_SECONDS = 1 +CITY_KEYS = ("city", "town", "village", "municipality") +# Nominatim finds nothing for many street-level addresses, Google's especially; their last +# few parts (locality, city, state, country) still place the venue well enough for a pin. +ADDRESS_ENDINGS = (4, 3) +# Up to four lookups a venue at one a second keeps a batch well inside its job timeout. +GEOCODE_BATCH_SIZE = 200 + + +def has_coordinates(venue) -> bool: + return bool(flt(venue.latitude) and flt(venue.longitude)) + + +def needs_geocoding(venue) -> bool: + is_complete = has_coordinates(venue) and venue.city and venue.venue_country + return bool(venue.address or has_coordinates(venue)) and not is_complete + + +def enqueue_geocode(venue_name: str) -> None: + frappe.enqueue( + "buzz.events.doctype.event_venue.geocode.geocode_venue", + venue_name=venue_name, + job_id=f"geocode_venue::{venue_name}", + deduplicate=True, + enqueue_after_commit=True, + ) + + +def geocode_venue(venue_name: str) -> None: + VenueGeocoder(frappe.get_doc("Event Venue", venue_name)).fill() + + +def enqueue_geocode_batch(venue_names: list[str]) -> None: + frappe.enqueue( + "buzz.events.doctype.event_venue.geocode.geocode_venues", + venue_names=venue_names, + queue="long", + timeout=3600, + ) + + +def geocode_venues(venue_names: list[str]) -> None: + """Geocodes one batch, then queues the rest, so batches never run side by side.""" + batch, rest = venue_names[:GEOCODE_BATCH_SIZE], venue_names[GEOCODE_BATCH_SIZE:] + try: + for venue_name in batch: + geocode_venue(venue_name) + time.sleep(REQUEST_INTERVAL_SECONDS) + finally: + if rest: + enqueue_geocode_batch(rest) + + +def venues_to_geocode() -> list[str]: + fields = ["name", "address", "latitude", "longitude", "city", "venue_country"] + return [venue.name for venue in frappe.get_all("Event Venue", fields=fields) if needs_geocoding(venue)] + + +def address_queries(address: str) -> list[str]: + """The full address, then its shorter endings, without repeats.""" + parts = [part.strip() for part in address.split(",") if part.strip()] + endings = [", ".join(parts[-size:]) for size in ADDRESS_ENDINGS if len(parts) > size] + return list(dict.fromkeys([address, *endings])) + + +def country_with_code(code: str | None) -> str | None: + return frappe.db.get_value("Country", {"code": code.lower()}) if code else None + + +class VenueGeocoder: + """Fills a venue's empty coordinates, city and country from OpenStreetMap Nominatim.""" + + def __init__(self, venue): + self.venue = venue + + def fill(self) -> None: + place = self.lookup() + if not place or "error" in place: + return + venue, address = self.venue, place.get("address") or {} + if not has_coordinates(venue): + venue.latitude, venue.longitude = flt(place.get("lat")), flt(place.get("lon")) + venue.city = venue.city or next((address[key] for key in CITY_KEYS if address.get(key)), None) + venue.venue_country = venue.venue_country or country_with_code(address.get("country_code")) + venue.flags.geocoded = True + venue.save(ignore_permissions=True) + + def lookup(self) -> dict | None: + if has_coordinates(self.venue): + # Zoom 10 answers at city level rather than with the nearest building. + return self.request( + "reverse", {"lat": self.venue.latitude, "lon": self.venue.longitude, "zoom": 10} + ) + for index, query in enumerate(address_queries(self.venue.address)): + if index: + time.sleep(REQUEST_INTERVAL_SECONDS) + if results := self.request("search", {"q": query, "limit": 1}): + return results[0] + return None + + def request(self, endpoint: str, params: dict): + try: + response = requests.get( + f"{NOMINATIM_URL}/{endpoint}", + params={**params, "format": "jsonv2", "addressdetails": 1}, + # Nominatim's usage policy asks each application to say who it is + headers={"User-Agent": f"Buzz event venues ({get_url()})"}, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return response.json() + except (requests.RequestException, ValueError): + frappe.log_error(f"Geocoding venue {self.venue.name} failed") + return None diff --git a/buzz/events/doctype/event_venue/test_geocode.py b/buzz/events/doctype/event_venue/test_geocode.py new file mode 100644 index 00000000..e38d065f --- /dev/null +++ b/buzz/events/doctype/event_venue/test_geocode.py @@ -0,0 +1,102 @@ +from unittest.mock import Mock, patch + +import frappe +import requests +from frappe.tests import IntegrationTestCase + +from buzz.events.doctype.event_venue.geocode import geocode_venue, venues_to_geocode +from buzz.tests.factories import BuzzTeamFactory, EventVenueFactory + +REQUESTS_GET = "buzz.events.doctype.event_venue.geocode.requests.get" + + +def nominatim_answer(payload) -> Mock: + return Mock(json=Mock(return_value=payload), raise_for_status=Mock()) + + +class TestGeocodeVenue(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.team = BuzzTeamFactory.create_owned_by().name + + def create_venue(self, **values): + return EventVenueFactory.create(team=self.team, **values) + + @patch(REQUESTS_GET) + def test_an_address_fills_coordinates_city_and_country(self, get): + get.return_value = nominatim_answer( + [{"lat": "19.0596", "lon": "72.8295", "address": {"city": "Mumbai", "country_code": "in"}}] + ) + venue = self.create_venue(address="Bandstand, Bandra West, Mumbai") + + geocode_venue(venue.name) + + venue.reload() + self.assertEqual((venue.latitude, venue.longitude), (19.0596, 72.8295)) + self.assertEqual((venue.city, venue.venue_country), ("Mumbai", "India")) + self.assertIn("/search", get.call_args.args[0]) + + @patch(REQUESTS_GET) + def test_set_values_are_never_overwritten(self, get): + get.return_value = nominatim_answer( + {"lat": "12.9", "lon": "77.5", "address": {"town": "Elsewhere", "country_code": "in"}} + ) + venue = self.create_venue(latitude=12.9699, longitude=77.6391, city="Indiranagar") + + geocode_venue(venue.name) + + venue.reload() + self.assertEqual((venue.latitude, venue.longitude), (12.9699, 77.6391)) + self.assertEqual((venue.city, venue.venue_country), ("Indiranagar", "India")) + self.assertIn("/reverse", get.call_args.args[0]) + + @patch("buzz.events.doctype.event_venue.geocode.time.sleep") + @patch(REQUESTS_GET) + def test_a_street_address_nominatim_misses_falls_back_to_its_locality(self, get, sleep): + found = [ + { + "lat": "12.9352", + "lon": "77.6245", + "address": {"suburb": "Koramangala", "city": "Bengaluru", "country_code": "in"}, + } + ] + get.side_effect = [nominatim_answer([]), nominatim_answer(found)] + venue = self.create_venue( + address="No. 374, 3rd Block, situated at, Koramangala, Bengaluru, Karnataka 560034, India" + ) + + geocode_venue(venue.name) + + venue.reload() + self.assertEqual((venue.latitude, venue.city), (12.9352, "Bengaluru")) + self.assertEqual( + get.call_args.kwargs["params"]["q"], "Koramangala, Bengaluru, Karnataka 560034, India" + ) + + @patch(REQUESTS_GET, side_effect=requests.ConnectionError) + def test_a_failed_lookup_leaves_the_venue_as_it_was(self, get): + venue = self.create_venue(address="Nowhere in particular") + + geocode_venue(venue.name) + + venue.reload() + self.assertFalse(venue.latitude or venue.venue_country) + + def test_complete_venues_are_not_geocoded_again(self): + complete = self.create_venue( + latitude=51.5, longitude=-0.12, city="London", venue_country="United Kingdom" + ) + incomplete = self.create_venue(address="1 Unknown Road") + + names = venues_to_geocode() + + self.assertIn(incomplete.name, names) + self.assertNotIn(complete.name, names) + + @patch("buzz.events.doctype.event_venue.event_venue.enqueue_geocode") + def test_saving_a_new_address_queues_a_lookup(self, enqueue_geocode): + with patch.object(frappe, "in_test", False): + venue = self.create_venue(address="2 New Street") + + enqueue_geocode.assert_called_once_with(venue.name) diff --git a/buzz/hooks.py b/buzz/hooks.py index 56175763..5c3bea3d 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -240,6 +240,7 @@ "Buzz Team": "buzz.permissions.team_doc_query_conditions", "Buzz Team Membership": "buzz.permissions.membership_query_conditions", "Talk Proposal": "buzz.proposals.doctype.talk_proposal.talk_proposal.get_permission_query_conditions", + "Community Event Request": "buzz.events.doctype.community_event_request.community_event_request.request_query_conditions", } # Every Buzz Team link field, not one form at a time: the permission hooks let a System @@ -273,6 +274,7 @@ "Buzz Team": "buzz.permissions.team_doc_has_permission", "Buzz Team Membership": "buzz.permissions.team_admin_has_permission", "Talk Proposal": "buzz.proposals.doctype.talk_proposal.talk_proposal.has_talk_proposal_permission", + "Community Event Request": "buzz.events.doctype.community_event_request.community_event_request.has_request_permission", } # Document Events diff --git a/buzz/patches.txt b/buzz/patches.txt index 8926e8c7..ecde99fa 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -29,3 +29,4 @@ buzz.patches.set_event_venue_name 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 diff --git a/buzz/patches/geocode_event_venues.py b/buzz/patches/geocode_event_venues.py new file mode 100644 index 00000000..a2c7e550 --- /dev/null +++ b/buzz/patches/geocode_event_venues.py @@ -0,0 +1,6 @@ +from buzz.events.doctype.event_venue.geocode import enqueue_geocode_batch, venues_to_geocode + + +def execute(): + """Venues saved before geocoding have no city or country; fill them in the background.""" + enqueue_geocode_batch(venues_to_geocode()) diff --git a/buzz/public/css/espresso.bundle.scss b/buzz/public/css/espresso.bundle.scss index 9237d165..82a8c797 100644 --- a/buzz/public/css/espresso.bundle.scss +++ b/buzz/public/css/espresso.bundle.scss @@ -4,3 +4,5 @@ @import "./espresso/effects"; @import "./espresso/typography"; @import "./espresso/components/button"; +@import "./espresso/components/tab_buttons"; +@import "./espresso/components/badge"; diff --git a/buzz/public/css/espresso/components/badge.css b/buzz/public/css/espresso/components/badge.css new file mode 100644 index 00000000..72fb2066 --- /dev/null +++ b/buzz/public/css/espresso/components/badge.css @@ -0,0 +1,146 @@ +/* Copied from frappe frappe/public/css/espresso/components/badge.css @ 70b411faae. Re-copy, do not edit. */ +/* Espresso Badge — framework-neutral component, plain CSS. Styled via .es-badge + * + data-attributes (data-variant / data-size / data-theme), binding only to + * espresso color/radius/spacing tokens. Source of truth in Framework; frappe-ui + * apps import this and wrap it in @layer components for Tailwind override. + * Unlayered here (Frappe/Bootstrap is unlayered; a layer would lose to it). */ + +.es-badge { + /* default theme = gray (gray is the exception: solid bg is -10, text is + ink-base / ink-gray-6 instead of the -1 / -8 scale) */ + --c-solid-bg: var(--surface-gray-10); + --c-solid-fg: var(--ink-base); + --c-fill: var(--surface-gray-2); + --c-line: var(--outline-gray-2); + --c-fg: var(--ink-gray-6); + + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing); + /* 1 step (gap-1) */ + width: fit-content; + flex-shrink: 0; + user-select: none; + white-space: nowrap; + overflow: clip; + border: 1px solid transparent; + border-radius: var(--radius-full, 9999px); + font-weight: var(--font-weight-regular, 420); + + /* default size = md */ + height: calc(var(--spacing) * 5); + padding-inline: calc(var(--spacing) * 1.5); + font-size: 0.75rem; + line-height: 1; + + /* default variant = subtle */ + background: var(--c-fill); + color: var(--c-fg); +} + +/* ---- sizes (override the md default) ---- */ +.es-badge[data-size="sm"] { + height: calc(var(--spacing) * 4); + padding-inline: calc(var(--spacing) * 1.5); +} + +.es-badge[data-size="lg"] { + height: calc(var(--spacing) * 6); + padding-inline: calc(var(--spacing) * 2); + font-size: 0.8125rem; + letter-spacing: 0.02em; +} + +/* ---- themes (override the gray default — the unrolled @each) ---- */ +.es-badge[data-theme="blue"] { + --c-solid-bg: var(--surface-blue-7); + --c-solid-fg: var(--ink-blue-1); + --c-fill: var(--surface-blue-2); + --c-line: var(--outline-blue-3); + --c-fg: var(--ink-blue-8); +} + +.es-badge[data-theme="green"] { + --c-solid-bg: var(--surface-green-7); + --c-solid-fg: var(--ink-green-1); + --c-fill: var(--surface-green-2); + --c-line: var(--outline-green-3); + --c-fg: var(--ink-green-8); +} + +.es-badge[data-theme="amber"] { + --c-solid-bg: var(--surface-amber-7); + --c-solid-fg: var(--ink-amber-1); + --c-fill: var(--surface-amber-2); + --c-line: var(--outline-amber-3); + --c-fg: var(--ink-amber-8); +} + +.es-badge[data-theme="red"] { + --c-solid-bg: var(--surface-red-7); + --c-solid-fg: var(--ink-red-1); + --c-fill: var(--surface-red-2); + --c-line: var(--outline-red-3); + --c-fg: var(--ink-red-8); +} + +.es-badge[data-theme="violet"] { + --c-solid-bg: var(--surface-violet-7); + --c-solid-fg: var(--ink-violet-1); + --c-fill: var(--surface-violet-2); + --c-line: var(--outline-violet-3); + --c-fg: var(--ink-violet-8); +} + +/* ---- variants (override the subtle default) ---- + * outline/ghost must reset the subtle background the base applies. */ +.es-badge[data-variant="solid"] { + background: var(--c-solid-bg); + color: var(--c-solid-fg); +} + +.es-badge[data-variant="outline"] { + background: transparent; + color: var(--c-fg); + border-color: var(--c-line); +} + +.es-badge[data-variant="ghost"] { + background: transparent; + color: var(--c-fg); +} + +/* ---- icons ---- + * One rule covers both framework idioms: React passes icons as direct + * children; Vue wraps prefix/suffix slots in + * .es-badge__affix (like the frappe-ui badge). Sizing lives here, not per app. + * Icon size follows the badge size; the md default applies when size is absent. */ +.es-badge>svg { + pointer-events: none; +} + +.es-badge>svg, +.es-badge__affix { + flex-shrink: 0; + width: calc(var(--spacing) * 2.5); + height: calc(var(--spacing) * 2.5); +} + +.es-badge__affix { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* slot icons size to the affix, whatever size class they carry themselves */ +.es-badge__affix>svg { + width: 100%; + height: 100%; +} + +.es-badge[data-size="lg"]>svg, +.es-badge[data-size="lg"] .es-badge__affix { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); +} \ No newline at end of file diff --git a/buzz/public/css/espresso/components/tab_buttons.css b/buzz/public/css/espresso/components/tab_buttons.css new file mode 100644 index 00000000..809554ce --- /dev/null +++ b/buzz/public/css/espresso/components/tab_buttons.css @@ -0,0 +1,352 @@ +/* Copied from frappe frappe/public/css/espresso/components/tab_buttons.css @ 70b411faae. Re-copy, do not edit. */ +/* Espresso Tab Buttons — a segmented single-select where exactly one + * button is pressed (frappe-ui's TabButtons), built from Pills. For + * choices, not navigation: page-size selectors, calendar day/week/month, + * view toggles. No panels — that's what Tabs (es-tabs) are for. + * + * Two classes, mirroring frappe-ui's split: + * .es-pill the unitary piece (frappe-ui's Pill) — styled entirely + * by its own data-attributes, so it also works standalone + * .es-tab-buttons the group: the rail behind the pills, the gaps, the + * orientation + * + *
+ * + * + *
+ * + * Group attributes: + * data-type="subtle|ghost|underline|browser-tab" subtle is the default + * data-size="sm|md" sm is the default + * data-orientation="vertical" + * data-direction="right" which edge vertical browser-tabs attach to + * + * Pill attributes (data-state="active|inactive" is what frappe-ui's Pill + * emits, so Vue apps get this styling from their component as-is): + * data-state="active|inactive" + * data-variant="outline|underline|browser-tab" none = default look + * data-size="md" none = sm + * data-active-style="subtle" none = raised + * data-icon-only square, label is aria-only + * data-tab-base="default|left|right" browser-tab edge shape + * + * Sizing comes from padding + line-height only, never a fixed height. */ + +/* ================= the pill ================= */ + +.es-pill { + display: inline-flex; + box-sizing: border-box; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: calc(var(--spacing) * 2); + padding: 5px calc(var(--spacing) * 2); + border: 0; + background: transparent; + border-radius: 7px; + font-size: var(--text-sm); + line-height: calc(var(--spacing) * 4); + color: var(--ink-gray-5); + white-space: nowrap; + user-select: none; + cursor: pointer; + text-decoration: none; + transition: background-color 150ms ease-out, color 150ms ease-out, + box-shadow 150ms ease-out, border-color 150ms ease-out; +} + +.es-pill[data-size="md"] { + padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5); + border-radius: 9px; +} + +.es-pill[data-state="active"] { + color: var(--ink-gray-8); +} + +.es-pill:disabled, +.es-pill[data-disabled] { + opacity: 0.6; + pointer-events: none; +} + +.es-pill:focus-visible { + outline: var(--focus-outline-default, 2px solid var(--outline-gray-3)); + outline-offset: 0; +} + +/* icons: 16px at sm, 18px at md (Pill.vue size-4 / size-[18px]) */ +.es-pill > svg, +.es-pill > img, +.es-pill > .icon { + width: calc(var(--spacing) * 4); + height: calc(var(--spacing) * 4); + flex-shrink: 0; + margin: 0; +} + +.es-pill[data-size="md"] > svg, +.es-pill[data-size="md"] > img, +.es-pill[data-size="md"] > .icon { + width: 18px; + height: 18px; +} + +/* icon-only pills are square (Pill.vue: p-[5px]), label goes aria-only */ +.es-pill[data-icon-only] { + gap: calc(var(--spacing) * 1.5); + padding: 5px; +} + +.es-pill[data-size="md"][data-icon-only] { + padding: calc(var(--spacing) * 1.5); +} + +/* ---- default look: quiet, raised when pressed ---- */ +.es-pill:not([data-variant]):hover { + /* Pill.vue: hover:bg-surface-gray-3/80 — the token at 80% opacity */ + background: color-mix(in srgb, var(--surface-gray-3) 80%, transparent); + color: var(--ink-gray-7); +} + +.es-pill:not([data-variant])[data-state="active"] { + background: var(--surface-elevation-3); + box-shadow: var(--elevation-base); + color: var(--ink-gray-8); +} + +/* on a ghost group there is no rail to rise out of — the pressed pill + just tints (Pill's activeStyle="subtle") */ +.es-pill[data-active-style="subtle"][data-state="active"] { + background: var(--surface-gray-2); + box-shadow: none; +} + +/* ---- outline look ---- */ +.es-pill[data-variant="outline"] { + border: 1px solid var(--outline-gray-1); +} + +.es-pill[data-variant="outline"]:hover { + background: var(--surface-gray-2); + color: var(--ink-gray-7); +} + +.es-pill[data-variant="outline"][data-state="active"] { + border-color: transparent; + background: var(--surface-base); + box-shadow: var(--elevation-sm); +} + +/* ---- underline look: reads like a tab, works like a radio ---- */ +.es-pill[data-variant="underline"] { + position: relative; + border-radius: 0; + padding: 6px 0; + /* an invisible slot on the rail edge, so activating doesn't shift text */ + border-bottom: 1px solid transparent; +} + +.es-pill[data-variant="underline"][data-size="md"] { + padding: 7px 0; +} + +.es-pill[data-variant="underline"]:hover { + color: var(--ink-gray-7); +} + +.es-pill[data-variant="underline"][data-state="active"] { + font-weight: var(--font-weight-medium); + color: var(--ink-gray-8); +} + +/* the indicator overlays the group's rail segment so it reads as a darker + stretch of the same line, not a separate bar floating beside the label */ +.es-pill[data-variant="underline"][data-state="active"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: var(--ink-gray-8); +} + +/* vertical underline pills: the rail (and indicator) sit on the end edge */ +[data-orientation="vertical"] .es-pill[data-variant="underline"] { + padding: 6px calc(var(--spacing) * 2) 6px 0; + border-bottom: 0; + border-inline-end: 1px solid transparent; +} + +[data-orientation="vertical"] .es-pill[data-variant="underline"][data-size="md"] { + padding: 7px calc(var(--spacing) * 2) 7px 0; +} + +[data-orientation="vertical"] + .es-pill[data-variant="underline"][data-state="active"]::after { + left: auto; + right: -2px; + bottom: calc(var(--spacing) * 1.5); + top: calc(var(--spacing) * 1.5); + width: 1px; + height: auto; +} + +/* ---- browser-tab look ---- */ +.es-pill[data-variant="browser-tab"] { + border: 1px solid transparent; +} + +.es-pill[data-variant="browser-tab"]:hover { + background: var(--surface-gray-2); + color: var(--ink-gray-7); +} + +/* the active tab "punches through" the rail: a background-colored strip + extends a few pixels past the rail line so the tab visibly continues + into the area below instead of terminating at the rail (which the eye + reads as a closed corner). Border one step darker than the rail so the + tab reads as a distinct raised shape; no drop shadow — it would paint + below the rail and re-close the edge this opens. */ +.es-pill[data-variant="browser-tab"][data-state="active"] { + position: relative; + background: var(--surface-base); + border-color: var(--outline-gray-2); +} + +.es-pill[data-variant="browser-tab"][data-state="active"]::after { + content: ""; + position: absolute; + background: var(--surface-base); +} + +/* top-attached (horizontal groups) */ +.es-pill[data-variant="browser-tab"][data-tab-base="default"] { + border-radius: 7px 7px 0 0; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-size="md"] { + border-radius: 9px 9px 0 0; +} + +/* the rail-side edge stays 1px wide but transparent — zeroing its WIDTH + (border-bottom: 0) resets the color to currentColor, and on deselect + the border-color transition would animate that near-black back to + transparent: a dark line flashing under the tab you just left */ +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-state="active"] { + border-bottom-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-state="active"]::after { + left: -1px; + right: -1px; + bottom: -3px; + height: 3px; +} + +/* side-attached (vertical groups; base = the edge touching the rail) */ +.es-pill[data-variant="browser-tab"][data-tab-base="left"] { + border-radius: 0 7px 7px 0; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="left"][data-state="active"] { + border-left-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="left"][data-state="active"]::after { + left: -3px; + top: -1px; + bottom: -1px; + width: 3px; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"] { + border-radius: 7px 0 0 7px; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"][data-state="active"] { + border-right-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"][data-state="active"]::after { + right: -3px; + top: -1px; + bottom: -1px; + width: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .es-pill { + transition: none; + } +} + +/* ================= the group ================= */ + +.es-tab-buttons { + display: inline-flex; + align-items: center; + gap: calc(var(--spacing) * 1); + /* the 1px frame that shows the rail around the raised pill */ + padding: 1px; + background: var(--surface-gray-2); + /* rail radius = pill radius + the 1px frame */ + border-radius: 8px; +} + +.es-tab-buttons[data-size="md"] { + gap: calc(var(--spacing) * 1.5); + border-radius: 10px; +} + +.es-tab-buttons[data-type="ghost"] { + background: transparent; +} + +.es-tab-buttons[data-type="underline"] { + padding: 0; + gap: calc(var(--spacing) * 6); + background: transparent; + border-radius: 0; + border-bottom: 1px solid var(--outline-gray-1); +} + +.es-tab-buttons[data-type="browser-tab"] { + padding: 0; + gap: calc(var(--spacing) * 1); + background: transparent; + border-radius: 0; + border-bottom: 1px solid var(--outline-gray-1); +} + +/* ---- vertical groups ---- */ +.es-tab-buttons[data-orientation="vertical"] { + flex-direction: column; +} + +/* every pill hugs the start edge and spans the column, like vertical tabs */ +.es-tab-buttons[data-orientation="vertical"] .es-pill { + width: 100%; + justify-content: flex-start; + text-align: start; +} + +.es-tab-buttons[data-orientation="vertical"][data-type="underline"] { + border-bottom: 0; + border-inline-end: 1px solid var(--outline-gray-1); + gap: calc(var(--spacing) * 1.5); +} + +.es-tab-buttons[data-orientation="vertical"][data-type="browser-tab"] { + border-bottom: 0; + border-inline-start: 1px solid var(--outline-gray-1); +} + +.es-tab-buttons[data-orientation="vertical"][data-type="browser-tab"][data-direction="right"] { + border-inline-start: 0; + border-inline-end: 1px solid var(--outline-gray-1); +} diff --git a/buzz/public/images/world-dots.svg b/buzz/public/images/world-dots.svg new file mode 100644 index 00000000..a1c31048 --- /dev/null +++ b/buzz/public/images/world-dots.svg @@ -0,0 +1 @@ + diff --git a/buzz/public/js/team_page.bundle.ts b/buzz/public/js/team_page.bundle.ts new file mode 100644 index 00000000..bf0cbb1e --- /dev/null +++ b/buzz/public/js/team_page.bundle.ts @@ -0,0 +1,63 @@ +// A team page: Upcoming / Past tabs, and with events, the map, country chips and calendar. +import { MonthCalendar } from "./team_page/calendar" +import { TeamMap } from "./team_page/map" +import { readPageData } from "./team_page/page_data" +import { scrollAndHighlight, showPanel, wireTabs } from "./team_page/timeline" + +wireTabs() + +const data = readPageData() +const mapElement = document.querySelector("[data-map]") +const map = data && mapElement ? new TeamMap(mapElement, data.upcoming) : null +const visitorTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone +const visitorCountry = data?.time_zone_countries[visitorTimeZone] || "" +const countryChips = [...document.querySelectorAll("[data-country-filter]")] +const nearbyButton = document.querySelector('[data-zoom-to="nearby"]') +const worldButton = document.querySelector('[data-zoom-to="world"]') + +function hasVisibleEvents(scope: Element): boolean { + return Boolean(scope.querySelector("[data-event-card]:not([hidden])")) +} + +// Shows one country's events, or every event for "". +function filterByCountry(country: string) { + for (const chip of countryChips) { + chip.setAttribute("aria-pressed", String(chip.dataset.countryFilter === country)) + } + nearbyButton?.setAttribute("aria-pressed", String(Boolean(country) && country === visitorCountry)) + worldButton?.setAttribute("aria-pressed", String(!country)) + for (const card of document.querySelectorAll("[data-event-card]")) { + card.hidden = Boolean(country) && card.dataset.country !== country + } + for (const group of document.querySelectorAll("[data-day-group]")) { + group.hidden = !hasVisibleEvents(group) + } + // A panel with no events at all already says so; this note is for one the filter emptied. + for (const note of document.querySelectorAll("[data-filter-empty]")) { + const panel = note.parentElement! + note.hidden = !country || hasVisibleEvents(panel) || !panel.querySelector("[data-event-card]") + } + map?.filterByCountry(country) +} + +function showDate(date: string) { + const group = document.querySelector(`[data-day="${date}"]`) + if (!group) return + if (group.hidden) filterByCountry("") + showPanel(group.closest("[data-panel]")?.dataset.panel || "upcoming") + scrollAndHighlight(group) +} + +for (const chip of countryChips) { + chip.addEventListener("click", () => filterByCountry(chip.dataset.countryFilter || "")) +} +nearbyButton?.addEventListener("click", () => filterByCountry(visitorCountry)) +worldButton?.addEventListener("click", () => filterByCountry("")) +if (nearbyButton) nearbyButton.hidden = !visitorCountry +for (const card of document.querySelectorAll("[data-event-card]")) { + card.addEventListener("pointerenter", () => map?.highlightEvent(card.dataset.eventRoute || null)) + card.addEventListener("pointerleave", () => map?.highlightEvent(null)) +} +const calendarElement = document.querySelector("[data-calendar]") +if (data && calendarElement) new MonthCalendar(calendarElement, data, showDate).render() +if (data && countryChips.length) filterByCountry(visitorCountry) diff --git a/buzz/public/js/team_page/calendar.ts b/buzz/public/js/team_page/calendar.ts new file mode 100644 index 00000000..a72113fc --- /dev/null +++ b/buzz/public/js/team_page/calendar.ts @@ -0,0 +1,80 @@ +import { formatDay, type PageData } from "./page_data" + +// 1 January 2023 was a Sunday, the first column. +const WEEK = Array.from({ length: 7 }, (_, day) => new Date(2023, 0, 1 + day)) +const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "narrow" }) +const monthFormat = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }) + +// Swedish dates are written YYYY-MM-DD, the form the page data uses. +const isoDateFormat = new Intl.DateTimeFormat("sv-SE") + +/** A month grid with a dot on each day that has an event; a dotted day jumps to its group. */ +export class MonthCalendar { + private month: Date + private datesWithEvents: Set + + constructor( + private root: HTMLElement, + private data: PageData, + private onSelectDate: (date: string) => void, + ) { + const today = new Date(`${data.today}T00:00`) + this.month = new Date(today.getFullYear(), today.getMonth(), 1) + this.datesWithEvents = new Set([...data.upcoming, ...data.past].map((event) => event.date)) + for (const button of root.querySelectorAll("[data-calendar-step]")) { + button.addEventListener("click", () => this.changeMonth(Number(button.dataset.calendarStep))) + } + } + + private changeMonth(months: number) { + this.month = new Date(this.month.getFullYear(), this.month.getMonth() + months, 1) + this.render() + } + + render() { + this.root.querySelector("[data-calendar-title]")!.textContent = monthFormat.format(this.month) + const grid = this.root.querySelector("[data-calendar-grid]")! + grid.replaceChildren( + ...WEEK.map((day) => this.weekday(day)), + ...this.emptyCells(), + ...this.dayCells(), + ) + } + + private weekday(day: Date): HTMLElement { + const cell = document.createElement("span") + cell.className = "calendar-weekday" + cell.textContent = weekdayFormat.format(day) + return cell + } + + private emptyCells(): HTMLElement[] { + return Array.from({ length: this.month.getDay() }, () => document.createElement("span")) + } + + private dayCells(): HTMLElement[] { + const count = new Date(this.month.getFullYear(), this.month.getMonth() + 1, 0).getDate() + return Array.from({ length: count }, (_, index) => this.dayCell(index + 1)) + } + + private dayCell(number: number): HTMLElement { + const date = isoDateFormat.format( + new Date(this.month.getFullYear(), this.month.getMonth(), number), + ) + const hasEvents = this.datesWithEvents.has(date) + const cell = document.createElement(hasEvents ? "button" : "span") + cell.className = "calendar-day" + cell.textContent = String(number) + cell.toggleAttribute("data-today", date === this.data.today) + cell.toggleAttribute("data-past", date < this.data.today) + if (hasEvents) this.addDateButton(cell as HTMLButtonElement, date) + return cell + } + + private addDateButton(button: HTMLButtonElement, date: string) { + button.type = "button" + button.setAttribute("aria-label", formatDay(date)) + button.append(Object.assign(document.createElement("span"), { className: "calendar-dot" })) + button.addEventListener("click", () => this.onSelectDate(date)) + } +} diff --git a/buzz/public/js/team_page/map.ts b/buzz/public/js/team_page/map.ts new file mode 100644 index 00000000..3f9603bf --- /dev/null +++ b/buzz/public/js/team_page/map.ts @@ -0,0 +1,211 @@ +import { type EventCard, formatDay, reducedMotion } from "./page_data" +import { findEventCard, scrollAndHighlight } from "./timeline" + +interface View { + x: number + y: number + w: number + h: number +} + +interface Pin { + element: HTMLAnchorElement + x: number + y: number + country: string | null + events: EventCard[] + tooltip: HTMLElement + locationLabel: string +} + +// The equirectangular world, x = longitude + 180 and y = 90 - latitude, cropped to land. +const WORLD_VIEW: View = { x: 0, y: 6, w: 360, h: 140 } +const ZOOM_DURATION_MILLISECONDS = 420 +// The narrowest frame, in degrees: one country on its own is too few dots to read as land. +const MINIMUM_VIEW_WIDTH = 60 +// Everywhere frames a little wider than one country, so it differs from Nearby even when +// every event is in the same city. +const EVERYWHERE_MINIMUM_VIEW_WIDTH = 100 +// Land dots sit on a 1.2° grid and grow with the zoom, within a readable range of sizes. +const GRID_DEGREES = 1.2 +const DOT_SHARE_OF_GRID = 0.2 +const DOT_PIXELS = { minimum: 2.5, maximum: 6 } +// Within this many pixels of an edge, a tooltip turns away from it. +const TOOLTIP_EDGE_DISTANCE = { top: 96, side: 120 } + +const easeOut = (progress: number) => 1 - (1 - progress) ** 3 + +function createElement( + tag: K, + className: string, + text = "", +) { + return Object.assign(document.createElement(tag), { className, textContent: text }) +} + +function groupByLocation(events: EventCard[]): EventCard[][] { + const locations = new Map() + for (const event of events) { + // Two Cambridges are two locations; a venue without a city is its own location. + const location = event.city + ? `${event.city}|${event.country}` + : `${event.latitude},${event.longitude}` + locations.set(location, [...(locations.get(location) || []), event]) + } + return [...locations.values()] +} + +// A pin can hold several events; its tooltip shows one of them at a time. +function fillTooltip(tooltip: HTMLElement, event: EventCard, locationLabel: string) { + tooltip.replaceChildren( + createElement("strong", "", locationLabel), + createElement("span", "", event.title), + createElement("span", "", `${formatDay(event.date)} · ${event.time}`), + ) +} + +function createPin(events: EventCard[], isNextEvent: boolean): Pin { + const [first] = events + const locationLabel = first.city || first.country || "" + const link = createElement("a", "team-map-pin") + link.href = first.url + link.toggleAttribute("data-next-event", isNextEvent) + link.setAttribute("aria-label", `${locationLabel}: ${first.title}`) + const count = events.length > 1 ? String(events.length) : "" + const tooltip = createElement("span", "team-map-pin-tooltip") + fillTooltip(tooltip, first, locationLabel) + link.append(createElement("span", "team-map-pin-dot", count), tooltip) + link.addEventListener("click", (click) => { + const card = findEventCard(first.route) + if (!card?.offsetParent) return + click.preventDefault() + scrollAndHighlight(card) + }) + return { + element: link, + x: first.longitude! + 180, + y: 90 - first.latitude!, + country: first.country, + events, + tooltip, + locationLabel, + } +} + +/** The dot-matrix map: a pin per location, framed on a country by animating the viewBox. */ +export class TeamMap { + private svg: SVGSVGElement + private dots: SVGUseElement | null + private pins: Pin[] + private view: View + private country = "" + private frame = 0 + + constructor( + private root: HTMLElement, + upcoming: EventCard[], + ) { + this.svg = root.querySelector("svg")! + this.dots = root.querySelector(".team-map-land") + const locations = groupByLocation(upcoming.filter((event) => event.latitude !== null)) + this.pins = locations.map((events, index) => createPin(events, index === 0)) + root.querySelector(".team-map-pins")!.append(...this.pins.map((pin) => pin.element)) + this.view = this.targetView() + this.render() + new ResizeObserver(() => this.showView(this.targetView())).observe(root) + } + + filterByCountry(country: string) { + this.country = country + for (const pin of this.pins) { + pin.element.toggleAttribute("data-dimmed", Boolean(country) && pin.country !== country) + } + this.animateToView(this.targetView()) + } + + // A hovered card lights its pin, which then shows that card's event rather than the next one. + highlightEvent(route: string | null) { + for (const pin of this.pins) { + const event = pin.events.find((pinEvent) => pinEvent.route === route) + pin.element.toggleAttribute("data-highlighted", Boolean(event)) + fillTooltip(pin.tooltip, event || pin.events[0], pin.locationLabel) + } + } + + // Frames the pins in view with some padding: one country's, or every pin. + private targetView(): View { + const pins = this.pins.filter((pin) => !this.country || pin.country === this.country) + if (!pins.length || !this.root.clientWidth) return this.fitToAspectRatio(WORLD_VIEW) + const xs = pins.map((pin) => pin.x) + const ys = pins.map((pin) => pin.y) + const [left, right, top, bottom] = [ + Math.min(...xs), + Math.max(...xs), + Math.min(...ys), + Math.max(...ys), + ] + const minimumWidth = this.country ? MINIMUM_VIEW_WIDTH : EVERYWHERE_MINIMUM_VIEW_WIDTH + const w = Math.max(right - left + 16, minimumWidth) + const h = Math.max(bottom - top + 12, 16) + return this.fitToAspectRatio({ x: (left + right - w) / 2, y: (top + bottom - h) / 2, w, h }) + } + + // Grows the box on one axis to the strip's aspect ratio, so `slice` never crops a pin away. + private fitToAspectRatio(view: View): View { + const aspectRatio = this.root.clientWidth / this.root.clientHeight || 2 + const w = Math.max(view.w, view.h * aspectRatio) + const h = Math.max(view.h, view.w / aspectRatio) + return { x: view.x + (view.w - w) / 2, y: view.y + (view.h - h) / 2, w, h } + } + + private showView(view: View) { + cancelAnimationFrame(this.frame) + this.view = view + this.render() + } + + private animateToView(view: View) { + if (reducedMotion.matches) return this.showView(view) + cancelAnimationFrame(this.frame) + const from = { ...this.view } + const start = performance.now() + const step = (now: number) => { + const progress = easeOut(Math.min(1, (now - start) / ZOOM_DURATION_MILLISECONDS)) + for (const key of ["x", "y", "w", "h"] as const) + this.view[key] = from[key] + (view[key] - from[key]) * progress + this.render() + if (progress < 1) this.frame = requestAnimationFrame(step) + } + this.frame = requestAnimationFrame(step) + } + + private render() { + const { x, y, w, h } = this.view + this.svg.setAttribute("viewBox", `${x} ${y} ${w} ${h}`) + const { clientWidth: width, clientHeight: height } = this.root + const scale = Math.max(width / w, height / h) + this.sizeDots(scale) + for (const pin of this.pins) { + const left = (width - w * scale) / 2 + (pin.x - x) * scale + this.positionPin(pin, left, (height - h * scale) / 2 + (pin.y - y) * scale) + } + } + + // Stroke width is in map units, so a dot's pixel size is its width times the scale. + private sizeDots(scale: number) { + const pixels = Math.min( + Math.max(GRID_DEGREES * scale * DOT_SHARE_OF_GRID, DOT_PIXELS.minimum), + DOT_PIXELS.maximum, + ) + this.dots?.setAttribute("stroke-width", String(pixels / scale)) + } + + private positionPin(pin: Pin, left: number, top: number) { + pin.element.style.transform = `translate(${left}px, ${top}px)` + pin.element.toggleAttribute("data-tooltip-below", top < TOOLTIP_EDGE_DISTANCE.top) + const { side } = TOOLTIP_EDGE_DISTANCE + const width = this.root.clientWidth + pin.element.dataset.tooltipAlign = + left < side ? "start" : left > width - side ? "end" : "center" + } +} diff --git a/buzz/public/js/team_page/page_data.ts b/buzz/public/js/team_page/page_data.ts new file mode 100644 index 00000000..65583a79 --- /dev/null +++ b/buzz/public/js/team_page/page_data.ts @@ -0,0 +1,35 @@ +export interface EventCard { + route: string + date: string + title: string + url: string + time: string + city: string | null + country: string | null + latitude: number | null + longitude: number | null +} + +export interface PageData { + upcoming: EventCard[] + past: EventCard[] + today: string + time_zone_countries: Record +} + +export const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)") + +const dayFormat = new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", +}) + +export function formatDay(date: string): string { + return dayFormat.format(new Date(`${date}T00:00`)) +} + +export function readPageData(): PageData | null { + const script = document.getElementById("team-page-data") + return script ? JSON.parse(script.textContent || "null") : null +} diff --git a/buzz/public/js/team_page/timeline.ts b/buzz/public/js/team_page/timeline.ts new file mode 100644 index 00000000..e0f56e7a --- /dev/null +++ b/buzz/public/js/team_page/timeline.ts @@ -0,0 +1,43 @@ +import { reducedMotion } from "./page_data" + +const pills = [...document.querySelectorAll("[data-tab-buttons] .es-pill")] + +export function showPanel(name: string) { + for (const pill of pills) { + const active = pill.dataset.tab === name + pill.dataset.state = active ? "active" : "inactive" + pill.setAttribute("aria-checked", String(active)) + pill.tabIndex = active ? 0 : -1 + } + for (const panel of document.querySelectorAll("[data-panel]")) { + panel.hidden = panel.dataset.panel !== name + } +} + +// Arrow keys move within the group, as a radiogroup expects. +function moveFocus(from: HTMLButtonElement, offset: number) { + const next = pills[(pills.indexOf(from) + offset + pills.length) % pills.length] + showPanel(next.dataset.tab || "") + next.focus() +} + +export function wireTabs() { + for (const pill of pills) { + pill.addEventListener("click", () => showPanel(pill.dataset.tab || "")) + pill.addEventListener("keydown", (event) => { + if (event.key === "ArrowRight") moveFocus(pill, 1) + if (event.key === "ArrowLeft") moveFocus(pill, -1) + }) + } +} + +export function findEventCard(route: string): HTMLElement | null { + return document.querySelector(`[data-event-route="${CSS.escape(route)}"]`) +} + +// A brief ring, so the eye lands on what a calendar day or a pin pointed at. +export function scrollAndHighlight(element: HTMLElement) { + element.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "center" }) + element.classList.remove("is-highlighted") + requestAnimationFrame(() => element.classList.add("is-highlighted")) +} diff --git a/buzz/public/themes/team.css b/buzz/public/themes/team.css new file mode 100644 index 00000000..73a79808 --- /dev/null +++ b/buzz/public/themes/team.css @@ -0,0 +1,246 @@ +/* Team page: identity, a day timeline with the dashboard's rail, and an about sidebar. */ + +.team-identity { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +/* frappe-ui's square 3xl Avatar: 46px, 10px corners, 20px initial. */ +.team-logo { + display: grid; + flex-shrink: 0; + place-items: center; + width: calc(var(--spacing) * 11.5); + height: calc(var(--spacing) * 11.5); + border-radius: 10px; + font-size: var(--text-3xl); + background: var(--surface-raised); + box-shadow: 0 0 0 1px var(--border); + color: var(--ink-strong); + font-weight: 600; + object-fit: cover; +} + +.team-identity-text { + flex: 1; + min-width: 240px; +} + +.team-identity .event-title { + font-size: 34px; + line-height: 1.1; +} + +.team-stats { + margin-top: 4px; + color: var(--ink-muted); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.team-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 248px; + gap: 40px; + margin-top: 32px; +} + +.team-columns:not(:has(.team-aside)) { + grid-template-columns: minmax(0, 1fr); +} + +[data-tab-buttons] { + margin-bottom: 24px; +} + +/* The dashboard timeline's rail: a dot at each day, a line fading down past its cards. */ +.team-day { + position: relative; + padding: 0 0 28px 24px; +} + +.team-day::before { + content: ""; + position: absolute; + top: 7px; + left: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--surface-gray-4); +} + +.team-day::after { + content: ""; + position: absolute; + top: 21px; + bottom: 0; + left: 3.5px; + width: 1px; + background: linear-gradient(to bottom, var(--outline-gray-2) 75%, transparent); +} + +.team-day-heading { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 12px; + color: var(--ink-gray-8); + font-size: 15px; + font-weight: 600; +} + +.team-day-heading span { + color: var(--ink-gray-5); + font-weight: 400; +} + +.team-day-events { + display: grid; + gap: 12px; +} + +/* Mirrors the dashboard's EventCard. */ +.team-event-card { + display: flex; + gap: 16px; + padding: 12px; + border: 1px solid var(--outline-gray-2); + border-radius: var(--radius-8); + transition: + transform 120ms var(--ease-out), + border-color 150ms ease; +} + +.team-event-card:active { + transform: scale(0.995); +} + +.team-event-card-image { + flex-shrink: 0; + width: 120px; + height: 120px; + border-radius: var(--radius-4); + background: var(--surface-gray-2); + object-fit: cover; + object-position: top; +} + +.team-event-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + min-width: 0; + padding-block: 4px; +} + +.team-event-card-time { + display: flex; + align-items: center; + gap: 8px; +} + +.team-event-card-time, +.team-event-card-place { + color: var(--ink-gray-5); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.team-event-card-title { + color: var(--ink-gray-8); + font-size: 16px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.team-event-card-host { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--ink-gray-6); + font-size: 13px; + overflow-wrap: anywhere; +} + +.team-event-card-avatar { + display: grid; + flex-shrink: 0; + place-items: center; + width: 16px; + height: 16px; + margin-top: 1px; + border-radius: 50%; + background: var(--surface-gray-3); + color: var(--ink-gray-6); + font-size: 9px; + font-weight: 600; + object-fit: cover; +} + +.team-aside { + position: sticky; + top: 76px; + align-self: start; +} + +.team-about { + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.team-about .label { + margin-top: 0; +} + +.team-tagline { + margin-bottom: 12px; + color: var(--ink-strong); +} + +.team-about .prose { + font-size: 14px; +} + +.team-links { + gap: 2px; + margin-top: 16px; +} + +@media (hover: hover) and (pointer: fine) { + .team-event-card:hover { + border-color: var(--outline-gray-3); + } +} + +@media (max-width: 900px) { + .team-columns { + grid-template-columns: 1fr; + } + + .team-aside { + position: static; + order: -1; + } +} + +@media (max-width: 760px) { + .team-event-card-image { + width: 80px; + height: 80px; + } +} + +@media (prefers-reduced-motion: reduce) { + .team-event-card:active { + transform: none; + } +} diff --git a/buzz/public/themes/team_map.css b/buzz/public/themes/team_map.css new file mode 100644 index 00000000..9a735fd6 --- /dev/null +++ b/buzz/public/themes/team_map.css @@ -0,0 +1,351 @@ +/* Team page: the dot-matrix map strip, country chips and the mini calendar. */ + +/* Cards set their own display, which would otherwise outrank the country filter's `hidden`. */ +.team-page [hidden] { + display: none; +} + +.team-map { + position: relative; + height: clamp(220px, 24vw, 340px); + overflow: hidden; + border-block: 1px solid var(--border); + background: + radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, #e3e3e3 12%, transparent), transparent 70%), + #0f0f0f; + isolation: isolate; +} + +:root[data-mode="light"] .team-map { + background: + radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, #000000 12%, transparent), transparent 70%), + #fafafa; +} + +.team-map-svg { + display: block; + width: 100%; + height: 100%; +} + +.team-map-land { + color: #969696; + opacity: 0.6; +} + +:root[data-mode="light"] .team-map-land { + color: #404040; + opacity: 0.6; +} + +.team-map-pins { + position: absolute; + inset: 0; +} + +.team-map-pin { + position: absolute; + top: 0; + left: 0; + transition: opacity 200ms ease; +} + +.team-map-pin-dot { + position: absolute; + display: grid; + place-items: center; + min-width: 12px; + height: 12px; + padding: 0 3px; + border-radius: 999px; + background: var(--accent); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent), + 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent); + color: var(--accent-ink); + font-size: 9px; + font-weight: 700; + line-height: 1; + transform: translate(-50%, -50%); + transition: transform 160ms var(--ease-out); +} + +.team-map-pin-dot:not(:empty) { + min-width: 18px; + height: 18px; +} + +/* The next event's pin pulses. */ +.team-map-pin[data-next-event] .team-map-pin-dot::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: 0 0 0 2px var(--accent); + animation: pin-pulse 2s var(--ease-out) infinite; +} + +@keyframes pin-pulse { + from { + opacity: 0.9; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(2.6); + } +} + +.team-map-pin[data-dimmed] { + opacity: 0.2; +} + +.team-map-pin[data-highlighted] .team-map-pin-dot, +.team-map-pin:focus-visible .team-map-pin-dot { + transform: translate(-50%, -50%) scale(1.35); +} + +.team-map-pin:focus-visible { + outline: none; +} + +.team-map-pin-tooltip { + position: absolute; + bottom: 14px; + left: 0; + z-index: 1; + display: flex; + flex-direction: column; + width: max-content; + max-width: 220px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-raised); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); + color: var(--ink-muted); + font-size: 12px; + line-height: 1.4; + opacity: 0; + pointer-events: none; + transform: translate(var(--tooltip-x, -50%), 4px) scale(0.97); + transform-origin: bottom center; + transition: + opacity 150ms ease-out, + transform 150ms var(--ease-out); +} + +.team-map-pin-tooltip strong { + color: var(--ink-strong); + font-weight: 600; +} + +.team-map-pin:hover .team-map-pin-tooltip, +.team-map-pin:focus-visible .team-map-pin-tooltip, +.team-map-pin[data-highlighted] .team-map-pin-tooltip { + opacity: 1; + transform: translate(var(--tooltip-x, -50%), 0) scale(1); +} + +/* Tooltips turn away from the edge they would be clipped at. */ +.team-map-pin[data-tooltip-below] .team-map-pin-tooltip { + top: 14px; + bottom: auto; + transform-origin: top center; +} + +.team-map-pin[data-tooltip-align="start"] .team-map-pin-tooltip { + --tooltip-x: -16px; +} + +.team-map-pin[data-tooltip-align="end"] .team-map-pin-tooltip { + --tooltip-x: calc(-100% + 16px); +} + +/* Inset like the site header, so the controls line up with the page column. */ +.team-map-controls { + position: absolute; + right: max(16px, (100% - var(--page-width)) / 2); + bottom: 12px; + display: flex; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: color-mix(in srgb, var(--page-bg) 80%, transparent); + backdrop-filter: blur(8px); +} + +.team-map-controls button { + padding: 3px 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--ink-muted); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.team-map-controls button[aria-pressed="true"] { + background: var(--surface-raised); + color: var(--ink-strong); +} + +.team-map-credit { + position: absolute; + left: max(16px, (100% - var(--page-width)) / 2); + bottom: 14px; + color: var(--ink-muted); + font-size: 11px; +} + +.country-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 16px; +} + +.country-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: none; + color: var(--ink-body); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: + transform 160ms var(--ease-out), + background-color 150ms ease, + border-color 150ms ease; +} + +.country-chip span { + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} + +.country-chip[aria-pressed="true"] { + border-color: var(--ink-strong); + background: var(--surface-raised); + color: var(--ink-strong); +} + +.country-chip:active { + transform: scale(0.96); +} + +.team-aside > * + * { + margin-top: 16px; +} + +.calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.calendar-title { + color: var(--ink-strong); + font-size: 14px; + font-weight: 600; +} + +.calendar-nav { + display: flex; + gap: 2px; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + text-align: center; +} + +.calendar-weekday { + padding-bottom: 4px; + color: var(--ink-muted); + font-size: 11px; + font-weight: 500; +} + +.calendar-day { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1; + border: 0; + border-radius: 8px; + background: none; + color: var(--ink-muted); + font: inherit; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +button.calendar-day { + color: var(--ink-strong); + font-weight: 600; + cursor: pointer; + transition: background-color 150ms ease; +} + +.calendar-day[data-today] { + box-shadow: inset 0 0 0 1px var(--ink-muted); +} + +.calendar-dot { + position: absolute; + bottom: 4px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); +} + +.calendar-day[data-past] .calendar-dot { + background: var(--ink-muted); +} + +/* A calendar day or a pin pointed here: a brief ring so the eye follows. */ +.is-highlighted { + animation: highlight-ring 900ms var(--ease-out); +} + +@keyframes highlight-ring { + from { + box-shadow: 0 0 0 3px var(--accent); + } + to { + box-shadow: 0 0 0 3px transparent; + } +} + +@media (hover: hover) and (pointer: fine) { + button.calendar-day:hover { + background: var(--surface-raised); + } +} + +@media (prefers-reduced-motion: reduce) { + .team-map-pin[data-next-event] .team-map-pin-dot::after, + .is-highlighted { + animation: none; + } + + .team-map-pin-tooltip, + .team-map-pin-dot, + .country-chip { + transition: none; + } +} diff --git a/buzz/templates/emails/community_event_approved.html b/buzz/templates/emails/community_event_approved.html new file mode 100644 index 00000000..3e83c1dc --- /dev/null +++ b/buzz/templates/emails/community_event_approved.html @@ -0,0 +1,15 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}{{ event_title | e }} is now listed 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 +
+{% endblock %} diff --git a/buzz/templates/emails/community_event_rejected.html b/buzz/templates/emails/community_event_rejected.html new file mode 100644 index 00000000..58c244a8 --- /dev/null +++ b/buzz/templates/emails/community_event_rejected.html @@ -0,0 +1,11 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}{{ community_name | e }} did not approve {{ event_title | e }}.{% endblock %} +{% 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.

+{% if review_note %} +

{{ review_note | e }}

+{% endif %} +{% endblock %} diff --git a/buzz/templates/emails/community_event_submitted.html b/buzz/templates/emails/community_event_submitted.html new file mode 100644 index 00000000..69f83982 --- /dev/null +++ b/buzz/templates/emails/community_event_submitted.html @@ -0,0 +1,8 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}{{ team_name | e }} wants {{ event_title | e }} listed 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.

+{% endblock %} diff --git a/buzz/tests/factories/buzz_team_factory.py b/buzz/tests/factories/buzz_team_factory.py index 9db820f4..1d63639e 100644 --- a/buzz/tests/factories/buzz_team_factory.py +++ b/buzz/tests/factories/buzz_team_factory.py @@ -17,12 +17,12 @@ class BuzzTeamFactory(BaseFactory[BuzzTeam]): doctype = "Buzz Team" @classmethod - def create_owned_by(cls, user: str | None = None, **overrides: Any) -> BuzzTeam: + def create_owned_by(cls, user: str | None = None, *traits: str, **overrides: Any) -> BuzzTeam: """Prefer over `create()`: an Administrator-owned test team becomes its default team.""" from buzz.tests.factories import UserFactory owner = user or UserFactory.create_once(LINK_TEAM_OWNER).name - team = cls.create(flags={"owner_user": owner}, **overrides) + team = cls.create(*traits, flags={"owner_user": owner}, **overrides) # Paid tickets are the common case in tests; a test about the flag turns it off. cls.set_settings(team.name, {PAID_EVENTS_FLAG: 1}) return team @@ -36,3 +36,7 @@ def set_settings(cls, team: str, values: dict[str, Any]): @property def default_attributes(self) -> dict[str, Any]: return {"team_name": f"{_fake.word().capitalize()} Team"} + + @property + def community(self) -> dict[str, Any]: + return {"is_published": 1, "is_a_community": 1} diff --git a/buzz/www/event/index.html b/buzz/www/event/index.html index 1e8003df..b2b9991f 100644 --- a/buzz/www/event/index.html +++ b/buzz/www/event/index.html @@ -17,6 +17,11 @@ {% endautoescape %}{% endblock %} {% block content %}{% autoescape true %} + {% macro host_identity(host) %} + {% if host.logo %}{% else %}{% endif %} + {{ host.label }} + {% endmacro %} + {% macro link_list(class_name) %}