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
6 changes: 5 additions & 1 deletion l10n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);");
6 changes: 5 additions & 1 deletion l10n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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);"
}
6 changes: 5 additions & 1 deletion l10n/de_DE.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);");
6 changes: 5 additions & 1 deletion l10n/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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);"
}
6 changes: 3 additions & 3 deletions lib/Controller/AppointmentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
]);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
75 changes: 75 additions & 0 deletions lib/Migration/Version000027Date20261012120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace OCA\Attendance\Migration;

use Closure;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Seed the new export permission from manage_appointments. Export used to be
* a manager-only action, so the same mode and groups keep every install's
* effective access exactly where it was — unlike see_statistics, "all" carries
* over too, because exporting was open to everyone wherever managing was.
*
* Config keys and modes are inlined as a snapshot instead of referencing
* PermissionService: migrations also run at install and on multi-version
* jumps, where the service's live logic may no longer match this step.
*/
final class Version000027Date20261012120000 extends SimpleMigrationStep {
private const APP_ID = 'attendance';
private const SOURCE = 'manage_appointments';
private const TARGET = 'export';
private const MODES = ['all', 'groups', 'nobody'];

public function __construct(
private IAppConfig $appConfig,
private IConfig $config,
) {
}

#[\Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$targetModeKey = 'permission_' . self::TARGET . '_mode';

// A stored target mode means this ran before, or an admin already
// decided — either way the seed must not overwrite it.
if (in_array($this->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);
}
}
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
* canSeeIndividualResponses: bool,
* canSeeAllAppointments: bool,
* canSeeTeamVacations: bool,
* canExport: bool,
* }
* @psalm-type AttendanceCapabilities = array{
* calendarAvailable: bool,
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/PermissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = [
Expand All @@ -83,6 +85,7 @@ class PermissionService {
self::PERMISSION_SEE_STATISTICS,
self::PERMISSION_SEE_ALL_APPOINTMENTS,
self::PERMISSION_SEE_TEAM_VACATIONS,
self::PERMISSION_EXPORT,
];

/**
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion openapi-full.json
Original file line number Diff line number Diff line change
Expand Up @@ -2476,7 +2476,8 @@
"canSeeStatistics",
"canSeeIndividualResponses",
"canSeeAllAppointments",
"canSeeTeamVacations"
"canSeeTeamVacations",
"canExport"
],
"properties": {
"canManageAppointments": {
Expand Down Expand Up @@ -2514,6 +2515,9 @@
},
"canSeeTeamVacations": {
"type": "boolean"
},
"canExport": {
"type": "boolean"
}
}
},
Expand Down
6 changes: 5 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2082,7 +2082,8 @@
"canSeeStatistics",
"canSeeIndividualResponses",
"canSeeAllAppointments",
"canSeeTeamVacations"
"canSeeTeamVacations",
"canExport"
],
"properties": {
"canManageAppointments": {
Expand Down Expand Up @@ -2120,6 +2121,9 @@
},
"canSeeTeamVacations": {
"type": "boolean"
},
"canExport": {
"type": "boolean"
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,14 @@
<code><![CDATA[Version000026Date20261004140000]]></code>
</UnusedClass>
</file>
<file src="lib/Migration/Version000027Date20261012120000.php">
<DeprecatedMethod>
<code><![CDATA[setAppValue]]></code>
</DeprecatedMethod>
<UnusedClass>
<code><![CDATA[Version000027Date20261012120000]]></code>
</UnusedClass>
</file>
<file src="lib/Notification/Notifier.php">
<ClassMustBeFinal>
<code><![CDATA[Notifier]]></code>
Expand Down
2 changes: 1 addition & 1 deletion scripts/reset-dev-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
2 changes: 1 addition & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="permissions.canManageAppointments"
v-if="permissions.canExport"
:name="t('attendance', 'Export')"
data-test="button-export"
@click.prevent="showExportDialog = true">
Expand Down
2 changes: 1 addition & 1 deletion src/components/appointment/AppointmentActionsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
{{ t("attendance", "Copy") }}
</NcActionButton>
<NcActionButton
v-if="permissions.canManageAppointments"
v-if="permissions.canExport"
:closeAfterClick="true"
data-test="action-export"
@click="emit('export', appointment.id)">
Expand Down
2 changes: 1 addition & 1 deletion src/components/onboarding/OnboardingWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/composables/usePermissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const DEFAULTS = {
canSeeIndividualResponses: false,
canSeeAllAppointments: false,
canSeeTeamVacations: false,
canExport: false,
},
capabilities: {
calendarAvailable: false,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/utils/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion src/views/StatisticsOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@
}}
</p>

<NcNoteCard v-if="teamsNotConfigured" type="info" data-test="statistics-teams-hint">
{{ 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.") }}
</NcNoteCard>

<div v-if="!reduced" class="statistics__highlights-head">
<!-- TRANSLATORS: Heading over the small summary cards above the charts. -->
<h3>{{ t("attendance", "Highlights") }}</h3>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading