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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Die folgenden IDs sind initiale Standardwerte. Assistenzteam-Präfix, sichtbarer
- EB-Rechte: Nutzer*innen, die zugleich in der Assistenznehmer-Gruppe und der gemeinsamen Rollengruppe `ad-EB` sind. Rollen-/Bereichskombinationen werden nicht als eigene Gruppen akzeptiert.
- Bereichszuordnungen werden app-uebergreifend separat als `ad-Bereich-<Name>` gepflegt; kombinierte Rollen-/Bereichsgruppen werden dynamisch abgeleitet.
- AdPlaner und AD Urlaub verwenden dieselben Assistenzteam-Gruppen; separate Suffix-Gruppen werden nicht unterstützt.
- Der app-eigene Adminabschnitt installiert Demo-Inhalte nur nach ausdrücklicher Bestätigung. Das Pack legt Team A, Team B und Team C mit ausschließlich synthetischen lokalen Konten und Standardschichten an; WordPress-Bestandsdaten werden nicht importiert.
- Fremde oder LDAP-verwaltete Konten werden nicht als Demokonten übernommen. Bestehende read-only LDAP-Team- oder Rollengruppen brechen die Demo-Installation im Preflight vor jeder Mutation ab.
- Schichten werden ausschließlich über die strukturierte Schichtkonfiguration verwaltet. Frühere einzelne Legacy-Parameter für Früh-, Spät- oder Nachtschichten werden nicht weitergeführt.
- Die Schichtkonfiguration eines Assistenzteams ist eine delegierte fachliche Teamkonfiguration und wird durch die zuständige EB im AdPlaner gepflegt. Sie ist keine ausschließlich für Nextcloud-Admins bestimmte organisationsweite Einstellung und gehört deshalb nicht in den Suite-Adminbereich.

Expand Down Expand Up @@ -101,8 +103,9 @@ Diese Regeln gelten sinngemaess auch fuer andere eigene Nextcloud-Apps; die fach

### Gemeinsame Suite-Navigation

- AdPlaner 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="adplaner"` ein.
- Ohne aktive OrgSuite registriert AdPlaner 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="adplaner"` bereit, lädt aber keine OrgSuite-Assets direkt.
- Ohne AD Urlaub oder AD Kalender bleibt die Assistenzplanung eigenständig nutzbar; optionale Abwesenheits- und Konflikthinweise dürfen den Monatsplan nicht blockieren.
- Team- und Planungsrechte bleiben ausschliesslich serverseitig im AdPlaner; Menuesichtbarkeit ist keine Berechtigung.
- Der deckende Hintergrund und das vertikale Scrolling liegen am App-Root `#adplaner-app`; globale Nextcloud-Container wie `#content` werden nicht ueberschrieben.

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.3.0-rc.1

- Eigenständige Navigation ohne OrgSuite ergänzt.
- Assistenzplanfähigkeit über den optionalen LocalBase-Integrationsvertrag veröffentlicht.
- Ungültige harte App-Abhängigkeiten aus den Nextcloud-Metadaten entfernt.

## 0.2.9-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 @@ Monatliche Wunschdienstplanung für Assistenzteams. Urlaubsplanung liegt ausschl

- 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: `adplaner`

