Skip to content
Merged
12 changes: 4 additions & 8 deletions buzz/api/events/taxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

from buzz.api.events.exceptions import TaxDetailsMissing
from buzz.api.events.services import ensure_event_team_access, manageable_event
from buzz.api.teams.exceptions import CannotEditTeam
from buzz.permissions import can_manage_members


def team_tax_details(team: str) -> dict:
Expand All @@ -31,12 +29,10 @@ def update_tax_settings(

def update_team_tax_details(event: str, legal_name: str, tax_id: str, billing_address: str) -> None:
ensure_event_team_access(event)
team = frappe.db.get_value("Buzz Event", event, "team")
if not can_manage_members(team):
CannotEditTeam.throw()
# Empty details are valid on the team, but this endpoint exists to add them.
if not tax_id.strip():
frappe.throw(_("Enter your team's legal name, tax ID and billing address."))
settings = frappe.get_doc("Buzz Team Settings", team)
settings.update({"legal_name": legal_name, "tax_id": tax_id, "billing_address": billing_address})
settings.save(ignore_permissions=True)
# Imported here: teams.services reads events.schemas, whose package imports this module.
from buzz.api.teams.services import update_tax_details

update_tax_details(frappe.db.get_value("Buzz Event", event, "team"), legal_name, tax_id, billing_address)
48 changes: 36 additions & 12 deletions buzz/api/teams/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import frappe

from buzz.api.teams import invitations, services
from buzz.api.events.schemas import RouteAvailability
from buzz.api.teams import invitations, public_page, services
from buzz.api.teams.schemas import InviteOutcome, TeamOption, TeamOverview
from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags


@frappe.whitelist()
def get_my_teams() -> list[TeamOption]:
"""Teams the session user belongs to, for the settings dialog.
"""Teams the session user belongs to, for the teams page and the settings dialog.

Reads past permissions on purpose: Buzz Team is readable by Event Manager only, while a
Frontdesk or Viewer member still has to see the team they work in. Rows are filtered to
Expand All @@ -20,18 +21,29 @@ def get_my_teams() -> list[TeamOption]:
frappe.qb.from_(membership)
.inner_join(team)
.on(team.name == membership.team)
.select(team.name, team.team_name, team.logo, membership.team_role)
.select(
team.name,
team.team_name,
team.logo,
team.slug,
team.is_published,
team.is_a_community,
team.short_description,
membership.team_role,
)
.where((membership.user == frappe.session.user) & (membership.enabled == 1))
).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]

return [
TeamOption(
**my_team,
members=services.members_of(my_team.name),
feature_flags=feature_flags(my_team.name),
)
for my_team in my_teams
]

def team_option(my_team: dict, upcoming_event_count: int) -> TeamOption:
return TeamOption(
**my_team,
upcoming_event_count=upcoming_event_count,
members=services.members_of(my_team.name),
feature_flags=feature_flags(my_team.name),
)


@frappe.whitelist()
Expand Down Expand Up @@ -67,8 +79,20 @@ def update_public_page(
short_description: str | None = None,
about: str | None = None,
is_a_community: bool = False,
slug: str | None = None,
) -> None:
services.update_public_page(team, is_published, links, short_description, about, is_a_community)
services.update_public_page(team, is_published, links, short_description, about, is_a_community, slug)


@frappe.whitelist()
def check_team_slug(team: str, slug: str) -> RouteAvailability:
"""Whether a team can move to this address. `team` is the one being edited."""
return public_page.slug_availability(team, slug)


@frappe.whitelist(methods=["POST"])
def update_tax_details(team: str, legal_name: str, tax_id: str, billing_address: str) -> None:
services.update_tax_details(team, legal_name, tax_id, billing_address)


@frappe.whitelist(methods=["POST"])
Expand Down
12 changes: 11 additions & 1 deletion buzz/api/teams/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from frappe import _lt

from buzz.api.exceptions import BuzzAPIError, NotPermitted
from buzz.api.exceptions import BuzzAPIError, Conflict, NotPermitted


