diff --git a/l10n/de.js b/l10n/de.js
index 5ad6cd32..c64d5e64 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -1097,6 +1097,10 @@ 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.",
+ "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 00ca0ca2..dd7140c8 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -1095,6 +1095,10 @@
"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.",
+ "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 acdbf8af..99d23329 100644
--- a/l10n/de_DE.js
+++ b/l10n/de_DE.js
@@ -1097,6 +1097,10 @@ 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.",
+ "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 8a6856f0..35f607bb 100644
--- a/l10n/de_DE.json
+++ b/l10n/de_DE.json
@@ -1095,6 +1095,10 @@
"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.",
+ "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/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..87b1c9fb 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -113,6 +113,10 @@ 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',
+ // Selects teams for grouping and grants see_statistics instance-wide.
+ '43-statistics-teams.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/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/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/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 037b813a..ccb93653 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: [] },
})
/**
@@ -510,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/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'));
+ }
}
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();