From 63eb11a74a3afe3e4d47313e9370951c5de703a1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 21:18:24 +0000
Subject: [PATCH 1/2] feat(export): make export a permission of its own
Exporting the response and check-in lists used to need manage rights, so a
sports officer who may see all statistics had no way to hand out the lists.
`export` is now its own permission, shown in the admin settings and the
setup wizard.
Version000027 seeds it from manage_appointments (mode and groups), so no
install changes its effective access on update. Managers do not hold it
implicitly. Guests never do. `getPermissions` reports `canExport`; the
mobile app has no export, so no capability flag.
psalm-baseline.xml gets the two entries every migration needs
(UnusedClass, and DeprecatedMethod for the IConfig write that keeps the
group list readable by older versions), like Version000021.
---
l10n/de.js | 5 +-
l10n/de.json | 5 +-
l10n/de_DE.js | 5 +-
l10n/de_DE.json | 5 +-
lib/Controller/AppointmentController.php | 6 +-
.../Version000027Date20261012120000.php | 75 ++++++++++
lib/ResponseDefinitions.php | 1 +
lib/Service/PermissionService.php | 12 ++
openapi-full.json | 6 +-
openapi.json | 6 +-
playwright.config.js | 2 +
psalm-baseline.xml | 8 +
scripts/reset-dev-data.sh | 2 +-
src/App.vue | 2 +-
.../appointment/AppointmentActionsMenu.vue | 2 +-
.../onboarding/OnboardingWizard.vue | 2 +-
src/composables/usePermissions.js | 2 +
src/utils/permissions.js | 7 +
tests/e2e/42-export-permission.spec.js | 108 ++++++++++++++
tests/e2e/fixtures/nextcloud.js | 1 +
...ointmentControllerExportPermissionTest.php | 117 +++++++++++++++
.../Version000027Date20261012120000Test.php | 137 ++++++++++++++++++
tests/unit/Service/PermissionServiceTest.php | 40 +++++
23 files changed, 543 insertions(+), 13 deletions(-)
create mode 100644 lib/Migration/Version000027Date20261012120000.php
create mode 100644 tests/e2e/42-export-permission.spec.js
create mode 100644 tests/unit/Controller/AppointmentControllerExportPermissionTest.php
create mode 100644 tests/unit/Migration/Version000027Date20261012120000Test.php
diff --git a/l10n/de.js b/l10n/de.js
index 5ad6cd32..22f58ca9 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1097,6 +1097,9 @@ OC.L10N.register(
"Open Notifications settings" : "Einstellungen der Benachrichtigungs-App öffnen",
"Hide hint" : "Hinweis ausblenden",
"Show hint" : "Hinweis anzeigen",
- "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter."
+ "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
+ "May export appointments" : "Darf Termine exportieren",
+ "Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
},
"nplurals=2; plural=(n != 1);");
diff --git a/l10n/de.json b/l10n/de.json
index 00ca0ca2..d4a91653 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -1095,6 +1095,9 @@
"Open Notifications settings" : "Einstellungen der Benachrichtigungs-App öffnen",
"Hide hint" : "Hinweis ausblenden",
"Show hint" : "Hinweis anzeigen",
- "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter."
+ "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
+ "May export appointments" : "Darf Termine exportieren",
+ "Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
\ No newline at end of file
diff --git a/l10n/de_DE.js b/l10n/de_DE.js
index acdbf8af..80572fcf 100644
--- a/l10n/de_DE.js
+++ b/l10n/de_DE.js
@@ -1097,6 +1097,9 @@ OC.L10N.register(
"Open Notifications settings" : "Einstellungen der Benachrichtigungs-App öffnen",
"Hide hint" : "Hinweis ausblenden",
"Show hint" : "Hinweis anzeigen",
- "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter."
+ "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
+ "May export appointments" : "Darf Termine exportieren",
+ "Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
},
"nplurals=2; plural=(n != 1);");
diff --git a/l10n/de_DE.json b/l10n/de_DE.json
index 8a6856f0..f5534775 100644
--- a/l10n/de_DE.json
+++ b/l10n/de_DE.json
@@ -1095,6 +1095,9 @@
"Open Notifications settings" : "Einstellungen der Benachrichtigungs-App öffnen",
"Hide hint" : "Hinweis ausblenden",
"Show hint" : "Hinweis anzeigen",
- "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter."
+ "Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
+ "May export appointments" : "Darf Termine exportieren",
+ "Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
\ No newline at end of file
diff --git a/lib/Controller/AppointmentController.php b/lib/Controller/AppointmentController.php
index 5e3c9bab..c3b500ac 100644
--- a/lib/Controller/AppointmentController.php
+++ b/lib/Controller/AppointmentController.php
@@ -1093,6 +1093,7 @@ public function getPermissions(): DataResponse {
'canSeeIndividualResponses' => $this->permissionService->canSeeIndividualResponses($user->getUID()),
'canSeeAllAppointments' => $this->permissionService->canSeeAllAppointments($user->getUID()),
'canSeeTeamVacations' => $this->permissionService->canSeeTeamVacations($user->getUID()),
+ 'canExport' => $this->permissionService->canExport($user->getUID()),
]);
}
@@ -1413,8 +1414,7 @@ public function export(
return new DataResponse(['error' => 'User not authenticated'], 401);
}
- // Check if user can manage appointments
- if (!$this->permissionService->canManageAppointments($user->getUID())) {
+ if (!$this->permissionService->canExport($user->getUID())) {
return new DataResponse(['error' => 'Insufficient permissions to export appointments'], 403);
}
@@ -1473,7 +1473,7 @@ public function exportSeries(): DataResponse {
return new DataResponse(['error' => 'User not authenticated'], 401);
}
- if (!$this->permissionService->canManageAppointments($user->getUID())) {
+ if (!$this->permissionService->canExport($user->getUID())) {
return new DataResponse(['error' => 'Insufficient permissions to export appointments'], 403);
}
diff --git a/lib/Migration/Version000027Date20261012120000.php b/lib/Migration/Version000027Date20261012120000.php
new file mode 100644
index 00000000..4e91d70b
--- /dev/null
+++ b/lib/Migration/Version000027Date20261012120000.php
@@ -0,0 +1,75 @@
+appConfig->getValueString(self::APP_ID, $targetModeKey), self::MODES, true)) {
+ return;
+ }
+
+ $roles = array_values(array_filter(
+ $this->appConfig->getValueArray(self::APP_ID, 'permission_' . self::SOURCE),
+ 'is_string',
+ ));
+
+ // No stored mode means a jump from before explicit modes, where a bare
+ // group list meant "these groups" and an empty one meant "everyone".
+ $mode = $this->appConfig->getValueString(self::APP_ID, 'permission_' . self::SOURCE . '_mode');
+ if (!in_array($mode, self::MODES, true)) {
+ $mode = $roles !== [] ? 'groups' : 'all';
+ }
+
+ // Groups mode with no group names nobody.
+ if ($mode === 'groups' && $roles === []) {
+ $mode = 'nobody';
+ }
+
+ if ($mode === 'groups') {
+ // Written through IConfig like PermissionService does — the typed
+ // setter would mark the key as an array and collide on the next save.
+ $this->config->setAppValue(
+ self::APP_ID,
+ 'permission_' . self::TARGET,
+ json_encode($roles, JSON_THROW_ON_ERROR),
+ );
+ $output->info('Seeded export from manage_appointments (' . count($roles) . ' group(s))');
+ }
+
+ $this->appConfig->setValueString(self::APP_ID, $targetModeKey, $mode);
+ }
+}
diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php
index 0711a008..3bdaaaa8 100644
--- a/lib/ResponseDefinitions.php
+++ b/lib/ResponseDefinitions.php
@@ -244,6 +244,7 @@
* canSeeIndividualResponses: bool,
* canSeeAllAppointments: bool,
* canSeeTeamVacations: bool,
+ * canExport: bool,
* }
* @psalm-type AttendanceCapabilities = array{
* calendarAvailable: bool,
diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php
index 29e0a249..b61a204d 100644
--- a/lib/Service/PermissionService.php
+++ b/lib/Service/PermissionService.php
@@ -52,6 +52,7 @@ class PermissionService {
public const PERMISSION_SEE_STATISTICS = 'see_statistics';
public const PERMISSION_SEE_ALL_APPOINTMENTS = 'see_all_appointments';
public const PERMISSION_SEE_TEAM_VACATIONS = 'see_team_vacations';
+ public const PERMISSION_EXPORT = 'export';
public const MODE_ALL = 'all';
public const MODE_GROUPS = 'groups';
@@ -73,6 +74,7 @@ class PermissionService {
self::PERMISSION_SEE_STATISTICS,
self::PERMISSION_SEE_ALL_APPOINTMENTS,
self::PERMISSION_SEE_TEAM_VACATIONS,
+ self::PERMISSION_EXPORT,
];
private const GUEST_BLOCKED_PERMISSIONS = [
@@ -83,6 +85,7 @@ class PermissionService {
self::PERMISSION_SEE_STATISTICS,
self::PERMISSION_SEE_ALL_APPOINTMENTS,
self::PERMISSION_SEE_TEAM_VACATIONS,
+ self::PERMISSION_EXPORT,
];
/**
@@ -439,6 +442,15 @@ public function canSeeStatistics(string $userId): bool {
return $this->hasPermission($userId, self::PERMISSION_SEE_STATISTICS);
}
+ /**
+ * Check if user may export appointments (the response and check-in lists).
+ * Deliberately separate from managing: managers do not get it implicitly,
+ * and holders need no manage rights.
+ */
+ public function canExport(string $userId): bool {
+ return $this->hasPermission($userId, self::PERMISSION_EXPORT);
+ }
+
/**
* Check if user may see appointments beyond the ones addressed to them or
* organized by them. Managers hold it implicitly — they can edit any
diff --git a/openapi-full.json b/openapi-full.json
index 00f228e1..13434e10 100644
--- a/openapi-full.json
+++ b/openapi-full.json
@@ -2476,7 +2476,8 @@
"canSeeStatistics",
"canSeeIndividualResponses",
"canSeeAllAppointments",
- "canSeeTeamVacations"
+ "canSeeTeamVacations",
+ "canExport"
],
"properties": {
"canManageAppointments": {
@@ -2514,6 +2515,9 @@
},
"canSeeTeamVacations": {
"type": "boolean"
+ },
+ "canExport": {
+ "type": "boolean"
}
}
},
diff --git a/openapi.json b/openapi.json
index 86632350..305d1dac 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2082,7 +2082,8 @@
"canSeeStatistics",
"canSeeIndividualResponses",
"canSeeAllAppointments",
- "canSeeTeamVacations"
+ "canSeeTeamVacations",
+ "canExport"
],
"properties": {
"canManageAppointments": {
@@ -2120,6 +2121,9 @@
},
"canSeeTeamVacations": {
"type": "boolean"
+ },
+ "canExport": {
+ "type": "boolean"
}
}
},
diff --git a/playwright.config.js b/playwright.config.js
index 368cf66d..38e1eb48 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -113,6 +113,8 @@ export default defineConfig({
'40-capacity-ui.spec.js',
// Switches the response summary grouping mode and the group list.
'41-checkin-group-filter.spec.js',
+ // Restricts the export permission to a dedicated group.
+ '42-export-permission.spec.js',
],
fullyParallel: false,
workers: 1,
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 3f187378..5432484e 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1134,6 +1134,14 @@
+
+
+
+
+
+
+
+
diff --git a/scripts/reset-dev-data.sh b/scripts/reset-dev-data.sh
index a3029f8a..a7c1a708 100755
--- a/scripts/reset-dev-data.sh
+++ b/scripts/reset-dev-data.sh
@@ -41,7 +41,7 @@ PERMISSIONS=(
manage_appointments checkin
see_response_overview see_response_counts see_comments
self_checkin create_appointments respond_for_others
- see_statistics
+ see_statistics export
)
TABLES=(
diff --git a/src/App.vue b/src/App.vue
index e6165f9e..c73c7be0 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -170,7 +170,7 @@
diff --git a/src/components/appointment/AppointmentActionsMenu.vue b/src/components/appointment/AppointmentActionsMenu.vue
index abb51c4b..3e6aa8d8 100644
--- a/src/components/appointment/AppointmentActionsMenu.vue
+++ b/src/components/appointment/AppointmentActionsMenu.vue
@@ -108,7 +108,7 @@
{{ t("attendance", "Copy") }}
diff --git a/src/components/onboarding/OnboardingWizard.vue b/src/components/onboarding/OnboardingWizard.vue
index 6d28e65b..ca7ab04d 100644
--- a/src/components/onboarding/OnboardingWizard.vue
+++ b/src/components/onboarding/OnboardingWizard.vue
@@ -305,7 +305,7 @@ const STEPS = [
id: 'responses',
label: t('attendance', 'Responses'),
lead: t('attendance', 'Who gets to see the replies of everyone else? The detailed summary shows names, the counts show only how many said yes, no or maybe, and the statistics evaluate both across all appointments.'),
- permissions: ['see_response_overview', 'see_response_counts', 'see_comments', 'see_statistics'],
+ permissions: ['see_response_overview', 'see_response_counts', 'see_comments', 'see_statistics', 'export'],
},
{
id: 'audit',
diff --git a/src/composables/usePermissions.js b/src/composables/usePermissions.js
index a1c3428c..7948ce3c 100644
--- a/src/composables/usePermissions.js
+++ b/src/composables/usePermissions.js
@@ -18,6 +18,7 @@ const DEFAULTS = {
canSeeIndividualResponses: false,
canSeeAllAppointments: false,
canSeeTeamVacations: false,
+ canExport: false,
},
capabilities: {
calendarAvailable: false,
@@ -98,6 +99,7 @@ export function usePermissions() {
state.permissions.canSeeIndividualResponses = permissionsRes.data.canSeeIndividualResponses === true
state.permissions.canSeeAllAppointments = permissionsRes.data.canSeeAllAppointments === true
state.permissions.canSeeTeamVacations = permissionsRes.data.canSeeTeamVacations === true
+ state.permissions.canExport = permissionsRes.data.canExport === true
state.capabilities.calendarAvailable = capabilitiesRes.data.calendarAvailable || false
state.capabilities.calendarSyncEnabled = capabilitiesRes.data.calendarSyncEnabled || false
diff --git a/src/utils/permissions.js b/src/utils/permissions.js
index 92345069..824cfe78 100644
--- a/src/utils/permissions.js
+++ b/src/utils/permissions.js
@@ -58,6 +58,13 @@ export const PERMISSION_ROWS = {
implication: t('attendance', 'Without this, users only see their own numbers.'),
warningWhenAll: t('attendance', 'Every user can see how often each person responded and attended.'),
},
+ export: {
+ group: 'responses',
+ title: t('attendance', 'May export appointments'),
+ hint: t('attendance', 'Download the responses and check-ins of appointments as a spreadsheet.'),
+ implication: t('attendance', 'Not granted automatically to users who can manage appointments.'),
+ warningWhenAll: t('attendance', 'Every user can export who answered what and who checked in.'),
+ },
respond_for_others: {
group: 'responses',
// TRANSLATORS: Permission title. "set" means recording or changing an existing answer on someone's behalf — nothing new is created.
diff --git a/tests/e2e/42-export-permission.spec.js b/tests/e2e/42-export-permission.spec.js
new file mode 100644
index 00000000..915a6499
--- /dev/null
+++ b/tests/e2e/42-export-permission.spec.js
@@ -0,0 +1,108 @@
+import {
+ addUserToGroupViaOCS,
+ authHeaders,
+ BASE_URL,
+ createAppointmentViaAPI,
+ createGroupViaOCS,
+ deleteAllAppointments,
+ expect,
+ PERMISSIVE_PERMISSIONS,
+ reloadWebWorkers,
+ resetAdminSettings,
+ restrictPermissionToGroup,
+ saveAdminSettings,
+ test,
+} from './fixtures/nextcloud.js'
+
+const API = `${BASE_URL}/index.php/apps/attendance/api`
+
+/**
+ * Exporting the response and check-in lists is a permission of its own, not a
+ * side effect of managing appointments: a sports officer can hold it without
+ * any manage rights, and a manager without it has no export. Changes global
+ * permission state, so it lives in the sequential-admin project.
+ */
+test.describe('Attendance App - Export permission (sequential)', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ test.beforeAll(async ({ request }) => {
+ await deleteAllAppointments(request)
+ await createGroupViaOCS(request, 'exporters')
+ await addUserToGroupViaOCS(request, 'test3', 'exporters')
+ await createAppointmentViaAPI(request, { name: 'Export Permission Appointment', daysFromNow: 5 })
+ // Managers: admin only. Export starts closed to everybody; the first
+ // test opens it to the exporters group the way an admin would.
+ await saveAdminSettings(request, {
+ whitelistedGroups: [],
+ whitelistedTeams: [],
+ permissions: {
+ ...PERMISSIVE_PERMISSIONS,
+ manage_appointments: { mode: 'groups', groups: ['admin'] },
+ export: { mode: 'nobody', groups: [] },
+ },
+ reminders: { enabled: false },
+ })
+ await reloadWebWorkers()
+ })
+
+ test.afterAll(async ({ request }) => {
+ await resetAdminSettings(request)
+ await reloadWebWorkers()
+ await deleteAllAppointments(request)
+ })
+
+ test('admin grants the export permission to a group in the settings', async ({ page, loginAsUser }) => {
+ await loginAsUser('admin', 'admin')
+ await page.goto('/settings/admin/attendance')
+ await page.waitForLoadState('networkidle')
+
+ await restrictPermissionToGroup(page, 'export', 'exporters')
+ await reloadWebWorkers()
+ })
+
+ test('a group member without manage rights gets the export and a file out of it', async ({ page, loginAsUser, attendanceApp }) => {
+ await loginAsUser('test3', 'test3')
+ await attendanceApp()
+ await page.waitForLoadState('networkidle')
+
+ // Exporting is not managing — nothing else of the manager UI shows up.
+ await expect(page.locator('[data-test="button-create-appointment"]')).toHaveCount(0)
+ await page.locator('[data-test="button-export"]').click()
+
+ const exportDialog = page.getByRole('dialog', { name: 'Export appointments' })
+ await expect(exportDialog).toBeVisible()
+ await exportDialog.getByRole('button', { name: 'Export' }).click()
+
+ await page.waitForURL(/.*\/apps\/files.*/, { timeout: 15000 })
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByText(/attendance_export.*\.ods/).first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('a manager outside the group has no export — managing does not imply it', async ({ page, request, loginAsUser, attendanceApp }) => {
+ await loginAsUser('admin', 'admin')
+ await attendanceApp()
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.locator('[data-test="button-create-appointment"]')).toBeVisible()
+ await expect(page.locator('[data-test="button-export"]')).toHaveCount(0)
+
+ const series = await request.get(`${API}/export/series`, { headers: authHeaders('admin', 'admin') })
+ expect(series.status()).toBe(403)
+ const exported = await request.post(`${API}/export`, { headers: authHeaders('admin', 'admin'), data: {} })
+ expect(exported.status()).toBe(403)
+ })
+
+ test('a user with neither right has no export, and the server agrees', async ({ page, request, loginAsUser, attendanceApp }) => {
+ await loginAsUser('test', 'test')
+ await attendanceApp()
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.locator('[data-test="button-export"]')).toHaveCount(0)
+
+ const exported = await request.post(`${API}/export`, { headers: authHeaders('test', 'test'), data: {} })
+ expect(exported.status()).toBe(403)
+
+ const holder = await request.get(`${API}/export/series`, { headers: authHeaders('test3', 'test3') })
+ expect(holder.status()).toBe(200)
+ })
+})
diff --git a/tests/e2e/fixtures/nextcloud.js b/tests/e2e/fixtures/nextcloud.js
index 037b813a..b315334d 100644
--- a/tests/e2e/fixtures/nextcloud.js
+++ b/tests/e2e/fixtures/nextcloud.js
@@ -437,6 +437,7 @@ export const PERMISSIVE_PERMISSIONS = Object.freeze({
see_statistics: { mode: 'nobody', groups: [] },
see_all_appointments: { mode: 'nobody', groups: [] },
see_team_vacations: { mode: 'nobody', groups: [] },
+ export: { mode: 'all', groups: [] },
})
/**
diff --git a/tests/unit/Controller/AppointmentControllerExportPermissionTest.php b/tests/unit/Controller/AppointmentControllerExportPermissionTest.php
new file mode 100644
index 00000000..2a3049a3
--- /dev/null
+++ b/tests/unit/Controller/AppointmentControllerExportPermissionTest.php
@@ -0,0 +1,117 @@
+exportService = $this->createMock(ExportService::class);
+ $this->permissionService = $this->createMock(PermissionService::class);
+ $this->userSession = $this->createMock(IUserSession::class);
+
+ $this->controller = new AppointmentController(
+ 'attendance',
+ $this->createMock(IRequest::class),
+ $this->createMock(AppointmentService::class),
+ $this->createMock(AttachmentService::class),
+ $this->createMock(CalendarService::class),
+ $this->createMock(CheckinService::class),
+ $this->createMock(ConfigService::class),
+ $this->permissionService,
+ $this->exportService,
+ $this->createMock(NotificationService::class),
+ $this->createMock(VisibilityService::class),
+ $this->createMock(IAppManager::class),
+ $this->userSession,
+ $this->createMock(ISecureRandom::class),
+ $this->createMock(GuestService::class),
+ $this->createMock(BookingService::class),
+ $this->createMock(TalkRoomService::class),
+ $this->createMock(DirectorySearchService::class),
+ );
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $this->userSession->method('getUser')->willReturn($user);
+ }
+
+ public function testManagerWithoutExportPermissionCannotExport(): void {
+ $this->permissionService->method('canManageAppointments')->willReturn(true);
+ $this->permissionService->method('canExport')->with('alice')->willReturn(false);
+ $this->exportService->expects($this->never())->method('exportToOds');
+
+ $response = $this->controller->export();
+
+ $this->assertSame(403, $response->getStatus());
+ }
+
+ public function testExportHolderWithoutManageRightsCanExport(): void {
+ $this->permissionService->method('canManageAppointments')->willReturn(false);
+ $this->permissionService->method('canExport')->with('alice')->willReturn(true);
+ $this->exportService->expects($this->once())
+ ->method('exportToOds')
+ ->willReturn(['path' => '/Attendance/x.ods', 'filename' => 'x.ods']);
+
+ $response = $this->controller->export();
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame('x.ods', $response->getData()['filename']);
+ }
+
+ public function testSeriesListFollowsTheExportPermissionToo(): void {
+ $this->permissionService->method('canManageAppointments')->willReturn(true);
+ $this->permissionService->method('canExport')->willReturn(false);
+ $this->exportService->expects($this->never())->method('getSeriesOverview');
+
+ $this->assertSame(403, $this->controller->exportSeries()->getStatus());
+ }
+
+ public function testExportHolderSeesTheSeriesList(): void {
+ $this->permissionService->method('canExport')->willReturn(true);
+ $this->exportService->method('getSeriesOverview')->willReturn([]);
+
+ $this->assertSame(200, $this->controller->exportSeries()->getStatus());
+ }
+
+ public function testPermissionsReportTheExportRight(): void {
+ $this->permissionService->method('canExport')->with('alice')->willReturn(true);
+
+ $this->assertTrue($this->controller->getPermissions()->getData()['canExport']);
+ }
+}
diff --git a/tests/unit/Migration/Version000027Date20261012120000Test.php b/tests/unit/Migration/Version000027Date20261012120000Test.php
new file mode 100644
index 00000000..36439710
--- /dev/null
+++ b/tests/unit/Migration/Version000027Date20261012120000Test.php
@@ -0,0 +1,137 @@
+appConfig = $this->createMock(IAppConfig::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->output = $this->createMock(IOutput::class);
+
+ $this->migration = new Version000027Date20261012120000($this->appConfig, $this->config);
+ }
+
+ public function testCarriesOverGroupsThatMayManageAppointments(): void {
+ $this->givenSource('groups', ['board', 'office'], targetMode: '');
+
+ $this->config->expects($this->once())
+ ->method('setAppValue')
+ ->with('attendance', 'permission_export', '["board","office"]');
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'groups');
+
+ $this->migrate();
+ }
+
+ /**
+ * Export was open to everybody wherever managing was, so an untouched
+ * instance keeps exactly that — no group list to copy.
+ */
+ public function testCarriesOverAnUnrestrictedSource(): void {
+ $this->givenSource('all', [], targetMode: '');
+
+ $this->config->expects($this->never())->method('setAppValue');
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'all');
+
+ $this->migrate();
+ }
+
+ public function testKeepsNobodyWhenNobodyMayManage(): void {
+ $this->givenSource('nobody', [], targetMode: '');
+
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'nobody');
+
+ $this->migrate();
+ }
+
+ public function testGroupsModeWithoutGroupsGrantsNobody(): void {
+ $this->givenSource('groups', [], targetMode: '');
+
+ $this->config->expects($this->never())->method('setAppValue');
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'nobody');
+
+ $this->migrate();
+ }
+
+ /**
+ * A jump from before explicit modes has only the bare group list, which
+ * used to mean "these groups".
+ */
+ public function testInfersGroupsModeFromABareGroupList(): void {
+ $this->givenSource('', ['board'], targetMode: '');
+
+ $this->config->expects($this->once())
+ ->method('setAppValue')
+ ->with('attendance', 'permission_export', '["board"]');
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'groups');
+
+ $this->migrate();
+ }
+
+ public function testInfersEveryoneFromNoStoredSettingAtAll(): void {
+ $this->givenSource('', [], targetMode: '');
+
+ $this->appConfig->expects($this->once())
+ ->method('setValueString')
+ ->with('attendance', self::MODE_KEY, 'all');
+
+ $this->migrate();
+ }
+
+ public function testLeavesAnExistingTargetSettingAlone(): void {
+ $this->givenSource('groups', ['board'], targetMode: 'nobody');
+
+ $this->config->expects($this->never())->method('setAppValue');
+ $this->appConfig->expects($this->never())->method('setValueString');
+
+ $this->migrate();
+ }
+
+ /**
+ * @param list $roles
+ */
+ private function givenSource(string $sourceMode, array $roles, string $targetMode): void {
+ $this->appConfig->method('getValueString')->willReturnCallback(
+ static function (string $app, string $key) use ($sourceMode, $targetMode): string {
+ if ($key === self::MODE_KEY) {
+ return $targetMode;
+ }
+ return $key === 'permission_manage_appointments_mode' ? $sourceMode : '';
+ },
+ );
+ $this->appConfig->method('getValueArray')->willReturn($roles);
+ }
+
+ private function migrate(): void {
+ $this->migration->postSchemaChange($this->output, static fn () => null, []);
+ }
+}
diff --git a/tests/unit/Service/PermissionServiceTest.php b/tests/unit/Service/PermissionServiceTest.php
index e77fcf41..5f3fad43 100644
--- a/tests/unit/Service/PermissionServiceTest.php
+++ b/tests/unit/Service/PermissionServiceTest.php
@@ -853,4 +853,44 @@ public function testGuestsNeverSeeTeamVacations(): void {
$this->assertFalse($this->service->canSeeTeamVacations('guestuser'));
}
+
+ public function testExportGrantedWithoutManageRights(): void {
+ $this->guestService->method('isGuestUser')->willReturn(false);
+ $this->configValues([
+ 'permission_manage_appointments_mode' => 'groups',
+ 'permission_manage_appointments' => '["board"]',
+ 'permission_export_mode' => 'groups',
+ 'permission_export' => '["sports"]',
+ ]);
+ $this->userInGroups('alice', ['sports']);
+
+ $this->assertTrue($this->service->canExport('alice'));
+ $this->assertFalse($this->service->canManageAppointments('alice'));
+ }
+
+ public function testExportIsNotImpliedByManageRights(): void {
+ $this->guestService->method('isGuestUser')->willReturn(false);
+ $this->configValues([
+ 'permission_manage_appointments_mode' => 'all',
+ 'permission_export_mode' => 'nobody',
+ ]);
+
+ $this->assertTrue($this->service->canManageAppointments('alice'));
+ $this->assertFalse($this->service->canExport('alice'));
+ }
+
+ public function testExportKeepsTodaysBehaviourWhileUnconfigured(): void {
+ $this->guestService->method('isGuestUser')->willReturn(false);
+ $this->configValues([]);
+
+ $this->assertSame(PermissionService::MODE_ALL, $this->service->getModeForPermission(PermissionService::PERMISSION_EXPORT));
+ $this->assertTrue($this->service->canExport('alice'));
+ }
+
+ public function testGuestsNeverExport(): void {
+ $this->guestService->method('isGuestUser')->with('guestuser')->willReturn(true);
+ $this->configValues(['permission_export_mode' => 'all']);
+
+ $this->assertFalse($this->service->canExport('guestuser'));
+ }
}
From e26288e9260dcd1214af7b7373983f939e7c4a1c Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 21:18:32 +0000
Subject: [PATCH 2/2] fix(statistics): explain why grouping by teams puts
everyone under Others
The team axis only sections by the teams an admin selected under "Response
summary teams". Without any, which is the default, every person lands in
Others while the select still offers "Teams", so the grouping looked broken.
The page now says why. The team-axis path of StatisticsService gets unit
tests, and an e2e spec covers the hint and a real team.
---
l10n/de.js | 3 +-
l10n/de.json | 3 +-
l10n/de_DE.js | 3 +-
l10n/de_DE.json | 3 +-
playwright.config.js | 2 +
src/views/StatisticsOverview.vue | 18 ++-
tests/e2e/43-statistics-teams.spec.js | 117 +++++++++++++++++++
tests/e2e/fixtures/nextcloud.js | 43 +++++++
tests/unit/Service/StatisticsServiceTest.php | 52 ++++++++-
9 files changed, 238 insertions(+), 6 deletions(-)
create mode 100644 tests/e2e/43-statistics-teams.spec.js
diff --git a/l10n/de.js b/l10n/de.js
index 22f58ca9..c64d5e64 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1100,6 +1100,7 @@ OC.L10N.register(
"Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
"May export appointments" : "Darf Termine exportieren",
"Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
- "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist.",
+ "Nobody here is in a team selected for grouping, so everyone appears under Others. Administrators select the teams in the admin settings under Response summary teams." : "Niemand hier gehört zu einem für die Gruppierung ausgewählten Team, deshalb erscheinen alle unter „Andere“. Die Teams wählt die Administration in den Administrationseinstellungen unter „Teams in Antwortübersicht“ aus."
},
"nplurals=2; plural=(n != 1);");
diff --git a/l10n/de.json b/l10n/de.json
index d4a91653..dd7140c8 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -1098,6 +1098,7 @@
"Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
"May export appointments" : "Darf Termine exportieren",
"Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
- "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist.",
+ "Nobody here is in a team selected for grouping, so everyone appears under Others. Administrators select the teams in the admin settings under Response summary teams." : "Niemand hier gehört zu einem für die Gruppierung ausgewählten Team, deshalb erscheinen alle unter „Andere“. Die Teams wählt die Administration in den Administrationseinstellungen unter „Teams in Antwortübersicht“ aus."
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
\ No newline at end of file
diff --git a/l10n/de_DE.js b/l10n/de_DE.js
index 80572fcf..99d23329 100644
--- a/l10n/de_DE.js
+++ b/l10n/de_DE.js
@@ -1100,6 +1100,7 @@ OC.L10N.register(
"Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
"May export appointments" : "Darf Termine exportieren",
"Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
- "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist.",
+ "Nobody here is in a team selected for grouping, so everyone appears under Others. Administrators select the teams in the admin settings under Response summary teams." : "Niemand hier gehört zu einem für die Gruppierung ausgewählten Team, deshalb erscheinen alle unter „Andere“. Die Teams wählt die Administration in den Administrationseinstellungen unter „Teams in Antwortübersicht“ aus."
},
"nplurals=2; plural=(n != 1);");
diff --git a/l10n/de_DE.json b/l10n/de_DE.json
index f5534775..35f607bb 100644
--- a/l10n/de_DE.json
+++ b/l10n/de_DE.json
@@ -1098,6 +1098,7 @@
"Nextcloud does not forward notifications by email." : "Nextcloud leitet Benachrichtigungen nicht per E-Mail weiter.",
"May export appointments" : "Darf Termine exportieren",
"Download the responses and check-ins of appointments as a spreadsheet." : "Die Antworten und Check-ins von Terminen als Tabelle herunterladen.",
- "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist."
+ "Every user can export who answered what and who checked in." : "Jeder Benutzer kann exportieren, wer was geantwortet hat und wer eingecheckt ist.",
+ "Nobody here is in a team selected for grouping, so everyone appears under Others. Administrators select the teams in the admin settings under Response summary teams." : "Niemand hier gehört zu einem für die Gruppierung ausgewählten Team, deshalb erscheinen alle unter „Andere“. Die Teams wählt die Administration in den Administrationseinstellungen unter „Teams in Antwortübersicht“ aus."
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
\ No newline at end of file
diff --git a/playwright.config.js b/playwright.config.js
index 38e1eb48..87b1c9fb 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -115,6 +115,8 @@ export default defineConfig({
'41-checkin-group-filter.spec.js',
// Restricts the export permission to a dedicated group.
'42-export-permission.spec.js',
+ // Selects teams for grouping and grants see_statistics instance-wide.
+ '43-statistics-teams.spec.js',
],
fullyParallel: false,
workers: 1,
diff --git a/src/views/StatisticsOverview.vue b/src/views/StatisticsOverview.vue
index 1940b239..333aad9e 100644
--- a/src/views/StatisticsOverview.vue
+++ b/src/views/StatisticsOverview.vue
@@ -179,6 +179,10 @@
}}
+
+ {{ t("attendance", "Nobody here is in a team selected for grouping, so everyone appears under Others. Administrators select the teams in the admin settings under Response summary teams.") }}
+
+
{{ t("attendance", "Highlights") }}
@@ -315,7 +319,7 @@ import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translatePlural as n, translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
-import { NcButton, NcChip, NcEmptyContent, NcFormGroup, NcPopover, NcRadioGroup, NcRadioGroupButton, NcTextField } from '@nextcloud/vue'
+import { NcButton, NcChip, NcEmptyContent, NcFormGroup, NcNoteCard, NcPopover, NcRadioGroup, NcRadioGroupButton, NcTextField } from '@nextcloud/vue'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import ChartLineIcon from 'vue-material-design-icons/ChartLine.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
@@ -396,6 +400,18 @@ watch([grouping, detail, visibleColumns, visibleCards], writeUrlState)
const reduced = computed(() => !permissions.canSeeStatistics)
+// Matches StatisticsService::SECTION_OTHERS.
+const OTHERS_SECTION = '__others__'
+
+// Teams section only by the ones an admin selected: without any (the default) the
+// team axis is a lone Others bucket, which reads like a grouping that did nothing.
+const teamsNotConfigured = computed(() => {
+ const sections = statistics.value?.sections ?? []
+ return statistics.value?.groupBy === 'teams'
+ && sections.length > 0
+ && sections.every((section) => section.id === OTHERS_SECTION)
+})
+
// The drill-down lists what one person answered per appointment, which is the
// response overview's disclosure — an aggregate row does not carry it.
const canDrillDown = computed(() => permissions.canSeeIndividualResponses)
diff --git a/tests/e2e/43-statistics-teams.spec.js b/tests/e2e/43-statistics-teams.spec.js
new file mode 100644
index 00000000..3f1c150b
--- /dev/null
+++ b/tests/e2e/43-statistics-teams.spec.js
@@ -0,0 +1,117 @@
+import {
+ addUserToTeamViaOCS,
+ createAppointmentViaAPI,
+ createTeamViaOCS,
+ deleteTeamViaOCS,
+ expect,
+ forceWipeAllAppointments,
+ PERMISSIVE_PERMISSIONS,
+ reloadWebWorkers,
+ resetAdminSettings,
+ saveAdminSettings,
+ teamsAvailable,
+ test,
+} from './fixtures/nextcloud.js'
+
+const APPOINTMENT_OFFSET_DAYS = -7
+// The appointment sits a week back — in January that is already last year.
+const YEAR = new Date(Date.now() + APPOINTMENT_OFFSET_DAYS * 24 * 60 * 60 * 1000).getFullYear()
+
+/**
+ * @param {import('@playwright/test').APIRequestContext} request - Playwright request context.
+ * @param {string[]} whitelistedTeams - Teams the statistics may section by.
+ */
+async function selectTeamsForGrouping(request, whitelistedTeams) {
+ await saveAdminSettings(request, {
+ whitelistedGroups: [],
+ whitelistedTeams,
+ responseSummaryTeamsMode: whitelistedTeams.length > 0 ? 'specific' : 'none',
+ permissions: {
+ ...PERMISSIVE_PERMISSIONS,
+ see_statistics: { mode: 'all', groups: [] },
+ },
+ })
+ await reloadWebWorkers()
+}
+
+/**
+ * "Group by: Teams" in the statistics. Without any team selected for grouping
+ * — the default — it used to look like the switch did nothing, so the page now
+ * says why. Changes global permission state, so it lives in the
+ * sequential-admin project.
+ */
+test.describe('Attendance App - Statistics grouped by teams (sequential)', () => {
+ test.beforeAll(async ({ request }) => {
+ await resetAdminSettings(request)
+ await reloadWebWorkers()
+ await forceWipeAllAppointments(request)
+ await createAppointmentViaAPI(request, { name: 'Statistics teams', daysFromNow: APPOINTMENT_OFFSET_DAYS })
+ })
+
+ test.afterAll(async ({ request }) => {
+ await forceWipeAllAppointments(request)
+ await resetAdminSettings(request)
+ await reloadWebWorkers()
+ })
+
+ test('explains why everyone is under Others while no team is selected', async ({ page, request, loginAsUser }) => {
+ await selectTeamsForGrouping(request, [])
+ await loginAsUser('admin', 'admin')
+
+ await page.goto(`/apps/attendance/statistics?period=${YEAR}&groupBy=teams`)
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.locator('[data-test="statistics-teams-hint"]')).toBeVisible()
+ await expect(page.locator('[data-test^="statistics-section-"]')).toHaveCount(1)
+ await expect(page.locator('[data-test="statistics-section-__others__"]')).toBeVisible()
+ })
+
+ test('says nothing when grouping by groups', async ({ page, request, loginAsUser }) => {
+ await selectTeamsForGrouping(request, [])
+ await loginAsUser('admin', 'admin')
+
+ await page.goto(`/apps/attendance/statistics?period=${YEAR}`)
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.locator('[data-test="statistics-person-row"]').first()).toBeVisible()
+ await expect(page.locator('[data-test="statistics-teams-hint"]')).toHaveCount(0)
+ })
+
+ test.describe('with a team selected for grouping', () => {
+ const teamName = `Statistics team ${Math.random().toString(36).slice(2, 8)}`
+ let teamId = null
+
+ test.beforeAll(async ({ request }) => {
+ test.skip(!(await teamsAvailable(request)), 'The Teams app is not enabled on this server')
+
+ teamId = await createTeamViaOCS(request, teamName)
+ await addUserToTeamViaOCS(request, teamId, 'test3')
+ await selectTeamsForGrouping(request, [teamId])
+ })
+
+ test.afterAll(async ({ request }) => {
+ if (teamId !== null) {
+ await deleteTeamViaOCS(request, teamId)
+ }
+ })
+
+ test('lists the team as a section with its members and the rest under Others', async ({ page, loginAsUser }) => {
+ await loginAsUser('admin', 'admin')
+
+ await page.goto(`/apps/attendance/statistics?period=${YEAR}&groupBy=teams`)
+ await page.waitForLoadState('networkidle')
+
+ const teamSection = page.locator(`[data-test="statistics-section-${teamId}"]`)
+ await expect(teamSection).toContainText(teamName)
+
+ const teamBody = page.locator('tbody', { has: teamSection })
+ await expect(teamBody.getByRole('rowheader', { name: 'test3', exact: true })).toBeVisible()
+ await expect(teamBody.getByRole('rowheader', { name: 'test', exact: true })).toHaveCount(0)
+
+ // Somebody outside the team still has to land somewhere, and nothing
+ // is wrong any more, so there is no hint.
+ await expect(page.locator('[data-test="statistics-section-__others__"]')).toBeVisible()
+ await expect(page.locator('[data-test="statistics-teams-hint"]')).toHaveCount(0)
+ })
+ })
+})
diff --git a/tests/e2e/fixtures/nextcloud.js b/tests/e2e/fixtures/nextcloud.js
index b315334d..ccb93653 100644
--- a/tests/e2e/fixtures/nextcloud.js
+++ b/tests/e2e/fixtures/nextcloud.js
@@ -511,6 +511,49 @@ export async function addUserToGroupViaOCS(request, username, groupName) {
})
}
+/**
+ * Whether the Teams (Circles) app is enabled on the test server.
+ */
+export async function teamsAvailable(request) {
+ const resp = await resilientJson(() => request.get(`${API_BASE}/apps/attendance/api/capabilities`, {
+ headers: authHeaders('admin', 'admin'),
+ }))
+ return (await resp.json()).teamsAvailable === true
+}
+
+/**
+ * Create a Nextcloud Team via the Circles OCS API, owned by admin. Returns
+ * the team's id (what the app stores as the team reference).
+ */
+export async function createTeamViaOCS(request, name) {
+ const ocsBase = await getOcsBase(request)
+ const resp = await request.post(`${ocsBase}/apps/circles/circles?format=json`, {
+ headers: { ...authHeaders('admin', 'admin'), 'Content-Type': 'application/x-www-form-urlencoded' },
+ form: { name, personal: 'false', local: 'true' },
+ })
+ expect(resp.ok(), `creating team "${name}" failed: ${resp.status()} ${await resp.text()}`).toBeTruthy()
+ return (await resp.json()).ocs.data.id
+}
+
+/**
+ * Add a local user to a team as a plain member.
+ */
+export async function addUserToTeamViaOCS(request, teamId, username) {
+ const ocsBase = await getOcsBase(request)
+ const resp = await request.post(`${ocsBase}/apps/circles/circles/${teamId}/members?format=json`, {
+ headers: { ...authHeaders('admin', 'admin'), 'Content-Type': 'application/x-www-form-urlencoded' },
+ form: { userId: username, type: '1' },
+ })
+ expect(resp.ok(), `adding ${username} to the team failed: ${resp.status()} ${await resp.text()}`).toBeTruthy()
+}
+
+export async function deleteTeamViaOCS(request, teamId) {
+ const ocsBase = await getOcsBase(request)
+ await request.delete(`${ocsBase}/apps/circles/circles/${teamId}?format=json`, {
+ headers: authHeaders('admin', 'admin'),
+ })
+}
+
/**
* Create a file via WebDAV (for attachment tests)
*/
diff --git a/tests/unit/Service/StatisticsServiceTest.php b/tests/unit/Service/StatisticsServiceTest.php
index 5b23faab..d87e6033 100644
--- a/tests/unit/Service/StatisticsServiceTest.php
+++ b/tests/unit/Service/StatisticsServiceTest.php
@@ -65,7 +65,6 @@ protected function setUp(): void {
$l10n->method('t')->willReturnArgument(0);
$this->categoryMapper->method('findAll')->willReturn([]);
- $this->configService->method('getWhitelistedTeams')->willReturn([]);
$this->service = new StatisticsService(
$this->appointmentMapper,
@@ -202,6 +201,57 @@ public function testSectionsFollowTheAdminOrderAndPushOthersLast(): void {
$this->assertSame(['sopranos', StatisticsService::SECTION_OTHERS], array_column($result['sections'], 'id'));
}
+ public function testTeamAxisSectionsFollowTheWhitelistedTeamsInsteadOfGroups(): void {
+ $this->givenUsers(['alice' => 'Alice', 'bob' => 'Bob', 'carol' => 'Carol']);
+ $this->givenUnrestrictedVisibility();
+ $this->configService->method('getWhitelistedTeams')->willReturn(['choir', 'board']);
+ $this->visibilityService->method('getTeamMembers')->willReturnMap([
+ ['choir', ['alice', 'bob']],
+ ['board', ['alice']],
+ ]);
+ $this->visibilityService->method('getTeamInfo')->willReturnCallback(
+ static fn (string $teamId): array => ['id' => $teamId, 'label' => ucfirst($teamId), 'type' => 'team'],
+ );
+ // The group axis would section by these; the team axis must not.
+ $this->groupManager->expects($this->never())->method('getUserGroupIds');
+
+ $this->appointmentMapper->method('findForStatistics')->willReturn([
+ $this->appointment(1, '2026-05-01 18:00:00', '2026-05-01 20:00:00'),
+ ]);
+ $this->givenResponses([
+ $this->response(1, 'alice', 'yes', 'yes'),
+ ]);
+
+ $result = $this->service->getStatistics(StatisticsFilter::fromWire(null, null, [], false, StatisticsFilter::GROUP_BY_TEAMS));
+
+ $this->assertSame('teams', $result['groupBy']);
+ $this->assertSame(['choir', 'board', StatisticsService::SECTION_OTHERS], array_column($result['sections'], 'id'));
+ $this->assertSame(['Choir', 'Board', 'Others'], array_column($result['sections'], 'displayName'));
+ $this->assertSame([2, 1, 1], array_column($result['sections'], 'personCount'));
+ $this->assertSame(['choir', 'board'], $this->person($result, 'alice')['sections']);
+ $this->assertSame([StatisticsService::SECTION_OTHERS], $this->person($result, 'carol')['sections'], 'in no selected team');
+ }
+
+ /**
+ * The default of an install: the Teams app is there, but no team was picked
+ * for grouping, so the team axis has nothing to section by.
+ */
+ public function testTeamAxisWithoutSelectedTeamsPutsEveryoneUnderOthers(): void {
+ $this->givenUsers(['alice' => 'Alice', 'bob' => 'Bob']);
+ $this->givenUnrestrictedVisibility();
+ $this->configService->method('getWhitelistedTeams')->willReturn([]);
+
+ $this->appointmentMapper->method('findForStatistics')->willReturn([
+ $this->appointment(1, '2026-05-01 18:00:00', '2026-05-01 20:00:00'),
+ ]);
+ $this->givenResponses([]);
+
+ $result = $this->service->getStatistics(StatisticsFilter::fromWire(null, null, [], false, StatisticsFilter::GROUP_BY_TEAMS));
+
+ $this->assertSame([StatisticsService::SECTION_OTHERS], array_column($result['sections'], 'id'));
+ $this->assertSame([2], array_column($result['sections'], 'personCount'));
+ }
+
public function testResponsesOfDeletedUsersAreIgnored(): void {
$this->givenUsers(['alice' => 'Alice']);
$this->givenUnrestrictedVisibility();