diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87c6d71..2170c07 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,10 +26,21 @@ jobs: uses: actions/checkout@v7 with: path: app + - name: LocalBase-Referenz bestimmen + id: localbase-ref + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + if git ls-remote --exit-code --heads https://github.com/Filzmann/nextcloud-localbase.git "refs/heads/$CANDIDATE_REF" >/dev/null 2>&1; then + echo "ref=$CANDIDATE_REF" >> "$GITHUB_OUTPUT" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + fi - name: LocalBase auschecken uses: actions/checkout@v7 with: repository: Filzmann/nextcloud-localbase + ref: ${{ steps.localbase-ref.outputs.ref }} path: localbase - name: PHP einrichten uses: shivammathur/setup-php@v2 @@ -50,10 +61,21 @@ jobs: uses: actions/checkout@v7 with: path: app + - name: LocalBase-Referenz bestimmen + id: localbase-ref + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + if git ls-remote --exit-code --heads https://github.com/Filzmann/nextcloud-localbase.git "refs/heads/$CANDIDATE_REF" >/dev/null 2>&1; then + echo "ref=$CANDIDATE_REF" >> "$GITHUB_OUTPUT" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + fi - name: LocalBase auschecken uses: actions/checkout@v7 with: repository: Filzmann/nextcloud-localbase + ref: ${{ steps.localbase-ref.outputs.ref }} path: localbase - name: Node.js einrichten uses: actions/setup-node@v6 diff --git a/AGENTS.md b/AGENTS.md index 9ba6c20..9505c5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ Kernprozess: - Jedes angemeldete Konto kann im eigenen Einstellungs-Tab Standard-Dienstzeiten je Wochentag speichern. Sie dienen als Vorschlag beim Anlegen; ein Ende vor dem Beginn bildet einen Dienst bis zum Folgetag. - Bewusst gespeicherte Standard-Dienstzeiten werden beim Aufruf einer Woche als normale Dienste materialisiert. Individuell bearbeitete Vorkommen bleiben einmalige Abweichungen; ein geloeschtes Vorkommen bleibt fuer genau dieses Datum dauerhaft unterdrueckt. - Zeitwerte werden serverseitig eindeutig gespeichert und fuer die Anzeige in der konfigurierten Nextcloud-Zeitzone formatiert. -- `adcalendar:demo:seed` synchronisiert neutrale, benannte Demokonten fuer jede Kalenderrolle und jeden Buerobereich. Namen tragen die fachliche Demo-Zuordnung in Klammern; Mehrfachrollen werden auch in Gruppentiteln als Hauptrolle mit weiteren Rollen in Klammern dargestellt. +- Das Kalender-Demo-Pack wird ausschließlich nach ausdrücklicher Bestätigung im app-eigenen Nextcloud-Adminabschnitt installiert; `adcalendar:demo:seed` delegiert auf denselben Service. Es synchronisiert neutrale, benannte Demokonten für jede Kalenderrolle und jeden Bürobereich. Namen tragen die fachliche Demo-Zuordnung in Klammern; Mehrfachrollen werden auch in Gruppentiteln als Hauptrolle mit weiteren Rollen in Klammern dargestellt. +- Demo-Provisioning übernimmt niemals ein vorhandenes fremdes oder LDAP-verwaltetes Konto. Read-only LDAP-Gruppen brechen das Pack im Preflight vor der ersten Mutation ab; eigene lokale Demokonten werden explizit in LocalBase registriert. - Ist `adurlaub` aktiviert, erscheinen geplante Urlaube read-only als `U?` ohne Blockade. Genehmigte Urlaube erscheinen als `U`, blockieren neue Dienste/Termine, verhindern Standarddienst-Materialisierung und werden aus Meetingluecken entfernt. Genehmigungen mit bestehenden Eintraegen werden ueber einen read-only Konfliktvertrag bereits in `adurlaub` abgelehnt. Verbindliches Gruppenschema: @@ -72,7 +73,10 @@ Hierarchie fuer Fremdbearbeitung: Offene Fachentscheidungen: - Dienste derselben Person duerfen sich nicht ueberschneiden; dadurch bleibt die Terminzuordnung eindeutig. - Welche Auswertungszeitraeume neben der Woche benoetigt werden. -- Ob und wie Bestandsdaten aus WordPress importiert werden. + +Nicht Bestandteil: + +- WordPress-Bestandsdaten werden nicht importiert. Es gibt keine Legacy-Importstrecke; Test- und Vorführdaten stammen ausschließlich aus bewusst installierten synthetischen Demo-Packs. ## Rechte- und Zugriffsschutz @@ -151,8 +155,9 @@ Migrationen laufen beim Aktivieren der App bzw. ueber `occ upgrade`; `occ migrat ### Gemeinsame Suite-Navigation -- AD Kalender besitzt keinen eigenen Nextcloud-Hauptnavigationseintrag. `orgsuite` stellt den gemeinsamen Einstieg `AD` bereit. -- Das Template bindet das zentrale OrgSuite-Menue mit `data-suite="ad"` und `data-current-app="adcalendar"` ein. +- Ohne aktive OrgSuite registriert AD Kalender einen eigenen Nextcloud-Hauptnavigationseintrag. Ab zwei AD-Produkten ersetzt `orgsuite` diesen durch den gemeinsamen Einstieg `AD`. +- Das Template stellt den optionalen Menühost mit `data-suite="ad"` und `data-current-app="adcalendar"` bereit, lädt aber keine OrgSuite-Assets direkt. +- Ohne AD Urlaub bleiben Sperrtermine der manuelle Abwesenheitsweg; fehlende optionale Provider dürfen die Wochenansicht nicht verhindern. - Fachliche Lese- und Bearbeitungsrechte bleiben ausschliesslich serverseitig im AD Kalender; Menuesichtbarkeit ist keine Berechtigung. - Wiederverwendbare app-spezifische Learnings werden Simon zuerst im Standardformat vorgeschlagen. diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c3cdd..73dea34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.12.0-rc.1 + +- Eigenständige Navigation ohne OrgSuite ergänzt. +- Kalenderfähigkeiten über den optionalen LocalBase-Integrationsvertrag veröffentlicht. +- Ungültige harte App-Abhängigkeiten aus den Nextcloud-Metadaten entfernt. + ## 0.11.14-rc.1 - Öffentliche Projekt-, Quellcode- und Fehlerkanäle ergänzt. diff --git a/README.md b/README.md index d21f432..d9ce70c 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,14 @@ Wochenbasierte Dienst- und Terminplanung mit Personensuche, Gruppenfiltern, Meet - Nextcloud 34 - PHP 8.3 oder neuer innerhalb des von Nextcloud 34 unterstützten Bereichs -- Abhängigkeiten: `localbase`, `orgsuite` +- Laufzeitbasis: `localbase`; `orgsuite` ist ab zwei AD-Fachprodukten optional aktiv - App-ID und Installationsordner: `adcalendar` ## Installation -```bash -sudo -u www-data php occ app:enable localbase -sudo -u www-data php occ app:enable orgsuite -sudo -u www-data php occ app:enable adcalendar -``` +Für Staging und Auslieferung das Produktbundle `ad-product-adcalendar-.tar.gz` und dessen enthaltenes `install.sh` verwenden. Es prüft und installiert LocalBase automatisch; ab dem zweiten AD-Fachprodukt aktiviert es OrgSuite. + +AD Kalender funktioniert einzeln. Ohne AD Urlaub stehen manuelle Sperrtermine zur Verfügung; die read-only Urlaubsmarkierungen entfallen. Der Befehl `adcalendar:demo:seed` ist ausschließlich für synthetische Testdaten gedacht und darf auf einem realitätsnahen Staging-System nicht ohne bewusste Entscheidung ausgeführt werden. diff --git a/appinfo/info.xml b/appinfo/info.xml index 4af7472..ac687b6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ AD Kalender Dienst- und Terminplanung für Mitarbeiter*innen im Büro. Zeigt Dienste, darin liegende Termine, externe Sperrtermine und Dienstsummen in einer gemeinsamen Wochenübersicht. - 0.11.14-rc.1 + 0.12.0-rc.2 agpl Simon https://github.com/Filzmann/ad-suite @@ -16,9 +16,12 @@ - orgsuite OCA\AdCalendar\Command\SeedDemoCommand + + OCA\AdCalendar\Settings\Admin + OCA\AdCalendar\Settings\AdminSection + diff --git a/appinfo/routes.php b/appinfo/routes.php index 9335e25..9fed745 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -14,5 +14,6 @@ ['name' => 'meeting#block', 'url' => '/api/meetings', 'verb' => 'POST'], ['name' => 'meeting#update', 'url' => '/api/meetings/{meetingUid}', 'verb' => 'PUT'], ['name' => 'meeting#delete', 'url' => '/api/meetings/{meetingUid}', 'verb' => 'DELETE'], + ['name' => 'demo_admin#install', 'url' => '/api/admin/demo-pack/install', 'verb' => 'POST'], ], ]; diff --git a/css/admin.css b/css/admin.css new file mode 100644 index 0000000..c409fe5 --- /dev/null +++ b/css/admin.css @@ -0,0 +1,32 @@ +.adc-admin { + max-width: 900px; +} + +.adc-admin-panel { + padding: 18px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large); + background: var(--color-main-background); +} + +.adc-demo-confirm { + display: block; + margin: 16px 0 10px; +} + +.adc-admin-notice { + padding: 10px 12px; + border: 2px solid var(--color-primary-element); + border-radius: var(--border-radius); + background: var(--color-primary-element-light); + color: var(--color-main-text); +} + +.adc-admin-notice.is-success { + border-color: var(--color-success); +} + +.adc-admin-notice.is-error { + border-color: var(--color-error); + background: var(--color-error-hover); +} diff --git a/js/admin.js b/js/admin.js new file mode 100644 index 0000000..b144ea6 --- /dev/null +++ b/js/admin.js @@ -0,0 +1,29 @@ +(function() { + 'use strict'; + + const confirmation = document.getElementById('adc-demo-confirm'); + const button = document.getElementById('adc-demo-install'); + const notice = document.getElementById('adc-demo-notice'); + if (!confirmation || !button || !notice) return; + + const client = new window.LocalBase.api.ApiClient({ appId: 'adcalendar' }); + confirmation.addEventListener('change', () => { button.disabled = !confirmation.checked; }); + button.addEventListener('click', async () => { + if (!confirmation.checked) return; + button.disabled = true; + notice.hidden = false; + notice.className = 'adc-admin-notice'; + notice.textContent = 'Demo-Pack wird geprüft und installiert …'; + try { + const response = await client.request('/api/admin/demo-pack/install', { method: 'POST', body: '{}' }); + const result = response.result; + notice.classList.add('is-success'); + notice.textContent = `${result.accounts.createdUsers} Konten und ${result.accounts.createdGroups} Gruppen angelegt; Kalenderdaten für ${result.createdCalendars} Personen erzeugt.`; + confirmation.checked = false; + } catch (error) { + notice.classList.add('is-error'); + notice.textContent = error.message || 'Das Demo-Pack konnte nicht installiert werden.'; + button.disabled = false; + } + }); +}()); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e5506aa..2769257 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -4,19 +4,28 @@ namespace OCA\AdCalendar\AppInfo; +use OCA\AdCalendar\Listener\IntegrationCapabilityQueryListener; use OCA\AdCalendar\Listener\ScheduleConflictQueryListener; +use OCA\AdCalendar\Listener\StandaloneNavigationListener; use OCA\LocalBase\Calendar\ScheduleConflictQueryEvent; +use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Navigation\Events\LoadAdditionalEntriesEvent; +/** Zweck: Registriert Kalender-, Capability- und Standalone-Navigationsverträge im Nextcloud-Bootstrap. */ class Application extends App implements IBootstrap { public const APP_ID = 'adcalendar'; public function __construct(array $urlParams = []) { parent::__construct(self::APP_ID, $urlParams); } - public function register(IRegistrationContext $context): void { $context->registerEventListener(ScheduleConflictQueryEvent::class, ScheduleConflictQueryListener::class); } + public function register(IRegistrationContext $context): void { + $context->registerEventListener(ScheduleConflictQueryEvent::class, ScheduleConflictQueryListener::class); + $context->registerEventListener(IntegrationCapabilityQueryEvent::class, IntegrationCapabilityQueryListener::class); + $context->registerEventListener(LoadAdditionalEntriesEvent::class, StandaloneNavigationListener::class); + } public function boot(IBootContext $context): void {} } diff --git a/lib/Command/SeedDemoCommand.php b/lib/Command/SeedDemoCommand.php index bf78cd4..b679737 100644 --- a/lib/Command/SeedDemoCommand.php +++ b/lib/Command/SeedDemoCommand.php @@ -4,14 +4,7 @@ namespace OCA\AdCalendar\Command; -use DateTimeImmutable; -use DateTimeZone; -use OCA\AdCalendar\Model\CalendarEntry; -use OCA\AdCalendar\Repository\CalendarEntryRepository; -use OCA\AdCalendar\Service\DemoFixtureCatalog; -use OCP\IGroupManager; -use OCP\IUser; -use OCP\IUserManager; +use OCA\AdCalendar\Service\CalendarDemoPackService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,10 +12,7 @@ /** Zweck: Erzeugt idempotent benannte Demokonten, Gruppenmitgliedschaften und neutrale Kalendereintraege. */ final class SeedDemoCommand extends Command { public function __construct( - private IGroupManager $groups, - private IUserManager $users, - private CalendarEntryRepository $entries, - private DemoFixtureCatalog $fixtures, + private CalendarDemoPackService $demoPack, ) { parent::__construct(); } protected function configure(): void { @@ -30,35 +20,9 @@ protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { - $fixtures = $this->fixtures->all(); - $monday = new DateTimeImmutable('monday this week 08:00', new DateTimeZone('Europe/Berlin')); - $createdEntries = 0; - $skippedEntries = 0; - foreach ($fixtures as $index => $fixture) { - $user = $this->ensureUser($fixture['uid'], $fixture['name']); - foreach ($fixture['groups'] as $groupId) $this->groups->createGroup($groupId)?->addUser($user); - if ($this->entries->existsCreatedByForEmployee('demo-seed', $user->getUID(), $monday, $monday->modify('+7 days'))) { - $skippedEntries++; - continue; - } - $day = $monday->modify('+' . ($index % 5) . ' days'); - $utc = new DateTimeZone('UTC'); - $start = $day->setTimezone($utc); - $end = $day->modify('+8 hours')->setTimezone($utc); - $shiftId = $this->entries->save(CalendarEntry::get(['employeeUid' => $user->getUID(), 'start' => $start, 'end' => $end, 'type' => 'shift']), 'demo-seed'); - $this->entries->save(CalendarEntry::get(['employeeUid' => $user->getUID(), 'start' => $day->modify('+2 hours')->setTimezone($utc), 'end' => $day->modify('+3 hours')->setTimezone($utc), 'type' => 'appointment', 'title' => 'Neutraler Teamtermin', 'parentEntryId' => $shiftId]), 'demo-seed'); - $this->entries->save(CalendarEntry::get(['employeeUid' => $user->getUID(), 'start' => $day->modify('+10 hours')->setTimezone($utc), 'end' => $day->modify('+11 hours')->setTimezone($utc), 'type' => 'appointment', 'title' => 'Neutraler Sperrtermin']), 'demo-seed'); - $createdEntries++; - } - $count = count($fixtures); - $output->writeln("{$count} Demokonten synchronisiert; Kalendereinträge für {$createdEntries} erzeugt, {$skippedEntries} bereits vorhanden."); + $result = $this->demoPack->install(); + $accountCount = $result['accounts']['createdUsers'] + $result['accounts']['reusedUsers']; + $output->writeln("{$accountCount} Demokonten synchronisiert; Kalendereinträge für {$result['createdCalendars']} erzeugt, {$result['skippedCalendars']} bereits vorhanden."); return self::SUCCESS; } - - private function ensureUser(string $uid, string $displayName): IUser { - $user = $this->users->get($uid) ?? $this->users->createUser($uid, bin2hex(random_bytes(24))); - if ($user === null) throw new \RuntimeException("Demokonto {$uid} konnte nicht angelegt werden."); - $user->setDisplayName($displayName); - return $user; - } } diff --git a/lib/Controller/DemoAdminController.php b/lib/Controller/DemoAdminController.php new file mode 100644 index 0000000..6571c37 --- /dev/null +++ b/lib/Controller/DemoAdminController.php @@ -0,0 +1,46 @@ +isAdmin()) return new JSONResponse(['error' => 'Keine Berechtigung.'], Http::STATUS_FORBIDDEN); + try { + return new JSONResponse(['result' => $this->demoPack->install()]); + } catch (\Throwable $error) { + $this->logger->error('Kalender-Demo-Pack konnte nicht installiert werden.', ['exception' => $error]); + return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + private function isAdmin(): bool { + $user = $this->session->getUser(); + return $user !== null && $this->groups->isAdmin($user->getUID()); + } +} diff --git a/lib/Listener/IntegrationCapabilityQueryListener.php b/lib/Listener/IntegrationCapabilityQueryListener.php new file mode 100644 index 0000000..0263a3d --- /dev/null +++ b/lib/Listener/IntegrationCapabilityQueryListener.php @@ -0,0 +1,22 @@ + */ +final class IntegrationCapabilityQueryListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof IntegrationCapabilityQueryEvent) return; + $event->provide(Application::APP_ID, [ + AdIntegrationCapabilities::SCHEDULE_CONFLICT_READ, + AdIntegrationCapabilities::SCHEDULE_BLOCK_WRITE, + ]); + } +} diff --git a/lib/Listener/StandaloneNavigationListener.php b/lib/Listener/StandaloneNavigationListener.php new file mode 100644 index 0000000..5bc4627 --- /dev/null +++ b/lib/Listener/StandaloneNavigationListener.php @@ -0,0 +1,21 @@ + */ +final class StandaloneNavigationListener implements IEventListener { + public function __construct(private StandaloneAppNavigationService $navigation) { + } + + public function handle(Event $event): void { + if (!$event instanceof LoadAdditionalEntriesEvent) return; + $this->navigation->addWhenStandalone('adcalendar', 'Kalender', 'adcalendar.page.index', 'app.svg', 80); + } +} diff --git a/lib/Service/CalendarDemoPackService.php b/lib/Service/CalendarDemoPackService.php new file mode 100644 index 0000000..17d4316 --- /dev/null +++ b/lib/Service/CalendarDemoPackService.php @@ -0,0 +1,70 @@ + CalendarDemoPackService -> LocalBase-Provisioning und CalendarEntryRepository. + * Vertrag: Konten und Gruppen werden vollständig geprüft, bevor LocalBase die erste Änderung ausführt. + */ +final class CalendarDemoPackService { + public function __construct( + private DemoAccountProvisioningService $accounts, + private CalendarEntryRepository $entries, + private DemoFixtureCatalog $fixtures, + ) {} + + /** @return array{accounts:array{createdUsers:int,reusedUsers:int,createdGroups:int},createdCalendars:int,skippedCalendars:int} */ + public function install(): array { + $fixtures = $this->fixtures->all(); + $accounts = $this->accounts->provision('ad-suite-demo', array_map(static fn(array $fixture): array => [ + 'uid' => $fixture['uid'], + 'displayName' => $fixture['name'], + 'groups' => $fixture['groups'], + ], $fixtures)); + + $monday = new DateTimeImmutable('monday this week 08:00', new DateTimeZone('Europe/Berlin')); + $utc = new DateTimeZone('UTC'); + $createdCalendars = 0; + $skippedCalendars = 0; + foreach ($fixtures as $index => $fixture) { + if ($this->entries->existsCreatedByForEmployee('demo-seed', $fixture['uid'], $monday, $monday->modify('+7 days'))) { + $skippedCalendars++; + continue; + } + $day = $monday->modify('+' . ($index % 5) . ' days'); + $shiftId = $this->entries->save(CalendarEntry::get([ + 'employeeUid' => $fixture['uid'], + 'start' => $day->setTimezone($utc), + 'end' => $day->modify('+8 hours')->setTimezone($utc), + 'type' => 'shift', + ]), 'demo-seed'); + $this->entries->save(CalendarEntry::get([ + 'employeeUid' => $fixture['uid'], + 'start' => $day->modify('+2 hours')->setTimezone($utc), + 'end' => $day->modify('+3 hours')->setTimezone($utc), + 'type' => 'appointment', + 'title' => 'Neutraler Teamtermin', + 'parentEntryId' => $shiftId, + ]), 'demo-seed'); + $this->entries->save(CalendarEntry::get([ + 'employeeUid' => $fixture['uid'], + 'start' => $day->modify('+10 hours')->setTimezone($utc), + 'end' => $day->modify('+11 hours')->setTimezone($utc), + 'type' => 'appointment', + 'title' => 'Neutraler Sperrtermin', + ]), 'demo-seed'); + $createdCalendars++; + } + + return compact('accounts', 'createdCalendars', 'skippedCalendars'); + } +} diff --git a/lib/Service/CalendarService.php b/lib/Service/CalendarService.php index 5a3b320..3a13d55 100644 --- a/lib/Service/CalendarService.php +++ b/lib/Service/CalendarService.php @@ -18,6 +18,7 @@ public function __construct( private CalendarEntryRepository $entries, private DefaultShiftMaterializer $defaultShifts, private AbsenceService $absences, + private ContainingShiftAssignment $shiftAssignment, ) {} public function week(DateTimeImmutable $start, array $employees): array { @@ -111,10 +112,7 @@ private function assertShiftDoesNotOverlap(CalendarEntry $entry): void { private function assignContainingShift(CalendarEntry $entry): CalendarEntry { if ($entry->type() !== CalendarEntry::TYPE_APPOINTMENT) return $entry; $parents = $this->entries->containingShifts($entry->employeeUid(), $entry->start(), $entry->end(), $entry->id()); - if (count($parents) > 1) { - throw new InvalidArgumentException('Der Termin liegt in mehreren Diensten. Bitte Dienste zuerst korrigieren.'); - } - return CalendarEntry::get(array_replace($entry->toArray(), ['parentEntryId' => $parents[0]->id() ?? null])); + return $this->shiftAssignment->assign($entry, $parents); } private function detachChildrenOutsideShift(CalendarEntry $entry, int $savedId): void { diff --git a/lib/Service/ContainingShiftAssignment.php b/lib/Service/ContainingShiftAssignment.php new file mode 100644 index 0000000..df907d4 --- /dev/null +++ b/lib/Service/ContainingShiftAssignment.php @@ -0,0 +1,24 @@ + $parents */ + public function assign(CalendarEntry $entry, array $parents): CalendarEntry { + if ($entry->type() !== CalendarEntry::TYPE_APPOINTMENT) return $entry; + if (count($parents) > 1) { + throw new InvalidArgumentException('Der Termin liegt in mehreren Diensten. Bitte Dienste zuerst korrigieren.'); + } + $parent = $parents[0] ?? null; + return CalendarEntry::get(array_replace($entry->toArray(), ['parentEntryId' => $parent?->id()])); + } +} diff --git a/lib/Service/DemoFixtureCatalog.php b/lib/Service/DemoFixtureCatalog.php index e6ebfc6..b929e7f 100644 --- a/lib/Service/DemoFixtureCatalog.php +++ b/lib/Service/DemoFixtureCatalog.php @@ -6,49 +6,26 @@ use OCA\LocalBase\Organization\AdOrganizationDefinition; use OCA\LocalBase\Organization\AdOrganizationSettingsService; +use OCA\LocalBase\Service\AdDemoFixtureCatalog; -/** Zweck: Erzeugt neutrale Demopersonen aus fachlichen Rollenschlüsseln und der aktuell konfigurierten Organisation. */ +/** Zweck: Hält den bisherigen Kalender-Payload stabil und delegiert die gemeinsamen Demopersonen an LocalBase. */ final class DemoFixtureCatalog { - public function __construct(private ?AdOrganizationSettingsService $organization = null, private ?AdOrganizationDefinition $override = null) {} + public function __construct( + private ?AdOrganizationSettingsService $organization = null, + private ?AdOrganizationDefinition $override = null, + private ?AdDemoFixtureCatalog $shared = null, + ) {} /** @return list}> */ public function all(): array { - $definition = $this->override ?? $this->organization?->definition() ?? AdOrganizationDefinition::defaults(); - return array_map(fn(array $fixture): array => [ - 'uid' => $fixture['uid'], - 'name' => $fixture['name'], - 'groups' => array_values(array_filter(array_merge( - array_map($definition->roleGroupId(...), $fixture['roles']), - array_map($definition->areaGroupId(...), $fixture['areas']), - ))), - ], $this->fixtures()); - } + $fixtures = $this->override !== null + ? (new AdDemoFixtureCatalog(null, $this->override))->all() + : ($this->shared ?? new AdDemoFixtureCatalog($this->organization))->all(); - private function fixtures(): array { - return [ - ['uid' => 'adc-demo-gf-as', 'name' => 'Alma Adler (GF-AS)', 'roles' => ['gf_as'], 'areas' => []], - ['uid' => 'adc-demo-gf-digi', 'name' => 'David Berger (GF-Digi)', 'roles' => ['gf_digi'], 'areas' => []], - ['uid' => 'adc-demo-pdl', 'name' => 'Paula Lindner (PDL)', 'roles' => ['pdl'], 'areas' => []], - ['uid' => 'adc-demo-asdgf-digi', 'name' => 'Alexis Dorn (AsdGF-Digi)', 'roles' => ['assistant_gf_digi'], 'areas' => []], - ['uid' => 'adc-demo-finanzleitung', 'name' => 'Leonie Frank (Leitung Finanzen und Lohn)', 'roles' => ['finance_lead'], 'areas' => []], - ['uid' => 'adc-demo-finanzen', 'name' => 'Finn Lohmann (Finanzen und Lohn)', 'roles' => ['finance'], 'areas' => []], - ['uid' => 'adc-demo-it', 'name' => 'Imani Teich (IT)', 'roles' => ['it'], 'areas' => []], - ['uid' => 'adc-demo-sekretariat', 'name' => 'Samira König (Sekretariat)', 'roles' => ['secretariat'], 'areas' => []], - ['uid' => 'adc-demo-hr', 'name' => 'Hanna Reuter (Stabsstelle HR)', 'roles' => ['staff_hr'], 'areas' => []], - ['uid' => 'adc-demo-qmb', 'name' => 'Quinn Meyer (Stabsstelle Qualitätsmanagement)', 'roles' => ['staff_qmb'], 'areas' => []], - ['uid' => 'adc-demo-bl-now', 'name' => 'Nora Winter (Büro Nordost und West, BL)', 'roles' => ['bl', 'office'], 'areas' => ['northeast', 'west']], - ['uid' => 'adc-demo-bl-sued', 'name' => 'Sofia Kern (Büro Süd, BL)', 'roles' => ['bl', 'office'], 'areas' => ['south']], - ['uid' => 'adc-demo-stvbl-no', 'name' => 'Nele Hartmann (EB Nordost, Stv. BL)', 'roles' => ['deputy_bl', 'eb'], 'areas' => ['northeast']], - ['uid' => 'adc-demo-stvbl-west', 'name' => 'Wiebke Hahn (EB West, Stv. BL)', 'roles' => ['deputy_bl', 'eb'], 'areas' => ['west']], - ['uid' => 'adc-demo-stvbl-sued', 'name' => 'Sina Maurer (EB Süd, Stv. BL)', 'roles' => ['deputy_bl', 'eb'], 'areas' => ['south']], - ['uid' => 'adc-demo-bo-no', 'name' => 'Mara Brandt (Büro Nordost)', 'roles' => ['office'], 'areas' => ['northeast']], - ['uid' => 'adc-demo-bo-west', 'name' => 'Mika Werner (Büro West)', 'roles' => ['office'], 'areas' => ['west']], - ['uid' => 'adc-demo-bo-sued', 'name' => 'Selin Krüger (Büro Süd)', 'roles' => ['office'], 'areas' => ['south']], - ['uid' => 'adc-demo-eb-no', 'name' => 'Enna Busch (EB Nordost)', 'roles' => ['eb'], 'areas' => ['northeast']], - ['uid' => 'adc-demo-eb-west', 'name' => 'Emil Weber (EB West)', 'roles' => ['eb'], 'areas' => ['west']], - ['uid' => 'adc-demo-eb-sued', 'name' => 'Eda Sommer (EB Süd)', 'roles' => ['eb'], 'areas' => ['south']], - ['uid' => 'adc-demo-pfk-a', 'name' => 'Petra Falk (PFK)', 'roles' => ['pfk'], 'areas' => []], - ['uid' => 'adc-demo-pfk-b', 'name' => 'Robin Keller (PFK)', 'roles' => ['pfk'], 'areas' => []], - ]; + return array_map(static fn(array $fixture): array => [ + 'uid' => $fixture['uid'], + 'name' => $fixture['displayName'], + 'groups' => $fixture['groups'], + ], $fixtures); } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 0000000..d7f47ff --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,16 @@ +url->imagePath(Application::APP_ID, 'app.svg'); } + public function getID(): string { return Application::APP_ID; } + public function getName(): string { return 'AD Kalender'; } + public function getPriority(): int { return 62; } +} diff --git a/templates/admin.php b/templates/admin.php new file mode 100644 index 0000000..08c11ec --- /dev/null +++ b/templates/admin.php @@ -0,0 +1,16 @@ + +
+

