diff --git a/buzz/api/listings/__init__.py b/buzz/api/listings/__init__.py new file mode 100644 index 000000000..7ac9b8a0b --- /dev/null +++ b/buzz/api/listings/__init__.py @@ -0,0 +1,14 @@ +import frappe + +from buzz.api.listings.schemas import EventListing +from buzz.api.listings.services import PartnerListings + + +@frappe.whitelist(methods=["POST"]) +def upsert_event(event: EventListing) -> None: + PartnerListings().upsert(event) + + +@frappe.whitelist(methods=["POST"]) +def remove_event(source_id: str) -> None: + PartnerListings().remove(source_id) diff --git a/buzz/api/listings/exceptions.py b/buzz/api/listings/exceptions.py new file mode 100644 index 000000000..fb781ddf8 --- /dev/null +++ b/buzz/api/listings/exceptions.py @@ -0,0 +1,7 @@ +from frappe import _lt + +from buzz.api.exceptions import NotPermitted + + +class NotAnIntegration(NotPermitted): + message = _lt("This account is not the integration of exactly one team.") diff --git a/buzz/api/listings/schemas.py b/buzz/api/listings/schemas.py new file mode 100644 index 000000000..3e85850da --- /dev/null +++ b/buzz/api/listings/schemas.py @@ -0,0 +1,11 @@ +from pydantic import AwareDatetime + +from buzz.api.communities.schemas import ExternalEvent, Text140 + + +class EventListing(ExternalEvent): + """An event a partner site lists on its community page.""" + + source_id: Text140 + start_datetime: AwareDatetime + end_datetime: AwareDatetime diff --git a/buzz/api/listings/services.py b/buzz/api/listings/services.py new file mode 100644 index 000000000..e2ac8fac5 --- /dev/null +++ b/buzz/api/listings/services.py @@ -0,0 +1,47 @@ +from zoneinfo import ZoneInfo + +import frappe +from frappe.utils import get_system_timezone + +from buzz.api.communities.submissions import REQUEST, external_request +from buzz.api.listings.exceptions import NotAnIntegration +from buzz.api.listings.schemas import EventListing +from buzz.permissions import INTEGRATION_ROLE + + +class PartnerListings: + """Events a partner site lists on its own community page, as that team's integration user.""" + + def __init__(self): + filters = {"user": frappe.session.user, "team_role": INTEGRATION_ROLE, "enabled": 1} + teams = frappe.get_all("Buzz Team Membership", filters=filters, pluck="team") + if len(teams) != 1: + NotAnIntegration.throw() + self.community = teams[0] + + def find_request_name(self, source_id: str) -> str | None: + return frappe.db.get_value(REQUEST, {"community": self.community, "source_id": source_id}) + + def upsert(self, listing: EventListing) -> None: + """A request the community took down stays down; only its details follow the partner.""" + name = self.find_request_name(listing.source_id) + request = frappe.get_doc(REQUEST, name) if name else self.new_request(listing) + request.update(in_system_time_zone(listing.model_dump())) + request.save(ignore_permissions=True) + + def new_request(self, listing: EventListing): + request = external_request(self.community, listing) + request.update({"status": "Approved", "reviewed_by": frappe.session.user}) + return request + + def remove(self, source_id: str) -> None: + if name := self.find_request_name(source_id): + frappe.delete_doc(REQUEST, name, ignore_permissions=True) + + +def in_system_time_zone(values: dict) -> dict: + """Datetime fields hold naive system time.""" + zone = ZoneInfo(get_system_timezone()) + for field in ("start_datetime", "end_datetime"): + values[field] = values[field].astimezone(zone).replace(tzinfo=None) + return values diff --git a/buzz/api/listings/test_listings.py b/buzz/api/listings/test_listings.py new file mode 100644 index 000000000..94e49e60e --- /dev/null +++ b/buzz/api/listings/test_listings.py @@ -0,0 +1,114 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.listings import remove_event, upsert_event +from buzz.api.listings.exceptions import NotAnIntegration +from buzz.api.listings.schemas import EventListing +from buzz.api.teams.services import assignable_roles, members_of +from buzz.permissions import has_team_access, my_team_names +from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory + +REQUEST = "Community Event Request" + + +class TestPartnerListings(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.curator = UserFactory.create_once("listing-curator@example.com").name + cls.bot = UserFactory.create_once("listing-bot@example.com").name + cls.manager = UserFactory.create_once("listing-manager@example.com").name + cls.community = BuzzTeamFactory.create_owned_by(cls.curator, "community").name + BuzzTeamMembershipFactory.create(team=cls.community, user=cls.bot, team_role="Integration") + BuzzTeamMembershipFactory.create(team=cls.community, user=cls.manager, team_role="Manager") + + def listing(self, **overrides) -> EventListing: + day = add_days(today(), 20) + values = { + "source_id": "partner.example.com:7", + "event_title": "Partner Meetup", + "host": "Partner", + "event_location": "Pune", + "start_datetime": f"{day}T18:00:00+05:30", + "end_datetime": f"{day}T20:00:00+05:30", + "event_url": "https://partner.example.com/events/meetup", + } + return EventListing(**(values | overrides)) + + def request_names(self, source_id: str = "partner.example.com:7") -> list[str]: + return frappe.get_all(REQUEST, {"community": self.community, "source_id": source_id}, pluck="name") + + def upsert(self, **overrides) -> None: + with self.set_user(self.bot): + upsert_event(self.listing(**overrides)) + + def test_upsert_creates_an_approved_request(self): + self.upsert() + request = frappe.get_doc(REQUEST, self.request_names()[0]) + self.assertEqual(request.status, "Approved") + self.assertEqual(request.reviewed_by, self.bot) + self.assertTrue(request.is_external_event) + self.assertEqual(request.event_title, "Partner Meetup") + + def test_second_upsert_updates_the_same_request(self): + self.upsert() + self.upsert(event_title="Renamed Meetup") + names = self.request_names() + self.assertEqual(len(names), 1) + self.assertEqual(frappe.db.get_value(REQUEST, names[0], "event_title"), "Renamed Meetup") + + def test_upsert_stores_system_time(self): + with patch("buzz.api.listings.services.get_system_timezone", return_value="Asia/Kolkata"): + self.upsert(start_datetime=f"{add_days(today(), 20)}T12:30:00+00:00") + start = frappe.db.get_value(REQUEST, self.request_names()[0], "start_datetime") + self.assertEqual(start.strftime("%H:%M"), "18:00") + + def test_upsert_keeps_a_request_the_community_took_down(self): + self.upsert() + frappe.db.set_value(REQUEST, self.request_names()[0], "status", "Rejected") + self.upsert(event_title="Renamed Meetup") + request = frappe.get_doc(REQUEST, self.request_names()[0]) + self.assertEqual((request.status, request.event_title), ("Rejected", "Renamed Meetup")) + + def test_remove_deletes_the_request(self): + self.upsert() + with self.set_user(self.bot): + remove_event("partner.example.com:7") + self.assertEqual(self.request_names(), []) + + def test_remove_of_an_unknown_id_does_nothing(self): + with self.set_user(self.bot): + remove_event("partner.example.com:unknown") + + def test_a_manager_is_not_an_integration(self): + with self.set_user(self.manager), self.assertRaises(NotAnIntegration): + upsert_event(self.listing()) + with self.set_user(self.manager), self.assertRaises(NotAnIntegration): + remove_event("partner.example.com:7") + + def test_an_integration_member_has_no_team_access(self): + self.assertFalse(has_team_access(self.community, "read", self.bot)) + self.assertEqual(my_team_names(self.bot), []) + + def test_integration_members_are_hidden_and_not_assignable(self): + self.assertNotIn(self.bot, [member.user for member in members_of(self.community)]) + self.assertIn(self.manager, [member.user for member in members_of(self.community)]) + self.assertNotIn("Integration", assignable_roles()) + + def test_only_a_system_manager_can_grant_the_role(self): + team = BuzzTeamFactory.create_owned_by(self.curator).name + with self.set_user(self.curator), self.assertRaises(frappe.ValidationError): + BuzzTeamMembershipFactory.create( + team=team, user=self.manager, team_role="Integration", flags={"ignore_permissions": True} + ) + + def test_only_a_system_manager_can_remove_the_integration(self): + membership = frappe.db.get_value( + "Buzz Team Membership", {"team": self.community, "user": self.bot}, "name" + ) + with self.set_user(self.curator), self.assertRaises(frappe.ValidationError): + frappe.delete_doc("Buzz Team Membership", membership, ignore_permissions=True) diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 82c962e89..322956fa8 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -4,6 +4,7 @@ from buzz.api.teams import calendar, invitations, public_page, services from buzz.api.teams.schemas import InviteOutcome, TeamEvents, TeamOption, TeamOverview from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags +from buzz.permissions import INTEGRATION_ROLE @frappe.whitelist() @@ -32,7 +33,11 @@ def get_my_teams() -> list[TeamOption]: team.short_description, membership.team_role, ) - .where((membership.user == frappe.session.user) & (membership.enabled == 1)) + .where( + (membership.user == frappe.session.user) + & (membership.enabled == 1) + & (membership.team_role != INTEGRATION_ROLE) + ) ).run(as_dict=True) event_counts = services.upcoming_event_counts([my_team.name for my_team in my_teams]) return [team_option(my_team, event_counts.get(my_team.name, 0)) for my_team in my_teams] diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index be8f1121b..cea153301 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -16,7 +16,13 @@ from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview, TeamTaxDetails from buzz.events.doctype.buzz_team.buzz_team import create_personal_team_for, personal_teams_enabled from buzz.events.doctype.buzz_team_settings.buzz_team_settings import TAX_DETAIL_FIELDS -from buzz.permissions import WRITE_ROLES, can_manage_members, team_role_of +from buzz.permissions import ( + HUMAN_MEMBER_FILTERS, + INTEGRATION_ROLE, + WRITE_ROLES, + can_manage_members, + team_role_of, +) TEAM_FIELDS = ( "name", @@ -47,7 +53,7 @@ def validate_role(team_role: str) -> None: def assignable_roles() -> list[str]: """The membership doctype's own options, minus the one nobody may be given.""" options = frappe.get_meta("Buzz Team Membership").get_field("team_role").options - return [role for role in options.split("\n") if role and role != "Owner"] + return [role for role in options.split("\n") if role and role not in ("Owner", INTEGRATION_ROLE)] def team_overview(team: str) -> TeamOverview: @@ -117,7 +123,9 @@ def members_of(team: str) -> list[TeamMember]: .inner_join(user) .on(user.name == membership.user) .select(membership.user, membership.team_role, user.full_name, user.user_image) - .where((membership.team == team) & (membership.enabled == 1)) + .where( + (membership.team == team) & (membership.enabled == 1) & (membership.team_role != INTEGRATION_ROLE) + ) .orderby(owner_first) .orderby(user.full_name) ).run(as_dict=True) @@ -151,7 +159,7 @@ def remove_member(team: str, user: str) -> None: if not can_manage_members(team): CannotManageMembers.throw() - name = frappe.db.exists("Buzz Team Membership", {"team": team, "user": user, "enabled": 1}) + name = frappe.db.exists("Buzz Team Membership", {**HUMAN_MEMBER_FILTERS, "team": team, "user": user}) if not name: NotATeamMember.throw() @@ -172,7 +180,7 @@ def change_role(team: str, user: str, team_role: str) -> None: CannotManageMembers.throw() validate_role(team_role) - name = frappe.db.exists("Buzz Team Membership", {"team": team, "user": user, "enabled": 1}) + name = frappe.db.exists("Buzz Team Membership", {**HUMAN_MEMBER_FILTERS, "team": team, "user": user}) if not name: NotATeamMember.throw() diff --git a/buzz/comments.py b/buzz/comments.py index 080fc7301..04d478dbf 100644 --- a/buzz/comments.py +++ b/buzz/comments.py @@ -3,6 +3,7 @@ from frappe.desk.notifications import extract_mentions from frappe.utils import get_fullname, get_url, strip_html +from buzz.permissions import HUMAN_MEMBER_FILTERS from buzz.subscriptions import Subscriptions DASHBOARD_ROUTES = {"Talk Proposal": "/b/manage/events/{event}/talks?proposal={name}"} @@ -22,7 +23,9 @@ def team_of(doctype: str, name: str) -> str | None: def team_members(team: str | None) -> list[str]: if not team: return [] - return frappe.get_all("Buzz Team Membership", filters={"team": team, "enabled": 1}, pluck="user") + return frappe.get_all( + "Buzz Team Membership", filters={**HUMAN_MEMBER_FILTERS, "team": team}, pluck="user" + ) def publish_to_team(doctype: str, name: str) -> None: diff --git a/buzz/events/buzz_events_listing.py b/buzz/events/buzz_events_listing.py new file mode 100644 index 000000000..f4c5de53b --- /dev/null +++ b/buzz/events/buzz_events_listing.py @@ -0,0 +1,104 @@ +import frappe +from frappe.integrations.utils import make_post_request +from frappe.utils import getdate, today + +from buzz.api.events.services import primary_host_of +from buzz.www.event.meta import EventMeta + +API_PATH = "/api/v2/method/buzz.api.listings" + + +def listing_enabled() -> bool: + return bool(frappe.db.get_single_value("Buzz Settings", "list_on_buzz_events")) + + +def source_id_of(event_name: str) -> str: + return f"{frappe.local.site}:{event_name}" + + +class ListingPush: + """Lists one event on the partner's community page on buzz.events, or takes it off.""" + + def __init__(self, event): + self.event = event + self.source_id = source_id_of(event.name) + + def sync_listing(self) -> None: + if self.event.is_published and not self.has_ended(): + post_to_buzz_events("upsert_event", {"event": self.payload()}) + else: + post_to_buzz_events("remove_event", {"source_id": self.source_id}) + + def has_ended(self) -> bool: + return getdate(self.event.end_date or self.event.start_date) < getdate(today()) + + def host_label(self) -> str: + host = primary_host_of(self.event.team) + return host.label if host else frappe.local.site + + def payload(self) -> dict: + meta = EventMeta(self.event, {}) + return { + "source_id": self.source_id, + "event_title": self.event.title, + "host": self.host_label(), + "event_location": self.event.get_venue_name() or "Online", + "start_datetime": meta.event_datetime(self.event.start_date, self.event.start_time), + "end_datetime": meta.end_datetime(), + "event_url": meta.url(), + } + + +def post_to_buzz_events(method: str, body: dict) -> None: + settings = frappe.get_cached_doc("Buzz Settings") + token = f"{settings.buzz_events_api_key}:{settings.get_password('buzz_events_api_secret')}" + make_post_request( + f"{settings.buzz_events_url.rstrip('/')}{API_PATH}.{method}", + headers={"Authorization": f"token {token}"}, + json=body, + timeout=30, + ) + + +def push_event(event_name: str) -> None: + if listing_enabled() and frappe.db.exists("Buzz Event", event_name): + ListingPush(frappe.get_doc("Buzz Event", event_name)).sync_listing() + + +def remove_listing(source_id: str) -> None: + if listing_enabled(): + post_to_buzz_events("remove_event", {"source_id": source_id}) + + +def sync_all_events() -> None: + # A one-day event may leave end_date empty. + upcoming_filters = {"end_date": [">=", today()], "start_date": [">=", today()]} + event_names = frappe.get_all("Buzz Event", {"is_published": 1}, or_filters=upcoming_filters, pluck="name") + for event_name in event_names: + try: + push_event(event_name) + except Exception: + frappe.log_error(title=f"buzz.events listing failed for event {event_name}") + + +def enqueue_push(event, method=None) -> None: + # An event that never went live has no listing to update or take down. + never_published = not event.is_published and not event.has_value_changed("is_published") + if never_published or not listing_enabled(): + return + frappe.enqueue( + "buzz.events.buzz_events_listing.push_event", + event_name=str(event.name), + job_id=f"buzz_events_listing_{event.name}", + deduplicate=True, + enqueue_after_commit=True, + ) + + +def enqueue_remove(event, method=None) -> None: + if event.is_published and listing_enabled(): + frappe.enqueue( + "buzz.events.buzz_events_listing.remove_listing", + source_id=source_id_of(str(event.name)), + enqueue_after_commit=True, + ) diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.json b/buzz/events/doctype/buzz_settings/buzz_settings.json index 9c95b7146..c808e83b3 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.json +++ b/buzz/events/doctype/buzz_settings/buzz_settings.json @@ -59,6 +59,13 @@ "default_sponsor_deck_reply_to", "column_break_sponsor", "default_sponsor_deck_cc", + "buzz_events_tab", + "buzz_events_section", + "list_on_buzz_events", + "buzz_events_url", + "column_break_buzz_events", + "buzz_events_api_key", + "buzz_events_api_secret", "section_break_vtep", "custom_fields_go_after_this" ], @@ -378,6 +385,50 @@ "fieldtype": "Data", "label": "Maps Embed API Key" }, + { + "fieldname": "buzz_events_tab", + "fieldtype": "Tab Break", + "label": "buzz.events" + }, + { + "fieldname": "buzz_events_section", + "fieldtype": "Section Break", + "label": "Partner Listing" + }, + { + "default": "0", + "description": "Lists your published events on your community page on buzz.events. Save again to resync.", + "fieldname": "list_on_buzz_events", + "fieldtype": "Check", + "label": "List Events on buzz.events" + }, + { + "default": "https://buzz.events", + "depends_on": "list_on_buzz_events", + "fieldname": "buzz_events_url", + "fieldtype": "Data", + "label": "buzz.events URL", + "mandatory_depends_on": "list_on_buzz_events" + }, + { + "fieldname": "column_break_buzz_events", + "fieldtype": "Column Break" + }, + { + "depends_on": "list_on_buzz_events", + "description": "From the integration user buzz.events gave your team.", + "fieldname": "buzz_events_api_key", + "fieldtype": "Data", + "label": "API Key", + "mandatory_depends_on": "list_on_buzz_events" + }, + { + "depends_on": "list_on_buzz_events", + "fieldname": "buzz_events_api_secret", + "fieldtype": "Password", + "label": "API Secret", + "mandatory_depends_on": "list_on_buzz_events" + }, { "fieldname": "whitelabel_tab", "fieldtype": "Tab Break", @@ -399,7 +450,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-10-20 23:43:49.827251", + "modified": "2026-10-24 10:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Settings", diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.py b/buzz/events/doctype/buzz_settings/buzz_settings.py index 487436d14..225a16fb6 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.py +++ b/buzz/events/doctype/buzz_settings/buzz_settings.py @@ -5,6 +5,8 @@ from frappe import _ from frappe.model.document import Document +LISTING_FIELDS = ("list_on_buzz_events", "buzz_events_url", "buzz_events_api_key", "buzz_events_api_secret") + class BuzzSettings(Document): # begin: auto-generated types @@ -24,6 +26,9 @@ class BuzzSettings(Document): app_logo: DF.AttachImage | None app_logo_dark: DF.AttachImage | None auto_send_pitch_deck: DF.Check + buzz_events_api_key: DF.Data | None + buzz_events_api_secret: DF.Password | None + buzz_events_url: DF.Data | None default_payment_gateway: DF.Link | None default_sponsor_deck_cc: DF.SmallText | None default_sponsor_deck_email_template: DF.Link | None @@ -37,6 +42,7 @@ class BuzzSettings(Document): google_maps_embed_api_key: DF.Data | None google_maps_enabled: DF.Check google_places_api_key: DF.Password | None + list_on_buzz_events: DF.Check login_banner: DF.MarkdownEditor | None show_hosting_banner: DF.Check support_email: DF.Data | None @@ -46,6 +52,19 @@ def validate(self): """Validate the settings.""" self.validate_transfer_days() self.validate_app_logos() + self.validate_buzz_events_url() + + def on_update(self): + listing_changed = any(self.has_value_changed(field) for field in LISTING_FIELDS) + if self.list_on_buzz_events and listing_changed: + frappe.enqueue( + "buzz.events.buzz_events_listing.sync_all_events", queue="long", enqueue_after_commit=True + ) + + def validate_buzz_events_url(self): + # The API secret travels in a header, so it must not go over plain HTTP. + if self.list_on_buzz_events and not (self.buzz_events_url or "").startswith("https://"): + frappe.throw(_("The buzz.events URL must start with https://.")) def validate_app_logos(self): if bool(self.app_logo) != bool(self.app_logo_dark): diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index cc760e9fb..2f2f43e28 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.py +++ b/buzz/events/doctype/buzz_team/buzz_team.py @@ -9,7 +9,7 @@ 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.permissions import HUMAN_MEMBER_FILTERS, can_manage_members, my_team_names from buzz.www.site_header import apply_site_context SEARCH_LIMIT = 10 @@ -179,7 +179,9 @@ def search_addable_users(self, txt: str = "") -> list[dict]: return [{"value": user.name, "description": user.full_name} for user in users] def member_users(self) -> list[str]: - return frappe.get_all("Buzz Team Membership", filters={"team": self.name, "enabled": 1}, pluck="user") + return frappe.get_all( + "Buzz Team Membership", filters={**HUMAN_MEMBER_FILTERS, "team": self.name}, pluck="user" + ) def check_can_manage_members(self): if not can_manage_members(self.name): diff --git a/buzz/events/doctype/buzz_team_membership/buzz_team_membership.json b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.json index a0b916e9c..4f222595f 100644 --- a/buzz/events/doctype/buzz_team_membership/buzz_team_membership.json +++ b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.json @@ -44,7 +44,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Team Role", - "options": "Owner\nAdmin\nManager\nFrontdesk\nViewer", + "options": "Owner\nAdmin\nManager\nFrontdesk\nViewer\nIntegration", "reqd": 1 }, { @@ -57,7 +57,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-07-31 17:42:45.268967", + "modified": "2026-10-24 10:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Team Membership", diff --git a/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py index 96b60e0df..38f35ccf7 100644 --- a/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py +++ b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py @@ -7,7 +7,7 @@ from frappe import _ from frappe.model.document import Document -from buzz.permissions import EDIT, SECTIONS, can_manage_members +from buzz.permissions import EDIT, INTEGRATION_ROLE, SECTIONS, can_manage_members, is_unrestricted if TYPE_CHECKING: from frappe.core.doctype.user.user import User @@ -36,6 +36,9 @@ def on_invitation_accepted(invitation: "UserInvitation", user: "User", user_inse if team_role == "Owner": frappe.throw(_("An invitation cannot grant ownership of a team.")) + if team_role == INTEGRATION_ROLE: + frappe.throw(_("An invitation cannot grant the Integration role.")) + # Same rule as invite time, so a System Manager's invitation can be accepted too. if not can_manage_members(team, invitation.invited_by): frappe.throw(_("{0} cannot invite members to this team.").format(invitation.invited_by)) @@ -123,7 +126,7 @@ class BuzzTeamMembership(Document): enabled: DF.Check team: DF.Link - team_role: DF.Literal["Owner", "Admin", "Manager", "Frontdesk", "Viewer"] + team_role: DF.Literal["Owner", "Admin", "Manager", "Frontdesk", "Viewer", "Integration"] user: DF.Link # end: auto-generated types @@ -136,6 +139,8 @@ def on_update(self): def on_trash(self): if self.team_role == "Owner": frappe.throw(_("The owner of a team cannot be removed.")) + if self.team_role == INTEGRATION_ROLE and not is_unrestricted(frappe.session.user): + frappe.throw(_("Only a System Manager can manage the Integration role.")) def after_delete(self): # Not on_trash — that runs before the row is gone, so it would still be counted. @@ -154,6 +159,7 @@ def drop_subscriptions(self): def validate(self): self.validate_duplicate() self.validate_owner_is_locked() + self.validate_integration_role() self.validate_personal_team_has_one_member() def validate_duplicate(self): @@ -164,6 +170,19 @@ def validate_duplicate(self): if existing: frappe.throw(_("{0} is already a member of this team.").format(self.user)) + def validate_integration_role(self): + """Only a System Manager creates, changes or toggles a bot membership.""" + previous_membership = self.get_doc_before_save() + integration_role_involved = INTEGRATION_ROLE in ( + self.team_role, + previous_membership and previous_membership.team_role, + ) + role_or_enabled_changed = ( + self.is_new() or self.has_value_changed("team_role") or self.has_value_changed("enabled") + ) + if integration_role_involved and role_or_enabled_changed and not is_unrestricted(frappe.session.user): + frappe.throw(_("Only a System Manager can manage the Integration role.")) + def validate_personal_team_has_one_member(self): others = frappe.db.exists("Buzz Team Membership", {"team": self.team, "name": ("!=", self.name)}) if others and frappe.db.get_value("Buzz Team", self.team, "is_personal"): diff --git a/buzz/events/doctype/community_event_request/community_event_request.json b/buzz/events/doctype/community_event_request/community_event_request.json index 1fc606160..6571335d5 100644 --- a/buzz/events/doctype/community_event_request/community_event_request.json +++ b/buzz/events/doctype/community_event_request/community_event_request.json @@ -25,6 +25,7 @@ "end_datetime", "latitude", "longitude", + "source_id", "section_break_review", "submitted_by", "column_break_review", @@ -157,6 +158,14 @@ "precision": "9", "read_only": 1 }, + { + "depends_on": "eval:doc.is_external_event", + "description": "The partner site's id for this event, when it was listed by an integration.", + "fieldname": "source_id", + "fieldtype": "Data", + "label": "Source ID", + "read_only": 1 + }, { "fieldname": "section_break_review", "fieldtype": "Section Break", @@ -189,7 +198,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-10-09 12:00:00.000000", + "modified": "2026-10-24 10:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Community Event Request", diff --git a/buzz/events/doctype/community_event_request/community_event_request.py b/buzz/events/doctype/community_event_request/community_event_request.py index 804be8f3c..cdbd64740 100644 --- a/buzz/events/doctype/community_event_request/community_event_request.py +++ b/buzz/events/doctype/community_event_request/community_event_request.py @@ -34,6 +34,7 @@ class CommunityEventRequest(Document): longitude: DF.Float review_note: DF.SmallText | None reviewed_by: DF.Link | None + source_id: DF.Data | None start_datetime: DF.Datetime | None status: DF.Literal["Pending", "Approved", "Rejected"] submitted_by: DF.Link | None @@ -99,6 +100,7 @@ def approved_external_requests(community: str) -> list: 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"]) + frappe.db.add_unique("Community Event Request", ["community", "source_id"]) def has_request_permission(doc, ptype: str = "read", user: str | None = None, **kwargs) -> bool: diff --git a/buzz/events/test_buzz_events_listing.py b/buzz/events/test_buzz_events_listing.py new file mode 100644 index 000000000..d5990fa97 --- /dev/null +++ b/buzz/events/test_buzz_events_listing.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.events.buzz_events_listing import ListingPush, enqueue_push, push_event, source_id_of +from buzz.tests.factories import BuzzEventFactory + +POST = "buzz.events.buzz_events_listing.make_post_request" +ENQUEUE = "buzz.events.buzz_events_listing.frappe.enqueue" +SETTINGS = { + "list_on_buzz_events": 1, + "buzz_events_url": "https://listings.example.com/", + "buzz_events_api_key": "key", + "buzz_events_api_secret": "secret", +} + + +class TestBuzzEventsListing(IntegrationTestCase): + def setUp(self): + self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + + def test_payload(self): + event = BuzzEventFactory.create(title="Launch Night", time_zone="Asia/Kolkata") + payload = ListingPush(event).payload() + self.assertEqual(payload["source_id"], f"{frappe.local.site}:{event.name}") + self.assertEqual(payload["event_title"], "Launch Night") + self.assertEqual(payload["event_location"], "Online") + self.assertTrue(payload["start_datetime"].endswith("+05:30")) + self.assertTrue(payload["event_url"].endswith(f"/events/{event.route}")) + + def test_published_event_is_upserted(self): + event = BuzzEventFactory.create() + with self.change_settings("Buzz Settings", SETTINGS), patch(POST) as post: + push_event(str(event.name)) + url = post.call_args.args[0] + self.assertEqual(url, "https://listings.example.com/api/v2/method/buzz.api.listings.upsert_event") + self.assertEqual(post.call_args.kwargs["headers"], {"Authorization": "token key:secret"}) + self.assertEqual(post.call_args.kwargs["json"]["event"]["event_title"], event.title) + + def test_unpublished_or_ended_event_is_removed(self): + past = add_days(today(), -3) + for event in ( + BuzzEventFactory.create("unpublished"), + BuzzEventFactory.create(start_date=past, end_date=past), + ): + with self.change_settings("Buzz Settings", SETTINGS), patch(POST) as post: + push_event(str(event.name)) + self.assertTrue(post.call_args.args[0].endswith("remove_event")) + self.assertEqual(post.call_args.kwargs["json"], {"source_id": source_id_of(event.name)}) + + def test_nothing_runs_when_disabled(self): + event = BuzzEventFactory.create() + with self.change_settings("Buzz Settings", {"list_on_buzz_events": 0}): + with patch(POST) as post, patch(ENQUEUE) as enqueue: + push_event(str(event.name)) + enqueue_push(event) + post.assert_not_called() + enqueue.assert_not_called() diff --git a/buzz/hooks.py b/buzz/hooks.py index b40e7a301..7c7f2f4ba 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -96,6 +96,13 @@ }, } +doc_events["Buzz Event"].update( + { + "on_update": ["buzz.events.buzz_events_listing.enqueue_push"], + "on_trash": ["buzz.events.buzz_events_listing.enqueue_remove"], + } +) + # Records a Buzz Tag can be applied to; deleting one also removes its tags. taggable_doctypes = ["Event Sponsor", "Buzz Event", "Talk Proposal", "Buzz Team Membership"] for _doctype in taggable_doctypes: diff --git a/buzz/permissions.py b/buzz/permissions.py index d55f15723..7dff1fa9e 100644 --- a/buzz/permissions.py +++ b/buzz/permissions.py @@ -8,6 +8,10 @@ WRITE_ROLES = frozenset({"Owner", "Admin", "Manager"}) ADMIN_ROLES = frozenset({"Owner", "Admin"}) CHECKIN_ROLES = frozenset({"Owner", "Admin", "Manager", "Frontdesk"}) +# A bot role for partner sites: it holds a membership but no access of its own. +INTEGRATION_ROLE = "Integration" +# Filters for the people on a team, leaving out integration users. +HUMAN_MEMBER_FILTERS = {"enabled": 1, "team_role": ["!=", INTEGRATION_ROLE]} WRITE_PTYPES = frozenset({"write", "create", "submit", "cancel", "amend", "delete"}) SECTIONS = ("details", "guests", "ticketing", "talks", "sponsors", "communications") @@ -67,12 +71,16 @@ def my_teams(user: str) -> QueryBuilder: return ( frappe.qb.from_(membership) .select(membership.team) - .where((membership.user == user) & (membership.enabled == 1)) + .where( + (membership.user == user) & (membership.enabled == 1) & (membership.team_role != INTEGRATION_ROLE) + ) ) def my_team_names(user: str) -> list[str]: - return frappe.get_all("Buzz Team Membership", filters={"user": user, "enabled": 1}, pluck="team") + return frappe.get_all( + "Buzz Team Membership", filters={**HUMAN_MEMBER_FILTERS, "user": user}, pluck="team" + ) @frappe.whitelist() @@ -153,7 +161,7 @@ def team_role_of(user: str, team: str | None) -> str | None: if not team: return None return frappe.db.get_value( - "Buzz Team Membership", {"user": user, "team": team, "enabled": 1}, "team_role" + "Buzz Team Membership", {**HUMAN_MEMBER_FILTERS, "user": user, "team": team}, "team_role" )