class NotATeamMember(NotPermitted):
Expand Down Expand Up @@ -29,3 +29,13 @@ class UnknownTeamRole(BuzzAPIError):
class NoPendingInvite(BuzzAPIError):
title = _lt("No Invitation")
message = _lt("There is no pending invitation for {email}.")


class InvalidTeamSlug(BuzzAPIError):
title = _lt("Invalid URL")
message = _lt("Use letters, numbers and hyphens for the team's URL.")


class TeamSlugTaken(Conflict):
title = _lt("URL Taken")
message = _lt("Another team already uses {slug}.")
39 changes: 39 additions & 0 deletions buzz/api/teams/public_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import frappe
from frappe import _
from frappe.website.utils import cleanup_page_name

from buzz.api.events.schemas import RouteAvailability
from buzz.api.teams.exceptions import InvalidTeamSlug, TeamSlugTaken


def clean_slug(slug: str | None) -> str:
return cleanup_page_name(slug or "").replace("_", "-").strip("-")


def slug_taken(team: str, slug: str) -> bool:
return bool(frappe.db.exists("Buzz Team", {"slug": slug, "name": ["!=", team]}))


def slug_availability(team: str, slug: str) -> RouteAvailability:
"""What the settings field says while someone types. Checks the slug as it would be
saved, so the answer matches what set_slug does."""
clean = clean_slug(slug)
if not clean:
return RouteAvailability(available=False, message=str(InvalidTeamSlug.message))
if slug_taken(team, clean):
return RouteAvailability(available=False, message=_("This URL is already taken"))
return RouteAvailability(available=True, message=_("Available"))


def set_slug(doc, slug: str | None) -> None:
"""Move the team to a new address. The route follows the slug even while the team is
private, so publishing later serves the address the team chose."""
if slug is None or slug == doc.slug:
return
clean = clean_slug(slug)
if not clean:
InvalidTeamSlug.throw()
if slug_taken(doc.name, clean):
TeamSlugTaken.throw(slug=clean)
doc.slug = clean
doc.route = doc.make_route()
14 changes: 14 additions & 0 deletions buzz/api/teams/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class TeamOption(APIResponse):
name: str
team_name: str
logo: str | None
# Every team page header links to the public page while the team is published.
slug: str | None
is_published: bool
is_a_community: bool
short_description: str | None
upcoming_event_count: int
team_role: str
# The settings list shows who is on each team, not only how many.
members: list[TeamMember]
Expand All @@ -31,6 +37,12 @@ class TeamInvite(APIResponse):
team_role: str


class TeamTaxDetails(APIResponse):
legal_name: str | None
tax_id: str | None
billing_address: str | None


class TeamOverview(APIResponse):
name: str
team_name: str
Expand All @@ -47,3 +59,5 @@ class TeamOverview(APIResponse):
members: list[TeamMember]
# Kept apart from members: these people cannot do anything on the team yet.
invites: list[TeamInvite]
# Every member reads them; only Owner/Admin can change them.
tax_details: TeamTaxDetails
40 changes: 37 additions & 3 deletions buzz/api/teams/services.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import frappe
from frappe.query_builder import Case
from frappe.utils import get_url
from frappe.query_builder.functions import Coalesce, Count
from frappe.utils import get_url, today

from buzz.api.events.schemas import EventExternalLink
from buzz.api.teams import public_page
from buzz.api.teams.exceptions import (
CannotEditTeam,
CannotGrantOwnership,
CannotManageMembers,
NotATeamMember,
UnknownTeamRole,
)
from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview
from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview, TeamTaxDetails
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

TEAM_FIELDS = (
Expand Down Expand Up @@ -62,6 +65,35 @@ def team_overview(team: str) -> TeamOverview:
my_role=role,
members=members_of(team),
invites=pending_invites_for(team),
tax_details=tax_details_of(team),
)


def tax_details_of(team: str) -> TeamTaxDetails:
row = frappe.db.get_value("Buzz Team Settings", team, TAX_DETAIL_FIELDS, as_dict=True)
return TeamTaxDetails(**(row or dict.fromkeys(TAX_DETAIL_FIELDS)))