AD Kalender

+
+

Demo-Pack

+

Das Demo-Pack legt ausschließlich synthetische lokale Konten, bei Bedarf lokale Gruppen sowie neutrale Dienste und Termine an. Es wird nicht automatisch installiert und importiert keine WordPress-Bestandsdaten.

+

Bereits vorhandene fremde Konten und schreibgeschützte LDAP-Gruppen führen vor der ersten Änderung zum Abbruch.

+ + + +
+
diff --git a/templates/index.php b/templates/index.php index dda56df..b1e9d00 100644 --- a/templates/index.php +++ b/templates/index.php @@ -3,7 +3,6 @@ \OCP\Util::addScript('localbase', 'models/model'); \OCP\Util::addScript('localbase', 'repositories/repository'); \OCP\Util::addScript('localbase', 'ui/ui'); -\OCP\Util::addScript('orgsuite', 'suite-navigation'); \OCP\Util::addScript('adcalendar', 'models/calendar-entry'); \OCP\Util::addScript('adcalendar', 'models/organization'); \OCP\Util::addScript('adcalendar', 'repositories/calendar-repository'); @@ -22,7 +21,6 @@ \OCP\Util::addScript('adcalendar', 'components/week-table'); \OCP\Util::addScript('adcalendar', 'main'); \OCP\Util::addStyle('adcalendar', 'style'); -\OCP\Util::addStyle('orgsuite', 'suite-navigation'); ?>
diff --git a/tests/Command/SeedDemoCommandSmokeTest.php b/tests/Command/SeedDemoCommandSmokeTest.php index 7785c6c..47df668 100644 --- a/tests/Command/SeedDemoCommandSmokeTest.php +++ b/tests/Command/SeedDemoCommandSmokeTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../../localbase/lib/Organization/AdOrganizationDefinition.php'; +require_once __DIR__ . '/../../../localbase/lib/Service/AdDemoFixtureCatalog.php'; require_once __DIR__ . '/../../lib/Service/DemoFixtureCatalog.php'; use OCA\AdCalendar\Service\DemoFixtureCatalog; @@ -28,8 +29,15 @@ } $source = file_get_contents(__DIR__ . '/../../lib/Command/SeedDemoCommand.php'); -if ($source === false) throw new RuntimeException('SeedDemoCommand konnte nicht gelesen werden.'); -foreach (['ensureUser', 'createGroup', 'setDisplayName', 'existsCreatedByForEmployee', "'parentEntryId' => \$shiftId"] as $contract) { - if (!str_contains($source, $contract)) throw new RuntimeException("Demo-Vertrag fehlt: {$contract}"); +$service = file_get_contents(__DIR__ . '/../../lib/Service/CalendarDemoPackService.php'); +if ($source === false || $service === false) throw new RuntimeException('Demo-Pack-Code konnte nicht gelesen werden.'); +foreach (['CalendarDemoPackService', '->install()'] as $contract) { + if (!str_contains($source, $contract)) throw new RuntimeException("Demo-Command delegiert nicht sicher: {$contract}"); +} +foreach (['DemoAccountProvisioningService', '->provision(', 'existsCreatedByForEmployee', "'parentEntryId' => \$shiftId"] as $contract) { + if (!str_contains($service, $contract)) throw new RuntimeException("Demo-Pack-Vertrag fehlt: {$contract}"); +} +foreach (['IUserManager', 'ensureUser(', 'createGroup(', 'setDisplayName('] as $unsafeContract) { + if (str_contains($source, $unsafeContract)) throw new RuntimeException("Demo-Command umgeht das sichere Provisioning: {$unsafeContract}"); } echo "SeedDemoCommandSmokeTest: OK\n"; diff --git a/tests/Controller/DemoAdminContractTest.php b/tests/Controller/DemoAdminContractTest.php new file mode 100644 index 0000000..599c9e4 --- /dev/null +++ b/tests/Controller/DemoAdminContractTest.php @@ -0,0 +1,21 @@ + 'POST'"] as $contract) if (!str_contains($routes, $contract)) throw new RuntimeException("Demo-Route fehlt: {$contract}"); +foreach (['OCA\\AdCalendar\\Settings\\Admin', 'OCA\\AdCalendar\\Settings\\AdminSection'] as $contract) if (!str_contains($info, $contract)) throw new RuntimeException("Adminregistrierung fehlt: {$contract}"); +foreach (['CalendarDemoPackService', 'private function isAdmin()', '$this->groups->isAdmin(', 'Http::STATUS_FORBIDDEN'] as $contract) if (!str_contains($controller, $contract)) throw new RuntimeException("Serverseitiger Demo-Adminschutz fehlt: {$contract}"); +if (str_contains($controller, 'NoCSRFRequired')) throw new RuntimeException('Demo-Installation darf den CSRF-Schutz nicht umgehen.'); +foreach (['id="adc-demo-confirm"', 'id="adc-demo-install"', 'nicht automatisch'] as $contract) if (!str_contains($template, $contract)) throw new RuntimeException("Demo-Adminoberfläche fehlt: {$contract}"); +foreach (['adc-demo-confirm', 'adc-demo-install', "client.request('/api/admin/demo-pack/install'"] as $contract) if (!str_contains($script, $contract)) throw new RuntimeException("Demo-Admininteraktion fehlt: {$contract}"); + +echo "DemoAdminContractTest: OK\n"; diff --git a/tests/Service/ContainingShiftAssignmentTest.php b/tests/Service/ContainingShiftAssignmentTest.php new file mode 100644 index 0000000..e156838 --- /dev/null +++ b/tests/Service/ContainingShiftAssignmentTest.php @@ -0,0 +1,50 @@ + CalendarEntry::get($payload + [ + 'employeeUid' => 'demo', + 'start' => '2026-07-15T10:00:00+02:00', + 'end' => '2026-07-15T11:00:00+02:00', + 'title' => '', +]); + +$appointment = $entry(['type' => CalendarEntry::TYPE_APPOINTMENT, 'title' => 'Besprechung']); +$shift = $entry([ + 'id' => 42, + 'type' => CalendarEntry::TYPE_SHIFT, + 'start' => '2026-07-15T08:00:00+02:00', + 'end' => '2026-07-15T16:00:00+02:00', +]); +$secondShift = $entry([ + 'id' => 43, + 'type' => CalendarEntry::TYPE_SHIFT, + 'start' => '2026-07-15T09:00:00+02:00', + 'end' => '2026-07-15T17:00:00+02:00', +]); + +$assignment = new ContainingShiftAssignment(); +if ($assignment->assign($appointment, [])->parentEntryId() !== null) { + throw new RuntimeException('Ein Termin ohne enthaltenden Dienst darf keine Parent-ID erhalten.'); +} +if ($assignment->assign($appointment, [$shift])->parentEntryId() !== 42) { + throw new RuntimeException('Der eindeutig enthaltende Dienst wurde nicht zugeordnet.'); +} + +$thrown = false; +try { + $assignment->assign($appointment, [$shift, $secondShift]); +} catch (InvalidArgumentException $error) { + $thrown = str_contains($error->getMessage(), 'mehreren Diensten'); +} +if (!$thrown) { + throw new RuntimeException('Mehrdeutige Dienstzuordnungen werden nicht abgewiesen.'); +} + +echo "ContainingShiftAssignmentTest: OK\n"; diff --git a/tests/Service/IntegrationCapabilityListenerTest.php b/tests/Service/IntegrationCapabilityListenerTest.php new file mode 100644 index 0000000..25846b8 --- /dev/null +++ b/tests/Service/IntegrationCapabilityListenerTest.php @@ -0,0 +1,28 @@ +handle(new Event()); + $event = new IntegrationCapabilityQueryEvent(AdIntegrationCapabilities::all()); + $listener->handle($event); + + if ($event->providersFor(AdIntegrationCapabilities::SCHEDULE_CONFLICT_READ) !== ['adcalendar']) throw new RuntimeException('Kalender-Konfliktfähigkeit fehlt.'); + if ($event->providersFor(AdIntegrationCapabilities::SCHEDULE_BLOCK_WRITE) !== ['adcalendar']) throw new RuntimeException('Kalender-Schreibfähigkeit fehlt.'); + if ($event->isAvailable(AdIntegrationCapabilities::ABSENCE_READ)) throw new RuntimeException('Kalender meldet eine fremde Fähigkeit.'); + + echo "AD Kalender capability listener test passed\n"; +} diff --git a/tests/Ui/LayoutSmokeTest.php b/tests/Ui/LayoutSmokeTest.php index def4295..23f50d2 100644 --- a/tests/Ui/LayoutSmokeTest.php +++ b/tests/Ui/LayoutSmokeTest.php @@ -9,7 +9,7 @@ $info = file_get_contents(__DIR__ . '/../../appinfo/info.xml'); if ($template === false || $css === false || $info === false) throw new RuntimeException('UI-Dateien konnten nicht gelesen werden.'); foreach (['partials/settings', 'partials/entry-dialog', 'partials/meeting-dialog'] as $partial) if (!str_contains($indexTemplate, "echo \$this->inc('{$partial}')")) throw new RuntimeException("Template-Partial fehlt: {$partial}"); -if (!str_contains($info, 'orgsuite') || str_contains($info, '')) throw new RuntimeException('OrgSuite-Appvertrag fehlt.'); +if (str_contains($info, '') || str_contains($info, '')) throw new RuntimeException('Standalone-Appvertrag fehlt.'); foreach (['role="tablist"', 'id="adc-tab-calendar"', 'id="adc-tab-settings"', 'id="adc-settings-view"', 'id="adc-shift-defaults-form"', '
', 'id="adc-filter-status"', 'id="adc-save-default"', 'id="adc-reset-selection"', 'Auswahl zurücksetzen', 'id="adc-entry-dialog"', 'id="adc-meeting-dialog"', 'id="adc-meeting-duration"', 'id="adc-meeting-title"', 'class="adc-overview"', 'class="adc-overview-header"', 'class="adc-button-icon icon-calendar-dark" aria-hidden="true"', "\\OCP\\Util::addScript('adcalendar', 'models/organization')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-date')", "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-state')", "\\OCP\\Util::addScript('adcalendar', 'modules/entry-workflow')", "\\OCP\\Util::addScript('adcalendar', 'modules/meeting-capabilities')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-filters')", "\\OCP\\Util::addScript('adcalendar', 'components/calendar-cell')", "\\OCP\\Util::addScript('adcalendar', 'components/entry-dialog')", "\\OCP\\Util::addScript('adcalendar', 'components/meeting-finder')", "\\OCP\\Util::addScript('adcalendar', 'components/shift-defaults')", "\\OCP\\Util::addScript('adcalendar', 'components/tab-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-navigation')", "\\OCP\\Util::addScript('adcalendar', 'components/week-table')"] as $contract) { if (!str_contains($template, $contract)) throw new RuntimeException("Kompakter Filtervertrag fehlt: {$contract}"); } @@ -17,9 +17,10 @@ if (!str_contains($template, $contract)) throw new RuntimeException("LocalBase-UI-Vertrag fehlt: {$contract}"); } if (!str_contains($template, "\\OCP\\Util::addScript('adcalendar', 'modules/calendar-timeline')")) throw new RuntimeException('Zeitachsenmodul fehlt im Template.'); -foreach (["\\OCP\\Util::addScript('orgsuite', 'suite-navigation')", "\\OCP\\Util::addStyle('orgsuite', 'suite-navigation')", 'data-orgsuite data-suite="ad" data-current-app="adcalendar"'] as $contract) { +foreach (['data-orgsuite data-suite="ad" data-current-app="adcalendar"'] as $contract) { if (!str_contains($template, $contract)) throw new RuntimeException("Suite-Navigationsvertrag fehlt: {$contract}"); } +if (str_contains($template, "addScript('orgsuite'") || str_contains($template, "addStyle('orgsuite'")) throw new RuntimeException('Direkte OrgSuite-Assetkopplung vorhanden.'); if (preg_match('/^\\s*(?:script|style)\\s*\\(/m', $template) === 1) throw new RuntimeException('Veralteter globaler Templatehelfer gefunden.'); if (!str_contains($template, 'erscheinen als feste Dienste im Kalender')) throw new RuntimeException('Standarddienst-Erklaerung fehlt in den Einstellungen.'); foreach (['height: 100%', 'min-height: 0', 'overflow-y: auto', 'overflow-x: hidden', 'background: var(--color-main-background)', '.adc-app [hidden] { display: none !important; }', '.adc-table-wrap { width: 100%; max-width: 100%; min-width: 0; overflow-x: auto', 'width: max-content', 'min-width: 0', 'table-layout: auto', '.adc-filter-grid', '.adc-selection-actions', 'height: auto !important', '.adc-dialog:not([open])', '.adc-quick-add', '.adc-quick-add[data-tooltip]::after', '.adc-meeting-people', '.adc-tabs', '.adc-shift-default-row', '.adc-overview-header', 'white-space: nowrap', '.adc-settings-view { width: 100%; max-width: none', '.adc-entry--blocked { border: 2px solid var(--color-error)', 'background: var(--color-error)', 'color: var(--color-error-text)'] as $contract) { diff --git a/tests/Ui/StandaloneNavigationListenerTest.php b/tests/Ui/StandaloneNavigationListenerTest.php new file mode 100644 index 0000000..7c5c8f7 --- /dev/null +++ b/tests/Ui/StandaloneNavigationListenerTest.php @@ -0,0 +1,33 @@ +user; } }; + $apps = new class implements IAppManager { public function isEnabledForUser($appId, $user = null): bool { return false; } }; + $nav = new class implements INavigationManager { public array $entries = []; public function add(callable $entry): void { $this->entries[] = $entry; } }; + $url = new class implements IURLGenerator { public function linkToRoute(string $routeName, array $arguments = []): string { return $routeName; } public function imagePath(string $appName, string $file): string { return "$appName/$file"; } }; + $listener = new StandaloneNavigationListener(new StandaloneAppNavigationService($session, $apps, $nav, $url)); + $listener->handle(new LoadAdditionalEntriesEvent()); + $entry = ($nav->entries[0] ?? static fn(): array => [])(); + if (($entry['id'] ?? '') !== 'adcalendar' || ($entry['name'] ?? '') !== 'Kalender' || ($entry['href'] ?? '') !== 'adcalendar.page.index') throw new RuntimeException('Standalone-Kalendernavigation fehlt.'); + echo "AD Kalender standalone navigation test passed\n"; +}