Hide inactive boards, keep source and target stack apart, make the interval configurable
Build package / php-lint (8.2) (push) Successful in 46s
Build package / php-lint (8.3) (push) Successful in 40s
Build package / php-lint (8.4) (push) Successful in 35s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Successful in 46s
Build package / package (push) Successful in 1m3s
Build package / php-lint (8.2) (push) Successful in 46s
Build package / php-lint (8.3) (push) Successful in 40s
Build package / php-lint (8.4) (push) Successful in 35s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Successful in 46s
Build package / package (push) Successful in 1m3s
- Boards: BoardService::getUserBoards() defaults to $includeArchived = true,
and that flag also gates the `deleted_at = 0` condition, so archived and
trashed boards showed up in the dropdown. Request the filtered query
instead, with a fallback to the no-arg call if the signature ever changes.
Stacks need nothing: StackMapper::findAll() always filters deleted_at.
- Each stack dropdown now hides whatever the other one holds, so source and
target can no longer be set to the same stack. The save-time check stays
as the backstop for rows stored before this rule.
- RunWorkflowsJob reads its interval from config.php
('workflow_deck_automation.interval', seconds, default 300, clamped to a
60s minimum). TimedJob re-reads the interval on every cron pass, so a
changed value takes effect without any occ command.
This commit is contained in:
@@ -18,6 +18,10 @@ Deck's ACL/permission checks read the *live* `IUserSession`, not a value frozen
|
|||||||
|
|
||||||
The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`.
|
The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`.
|
||||||
|
|
||||||
|
## Job interval comes from config.php
|
||||||
|
|
||||||
|
`RunWorkflowsJob` reads `workflow_deck_automation.interval` (seconds, default 300, clamped to a 60s minimum) via `IConfig::getSystemValueInt()` in its constructor. That works *because* `TimedJob` re-reads `$this->interval` on every cron pass from the freshly constructed job — so editing `config.php` takes effect immediately, with no `occ` command and no re-registration. Don't move this into `appinfo/info.xml` or a stored app config; the constants (`INTERVAL_CONFIG_KEY`, `DEFAULT_INTERVAL`, `MINIMUM_INTERVAL`) on the job are the single source of truth and are referenced from the README.
|
||||||
|
|
||||||
## Registration is declarative, not Bootstrap-based
|
## Registration is declarative, not Bootstrap-based
|
||||||
|
|
||||||
Background jobs and classic personal settings are registered in `appinfo/info.xml` (`<background-jobs>`, `<settings><personal>/<personal-section>`), **not** via `IRegistrationContext` in `lib/AppInfo/Application.php` — that interface has no `registerBackgroundJob()`/`registerSettings()` methods on NC 34. `Application.php` is intentionally near-empty.
|
Background jobs and classic personal settings are registered in `appinfo/info.xml` (`<background-jobs>`, `<settings><personal>/<personal-section>`), **not** via `IRegistrationContext` in `lib/AppInfo/Application.php` — that interface has no `registerBackgroundJob()`/`registerSettings()` methods on NC 34. `Application.php` is intentionally near-empty.
|
||||||
@@ -112,7 +116,9 @@ This app is on `@nextcloud/vue` ^9 / Vue 3. The v8 (Vue 2) prop spellings that m
|
|||||||
|
|
||||||
Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the settings UI's user dropdown came up empty *and* why the runner's assigned-user filter could never match. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it.
|
Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the settings UI's user dropdown came up empty *and* why the runner's assigned-user filter could never match. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it.
|
||||||
|
|
||||||
Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`. `BoardService::getUserBoards()` merges own/group/circle boards and can return the same board twice, and it includes archived and trashed boards — `listBoardsForCurrentUser()` dedupes by id and drops those.
|
Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`.
|
||||||
|
|
||||||
|
**`BoardService::getUserBoards()` defaults to `$includeArchived = true`, and that flag also gates the `deleted_at = 0` condition** — with the default you get archived *and* trashed boards back. We call `getUserBoards(null, false)` (with a fallback to the no-arg form if Deck ever changes the signature) and still dedupe by id afterwards, because it merges own/group/circle boards and can return the same board twice. Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state.
|
||||||
|
|
||||||
## Local-only notes
|
## Local-only notes
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder
|
|||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
- **Keine HTTP/OCS-Aufrufe gegen Deck.** Alles Lesen (Boards, Stapel, Karten, Labels, zugewiesene Benutzer) und das Verschieben von Karten läuft ausschließlich über Decks eigene interne PHP-Klassen (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), aufgelöst per Dependency Injection direkt im selben PHP-Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt.
|
- **Keine HTTP/OCS-Aufrufe gegen Deck.** Alles Lesen (Boards, Stapel, Karten, Labels, zugewiesene Benutzer) und das Verschieben von Karten läuft ausschließlich über Decks eigene interne PHP-Klassen (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), aufgelöst per Dependency Injection direkt im selben PHP-Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt.
|
||||||
- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft.
|
- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der standardmäßig alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft. Das Intervall ist über die `config.php` einstellbar, siehe unten.
|
||||||
- **Rechte-Kontext je Nutzer.** Da Decks Berechtigungsprüfungen die aktuell eingeloggte Session lesen, ein Hintergrundjob aber standardmäßig keinen eingeloggten Nutzer hat und Regeln mehrerer Nutzer in einem einzigen Lauf auswerten muss, "verkörpert" der `WorkflowRunner` für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer (`IUserSession::setUser()`), bevor die Deck-Klassen aufgerufen werden.
|
- **Rechte-Kontext je Nutzer.** Da Decks Berechtigungsprüfungen die aktuell eingeloggte Session lesen, ein Hintergrundjob aber standardmäßig keinen eingeloggten Nutzer hat und Regeln mehrerer Nutzer in einem einzigen Lauf auswerten muss, "verkörpert" der `WorkflowRunner` für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer (`IUserSession::setUser()`), bevor die Deck-Klassen aufgerufen werden.
|
||||||
- **E-Mail** läuft über Nextclouds eigenen `IMailer` (nutzt also den in der Nextcloud-Administration hinterlegten Mailserver) und geht an die im Profil des Workflow-Besitzers hinterlegte Adresse.
|
- **E-Mail** läuft über Nextclouds eigenen `IMailer` (nutzt also den in der Nextcloud-Administration hinterlegten Mailserver) und geht an die im Profil des Workflow-Besitzers hinterlegte Adresse.
|
||||||
|
|
||||||
@@ -36,7 +36,21 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder
|
|||||||
|
|
||||||
## Benutzung
|
## Benutzung
|
||||||
|
|
||||||
Jeder Nutzer findet die Einstellungen unter **Persönliche Einstellungen → Deck Workflow-Automatisierung**. Dort können neue Workflows angelegt, bestehende bearbeitet oder gelöscht werden. Die Dropdowns für Board/Stapel/Label/Benutzer werden live aus Deck geladen.
|
Jeder Nutzer findet die Einstellungen unter **Persönliche Einstellungen → Deck Workflow-Automatisierung**. Dort können neue Workflows angelegt, bestehende bearbeitet oder gelöscht werden. Die Dropdowns für Board/Stapel/Label/Benutzer werden live aus Deck geladen. Zur Auswahl stehen nur aktive Boards — archivierte und im Papierkorb liegende Boards sowie gelöschte Stapel blendet Deck bzw. die App aus.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Das Prüfintervall des Hintergrundjobs lässt sich in der `config.php` setzen (Wert in **Sekunden**):
|
||||||
|
|
||||||
|
```php
|
||||||
|
'workflow_deck_automation.interval' => 300,
|
||||||
|
```
|
||||||
|
|
||||||
|
- Standard ohne Eintrag: `300` (5 Minuten).
|
||||||
|
- Minimum: `60` — kleinere Werte werden auf 60 Sekunden angehoben.
|
||||||
|
- Die Änderung greift beim nächsten Cron-Durchlauf; ein `occ`-Befehl oder eine Neuinstallation der App ist nicht nötig.
|
||||||
|
|
||||||
|
Zu beachten: Das ist eine *Untergrenze* für den Abstand zwischen zwei Läufen, keine Garantie. Nextclouds Cron selbst läuft üblicherweise nur alle 5 Minuten — ein Intervall von 60 Sekunden führt also nur dann zu minütlichen Läufen, wenn der System-Cron entsprechend häufig ausgeführt wird.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,42 @@ use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
|||||||
use OCP\AppFramework\Utility\ITimeFactory;
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
use OCP\BackgroundJob\IJob;
|
use OCP\BackgroundJob\IJob;
|
||||||
use OCP\BackgroundJob\TimedJob;
|
use OCP\BackgroundJob\TimedJob;
|
||||||
|
use OCP\IConfig;
|
||||||
|
|
||||||
class RunWorkflowsJob extends TimedJob {
|
class RunWorkflowsJob extends TimedJob {
|
||||||
|
/**
|
||||||
|
* config.php key holding how often (in seconds) workflows are checked.
|
||||||
|
*/
|
||||||
|
public const INTERVAL_CONFIG_KEY = 'workflow_deck_automation.interval';
|
||||||
|
|
||||||
|
public const DEFAULT_INTERVAL = 5 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud's cron itself only ticks every 5 minutes by default, so
|
||||||
|
* anything below a minute would only add load without running more
|
||||||
|
* often. Values under this are clamped rather than rejected.
|
||||||
|
*/
|
||||||
|
public const MINIMUM_INTERVAL = 60;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ITimeFactory $time,
|
ITimeFactory $time,
|
||||||
|
IConfig $config,
|
||||||
private WorkflowRunner $runner,
|
private WorkflowRunner $runner,
|
||||||
) {
|
) {
|
||||||
parent::__construct($time);
|
parent::__construct($time);
|
||||||
$this->setInterval(5 * 60);
|
// TimedJob compares against this value on every cron pass, so a
|
||||||
|
// changed config.php takes effect on the next pass — no occ command
|
||||||
|
// and no re-registration of the job needed.
|
||||||
|
$this->setInterval(self::resolveInterval($config));
|
||||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||||
$this->setAllowParallelRuns(false);
|
$this->setAllowParallelRuns(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function resolveInterval(IConfig $config): int {
|
||||||
|
$interval = $config->getSystemValueInt(self::INTERVAL_CONFIG_KEY, self::DEFAULT_INTERVAL);
|
||||||
|
return max(self::MINIMUM_INTERVAL, $interval);
|
||||||
|
}
|
||||||
|
|
||||||
protected function run($argument): void {
|
protected function run($argument): void {
|
||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,19 @@ class DeckIntegrationService {
|
|||||||
*/
|
*/
|
||||||
public function listBoardsForCurrentUser(): array {
|
public function listBoardsForCurrentUser(): array {
|
||||||
$boards = $this->call(function () {
|
$boards = $this->call(function () {
|
||||||
return $this->resolve(BoardService::class)->getUserBoards();
|
$boardService = $this->resolve(BoardService::class);
|
||||||
|
try {
|
||||||
|
// getUserBoards(?int $since, bool $includeArchived, …): with
|
||||||
|
// the default $includeArchived = true, Deck skips the
|
||||||
|
// `archived = false AND deleted_at = 0` conditions entirely,
|
||||||
|
// so archived *and* trashed boards come back. Ask for the
|
||||||
|
// filtered query instead of sorting them out afterwards.
|
||||||
|
return $boardService->getUserBoards(null, false);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Older/newer Deck with a different signature: fall back to
|
||||||
|
// the unfiltered call, isBoardHidden() below still filters.
|
||||||
|
return $boardService->getUserBoards();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Deck merges own/group/circle boards, so the same board can come
|
// Deck merges own/group/circle boards, so the same board can come
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<NcSelect
|
<NcSelect
|
||||||
v-model="form.sourceStackId"
|
v-model="form.sourceStackId"
|
||||||
input-label="Quell-Stapel"
|
input-label="Quell-Stapel"
|
||||||
:options="stackOptions"
|
:options="sourceStackOptions"
|
||||||
:reduce="option => option.value"
|
:reduce="option => option.value"
|
||||||
:disabled="!form.boardId"
|
:disabled="!form.boardId"
|
||||||
placeholder="Quell-Stapel wählen" />
|
placeholder="Quell-Stapel wählen" />
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<NcSelect
|
<NcSelect
|
||||||
v-model="form.targetStackId"
|
v-model="form.targetStackId"
|
||||||
input-label="Ziel-Stapel"
|
input-label="Ziel-Stapel"
|
||||||
:options="stackOptions"
|
:options="targetStackOptions"
|
||||||
:reduce="option => option.value"
|
:reduce="option => option.value"
|
||||||
:disabled="!form.boardId"
|
:disabled="!form.boardId"
|
||||||
placeholder="Ziel-Stapel wählen" />
|
placeholder="Ziel-Stapel wählen" />
|
||||||
@@ -167,6 +167,12 @@ const participantOptions = ref([])
|
|||||||
|
|
||||||
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
|
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
|
||||||
|
|
||||||
|
// Source and target must differ, so each dropdown hides what the other one
|
||||||
|
// already holds — the error message on save stays as the backstop for
|
||||||
|
// workflows stored before this rule existed.
|
||||||
|
const sourceStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.targetStackId))
|
||||||
|
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
|
||||||
|
|
||||||
const form = reactive(emptyForm())
|
const form = reactive(emptyForm())
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
|
|||||||
Reference in New Issue
Block a user