## 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 adplaner
```
Für Staging und Auslieferung das Produktbundle `ad-product-adplaner-<release>.tar.gz` und dessen enthaltenes `install.sh` verwenden. Es prüft und installiert LocalBase automatisch; ab dem zweiten AD-Fachprodukt aktiviert es OrgSuite.

AdPlaner funktioniert einzeln; optionale Abwesenheits- oder Kalenderhinweise entfallen ohne die jeweilige Fachapp, ohne den Monatsplan zu blockieren.

Assistenzteams werden aus den zentral konfigurierten Nextcloud-Gruppen abgeleitet. Teambezogene Schichtkonfigurationen werden durch berechtigte Einsatzbegleitungen gepflegt.

Expand Down
11 changes: 8 additions & 3 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<name>Assistenz Dienstplanung</name>
<summary>Wunschdienstplanung für Assistenzteams.</summary>
<description>Verwaltet monatliche Wunschdienstpläne für Assistenzteams auf Basis dynamischer Nextcloud-Gruppen.</description>
<version>0.2.9-rc.1</version>
<version>0.3.0-rc.2</version>
<licence>agpl</licence>
<author>Simon</author>
<website>https://github.com/Filzmann/ad-suite</website>
Expand All @@ -16,7 +16,12 @@
<dependencies>
<php min-version="8.3"/>
<nextcloud min-version="34" max-version="34"/>
<app>localbase</app>
<app>orgsuite</app>
</dependencies>
<commands>
<command>OCA\AdPlaner\Command\SeedDemoCommand</command>
</commands>
<settings>
<admin>OCA\AdPlaner\Settings\Admin</admin>
<admin-section>OCA\AdPlaner\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 @@ -10,5 +10,6 @@
['name' => 'api#saveDayNote', 'url' => '/api/teams/{teamCode}/months/{month}/days/{workDate}/note', 'verb' => 'POST'],
['name' => 'api#addShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates', 'verb' => 'POST'],
['name' => 'api#removeShiftCandidate', 'url' => '/api/teams/{teamCode}/months/{month}/slots/{slotId}/candidates/remove', 'verb' => 'POST'],
['name' => 'demo_admin#install', 'url' => '/api/admin/demo-pack/install', 'verb' => 'POST'],
],
];
6 changes: 6 additions & 0 deletions css/admin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.adp-admin { max-width: 900px; }
.adp-admin-panel { padding: 18px; border: 1px solid var(--color-border); border-radius: var(--border-radius-large); background: var(--color-main-background); }
.adp-demo-confirm { display: block; margin: 16px 0 10px; }
.adp-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); }
.adp-admin-notice.is-success { border-color: var(--color-success); }
.adp-admin-notice.is-error { border-color: var(--color-error); background: var(--color-error-hover); }
26 changes: 26 additions & 0 deletions js/admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
(function() {
'use strict';
const confirmation = document.getElementById('adp-demo-confirm');
const button = document.getElementById('adp-demo-install');
const notice = document.getElementById('adp-demo-notice');
if (!confirmation || !button || !notice) return;
const client = new window.LocalBase.api.ApiClient({ appId: 'adplaner' });
confirmation.addEventListener('change', () => { button.disabled = !confirmation.checked; });
button.addEventListener('click', async () => {
if (!confirmation.checked) return;
button.disabled = true;
notice.hidden = false;
notice.className = 'adp-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: '{}' });
notice.classList.add('is-success');
notice.textContent = `${response.result.teams.join(', ')} wurden als Demoteams angelegt.`;
confirmation.checked = false;
} catch (error) {
notice.classList.add('is-error');
notice.textContent = error.message || 'Das Demo-Pack konnte nicht installiert werden.';
button.disabled = false;
}
});
}());
18 changes: 17 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,28 @@

namespace OCA\AdPlaner\AppInfo;

use OCA\AdPlaner\Listener\IntegrationCapabilityQueryListener;
use OCA\AdPlaner\Listener\StandaloneNavigationListener;
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;

class Application extends App {
/** Zweck: Registriert Assistenzplanfähigkeit und Standalone-Navigation im Nextcloud-Bootstrap. */
class Application extends App implements IBootstrap {
public const APP_ID = 'adplaner';

public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}

public function register(IRegistrationContext $context): void {
$context->registerEventListener(IntegrationCapabilityQueryEvent::class, IntegrationCapabilityQueryListener::class);
$context->registerEventListener(LoadAdditionalEntriesEvent::class, StandaloneNavigationListener::class);
}

public function boot(IBootContext $context): void {
}
}
21 changes: 21 additions & 0 deletions lib/Command/SeedDemoCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Command;

use OCA\AdPlaner\Service\PlanerDemoPackService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/** Zweck: Stellt den sicheren Assistenzplaner-Demo-Pack für automatisierte Staging-Setups bereit. */
final class SeedDemoCommand extends Command {
public function __construct(private PlanerDemoPackService $demoPack) { parent::__construct(); }
protected function configure(): void { $this->setName('adplaner:demo:seed')->setDescription('Erzeugt Team A, Team B und Team C mit synthetischen Demokonten.'); }
protected function execute(InputInterface $input, OutputInterface $output): int {
$result = $this->demoPack->install();
$output->writeln('<info>' . implode(', ', $result['teams']) . ' als Demoteams synchronisiert.</info>');
return self::SUCCESS;
}
}
33 changes: 33 additions & 0 deletions lib/Controller/DemoAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Controller;

use OCA\AdPlaner\AppInfo\Application;
use OCA\AdPlaner\Service\PlanerDemoPackService;
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: Startet den Assistenzplaner-Demo-Pack ausschließlich mit Admin- und CSRF-Schutz. */
final class DemoAdminController extends Controller {
public function __construct(IRequest $request, private IUserSession $session, private IGroupManager $groups, private PlanerDemoPackService $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('Assistenzplaner-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());
}
}
19 changes: 19 additions & 0 deletions lib/Listener/IntegrationCapabilityQueryListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Listener;

use OCA\AdPlaner\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::ASSISTANT_SCHEDULE_READ]);
}
}
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\AdPlaner\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('adplaner', 'Assistenzplanung', 'adplaner.page.index', 'app.svg', 82);
}
}
52 changes: 52 additions & 0 deletions lib/Service/PlanerDemoPackService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Service;

use OCA\LocalBase\Organization\AdOrganizationDefinition;
use OCA\LocalBase\Organization\AdOrganizationSettingsService;
use OCA\LocalBase\Service\DemoAccountProvisioningService;

/**
* Zweck: Installiert die neutralen Assistenzteams Team A, Team B und Team C mit lokalen Demokonten.
* Vertrag: Bestehende fremde Konten und read-only LDAP-Gruppen werden vom LocalBase-Preflight vor jeder Änderung abgewiesen.
*/
final class PlanerDemoPackService {
public function __construct(
private DemoAccountProvisioningService $accounts,
private TeamSettingsService $settings,
private ShiftConfigService $shiftConfig,
private ?AdOrganizationSettingsService $organization = null,
) {}

/** @return array{accounts:array,teams:list<string>} */
public function install(): array {
$definition = $this->organization?->definition() ?? AdOrganizationDefinition::defaults();
$teamCodes = ['A', 'B', 'C'];
$ebGroup = $definition->roleGroupId('eb');
$fixtures = [];
foreach ($teamCodes as $index => $teamCode) {
$teamGroup = $definition->teamGroupPrefix() . $teamCode;
$coordinatorNames = ['Enna Busch', 'Emil Weber', 'Eda Sommer'];
$fixtures[] = [
'uid' => 'ad-demo-eb-' . strtolower($teamCode),
'displayName' => $coordinatorNames[$index] . " (EB, Team {$teamCode})",
'groups' => [$ebGroup, $teamGroup],
];
foreach ([1, 2] as $number) {
$fixtures[] = [
'uid' => 'ad-demo-assistenz-' . strtolower($teamCode) . $number,
'displayName' => "Demo Assistenz {$teamCode}{$number} (Team {$teamCode})",
'groups' => [$teamGroup],
];
}
}

$accounts = $this->accounts->provision('ad-suite-demo', $fixtures);
foreach ($teamCodes as $teamCode) {
$this->settings->save($teamCode, "Team {$teamCode}", $this->shiftConfig->defaults());
}
return ['accounts' => $accounts, 'teams' => $teamCodes];
}
}
16 changes: 16 additions & 0 deletions lib/Settings/Admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Settings;

use OCA\AdPlaner\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings;

/** Zweck: Bindet app-spezifische Assistenzplaner-Administration in den Nextcloud-Adminbereich ein. */
final class Admin implements ISettings {
public function getForm(): TemplateResponse { return new TemplateResponse(Application::APP_ID, 'admin'); }
public function getSection(): string { return Application::APP_ID; }
public function getPriority(): int { return 30; }
}
18 changes: 18 additions & 0 deletions lib/Settings/AdminSection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace OCA\AdPlaner\Settings;

use OCA\AdPlaner\AppInfo\Application;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;

/** Zweck: Registriert den nur für Nextcloud-Admins sichtbaren Assistenzplaner-Abschnitt. */
final class AdminSection implements IIconSection {
public function __construct(private IURLGenerator $url) {}
public function getIcon(): string { return $this->url->imagePath(Application::APP_ID, 'app.svg'); }
public function getID(): string { return Application::APP_ID; }
public function getName(): string { return 'Assistenzplanung'; }
public function getPriority(): int { return 61; }
}
16 changes: 16 additions & 0 deletions templates/admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
\OCP\Util::addScript('localbase', 'api/api-client');
\OCP\Util::addScript('adplaner', 'admin');
\OCP\Util::addStyle('adplaner', 'admin');
?>
<section id="adplaner-admin" class="section adp-admin" aria-labelledby="adp-admin-heading">
<h2 id="adp-admin-heading">Assistenzplanung</h2>
<section class="adp-admin-panel" aria-labelledby="adp-demo-heading">
<h3 id="adp-demo-heading">Demo-Pack</h3>
<p>Das Pack legt Team A, Team B und Team C mit synthetischen Einsatzbegleitungen, Assistenzkräften und Standardschichten an. Es wird ausschließlich nach dieser Bestätigung installiert.</p>
<p>Fremde Konten und read-only LDAP-Gruppen werden vor der ersten Änderung abgewiesen.</p>
<p id="adp-demo-notice" class="adp-admin-notice" role="status" aria-live="polite" hidden></p>
<label class="adp-demo-confirm"><input id="adp-demo-confirm" type="checkbox"> Ich bestätige die Installation synthetischer Demodaten.</label>
<button id="adp-demo-install" type="button" class="primary" disabled>Planer-Demo-Pack installieren</button>
</section>
</section>
Loading