def update_tax_details(team: str, legal_name: str, tax_id: str, billing_address: str) -> None:
"""Owner/Admin. Buzz Team Settings holds the rules: all three together, and a tax ID
can change but never go back to blank."""
if not can_manage_members(team):
CannotEditTeam.throw()
settings = frappe.get_doc("Buzz Team Settings", team)
settings.update({"legal_name": legal_name, "tax_id": tax_id, "billing_address": billing_address})
settings.save(ignore_permissions=True)


def upcoming_event_counts(teams: list[str]) -> dict[str, int]:
if not teams:
return {}
event = frappe.qb.DocType("Buzz Event")
return dict(
frappe.qb.from_(event)
.select(event.team, Count("*"))
.where(event.team.isin(teams) & (Coalesce(event.end_date, event.start_date) >= today()))
.groupby(event.team)
.run()
)


Expand Down Expand Up @@ -196,19 +228,21 @@ def update_public_page(
short_description: str | None,
about: str | None,
is_a_community: bool = False,
slug: str | None = None,
) -> None:
"""Publish or edit a team's public page.

Owner/Admin/Manager, the roles that edit the team's events. Desk write on Buzz Team is
System Manager only, so the guard here is the authorization — the same shape as
`update_team`. The route is not editable here: it is filled on first publish.
`update_team`.
"""
if team_role_of(frappe.session.user, team) not in WRITE_ROLES:
CannotEditTeam.throw()

doc = frappe.get_doc("Buzz Team", team)
doc.is_published = int(is_published)
doc.is_a_community = int(is_a_community)
public_page.set_slug(doc, slug)
doc.short_description = (short_description or "").strip() or None
doc.about = about or None
doc.set("links", [{key: link.get(key) for key in ("icon", "label", "url")} for link in links])
Expand Down
28 changes: 27 additions & 1 deletion buzz/api/teams/test_teams.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, today

from buzz.api.teams import get_my_teams, get_team_overview, update_public_page, update_team
from buzz.api.teams.exceptions import CannotEditTeam, NotATeamMember
from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags
from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory
from buzz.tests.factories import (
BuzzEventFactory,
BuzzTeamFactory,
BuzzTeamMembershipFactory,
UserFactory,
)


class TeamTestCase(IntegrationTestCase):
Expand Down Expand Up @@ -74,6 +80,26 @@ def test_a_viewer_gets_the_teams_feature_flags(self):
with self.set_user(user):
self.assertEqual(get_my_teams()[0].feature_flags, feature_flags(team))

def test_flags_communities(self):
user = self.create_user("switcher-community@example.com")
community = BuzzTeamFactory.create_owned_by(user, "community").name
self.create_team(user)

with self.set_user(user):
flags = {option.name: option.is_a_community for option in get_my_teams()}

self.assertEqual(sorted(flags.values()), [False, True])
self.assertTrue(flags[community])

def test_counts_events_that_have_not_ended(self):
user = self.create_user("switcher-events@example.com")
team = self.create_team(user)
BuzzEventFactory.create(team=team)
BuzzEventFactory.create(team=team, start_date=add_days(today(), -3), end_date=add_days(today(), -2))

with self.set_user(user):
self.assertEqual(get_my_teams()[0].upcoming_event_count, 1)

def test_returns_nothing_for_a_user_on_no_team(self):
self.assertEqual(self.team_names_for(self.create_user("switcher-teamless@example.com")), [])

Expand Down
3 changes: 3 additions & 0 deletions buzz/events/doctype/buzz_team/buzz_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def onload(self):
def validate(self):
if not self.slug:
self.set_slug()
# Submissions are listed on the public page, so a private team has nowhere to show them.
if self.is_a_community and not self.is_published:
frappe.throw(_("Only a public team can accept community event submissions."))
# After the slug: the first publish builds the route from it.
super().validate()

Expand Down
Loading
Loading