From 801f6070868c0db68b5344a055095cf58d24295b Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 24 Oct 2026 19:16:23 +0530 Subject: [PATCH 1/6] feat(teams): add an Integration team role with no team access An integration user holds a membership so a partner site can act for its team, but every permission helper ignores the row: it reads and writes nothing, never shows in rosters or mentions, and only a System Manager can grant, change or remove it. --- buzz/api/teams/__init__.py | 7 ++++++- buzz/api/teams/services.py | 12 +++++++----- buzz/comments.py | 3 ++- buzz/events/doctype/buzz_team/buzz_team.py | 4 ++-- .../buzz_team_membership.json | 4 ++-- .../buzz_team_membership.py | 18 ++++++++++++++++-- buzz/permissions.py | 14 +++++++++----- 7 files changed, 44 insertions(+), 18 deletions(-) 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..8b463a569 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -16,7 +16,7 @@ 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 INTEGRATION_ROLE, PEOPLE, WRITE_ROLES, can_manage_members, team_role_of TEAM_FIELDS = ( "name", @@ -47,7 +47,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 +117,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 +153,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", {**PEOPLE, "team": team, "user": user}) if not name: NotATeamMember.throw() @@ -172,7 +174,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", {**PEOPLE, "team": team, "user": user}) if not name: NotATeamMember.throw() diff --git a/buzz/comments.py b/buzz/comments.py index 080fc7301..31fe83f3e 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 PEOPLE from buzz.subscriptions import Subscriptions DASHBOARD_ROUTES = {"Talk Proposal": "/b/manage/events/{event}/talks?proposal={name}"} @@ -22,7 +23,7 @@ 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={**PEOPLE, "team": team}, pluck="user") def publish_to_team(doctype: str, name: str) -> None: diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index cc760e9fb..c85323ad9 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 PEOPLE, can_manage_members, my_team_names from buzz.www.site_header import apply_site_context SEARCH_LIMIT = 10 @@ -179,7 +179,7 @@ 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={**PEOPLE, "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..9879568a0 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,14 @@ 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 = self.get_doc_before_save() + involved = INTEGRATION_ROLE in (self.team_role, previous and previous.team_role) + changed = self.is_new() or self.has_value_changed("team_role") or self.has_value_changed("enabled") + if involved and 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/permissions.py b/buzz/permissions.py index d55f15723..665f511bc 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. +PEOPLE = {"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,14 @@ 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={**PEOPLE, "user": user}, pluck="team") @frappe.whitelist() @@ -152,9 +158,7 @@ def belongs_to_user(doc, user: str) -> bool: 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" - ) + return frappe.db.get_value("Buzz Team Membership", {**PEOPLE, "user": user, "team": team}, "team_role") def role_allows(team_role: str, ptype: str) -> bool: From dc1cdd4e51332f9fdafeb12abae72efe0c556f89 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 24 Oct 2026 19:16:23 +0530 Subject: [PATCH 2/6] feat(listings): accept partner event listings from integration users upsert_event and remove_event let a team's integration user keep an approved external Community Event Request in sync, keyed by a new source_id. A request the community took down stays down. A guest openapi endpoint describes the contract as OpenAPI 3.1, generated from the pydantic schema. --- buzz/api/listings/__init__.py | 21 +++ buzz/api/listings/document.py | 47 +++++++ buzz/api/listings/exceptions.py | 7 + buzz/api/listings/schemas.py | 11 ++ buzz/api/listings/services.py | 47 +++++++ buzz/api/listings/test_listings.py | 120 ++++++++++++++++++ .../community_event_request.json | 11 +- .../community_event_request.py | 2 + 8 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 buzz/api/listings/__init__.py create mode 100644 buzz/api/listings/document.py create mode 100644 buzz/api/listings/exceptions.py create mode 100644 buzz/api/listings/schemas.py create mode 100644 buzz/api/listings/services.py create mode 100644 buzz/api/listings/test_listings.py diff --git a/buzz/api/listings/__init__.py b/buzz/api/listings/__init__.py new file mode 100644 index 000000000..b91c288ca --- /dev/null +++ b/buzz/api/listings/__init__.py @@ -0,0 +1,21 @@ +import frappe + +from buzz.api.listings.document import openapi_document +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) + + +# nosemgrep: guest-whitelisted-method +@frappe.whitelist(allow_guest=True) +def openapi() -> dict: + return openapi_document() diff --git a/buzz/api/listings/document.py b/buzz/api/listings/document.py new file mode 100644 index 000000000..57626febf --- /dev/null +++ b/buzz/api/listings/document.py @@ -0,0 +1,47 @@ +from frappe.utils import get_url + +from buzz.api.listings.schemas import EventListing + +METHOD_PATH = "/api/v2/method/buzz.api.listings" +SCHEMA_REF = {"$ref": "#/components/schemas/EventListing"} + + +def operation(summary: str, body_properties: dict, required: str) -> dict: + body = {"type": "object", "properties": body_properties, "required": [required]} + return { + "post": { + "summary": summary, + "security": [{"tokenAuth": []}], + "requestBody": {"required": True, "content": {"application/json": {"schema": body}}}, + "responses": {"200": {"description": "Done"}}, + } + } + + +def openapi_document() -> dict: + return { + "openapi": "3.1.0", + "info": {"title": "Buzz partner event listings", "version": "1.0.0"}, + "servers": [{"url": get_url()}], + "paths": { + f"{METHOD_PATH}.upsert_event": operation( + "List an event, or update it when the source_id is known", {"event": SCHEMA_REF}, "event" + ), + f"{METHOD_PATH}.remove_event": operation( + "Take an event off the community page", + {"source_id": {"type": "string", "maxLength": 140}}, + "source_id", + ), + }, + "components": { + "schemas": {"EventListing": EventListing.model_json_schema()}, + "securitySchemes": { + "tokenAuth": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "token :", + } + }, + }, + } 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..166ce3332 --- /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(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(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(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..40761ce27 --- /dev/null +++ b/buzz/api/listings/test_listings.py @@ -0,0 +1,120 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.listings import openapi, 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 self.change_settings("System Settings", {"time_zone": "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) + + def test_openapi_lists_the_operations_and_listing_fields(self): + document = openapi() + self.assertEqual(document["openapi"], "3.1.0") + self.assertEqual(len(document["paths"]), 2) + self.assertEqual(document["components"]["securitySchemes"]["tokenAuth"]["in"], "header") + properties = document["components"]["schemas"]["EventListing"]["properties"] + self.assertEqual(set(properties), set(EventListing.model_fields)) 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: From ceb18fe32f9c18fb2389d02103936e057602dfc2 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 24 Oct 2026 19:16:24 +0530 Subject: [PATCH 3/6] feat(listings): push a partner site's events to buzz.events A buzz.events tab in Buzz Settings takes the integration user's API key. Saving a Buzz Event then lists it on buzz.events, or takes it off once unpublished, ended or deleted. Turning listing on syncs every upcoming event. --- buzz/events/buzz_events_listing.py | 105 ++++++++++++++++++ .../doctype/buzz_settings/buzz_settings.json | 53 ++++++++- .../doctype/buzz_settings/buzz_settings.py | 19 ++++ buzz/events/test_buzz_events_listing.py | 60 ++++++++++ buzz/hooks.py | 7 ++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 buzz/events/buzz_events_listing.py create mode 100644 buzz/events/test_buzz_events_listing.py diff --git a/buzz/events/buzz_events_listing.py b/buzz/events/buzz_events_listing.py new file mode 100644 index 000000000..0f9f6fbd0 --- /dev/null +++ b/buzz/events/buzz_events_listing.py @@ -0,0 +1,105 @@ +import frappe +from frappe.integrations.utils import make_post_request +from frappe.utils import get_url, 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 push(self) -> None: + if self.event.is_published and not self.has_ended(): + post("upsert_event", {"event": self.payload()}) + else: + post("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.event_datetime( + self.event.end_date or self.event.start_date, self.event.end_time + ), + "event_url": get_url(f"/events/{self.event.route}"), + } + + +def post(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, + ) + + +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)).push() + + +def remove_listing(source_id: str) -> None: + if listing_enabled(): + post("remove_event", {"source_id": source_id}) + + +def sync_all_events() -> None: + # A one-day event may leave end_date empty. + upcoming = {"end_date": [">=", today()], "start_date": [">=", today()]} + event_names = frappe.get_all("Buzz Event", {"is_published": 1}, or_filters=upcoming, 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 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/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: From 0df4317516103d10359da08f3da1020240f4d663 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 24 Oct 2026 19:24:51 +0530 Subject: [PATCH 4/6] test(listings): pin the system time zone with a patch change_settings on System Settings fails on its mandatory language field in CI. --- buzz/api/listings/test_listings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/buzz/api/listings/test_listings.py b/buzz/api/listings/test_listings.py index 40761ce27..63b5a54b5 100644 --- a/buzz/api/listings/test_listings.py +++ b/buzz/api/listings/test_listings.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import frappe from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today @@ -60,7 +62,7 @@ def test_second_upsert_updates_the_same_request(self): self.assertEqual(frappe.db.get_value(REQUEST, names[0], "event_title"), "Renamed Meetup") def test_upsert_stores_system_time(self): - with self.change_settings("System Settings", {"time_zone": "Asia/Kolkata"}): + 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") From 9c326e13c86ef25191aa3dbbf3c3073fa6938aa1 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sun, 25 Oct 2026 14:09:29 +0530 Subject: [PATCH 5/6] refactor(listings): drop the OpenAPI document Nothing calls it yet; a project-wide document can come later. --- buzz/api/listings/__init__.py | 7 ----- buzz/api/listings/document.py | 47 ------------------------------ buzz/api/listings/test_listings.py | 10 +------ 3 files changed, 1 insertion(+), 63 deletions(-) delete mode 100644 buzz/api/listings/document.py diff --git a/buzz/api/listings/__init__.py b/buzz/api/listings/__init__.py index b91c288ca..7ac9b8a0b 100644 --- a/buzz/api/listings/__init__.py +++ b/buzz/api/listings/__init__.py @@ -1,6 +1,5 @@ import frappe -from buzz.api.listings.document import openapi_document from buzz.api.listings.schemas import EventListing from buzz.api.listings.services import PartnerListings @@ -13,9 +12,3 @@ def upsert_event(event: EventListing) -> None: @frappe.whitelist(methods=["POST"]) def remove_event(source_id: str) -> None: PartnerListings().remove(source_id) - - -# nosemgrep: guest-whitelisted-method -@frappe.whitelist(allow_guest=True) -def openapi() -> dict: - return openapi_document() diff --git a/buzz/api/listings/document.py b/buzz/api/listings/document.py deleted file mode 100644 index 57626febf..000000000 --- a/buzz/api/listings/document.py +++ /dev/null @@ -1,47 +0,0 @@ -from frappe.utils import get_url - -from buzz.api.listings.schemas import EventListing - -METHOD_PATH = "/api/v2/method/buzz.api.listings" -SCHEMA_REF = {"$ref": "#/components/schemas/EventListing"} - - -def operation(summary: str, body_properties: dict, required: str) -> dict: - body = {"type": "object", "properties": body_properties, "required": [required]} - return { - "post": { - "summary": summary, - "security": [{"tokenAuth": []}], - "requestBody": {"required": True, "content": {"application/json": {"schema": body}}}, - "responses": {"200": {"description": "Done"}}, - } - } - - -def openapi_document() -> dict: - return { - "openapi": "3.1.0", - "info": {"title": "Buzz partner event listings", "version": "1.0.0"}, - "servers": [{"url": get_url()}], - "paths": { - f"{METHOD_PATH}.upsert_event": operation( - "List an event, or update it when the source_id is known", {"event": SCHEMA_REF}, "event" - ), - f"{METHOD_PATH}.remove_event": operation( - "Take an event off the community page", - {"source_id": {"type": "string", "maxLength": 140}}, - "source_id", - ), - }, - "components": { - "schemas": {"EventListing": EventListing.model_json_schema()}, - "securitySchemes": { - "tokenAuth": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - "description": "token :", - } - }, - }, - } diff --git a/buzz/api/listings/test_listings.py b/buzz/api/listings/test_listings.py index 63b5a54b5..94e49e60e 100644 --- a/buzz/api/listings/test_listings.py +++ b/buzz/api/listings/test_listings.py @@ -4,7 +4,7 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today -from buzz.api.listings import openapi, remove_event, upsert_event +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 @@ -112,11 +112,3 @@ def test_only_a_system_manager_can_remove_the_integration(self): ) with self.set_user(self.curator), self.assertRaises(frappe.ValidationError): frappe.delete_doc("Buzz Team Membership", membership, ignore_permissions=True) - - def test_openapi_lists_the_operations_and_listing_fields(self): - document = openapi() - self.assertEqual(document["openapi"], "3.1.0") - self.assertEqual(len(document["paths"]), 2) - self.assertEqual(document["components"]["securitySchemes"]["tokenAuth"]["in"], "header") - properties = document["components"]["schemas"]["EventListing"]["properties"] - self.assertEqual(set(properties), set(EventListing.model_fields)) From efbc73ef88e6018f22843cc4e9aadb0c5df51725 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sun, 25 Oct 2026 14:09:34 +0530 Subject: [PATCH 6/6] fix(listings): time out pushes and name things plainly - Pushes to buzz.events give up after 30 seconds instead of holding a worker. - Deleting a never-published event no longer posts a removal. - Payload reuses EventMeta for the end time and event URL. - Rename PEOPLE to HUMAN_MEMBER_FILTERS, post to post_to_buzz_events, ListingPush.push to sync_listing, find to find_request_name. --- buzz/api/listings/services.py | 6 ++--- buzz/api/teams/services.py | 12 ++++++--- buzz/comments.py | 6 +++-- buzz/events/buzz_events_listing.py | 27 +++++++++---------- buzz/events/doctype/buzz_team/buzz_team.py | 6 +++-- .../buzz_team_membership.py | 13 ++++++--- buzz/permissions.py | 10 ++++--- 7 files changed, 49 insertions(+), 31 deletions(-) diff --git a/buzz/api/listings/services.py b/buzz/api/listings/services.py index 166ce3332..e2ac8fac5 100644 --- a/buzz/api/listings/services.py +++ b/buzz/api/listings/services.py @@ -19,12 +19,12 @@ def __init__(self): NotAnIntegration.throw() self.community = teams[0] - def find(self, source_id: str) -> str | None: + 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(listing.source_id) + 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) @@ -35,7 +35,7 @@ def new_request(self, listing: EventListing): return request def remove(self, source_id: str) -> None: - if name := self.find(source_id): + if name := self.find_request_name(source_id): frappe.delete_doc(REQUEST, name, ignore_permissions=True) diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 8b463a569..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 INTEGRATION_ROLE, PEOPLE, 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", @@ -153,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", {**PEOPLE, "team": team, "user": user}) + name = frappe.db.exists("Buzz Team Membership", {**HUMAN_MEMBER_FILTERS, "team": team, "user": user}) if not name: NotATeamMember.throw() @@ -174,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", {**PEOPLE, "team": team, "user": user}) + 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 31fe83f3e..04d478dbf 100644 --- a/buzz/comments.py +++ b/buzz/comments.py @@ -3,7 +3,7 @@ from frappe.desk.notifications import extract_mentions from frappe.utils import get_fullname, get_url, strip_html -from buzz.permissions import PEOPLE +from buzz.permissions import HUMAN_MEMBER_FILTERS from buzz.subscriptions import Subscriptions DASHBOARD_ROUTES = {"Talk Proposal": "/b/manage/events/{event}/talks?proposal={name}"} @@ -23,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={**PEOPLE, "team": team}, 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 index 0f9f6fbd0..f4c5de53b 100644 --- a/buzz/events/buzz_events_listing.py +++ b/buzz/events/buzz_events_listing.py @@ -1,6 +1,6 @@ import frappe from frappe.integrations.utils import make_post_request -from frappe.utils import get_url, getdate, today +from frappe.utils import getdate, today from buzz.api.events.services import primary_host_of from buzz.www.event.meta import EventMeta @@ -23,11 +23,11 @@ def __init__(self, event): self.event = event self.source_id = source_id_of(event.name) - def push(self) -> None: + def sync_listing(self) -> None: if self.event.is_published and not self.has_ended(): - post("upsert_event", {"event": self.payload()}) + post_to_buzz_events("upsert_event", {"event": self.payload()}) else: - post("remove_event", {"source_id": self.source_id}) + 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()) @@ -44,37 +44,36 @@ def payload(self) -> dict: "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.event_datetime( - self.event.end_date or self.event.start_date, self.event.end_time - ), - "event_url": get_url(f"/events/{self.event.route}"), + "end_datetime": meta.end_datetime(), + "event_url": meta.url(), } -def post(method: str, body: dict) -> None: +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)).push() + ListingPush(frappe.get_doc("Buzz Event", event_name)).sync_listing() def remove_listing(source_id: str) -> None: if listing_enabled(): - post("remove_event", {"source_id": source_id}) + 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 = {"end_date": [">=", today()], "start_date": [">=", today()]} - event_names = frappe.get_all("Buzz Event", {"is_published": 1}, or_filters=upcoming, pluck="name") + 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) @@ -97,7 +96,7 @@ def enqueue_push(event, method=None) -> None: def enqueue_remove(event, method=None) -> None: - if listing_enabled(): + if event.is_published and listing_enabled(): frappe.enqueue( "buzz.events.buzz_events_listing.remove_listing", source_id=source_id_of(str(event.name)), diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index c85323ad9..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 PEOPLE, 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={**PEOPLE, "team": self.name}, 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.py b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py index 9879568a0..38f35ccf7 100644 --- a/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py +++ b/buzz/events/doctype/buzz_team_membership/buzz_team_membership.py @@ -172,10 +172,15 @@ def validate_duplicate(self): def validate_integration_role(self): """Only a System Manager creates, changes or toggles a bot membership.""" - previous = self.get_doc_before_save() - involved = INTEGRATION_ROLE in (self.team_role, previous and previous.team_role) - changed = self.is_new() or self.has_value_changed("team_role") or self.has_value_changed("enabled") - if involved and changed and not is_unrestricted(frappe.session.user): + 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): diff --git a/buzz/permissions.py b/buzz/permissions.py index 665f511bc..7dff1fa9e 100644 --- a/buzz/permissions.py +++ b/buzz/permissions.py @@ -11,7 +11,7 @@ # 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. -PEOPLE = {"enabled": 1, "team_role": ["!=", INTEGRATION_ROLE]} +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") @@ -78,7 +78,9 @@ def my_teams(user: str) -> QueryBuilder: def my_team_names(user: str) -> list[str]: - return frappe.get_all("Buzz Team Membership", filters={**PEOPLE, "user": user}, pluck="team") + return frappe.get_all( + "Buzz Team Membership", filters={**HUMAN_MEMBER_FILTERS, "user": user}, pluck="team" + ) @frappe.whitelist() @@ -158,7 +160,9 @@ def belongs_to_user(doc, user: str) -> bool: def team_role_of(user: str, team: str | None) -> str | None: if not team: return None - return frappe.db.get_value("Buzz Team Membership", {**PEOPLE, "user": user, "team": team}, "team_role") + return frappe.db.get_value( + "Buzz Team Membership", {**HUMAN_MEMBER_FILTERS, "user": user, "team": team}, "team_role" + ) def role_allows(team_role: str, ptype: str) -> bool: