diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index 436ca332..2369cc62 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -1,6 +1,7 @@ import frappe -from buzz.api.teams.schemas import TeamOption +from buzz.api.teams import services +from buzz.api.teams.schemas import TeamOption, TeamOverview @frappe.whitelist() @@ -23,3 +24,8 @@ def get_my_teams() -> list[TeamOption]: ).run(as_dict=True) return [TeamOption(**my_team) for my_team in my_teams] + + +@frappe.whitelist() +def get_team_overview(team: str) -> TeamOverview: + return services.team_overview(team) diff --git a/buzz/api/teams/exceptions.py b/buzz/api/teams/exceptions.py new file mode 100644 index 00000000..864d3d7e --- /dev/null +++ b/buzz/api/teams/exceptions.py @@ -0,0 +1,7 @@ +from frappe import _lt + +from buzz.api.exceptions import NotPermitted + + +class NotATeamMember(NotPermitted): + message = _lt("You are not a member of this team.") diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index a4465251..3ff1b82b 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -6,3 +6,19 @@ class TeamOption(APIResponse): team_name: str logo: str | None team_role: str + + +class TeamMember(APIResponse): + user: str + full_name: str | None + user_image: str | None + team_role: str + + +class TeamOverview(APIResponse): + name: str + team_name: str + slug: str | None + logo: str | None + my_role: str + members: list[TeamMember] diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py new file mode 100644 index 00000000..70b6ca87 --- /dev/null +++ b/buzz/api/teams/services.py @@ -0,0 +1,45 @@ +import frappe + +from buzz.api.teams.exceptions import NotATeamMember +from buzz.api.teams.schemas import TeamMember, TeamOverview +from buzz.permissions import team_role_of + +TEAM_FIELDS = ("name", "team_name", "slug", "logo") + + +def team_overview(team: str) -> TeamOverview: + """Everything the team dashboard shows about one team. + + Reads past permissions like `get_my_teams`: Buzz Team is readable by Event Manager + only, while a Frontdesk or Viewer member still works inside the team. Membership is + the authorization. + """ + role = team_role_of(frappe.session.user, team) + if not role: + NotATeamMember.throw() + + details = frappe.db.get_value("Buzz Team", team, TEAM_FIELDS, as_dict=True) + if not details: + NotATeamMember.throw() + + return TeamOverview( + **details, + my_role=role, + members=members_of(team), + ) + + +def members_of(team: str) -> list[TeamMember]: + membership = frappe.qb.DocType("Buzz Team Membership") + user = frappe.qb.DocType("User") + + rows = ( + frappe.qb.from_(membership) + .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)) + .orderby(user.full_name) + ).run(as_dict=True) + + return [TeamMember(**row) for row in rows] diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index d86932e7..517c2e26 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -1,7 +1,8 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.teams import get_my_teams +from buzz.api.teams import get_my_teams, get_team_overview +from buzz.api.teams.exceptions import NotATeamMember from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership @@ -57,3 +58,56 @@ def test_returns_nothing_for_a_user_on_no_team(self): user = create_user("switcher-teamless@example.com", "Teamless") self.assertEqual(self.team_names_for(user), []) + + +class TestGetTeamOverview(IntegrationTestCase): + # Rollback is per class, not per test — every test owns its user and its team names. + def setUp(self): + frappe.set_user("Administrator") + self.addCleanup(frappe.set_user, "Administrator") + + def overview_for(self, user: str, team: str): + frappe.set_user(user) + return get_team_overview(team) + + def test_returns_the_team_and_the_callers_role(self): + user = create_user("overview-owner@example.com", "Owner") + team = create_owned_team("Overview Owned", user) + + overview = self.overview_for(user, team) + + self.assertEqual(overview.name, team) + self.assertEqual(overview.team_name, "Overview Owned") + self.assertEqual(overview.slug, "overview-owned") + self.assertEqual(overview.my_role, "Owner") + + def test_lists_enabled_members_only(self): + owner = create_user("overview-host@example.com", "Host") + viewer = create_user("overview-viewer@example.com", "Viewer") + lapsed = create_user("overview-lapsed@example.com", "Lapsed") + team = create_owned_team("Overview Members", owner) + upsert_membership(team, viewer, "Viewer") + upsert_membership(team, lapsed, "Manager") + frappe.db.set_value("Buzz Team Membership", {"team": team, "user": lapsed}, "enabled", 0) + + members = self.overview_for(viewer, team).members + + self.assertEqual(sorted(member.user for member in members), sorted([owner, viewer])) + self.assertEqual({member.user: member.team_role for member in members}[viewer], "Viewer") + + def test_a_viewer_reads_the_team_despite_no_read_permission(self): + user = create_user("overview-no-perm@example.com", "Viewer") + team = create_owned_team("Overview No Perm", create_user("overview-admin@example.com", "Admin")) + upsert_membership(team, user, "Viewer") + + frappe.set_user(user) + self.assertFalse(frappe.has_permission("Buzz Team", doc=team)) + self.assertEqual(get_team_overview(team).name, team) + + def test_refuses_a_team_the_user_is_not_on(self): + user = create_user("overview-outsider@example.com", "Outsider") + team = create_owned_team("Overview Outsider", create_user("overview-insider@example.com", "Insider")) + + frappe.set_user(user) + with self.assertRaises(NotATeamMember): + get_team_overview(team) diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index cf585e54..99e05048 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -48,6 +48,8 @@ declare module 'vue' { RouterView: typeof import('vue-router')['RouterView'] SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default'] SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default'] + TeamHero: typeof import('./src/components/dashboard/teams/TeamHero.vue')['default'] + TeamPageHeader: typeof import('./src/components/dashboard/teams/TeamPageHeader.vue')['default'] TeamSwitcher: typeof import('./src/components/TeamSwitcher.vue')['default'] TicketCard: typeof import('./src/components/TicketCard.vue')['default'] TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default'] diff --git a/dashboard/src/components/dashboard/events/EventCard.vue b/dashboard/src/components/dashboard/events/EventCard.vue index caa50dcb..863cf46a 100644 --- a/dashboard/src/components/dashboard/events/EventCard.vue +++ b/dashboard/src/components/dashboard/events/EventCard.vue @@ -1,10 +1,13 @@ + + diff --git a/dashboard/src/components/dashboard/teams/TeamPageHeader.vue b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue new file mode 100644 index 00000000..5905e5f5 --- /dev/null +++ b/dashboard/src/components/dashboard/teams/TeamPageHeader.vue @@ -0,0 +1,20 @@ + + + diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts new file mode 100644 index 00000000..dc9a3d65 --- /dev/null +++ b/dashboard/src/data/events.ts @@ -0,0 +1,10 @@ +import type { MyEvents } from "@/types" +import { useCall } from "frappe-ui" + +// v2 path: useCall reads the payload from `data`, which /api/method names `message`. +// Uncached: cacheKey would persist this user's feed to IndexedDB past a logout. +export function useMyEvents() { + return useCall({ + url: "/api/v2/method/buzz.api.events.get_my_events", + }) +} diff --git a/dashboard/src/data/teams.ts b/dashboard/src/data/teams.ts index 69d72750..e96db7bb 100644 --- a/dashboard/src/data/teams.ts +++ b/dashboard/src/data/teams.ts @@ -1,7 +1,7 @@ import { session } from "@/data/session" -import type { TeamOption } from "@/types" -import { createResource } from "frappe-ui" -import { computed, ref } from "vue" +import type { TeamOption, TeamOverview } from "@/types" +import { createResource, useCall } from "frappe-ui" +import { computed, ref, watch } from "vue" const STORAGE_KEY = "buzz:current-team" @@ -33,6 +33,28 @@ export async function isTeamMember(): Promise { return teams.value.length > 0 } +// Scoped to the selected team rather than to a route, so a switch re-reads whatever +// page is open. v2 path: useCall reads the payload from `data`, which /api/method +// names `message`. +const teamOverview = useCall({ + url: "/api/v2/method/buzz.api.teams.get_team_overview", + params: () => ({ team: selectedTeamName.value }), + immediate: false, +}) + +/** + * The selected team's details, for the pages that show them. + * + * The watcher belongs to the caller rather than to this module: at module scope it + * would fire as soon as get_my_teams settles, putting a request on every manage page + * instead of the few that read one. Scoped here it also means the page owns its own + * first fetch, so `loading` is true before the page paints. + */ +export function useTeamOverview() { + watch(currentTeam, (team) => team && teamOverview.reload(), { immediate: true }) + return teamOverview +} + export function selectTeam(name: string) { selectedTeamName.value = name localStorage.setItem(STORAGE_KEY, name) diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue index af524768..e269c6f7 100644 --- a/dashboard/src/layouts/ManagerLayout.vue +++ b/dashboard/src/layouts/ManagerLayout.vue @@ -3,7 +3,7 @@ import TeamSwitcher from "@/components/TeamSwitcher.vue"; import UserMenu from "@/components/UserMenu.vue"; import { isTeamMember } from "@/data/teams"; import NotFound from "@/pages/NotFound.vue"; -import { DesktopShell, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui"; +import { DesktopShell, PageHeaderTarget, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui"; import { ref } from "vue"; import { useRoute } from "vue-router"; @@ -26,8 +26,11 @@ const personalItems = [ { label: "Sponsorship", icon: "lucide-handshake", to: "/manage/sponsorship" }, ]; +// Team-scoped destinations read the active team from data/teams rather than the path, +// so they are fixed and need no team loaded to render. const teamItems = [ - { label: "Overview", icon: "lucide-layout-dashboard", to: "/manage/overview" }, + { label: "Overview", icon: "lucide-layout-dashboard", to: "/manage/team/overview" }, + { label: "Members", icon: "lucide-users-round", to: "/manage/team/members" }, { label: "Registrations", icon: "lucide-users", to: "/manage/registrations" }, { label: "Sponsors", icon: "lucide-badge-dollar-sign", to: "/manage/sponsors" }, { label: "More", icon: "lucide-ellipsis", to: "/manage/more" }, @@ -37,7 +40,8 @@ const teamItems = [