diff --git a/buzz/api/events/taxes.py b/buzz/api/events/taxes.py index bfacbe875..1a70afae5 100644 --- a/buzz/api/events/taxes.py +++ b/buzz/api/events/taxes.py @@ -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: @@ -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) diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 0448b3bfc..220cf4b43 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -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 @@ -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() @@ -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"]) diff --git a/buzz/api/teams/exceptions.py b/buzz/api/teams/exceptions.py index cf39ce528..0c88bf1ea 100644 --- a/buzz/api/teams/exceptions.py +++ b/buzz/api/teams/exceptions.py @@ -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): @@ -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}.") diff --git a/buzz/api/teams/public_page.py b/buzz/api/teams/public_page.py new file mode 100644 index 000000000..a72740509 --- /dev/null +++ b/buzz/api/teams/public_page.py @@ -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() diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index c22dcdccf..0f0f0c9a6 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -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] @@ -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 @@ -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 diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 372256395..73a9296c6 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -1,8 +1,10 @@ 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, @@ -10,7 +12,8 @@ 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 = ( @@ -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() ) @@ -196,12 +228,13 @@ 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() @@ -209,6 +242,7 @@ def update_public_page( 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]) diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index b1cf3c44e..affaed93f 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -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): @@ -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")), []) diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index 4cc89e521..45db7bbdd 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.py +++ b/buzz/events/doctype/buzz_team/buzz_team.py @@ -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() diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 95f566e45..358ea084c 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -62,6 +62,8 @@ declare module 'vue' { EventGuestSkeleton: typeof import('./src/components/dashboard/events/EventGuestSkeleton.vue')['default'] EventHosts: typeof import('./src/components/dashboard/events/EventHosts.vue')['default'] EventHoverCard: typeof import('./src/components/dashboard/events/EventHoverCard.vue')['default'] + EventLinkDialog: typeof import('./src/components/dashboard/events/EventLinkDialog.vue')['default'] + EventLinks: typeof import('./src/components/dashboard/events/EventLinks.vue')['default'] EventLocationField: typeof import('./src/components/dashboard/events/location/EventLocationField.vue')['default'] EventMedium: typeof import('./src/components/dashboard/events/EventMedium.vue')['default'] EventMyTickets: typeof import('./src/components/dashboard/events/EventMyTickets.vue')['default'] @@ -73,7 +75,8 @@ declare module 'vue' { EventTalkActions: typeof import('./src/components/dashboard/proposals/EventTalkActions.vue')['default'] EventTalkProposalDrawer: typeof import('./src/components/dashboard/proposals/EventTalkProposalDrawer.vue')['default'] EventThemeSetting: typeof import('./src/components/dashboard/events/EventThemeSetting.vue')['default'] - FilterBar: typeof import('./src/components/common/filters/FilterBar.vue')['default'] + FilterChip: typeof import('./src/components/common/filters/FilterChip.vue')['default'] + FloatingCreateEventButton: typeof import('./src/components/dashboard/FloatingCreateEventButton.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] GuestInfoDrawer: typeof import('./src/components/dashboard/events/GuestInfoDrawer.vue')['default'] GuestListExport: typeof import('./src/components/dashboard/events/GuestListExport.vue')['default'] @@ -81,6 +84,7 @@ declare module 'vue' { ImageCropper: typeof import('./src/components/common/ImageCropper.vue')['default'] ImageCropUploader: typeof import('./src/components/common/ImageCropUploader.vue')['default'] LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default'] + ListFilters: typeof import('./src/components/common/filters/ListFilters.vue')['default'] ListSection: typeof import('./src/components/common/ListSection.vue')['default'] LocationDialog: typeof import('./src/components/dashboard/events/location/LocationDialog.vue')['default'] LocationMap: typeof import('./src/components/dashboard/events/location/LocationMap.vue')['default'] @@ -97,9 +101,12 @@ declare module 'vue' { OfflinePaymentDialog: typeof import('./src/components/OfflinePaymentDialog.vue')['default'] PasswordSection: typeof import('./src/components/settings/PasswordSection.vue')['default'] PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default'] + PaymentGatewayLogo: typeof import('./src/components/PaymentGatewayLogo.vue')['default'] + PaymentProviders: typeof import('./src/components/dashboard/ticket-types/PaymentProviders.vue')['default'] PerkList: typeof import('./src/components/dashboard/sponsorships/PerkList.vue')['default'] PhoneInput: typeof import('./src/components/PhoneInput.vue')['default'] PreferencesPanel: typeof import('./src/components/settings/PreferencesPanel.vue')['default'] + PrefixedInput: typeof import('./src/components/common/PrefixedInput.vue')['default'] PricedItemRow: typeof import('./src/components/common/PricedItemRow.vue')['default'] PriceInput: typeof import('./src/components/dashboard/sponsorships/PriceInput.vue')['default'] PriceList: typeof import('./src/components/dashboard/sponsorships/PriceList.vue')['default'] @@ -115,8 +122,10 @@ declare module 'vue' { RegistrationActions: typeof import('./src/components/dashboard/events/RegistrationActions.vue')['default'] RegistrationDialog: typeof import('./src/components/dashboard/events/RegistrationDialog.vue')['default'] RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default'] + RevenueCards: typeof import('./src/components/dashboard/ticket-types/RevenueCards.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SaveActions: typeof import('./src/components/dashboard/teams/SaveActions.vue')['default'] SectionHeader: typeof import('./src/components/common/SectionHeader.vue')['default'] SelectionBar: typeof import('./src/components/common/SelectionBar.vue')['default'] SortableList: typeof import('./src/components/dashboard/sponsorships/SortableList.vue')['default'] @@ -125,8 +134,18 @@ declare module 'vue' { SponsorshipDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorshipDrawer.vue')['default'] SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default'] SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default'] + TaxSettings: typeof import('./src/components/dashboard/ticket-types/TaxSettings.vue')['default'] + TeamCard: typeof import('./src/components/dashboard/teams/TeamCard.vue')['default'] + TeamDisplaySettings: typeof import('./src/components/dashboard/teams/settings/TeamDisplaySettings.vue')['default'] + TeamIdentityForm: typeof import('./src/components/dashboard/teams/TeamIdentityForm.vue')['default'] TeamMembersTable: typeof import('./src/components/dashboard/teams/TeamMembersTable.vue')['default'] + TeamOptionsSettings: typeof import('./src/components/dashboard/teams/settings/TeamOptionsSettings.vue')['default'] + TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default'] + TeamPublicPage: typeof import('./src/components/dashboard/teams/TeamPublicPage.vue')['default'] + TeamRoleGuide: typeof import('./src/components/dashboard/teams/TeamRoleGuide.vue')['default'] TeamsPanel: typeof import('./src/components/dashboard/teams/TeamsPanel.vue')['default'] + TeamTaxDetailsDialog: typeof import('./src/components/dashboard/ticket-types/TeamTaxDetailsDialog.vue')['default'] + TeamTaxDetailsForm: typeof import('./src/components/dashboard/teams/TeamTaxDetailsForm.vue')['default'] ThemeSwitcher: typeof import('./src/components/settings/ThemeSwitcher.vue')['default'] TicketBookingDetails: typeof import('./src/components/dashboard/tickets/TicketBookingDetails.vue')['default'] TicketCard: typeof import('./src/components/TicketCard.vue')['default'] diff --git a/dashboard/src/components/common/AvatarUploader.vue b/dashboard/src/components/common/AvatarUploader.vue index 7ae8d20d7..50b8abba0 100644 --- a/dashboard/src/components/common/AvatarUploader.vue +++ b/dashboard/src/components/common/AvatarUploader.vue @@ -10,8 +10,9 @@ withDefaults( title: string description?: string shape?: "circle" | "square" + size?: "md" | "lg" }>(), - { description: "", shape: "circle" }, + { description: "", shape: "circle", size: "md" }, ) const image = defineModel({ required: true }) @@ -30,8 +31,11 @@ const image = defineModel({ required: true })
diff --git a/dashboard/src/components/dashboard/TimelineList.vue b/dashboard/src/components/dashboard/TimelineList.vue index 110654299..d1999386a 100644 --- a/dashboard/src/components/dashboard/TimelineList.vue +++ b/dashboard/src/components/dashboard/TimelineList.vue @@ -7,7 +7,7 @@ import type { MonthGroup } from "@/utils/eventGroups" import type { TimelineTab } from "@/utils/timelineTabs" defineProps<{ - heading: string + heading?: string icon?: string description?: string noun: string @@ -29,7 +29,7 @@ const tabOptions = [