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`.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder
|
||||
## 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,18 +8,42 @@ use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IConfig;
|
||||
|
||||
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(
|
||||
ITimeFactory $time,
|
||||
IConfig $config,
|
||||
private WorkflowRunner $runner,
|
||||
) {
|
||||
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->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 {
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
@@ -60,7 +60,19 @@ class DeckIntegrationService {
|
||||
*/
|
||||
public function listBoardsForCurrentUser(): array {
|
||||
$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
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<NcSelect
|
||||
v-model="form.sourceStackId"
|
||||
input-label="Quell-Stapel"
|
||||
:options="stackOptions"
|
||||
:options="sourceStackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
placeholder="Quell-Stapel wählen" />
|
||||
@@ -82,7 +82,7 @@
|
||||
<NcSelect
|
||||
v-model="form.targetStackId"
|
||||
input-label="Ziel-Stapel"
|
||||
:options="stackOptions"
|
||||
:options="targetStackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
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 })))
|
||||
|
||||
// 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())
|
||||
|
||||
function emptyForm() {
|
||||
|
||||
Reference in New Issue
Block a user