Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions buzz/api/listings/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions buzz/api/listings/exceptions.py
Original file line number Diff line number Diff line change
@@ -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.")
11 changes: 11 additions & 0 deletions buzz/api/listings/schemas.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions buzz/api/listings/services.py
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions buzz/api/listings/test_listings.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 6 additions & 1 deletion buzz/api/teams/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand Down
18 changes: 13 additions & 5 deletions buzz/api/teams/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion buzz/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"}
Expand All @@ -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:
Expand Down
104 changes: 104 additions & 0 deletions buzz/events/buzz_events_listing.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading