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
8 changes: 7 additions & 1 deletion buzz/api/teams/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
7 changes: 7 additions & 0 deletions buzz/api/teams/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 NotATeamMember(NotPermitted):
message = _lt("You are not a member of this team.")
16 changes: 16 additions & 0 deletions buzz/api/teams/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
45 changes: 45 additions & 0 deletions buzz/api/teams/services.py
Original file line number Diff line number Diff line change
@@ -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]
56 changes: 55 additions & 1 deletion buzz/api/teams/test_teams.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions dashboard/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
14 changes: 11 additions & 3 deletions dashboard/src/components/dashboard/events/EventCard.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<script setup lang="ts">
import type { MyEvent } from "@/types";
import { dayLabel } from "@/utils/dateLabels";
import { bannerPattern } from "@/utils/eventBanner";
import { Avatar } from "frappe-ui";
import { computed } from "vue";

const props = defineProps<{ event: MyEvent }>();
// The Events page files cards under a date heading; a standalone list has to
// carry the date on the card itself.
const props = defineProps<{ event: MyEvent; showDate?: boolean }>();

// Times arrive as a serialized timedelta ("9:00:00"), so the hour needs padding.
const startTime = computed((): string => {
Expand Down Expand Up @@ -46,8 +49,13 @@ const venue = computed(() => {

<div class="flex-1 py-1 flex flex-col justify-between">
<div class="flex-1 space-y-2">
<p v-if="startTime" class="text-base tabular-nums text-ink-gray-5">
{{ startTime }}
<p
v-if="showDate || startTime"
class="flex items-center gap-2 text-base tabular-nums text-ink-gray-5"
>
<span v-if="showDate">{{ dayLabel(event.start_date) }}</span>
<span v-if="showDate && startTime" class="text-ink-gray-4">·</span>
<span v-if="startTime">{{ startTime }}</span>
</p>

<h3 class="font-semibold text-lg text-ink-gray-8">{{ event.title }}</h3>
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/components/dashboard/teams/TeamHero.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { TeamOverview } from "@/types";
import { Avatar } from "frappe-ui";

defineProps<{ team: TeamOverview }>();
</script>

<template>
<header class="flex items-center gap-4">
<Avatar
:image="team.logo ?? undefined"
:label="team.team_name"
shape="square"
class="size-20 outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
<div class="flex flex-col gap-2">
<h1 class="text-xl font-semibold text-ink-gray-9">{{ team.team_name }}</h1>
<p v-if="team.slug" class="text-sm text-ink-gray-5">/{{ team.slug }}</p>
</div>
</header>
</template>
20 changes: 20 additions & 0 deletions dashboard/src/components/dashboard/teams/TeamPageHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { currentTeam } from "@/data/teams";
import { Breadcrumbs, Button, PageHeader } from "frappe-ui";
import { computed } from "vue";

const props = defineProps<{ title: string }>();

const items = computed(() => {
const team = currentTeam.value;
if (!team) return [{ label: props.title }];
return [{ label: team.team_name, route: "/manage/team/overview" }, { label: props.title }];
});
</script>

<template>
<PageHeader class="border-none pt-2">
<Breadcrumbs :items="items" />
<Button variant="solid" icon-left="plus" label="Create Event" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Create Event action is inert

When a team member clicks the visible Create Event action, the button has neither a route nor a click handler, so the click has no effect.

Prompt To Fix With AI
This is a comment left during a code review.
Path: dashboard/src/components/dashboard/teams/TeamPageHeader.vue
Line: 18

Comment:
**Create Event action is inert**

When a team member clicks the visible Create Event action, the button has neither a route nor a click handler, so the click has no effect.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in later stacked PRs

</PageHeader>
</template>
10 changes: 10 additions & 0 deletions dashboard/src/data/events.ts
Original file line number Diff line number Diff line change
@@ -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<MyEvents>({
url: "/api/v2/method/buzz.api.events.get_my_events",
})
}
28 changes: 25 additions & 3 deletions dashboard/src/data/teams.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -33,6 +33,28 @@ export async function isTeamMember(): Promise<boolean> {
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<TeamOverview, { team: string }>({
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)
Expand Down
21 changes: 15 additions & 6 deletions dashboard/src/layouts/ManagerLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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" },
Expand All @@ -37,7 +40,8 @@ const teamItems = [
<template>
<NotFound v-if="isMember === false" />

<DesktopShell v-else-if="isMember">
<!-- scroll=false: the rounded panel below owns its own scroll. -->
<DesktopShell v-else-if="isMember" :scroll="false">
<template #sidebar>
<Sidebar v-model:collapsed="collapsed">
<div class="flex h-12 shrink-0 items-center px-1">
Expand Down Expand Up @@ -71,9 +75,14 @@ const teamItems = [
</Sidebar>
</template>

<div class="h-lvh bg-surface-sidebar py-2 pl-2">
<div class="h-full rounded-l-lg bg-surface-elevation-1 shadow-base overflow-y-auto">
<router-view />
<div class="h-full min-h-0 bg-surface-sidebar py-2 pl-2">
<div
class="flex h-full flex-col overflow-hidden rounded-l-lg bg-surface-elevation-1 shadow-base"
>
<PageHeaderTarget />
<div class="min-h-0 flex-1 overflow-y-auto">
<router-view />
</div>
</div>
</div>
</DesktopShell>
Expand Down
9 changes: 2 additions & 7 deletions dashboard/src/pages/manage/MyEvents.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
<script setup lang="ts">
import TimelineList from "@/components/dashboard/TimelineList.vue";
import EventCard from "@/components/dashboard/events/EventCard.vue";
import type { MyEvents } from "@/types";
import { useMyEvents } from "@/data/events";
import { groupEventsByMonth } from "@/utils/eventGroups";
import type { TimelineTab } from "@/utils/timelineTabs";
import { useCall } from "frappe-ui";
import { computed, ref } from "vue";

// 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.
const myEvents = useCall<MyEvents>({
url: "/api/v2/method/buzz.api.events.get_my_events",
});
const myEvents = useMyEvents();

const tab = ref<TimelineTab>("upcoming");

Expand Down
Loading
Loading