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
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<release>.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.

Expand Down
7 changes: 5 additions & 2 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<name>AD Kalender</name>
<summary>Dienst- und Terminplanung für Mitarbeiter*innen im Büro.</summary>
<description>Zeigt Dienste, darin liegende Termine, externe Sperrtermine und Dienstsummen in einer gemeinsamen Wochenübersicht.</description>
<version>0.11.14-rc.1</version>
<version>0.12.0-rc.2</version>
<licence>agpl</licence>
<author>Simon</author>
<website>https://github.com/Filzmann/ad-suite</website>
Expand All @@ -16,9 +16,12 @@
<dependencies>
<php min-version="8.3"/>
<nextcloud min-version="34" max-version="34"/>
<app>orgsuite</app>
</dependencies>
<commands>
<command>OCA\AdCalendar\Command\SeedDemoCommand</command>
</commands>
<settings>
<admin>OCA\AdCalendar\Settings\Admin</admin>
<admin-section>OCA\AdCalendar\Settings\AdminSection</admin-section>
</settings>
</info>
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];
32 changes: 32 additions & 0 deletions css/admin.css
Original file line number Diff line number Diff line change
@@ -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);
}
29 changes: 29 additions & 0 deletions js/admin.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
}());
11 changes: 10 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
46 changes: 5 additions & 41 deletions lib/Command/SeedDemoCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,25 @@

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;

/** 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 {
$this->setName('adcalendar:demo:seed')->setDescription('Erzeugt vollständige AD-Kalender-Demokonten und Demodaten.');
}

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("<info>{$count} Demokonten synchronisiert; Kalendereinträge für {$createdEntries} erzeugt, {$skippedEntries} bereits vorhanden.</info>");
$result = $this->demoPack->install();
$accountCount = $result['accounts']['createdUsers'] + $result['accounts']['reusedUsers'];
$output->writeln("<info>{$accountCount} Demokonten synchronisiert; Kalendereinträge für {$result['createdCalendars']} erzeugt, {$result['skippedCalendars']} bereits vorhanden.</info>");
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;
}
}
46 changes: 46 additions & 0 deletions lib/Controller/DemoAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace OCA\AdCalendar\Controller;

use OCA\AdCalendar\AppInfo\Application;
use OCA\AdCalendar\Service\CalendarDemoPackService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/**
* Zweck: Installiert das Kalender-Demo-Pack ausschließlich nach einer bestätigten Adminaktion.
* Vertrag: Nextcloud-Admin- und CSRF-Schutz bleiben aktiv; zusätzlich wird die aktive Sitzung serverseitig geprüft.
*/
final class DemoAdminController extends Controller {
public function __construct(
IRequest $request,
private IUserSession $session,
private IGroupManager $groups,
private CalendarDemoPackService $demoPack,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
}

public function install(): JSONResponse {
if (!$this->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());
}
}
22 changes: 22 additions & 0 deletions lib/Listener/IntegrationCapabilityQueryListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace OCA\AdCalendar\Listener;

use OCA\AdCalendar\AppInfo\Application;
use OCA\LocalBase\Integration\AdIntegrationCapabilities;
use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;

/** @template-implements IEventListener<IntegrationCapabilityQueryEvent> */
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,
]);
}
}
21 changes: 21 additions & 0 deletions lib/Listener/StandaloneNavigationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace OCA\AdCalendar\Listener;

use OCA\LocalBase\Service\StandaloneAppNavigationService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;

/** @template-implements IEventListener<LoadAdditionalEntriesEvent> */
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);
}
}
Loading