Compare commits
13 Commits
6e373dcdd6
...
c50ce58ad8
| Author | SHA1 | Date | |
|---|---|---|---|
| c50ce58ad8 | |||
| 344fc0077f | |||
| 28afc01371 | |||
| 1a29e75db4 | |||
| 57ec960f40 | |||
| 6388afdad8 | |||
| 1a0e90b22d | |||
| 7ad6f6328a | |||
| c79a7124b7 | |||
| abbef5a10b | |||
| 2f559e3f0b | |||
| 9b5418db78 | |||
| 1678602fd6 |
@@ -20,7 +20,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
|
|||||||
config.set_main_option("sqlalchemy.url", database_url)
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
# Migrations can run inside the long-lived application process when module
|
||||||
|
# state changes. Do not let Alembic's logging setup disable loggers that the
|
||||||
|
# server already created (for example slow-request diagnostics).
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
|
||||||
def _target_metadata():
|
def _target_metadata():
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of
|
|||||||
access/tenant ORM models when they need labels, group membership, default
|
access/tenant ORM models when they need labels, group membership, default
|
||||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||||
|
|
||||||
|
Other stable runtime capabilities currently include:
|
||||||
|
|
||||||
|
- `identity.directory` and `identity.search`
|
||||||
|
- `organizations.directory`
|
||||||
|
- `idm.directory`
|
||||||
|
- `calendar.outbox` and `calendar.scheduling`
|
||||||
|
- `poll.scheduling`
|
||||||
|
- `notifications.dispatch`
|
||||||
|
|
||||||
### Named Interface Contracts
|
### Named Interface Contracts
|
||||||
|
|
||||||
Capabilities are runtime objects. Named interface contracts are compatibility
|
Capabilities are runtime objects. Named interface contracts are compatibility
|
||||||
@@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
|||||||
allowed, but an installed provider with an incompatible version blocks
|
allowed, but an installed provider with an incompatible version blocks
|
||||||
activation because the integration would otherwise bind to an unsafe API.
|
activation because the integration would otherwise bind to an unsafe API.
|
||||||
|
|
||||||
Current named interfaces:
|
Current named interfaces, generated from the source manifests by the workspace
|
||||||
|
contract checks, are:
|
||||||
|
|
||||||
- `files.campaign_attachments`
|
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
||||||
|
- `calendar.outbox`, `calendar.scheduling`
|
||||||
|
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||||
|
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||||
|
`campaigns.retention`
|
||||||
|
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
|
||||||
|
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
|
||||||
|
- `files.access`, `files.campaign_attachments`
|
||||||
- `mail.campaign_delivery`
|
- `mail.campaign_delivery`
|
||||||
- `campaigns.access`
|
- `notifications.dispatch`
|
||||||
- `campaigns.delivery_tasks`
|
- `poll.availability_matrix`, `poll.option_selection`,
|
||||||
- `campaigns.mail_policy_context`
|
`poll.response_collection`, `poll.signed_participation`,
|
||||||
- `campaigns.policy_context`
|
`poll.workflow_context`
|
||||||
- `campaigns.retention`
|
- `rest.function_publication`
|
||||||
|
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
|
||||||
|
- `soap.operation_publication`
|
||||||
|
|
||||||
Core validates named interface contracts in three places:
|
Core validates named interface contracts in three places:
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
|
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
|
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
|
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
|
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
||||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
|
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
||||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
|
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
||||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
|
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
|
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
|
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
||||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
|
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
||||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
|
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
||||||
|
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
|
||||||
|
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
|
||||||
|
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
|
||||||
|
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
|
||||||
|
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
|
||||||
|
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
|
||||||
|
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
|
||||||
|
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
|
||||||
|
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## WebUI Packages
|
||||||
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
|
|||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The module repositories include root-level npm package manifests so git
|
Module repositories with a frontend include root-level npm package manifests
|
||||||
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
|
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||||
`@govoplan/files-webui`, `@govoplan/mail-webui`,
|
resolved from repository roots even though their source lives below
|
||||||
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
|
`webui/src`.
|
||||||
roots even though their source lives below `webui/src`.
|
|
||||||
|
|
||||||
### Release Lockfile Strategy
|
### Release Lockfile Strategy
|
||||||
|
|
||||||
The supported release composition currently is the full GovOPlaN product: core
|
The supported backend release composition is the set pinned in the meta
|
||||||
plus access, admin, tenancy, organizations, identity, policy, audit,
|
repository's `requirements-release.txt`. The supported frontend composition
|
||||||
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
|
is the independently buildable module set pinned in
|
||||||
full-product release lockfile at
|
`webui/package.release.json`; backend-only modules do not need a frontend
|
||||||
|
package entry. Keep one committed full-product release lockfile at
|
||||||
`webui/package-lock.release.json`, generated from
|
`webui/package-lock.release.json`, generated from
|
||||||
`webui/package.release.json` in a clean release workspace. Development
|
`webui/package.release.json` in a clean release workspace. Development
|
||||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||||
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan
|
cd /mnt/DATA/git/govoplan
|
||||||
tools/release/push-release-tag.sh --version 0.1.6
|
tools/release/push-release-tag.sh --version 0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
||||||
@@ -134,13 +143,11 @@ Current tag-only module repositories:
|
|||||||
- `govoplan-fit-connect`
|
- `govoplan-fit-connect`
|
||||||
- `govoplan-forms`
|
- `govoplan-forms`
|
||||||
- `govoplan-identity-trust`
|
- `govoplan-identity-trust`
|
||||||
- `govoplan-idm`
|
|
||||||
- `govoplan-ledger`
|
- `govoplan-ledger`
|
||||||
- `govoplan-notifications`
|
|
||||||
- `govoplan-payments`
|
- `govoplan-payments`
|
||||||
- `govoplan-portal`
|
- `govoplan-portal`
|
||||||
|
- `govoplan-postbox`
|
||||||
- `govoplan-reporting`
|
- `govoplan-reporting`
|
||||||
- `govoplan-scheduling`
|
|
||||||
- `govoplan-search`
|
- `govoplan-search`
|
||||||
- `govoplan-tasks`
|
- `govoplan-tasks`
|
||||||
- `govoplan-templates`
|
- `govoplan-templates`
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||||
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||||
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||||
|
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||||
|
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||||
|
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -160,6 +163,50 @@ shared CSS tokens and persisted user preference selection.
|
|||||||
- Visual preview in settings is illustrative; it must reflect token families,
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
not become a second theme implementation.
|
not become a second theme implementation.
|
||||||
|
|
||||||
|
### DUE-009: Central Component And Exception Contract
|
||||||
|
|
||||||
|
Decision: module interfaces are compositions of the components exported by
|
||||||
|
`@govoplan/core-webui`. When Core already owns the matching interaction, using
|
||||||
|
the central component is required rather than preferred.
|
||||||
|
|
||||||
|
A route or domain-specific page composed from central components is ordinary
|
||||||
|
module composition. A new reusable UI control, presentation primitive, or
|
||||||
|
module-local substitute is a custom component. Before one is implemented, the
|
||||||
|
product owner must explicitly authorize it and the owning decision or issue must
|
||||||
|
record:
|
||||||
|
|
||||||
|
- its single, narrowly defined purpose and intended consumers
|
||||||
|
- why central components or their composition cannot meet that purpose
|
||||||
|
- the permitted scope and the boundary it must not grow beyond
|
||||||
|
- accessibility, reachable states, theme behavior, and test expectations
|
||||||
|
- whether the component remains domain-owned or is a candidate for Core
|
||||||
|
|
||||||
|
Custom components must not duplicate, fork, or cosmetically replace a central
|
||||||
|
component. An existing local implementation does not grant an exception. If a
|
||||||
|
central contract later covers the need, migrate to it unless the product owner
|
||||||
|
explicitly retains the exception.
|
||||||
|
|
||||||
|
### DUE-010: Scheduling Request Reference Composition
|
||||||
|
|
||||||
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
|
universal placement and component rules.
|
||||||
|
|
||||||
|
- The `Scheduling requests` header owns one `Add` action.
|
||||||
|
- Its left panel is used for the creation view only, not as a permanent second
|
||||||
|
creation affordance.
|
||||||
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
|
use the central `Card` component as four logical sections.
|
||||||
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
|
standard row-action placement and order.
|
||||||
|
- Calendar integration uses the central `ToggleSwitch`, with its dependent
|
||||||
|
controls shown when enabled.
|
||||||
|
- Each participant is edited as one structured row with name, email address,
|
||||||
|
and actions. The normal editor does not parse a free-form list of addresses;
|
||||||
|
that interaction requires a separate, explicitly designed bulk-import flow.
|
||||||
|
|
||||||
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
|
not Scheduling-local component variants.
|
||||||
|
|
||||||
## Implementation Sequence
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| Phase | Scope | Output |
|
||||||
@@ -222,6 +269,14 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Does the action surface show consequence, reversibility, and audit evidence
|
- Does the action surface show consequence, reversibility, and audit evidence
|
||||||
when rights, duties, records, money, communication, external systems, or
|
when rights, duties, records, money, communication, external systems, or
|
||||||
workflow state are affected?
|
workflow state are affected?
|
||||||
|
- Does the surface use every applicable central Core component? If it contains
|
||||||
|
a custom component, is the product-owner authorization, narrow purpose,
|
||||||
|
rationale, scope, and non-duplication evidence recorded?
|
||||||
|
- Is a collection-wide create action in the collection heading rather than
|
||||||
|
duplicated in a persistent side panel?
|
||||||
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
|
structured row per person respectively?
|
||||||
- If automation is involved, can the user see the trigger, system actor,
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
observed effects, and failure/manual-intervention state?
|
observed effects, and failure/manual-intervention state?
|
||||||
- Are technical details available without being the first thing the user sees?
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|||||||
@@ -83,6 +83,90 @@
|
|||||||
"release": "0.1.7",
|
"release": "0.1.7",
|
||||||
"track": "release",
|
"track": "release",
|
||||||
"squash_policy": "reviewed-manual"
|
"squash_policy": "reviewed-manual"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "2c3d4e5f7081"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "3d4e5f708192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "9e0f1a2b3c4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"9e0f1a2b3c4d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"2c3d4e5f7081"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"3d4e5f708192"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
|
"release": "0.1.8",
|
||||||
|
"track": "release",
|
||||||
|
"squash_policy": "reviewed-manual"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ def deliver_notification(self, notification_id: str):
|
|||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
|
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
|
||||||
@@ -129,7 +131,9 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
|
|||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
|
|||||||
|
|
||||||
IDENTITY_MODULE_ID = "identity"
|
IDENTITY_MODULE_ID = "identity"
|
||||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||||
|
CAPABILITY_IDENTITY_SEARCH = "identity.search"
|
||||||
|
|
||||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||||
|
|
||||||
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
|
|||||||
|
|
||||||
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentitySearchProvider(Protocol):
|
||||||
|
def search_identities(
|
||||||
|
self,
|
||||||
|
query: str | None = None,
|
||||||
|
*,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[IdentityRef]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_chec
|
|||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
|
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||||
|
|
||||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||||
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||||
|
|
||||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||||
|
|
||||||
@@ -126,6 +128,55 @@ class PolicyDecision:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulingParticipantPrivacyRequest:
|
||||||
|
"""Context for resolving what one Scheduling participant may see.
|
||||||
|
|
||||||
|
``requested_visibility`` is the Scheduling-owned configuration result. A
|
||||||
|
policy provider may preserve or reduce it, but must not broaden it. Core
|
||||||
|
deliberately defines no fallback when the optional capability is absent;
|
||||||
|
that secure default remains the responsibility of Scheduling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
scheduling_request_id: str
|
||||||
|
participant_id: str
|
||||||
|
requested_visibility: SchedulingParticipantVisibility
|
||||||
|
actor_user_id: str | None = None
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulingParticipantPrivacyDecision:
|
||||||
|
"""Policy-resolved visibility of other participants' names and statuses."""
|
||||||
|
|
||||||
|
effective_visibility: SchedulingParticipantVisibility
|
||||||
|
reason: str | None = None
|
||||||
|
source_path: tuple[PolicySourceStep, ...] = ()
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"effective_visibility": self.effective_visibility,
|
||||||
|
"reason": self.reason,
|
||||||
|
"source_path": [step.to_dict() for step in self.source_path],
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SchedulingParticipantPrivacyPolicy(Protocol):
|
||||||
|
"""Optional policy hook for Scheduling participant-roster disclosure."""
|
||||||
|
|
||||||
|
def resolve_scheduling_participant_visibility(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SchedulingParticipantPrivacyRequest,
|
||||||
|
) -> SchedulingParticipantPrivacyDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PrivacyRetentionService(Protocol):
|
class PrivacyRetentionService(Protocol):
|
||||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
@@ -160,3 +211,16 @@ class PrivacyRetentionService(Protocol):
|
|||||||
|
|
||||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def scheduling_participant_privacy_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SchedulingParticipantPrivacyPolicy | None:
|
||||||
|
"""Return the optional provider without selecting a visibility fallback."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
||||||
|
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ class PollUpdateCommand:
|
|||||||
closes_at: datetime | None
|
closes_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionUpdateCommand:
|
||||||
|
"""Complete mutable option snapshot supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must treat an exact repeat as a no-op. When the snapshot
|
||||||
|
changes, answers for this option are invalidated while answers for other
|
||||||
|
options are retained.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollInvitationCommand:
|
class PollInvitationCommand:
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
@@ -102,6 +117,14 @@ class PollInvitationRef:
|
|||||||
token: str
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRef:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollResponseRef:
|
class PollResponseRef:
|
||||||
invitation_id: str | None
|
invitation_id: str | None
|
||||||
@@ -110,6 +133,9 @@ class PollResponseRef:
|
|||||||
# consumer can use it to reconcile an authenticated response without
|
# consumer can use it to reconcile an authenticated response without
|
||||||
# trusting client-supplied invitation metadata.
|
# trusting client-supplied invitation metadata.
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
|
# Optional for backwards compatibility. Providers that support response
|
||||||
|
# editing expose the authoritative, still-valid answers here.
|
||||||
|
answers: tuple[PollAnswerRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -144,6 +170,19 @@ class PollSchedulingProvider(Protocol):
|
|||||||
) -> PollRef:
|
) -> PollRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def update_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str,
|
||||||
|
command: PollOptionUpdateCommand,
|
||||||
|
) -> PollOptionRef:
|
||||||
|
"""Update one option and invalidate only answers for that option."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -183,6 +222,19 @@ class PollSchedulingProvider(Protocol):
|
|||||||
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: Sequence[str],
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> PollResponseRef | None:
|
||||||
|
"""Return one response matching server-trusted participant identities."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PollResponseSubmissionProvider(Protocol):
|
class PollResponseSubmissionProvider(Protocol):
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
|
|||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.app import create_app
|
|
||||||
from govoplan_core.server.config import GovoplanServerConfig
|
from govoplan_core.server.config import GovoplanServerConfig
|
||||||
from govoplan_core.tenancy.scope import create_scope_tables
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
@@ -1382,6 +1381,8 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
app_configurators=(),
|
app_configurators=(),
|
||||||
)
|
)
|
||||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||||
|
from govoplan_core.server.app import create_app
|
||||||
|
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
|
|
||||||
create_scope_tables(database.engine)
|
create_scope_tables(database.engine)
|
||||||
|
|||||||
@@ -26,10 +26,21 @@ os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files")
|
|||||||
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
|
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
|
||||||
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
|
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
|
||||||
|
|
||||||
|
from govoplan_core.settings import Settings, settings as core_settings
|
||||||
|
|
||||||
|
# unittest discovery imports other contract modules before this smoke module.
|
||||||
|
# Refresh the shared settings object in place so modules that already imported
|
||||||
|
# it observe this test's isolated environment before the default app is built.
|
||||||
|
_isolated_settings = Settings()
|
||||||
|
for _field_name in Settings.model_fields:
|
||||||
|
setattr(core_settings, _field_name, getattr(_isolated_settings, _field_name))
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
||||||
|
from govoplan_core.db.migrations import alembic_config
|
||||||
from govoplan_core.db.session import configure_database, set_database
|
from govoplan_core.db.session import configure_database, set_database
|
||||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
||||||
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
||||||
@@ -61,6 +72,16 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
create_scope_tables(engine)
|
create_scope_tables(engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# This suite builds the current ORM schema directly. Record the matching
|
||||||
|
# release heads so module-lifecycle tests can safely run Alembic without
|
||||||
|
# misclassifying the create_all database as a historic dev revision.
|
||||||
|
if not getattr(type(self), "_migration_heads_stamped", False):
|
||||||
|
command.stamp(
|
||||||
|
alembic_config(database_url=os.environ["DATABASE_URL"]),
|
||||||
|
"heads",
|
||||||
|
purge=True,
|
||||||
|
)
|
||||||
|
type(self)._migration_heads_stamped = True
|
||||||
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
||||||
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
@@ -310,6 +331,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
|
roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
|
||||||
self.assertEqual(roles.status_code, 200, roles.text)
|
self.assertEqual(roles.status_code, 200, roles.text)
|
||||||
system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
|
system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
|
||||||
|
maintenance_operator = next(item for item in roles.json()["roles"] if item["slug"] == "maintenance_operator")
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
"/api/v1/admin/system/accounts",
|
"/api/v1/admin/system/accounts",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -319,7 +341,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"password": "approver-password",
|
"password": "approver-password",
|
||||||
"password_reset_required": False,
|
"password_reset_required": False,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"role_ids": [system_admin["id"]],
|
"role_ids": [system_admin["id"], maintenance_operator["id"]],
|
||||||
"memberships": [{
|
"memberships": [{
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
@@ -3432,6 +3454,127 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"202605-010001-90100010-9601741.XLSX",
|
"202605-010001-90100010-9601741.XLSX",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
||||||
|
headers, login = self._login()
|
||||||
|
user_id = login["user"]["id"]
|
||||||
|
campaign_json = {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
||||||
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {
|
||||||
|
"smtp": {
|
||||||
|
"host": "smtp.example.invalid",
|
||||||
|
"port": 587,
|
||||||
|
"username": "sender@example.org",
|
||||||
|
"password": "test-secret",
|
||||||
|
"security": "starttls",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
|
"allow_individual_to": True,
|
||||||
|
},
|
||||||
|
"template": {"subject": "Invoice", "text": "Please see the attached file."},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": "invoices",
|
||||||
|
"base_paths": [
|
||||||
|
{
|
||||||
|
"id": "personal-invoices",
|
||||||
|
"name": "My invoices",
|
||||||
|
"source": f"managed:user:{user_id}",
|
||||||
|
"path": "invoices",
|
||||||
|
"allow_individual": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"global": [],
|
||||||
|
"allow_individual": True,
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
|
||||||
|
"fields": {"invoice_number": "202605-010002"},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "exact-pattern",
|
||||||
|
"label": "Exact workbook",
|
||||||
|
"base_path_id": "personal-invoices",
|
||||||
|
"base_dir": "invoices",
|
||||||
|
"file_filter": "{{local:invoice_number}}.XLSX",
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validation_policy": {
|
||||||
|
"missing_email": "block",
|
||||||
|
"template_error": "block",
|
||||||
|
"missing_required_attachment": "block",
|
||||||
|
},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
}
|
||||||
|
|
||||||
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
|
self.assertEqual(created.status_code, 200, created.text)
|
||||||
|
campaign_id = created.json()["campaign"]["id"]
|
||||||
|
version_id = created.json()["version"]["id"]
|
||||||
|
|
||||||
|
uploaded = self.client.post(
|
||||||
|
"/api/v1/files/upload",
|
||||||
|
headers=headers,
|
||||||
|
data={
|
||||||
|
"owner_type": "user",
|
||||||
|
"owner_id": user_id,
|
||||||
|
"path": "invoices",
|
||||||
|
},
|
||||||
|
files=[("files", ("202605-010002.XLSX", b"unlinked workbook", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
|
||||||
|
)
|
||||||
|
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
||||||
|
|
||||||
|
preview = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||||
|
headers=headers,
|
||||||
|
json={"include_unmatched": True, "include_unlinked_candidates": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(preview.status_code, 200, preview.text)
|
||||||
|
preview_payload = preview.json()
|
||||||
|
self.assertEqual(preview_payload["matched_file_count"], 1)
|
||||||
|
self.assertEqual(preview_payload["unlinked_file_count"], 1)
|
||||||
|
self.assertEqual(preview_payload["rules"][0]["unlinked_match_count"], 1)
|
||||||
|
self.assertFalse(preview_payload["rules"][0]["matches"][0]["linked_to_campaign"])
|
||||||
|
|
||||||
|
dry_run = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/link-matches",
|
||||||
|
headers=headers,
|
||||||
|
json={"dry_run": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(dry_run.status_code, 200, dry_run.text)
|
||||||
|
self.assertEqual(dry_run.json()["linked_file_count"], 0)
|
||||||
|
self.assertEqual(len(dry_run.json()["linkable_files"]), 1)
|
||||||
|
|
||||||
|
validated = self.client.post(
|
||||||
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
||||||
|
headers=headers,
|
||||||
|
json={"check_files": True, "link_unshared_matches": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(validated.status_code, 200, validated.text)
|
||||||
|
self.assertTrue(validated.json()["ok"], validated.text)
|
||||||
|
|
||||||
|
linked_preview = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||||
|
headers=headers,
|
||||||
|
json={"include_unmatched": True, "include_unlinked_candidates": True},
|
||||||
|
)
|
||||||
|
self.assertEqual(linked_preview.status_code, 200, linked_preview.text)
|
||||||
|
linked_payload = linked_preview.json()
|
||||||
|
self.assertEqual(linked_payload["matched_file_count"], 1)
|
||||||
|
self.assertEqual(linked_payload["linked_file_count"], 1)
|
||||||
|
self.assertEqual(linked_payload["unlinked_file_count"], 0)
|
||||||
|
self.assertTrue(linked_payload["rules"][0]["matches"][0]["linked_to_campaign"])
|
||||||
|
|
||||||
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
|
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
@@ -5648,7 +5791,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
password="reader-password",
|
password="reader-password",
|
||||||
role_ids=[str(access_role["id"])],
|
role_ids=[str(access_role["id"])],
|
||||||
)
|
)
|
||||||
other = self._create_user(
|
self._create_user(
|
||||||
owner_headers,
|
owner_headers,
|
||||||
email="campaign-other@example.local",
|
email="campaign-other@example.local",
|
||||||
password="other-password",
|
password="other-password",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
class DatabaseMigrationTests(unittest.TestCase):
|
class DatabaseMigrationTests(unittest.TestCase):
|
||||||
|
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
|
||||||
|
logger = logging.getLogger("govoplan.request")
|
||||||
|
previous_disabled = logger.disabled
|
||||||
|
logger.disabled = False
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
|
||||||
|
database = Path(directory) / "logging.db"
|
||||||
|
command.stamp(
|
||||||
|
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
|
||||||
|
"heads",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(logger.disabled)
|
||||||
|
finally:
|
||||||
|
logger.disabled = previous_disabled
|
||||||
|
|
||||||
def test_migration_tracks_use_separate_version_locations(self) -> None:
|
def test_migration_tracks_use_separate_version_locations(self) -> None:
|
||||||
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
|
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
|
||||||
dev_locations = alembic_config(
|
dev_locations = alembic_config(
|
||||||
@@ -184,6 +201,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(result.reconciled_revision)
|
self.assertIsNone(result.reconciled_revision)
|
||||||
self.assertEqual(current, configured_migration_heads(url))
|
self.assertEqual(current, configured_migration_heads(url))
|
||||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||||
|
self.assertIn("calendar_outbox_operations", tables)
|
||||||
self.assertIn("calendar_sync_credentials", tables)
|
self.assertIn("calendar_sync_credentials", tables)
|
||||||
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
||||||
self.assertIn("file_connector_credentials", tables)
|
self.assertIn("file_connector_credentials", tables)
|
||||||
@@ -216,6 +234,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||||
self.assertIn("0f1e2d3c4b5a", current)
|
self.assertIn("0f1e2d3c4b5a", current)
|
||||||
self.assertIn("audit_outbox_events", tables)
|
self.assertIn("audit_outbox_events", tables)
|
||||||
|
self.assertIn("calendar_outbox_operations", tables)
|
||||||
self.assertIn("file_connector_profiles", tables)
|
self.assertIn("file_connector_profiles", tables)
|
||||||
self.assertIn("core_scopes", tables)
|
self.assertIn("core_scopes", tables)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
|||||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||||
|
|
||||||
identity_directory = SqlIdentityDirectory()
|
identity_directory = SqlIdentityDirectory()
|
||||||
idm_directory = SqlIdmDirectory()
|
|
||||||
organization_directory = SqlOrganizationDirectory()
|
organization_directory = SqlOrganizationDirectory()
|
||||||
|
idm_directory = SqlIdmDirectory(
|
||||||
|
identities=identity_directory,
|
||||||
|
organizations=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
||||||
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
|
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User
|
|||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
|
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
|
||||||
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||||
from govoplan_idm.backend.api.v1.routes import router as idm_router
|
from govoplan_idm.backend.api.v1.routes import router as idm_router
|
||||||
from govoplan_idm.backend.manifest import get_manifest
|
from govoplan_idm.backend.manifest import get_manifest
|
||||||
|
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||||
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
|
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
|
||||||
from tests.db_isolation import temporary_database
|
from tests.db_isolation import temporary_database
|
||||||
|
|
||||||
@@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _app(self, scopes: set[str]) -> FastAPI:
|
def _app(self, scopes: set[str]) -> FastAPI:
|
||||||
|
identity_directory = SqlIdentityDirectory()
|
||||||
|
organization_directory = SqlOrganizationDirectory()
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="identity",
|
||||||
|
name="Identity",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
|
||||||
|
CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="organizations",
|
||||||
|
name="Organizations",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context = ModuleContext(registry=registry, settings=object())
|
||||||
|
registry.configure_capability_context(context)
|
||||||
|
configure_runtime(context)
|
||||||
|
self.addCleanup(clear_runtime)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
app.include_router(idm_router, prefix="/api/v1")
|
app.include_router(idm_router, prefix="/api/v1")
|
||||||
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
|
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
|
||||||
return app
|
return app
|
||||||
|
|||||||
36
tests/test_poll_contract.py
Normal file
36
tests/test_poll_contract.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
||||||
|
|
||||||
|
|
||||||
|
class PollContractTests(unittest.TestCase):
|
||||||
|
def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None:
|
||||||
|
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
legacy = PollResponseRef(None, submitted_at)
|
||||||
|
detailed = PollResponseRef(
|
||||||
|
invitation_id="invitation-1",
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(legacy.answers, ())
|
||||||
|
self.assertEqual(detailed.answers[0].value, "available")
|
||||||
|
|
||||||
|
def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None:
|
||||||
|
command = PollOptionUpdateCommand(
|
||||||
|
label="Tuesday 10:00",
|
||||||
|
value={"slot_id": "slot-1"},
|
||||||
|
metadata={"source": "scheduling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(command.label, "Tuesday 10:00")
|
||||||
|
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
SCRIPT = ROOT / "scripts" / "release-doctor.py"
|
|
||||||
|
|
||||||
|
|
||||||
def load_doctor_module():
|
|
||||||
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseDoctorTests(unittest.TestCase):
|
|
||||||
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
|
|
||||||
names = set(doctor.release_repo_names(ROOT.parent))
|
|
||||||
|
|
||||||
self.assertIn("govoplan-core", names)
|
|
||||||
self.assertIn("govoplan-files", names)
|
|
||||||
self.assertIn("govoplan-idm", names)
|
|
||||||
|
|
||||||
def test_repo_findings_detect_dirty_repositories(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
repo = doctor.RepoState(
|
|
||||||
name="govoplan-files",
|
|
||||||
path="/tmp/govoplan-files",
|
|
||||||
exists=True,
|
|
||||||
dirty_entries=(" M pyproject.toml",),
|
|
||||||
pyproject_version="0.1.7",
|
|
||||||
local_tag_exists=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
|
||||||
|
|
||||||
self.assertEqual("blocker", findings[0].severity)
|
|
||||||
self.assertEqual("repo-dirty", findings[0].check_id)
|
|
||||||
self.assertIn("git status --short", [item.command for item in findings[0].commands])
|
|
||||||
|
|
||||||
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
repo = doctor.RepoState(
|
|
||||||
name="govoplan-files",
|
|
||||||
path="/tmp/govoplan-files",
|
|
||||||
exists=True,
|
|
||||||
safe_directory_required=True,
|
|
||||||
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
|
|
||||||
errors=("fatal: detected dubious ownership in repository",),
|
|
||||||
)
|
|
||||||
|
|
||||||
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
|
||||||
|
|
||||||
self.assertEqual("repo-safe-directory", findings[0].check_id)
|
|
||||||
self.assertEqual("blocker", findings[0].severity)
|
|
||||||
self.assertTrue(findings[0].commands[0].mutating)
|
|
||||||
self.assertIn("safe.directory", findings[0].commands[0].command)
|
|
||||||
|
|
||||||
def test_dubious_ownership_detection_matches_git_error(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
doctor.is_dubious_ownership_error(
|
|
||||||
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
|
|
||||||
"git config --global --add safe.directory /workspace/repo"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
|
|
||||||
findings = doctor.migration_findings(
|
|
||||||
{
|
|
||||||
"release": {
|
|
||||||
"ok": True,
|
|
||||||
"graph_errors": [],
|
|
||||||
"strict_errors": ["current migration heads are not recorded"],
|
|
||||||
},
|
|
||||||
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
|
|
||||||
},
|
|
||||||
target_version="0.2.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual("migration-release-baseline", findings[0].check_id)
|
|
||||||
self.assertEqual("blocker", findings[0].severity)
|
|
||||||
commands = [item.command for item in findings[0].commands]
|
|
||||||
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
|
|
||||||
|
|
||||||
def test_catalog_findings_detect_missing_signatures(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
|
|
||||||
findings = doctor.catalog_findings(
|
|
||||||
{
|
|
||||||
"catalog_exists": True,
|
|
||||||
"catalog_path": "/tmp/stable.json",
|
|
||||||
"core_release_version": "0.1.7",
|
|
||||||
"signature_count": 0,
|
|
||||||
"keyring_exists": True,
|
|
||||||
"key_count": 1,
|
|
||||||
},
|
|
||||||
target_version="0.1.7",
|
|
||||||
online=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual("catalog-signatures", findings[0].check_id)
|
|
||||||
self.assertEqual("blocker", findings[0].severity)
|
|
||||||
|
|
||||||
def test_suggested_commands_are_deduplicated(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
|
|
||||||
report = doctor.DoctorReport(
|
|
||||||
generated_at="2026-07-11T00:00:00Z",
|
|
||||||
workspace_root="/tmp",
|
|
||||||
target_version="0.1.7",
|
|
||||||
target_tag="v0.1.7",
|
|
||||||
online=False,
|
|
||||||
overall_status="blocked",
|
|
||||||
repos=(),
|
|
||||||
findings=(
|
|
||||||
doctor.Finding("a", "blocker", "A", "", (suggested,)),
|
|
||||||
doctor.Finding("b", "warning", "B", "", (suggested,)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
|
|
||||||
|
|
||||||
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
repo = doctor.RepoState(
|
|
||||||
name="govoplan-files",
|
|
||||||
path="/tmp/govoplan-files",
|
|
||||||
exists=True,
|
|
||||||
dirty_entries=(" M pyproject.toml",),
|
|
||||||
)
|
|
||||||
report = doctor.DoctorReport(
|
|
||||||
generated_at="2026-07-11T00:00:00Z",
|
|
||||||
workspace_root="/tmp",
|
|
||||||
target_version="0.1.7",
|
|
||||||
target_tag="v0.1.7",
|
|
||||||
online=False,
|
|
||||||
overall_status="blocked",
|
|
||||||
repos=(repo,),
|
|
||||||
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
|
|
||||||
)
|
|
||||||
|
|
||||||
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
|
|
||||||
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
|
|
||||||
|
|
||||||
self.assertIn("dirty=1", concise)
|
|
||||||
self.assertNotIn("M pyproject.toml", concise)
|
|
||||||
self.assertIn("M pyproject.toml", detailed)
|
|
||||||
|
|
||||||
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
|
|
||||||
doctor = load_doctor_module()
|
|
||||||
repo = doctor.RepoState(
|
|
||||||
name="govoplan-files",
|
|
||||||
path="/tmp/govoplan-files",
|
|
||||||
exists=True,
|
|
||||||
dirty_entries=(" M pyproject.toml",),
|
|
||||||
)
|
|
||||||
report = doctor.DoctorReport(
|
|
||||||
generated_at="2026-07-11T00:00:00Z",
|
|
||||||
workspace_root="/tmp",
|
|
||||||
target_version="0.1.7",
|
|
||||||
target_tag="v0.1.7",
|
|
||||||
online=False,
|
|
||||||
overall_status="blocked",
|
|
||||||
repos=(repo,),
|
|
||||||
findings=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
actions = dict(doctor.interactive_actions(report))
|
|
||||||
|
|
||||||
self.assertIn("commit-push-dirty", actions)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
|
|
||||||
|
|
||||||
|
|
||||||
def load_audit_module():
|
|
||||||
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseMigrationAuditTests(unittest.TestCase):
|
|
||||||
def test_typed_alembic_variables_are_parsed(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
|
||||||
path = Path(directory) / "1234_example.py"
|
|
||||||
path.write_text(
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
revision: str = "1234"
|
|
||||||
down_revision: Union[str, None] = "base"
|
|
||||||
depends_on: Union[str, None] = "core"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
migration = audit.parse_migration_file("govoplan-core", path)
|
|
||||||
|
|
||||||
self.assertIsNotNone(migration)
|
|
||||||
self.assertEqual(migration.revision, "1234")
|
|
||||||
self.assertEqual(migration.down_revisions, ("base",))
|
|
||||||
self.assertEqual(migration.depends_on, ("core",))
|
|
||||||
self.assertEqual(migration.branch_labels, ())
|
|
||||||
|
|
||||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
|
||||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={
|
|
||||||
"version": 1,
|
|
||||||
"releases": [
|
|
||||||
{
|
|
||||||
"release": "0.1.0",
|
|
||||||
"heads": [{"owner": "govoplan-core", "revision": "head"}],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(report["current_heads"], ["head"])
|
|
||||||
self.assertEqual(report["graph_errors"], [])
|
|
||||||
self.assertEqual(report["strict_errors"], [])
|
|
||||||
|
|
||||||
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
|
||||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={
|
|
||||||
"version": 1,
|
|
||||||
"releases": [
|
|
||||||
{
|
|
||||||
"release": "0.1.0",
|
|
||||||
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
|
|
||||||
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(report["current_heads"], ["core-head"])
|
|
||||||
self.assertEqual(report["strict_errors"], [])
|
|
||||||
|
|
||||||
def test_records_current_release_baseline(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
|
||||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
|
||||||
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={"version": 1, "releases": []},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
baseline = audit.record_release_baseline(
|
|
||||||
{"version": 1, "releases": []},
|
|
||||||
report,
|
|
||||||
release="0.2.0",
|
|
||||||
replace=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
release = baseline["releases"][0]
|
|
||||||
self.assertEqual(release["release"], "0.2.0")
|
|
||||||
self.assertEqual(release["squash_policy"], "reviewed-manual")
|
|
||||||
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
|
|
||||||
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
|
|
||||||
|
|
||||||
def test_missing_down_revision_is_a_graph_error(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={"version": 1, "releases": []},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("references missing down_revision", report["graph_errors"][0])
|
|
||||||
|
|
||||||
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
|
|
||||||
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={"version": 1, "releases": []},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(report["graph_errors"], [])
|
|
||||||
self.assertEqual(report["current_heads"], ["files"])
|
|
||||||
|
|
||||||
def test_missing_depends_on_is_a_graph_error(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={"version": 1, "releases": []},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("references missing depends_on", report["graph_errors"][0])
|
|
||||||
|
|
||||||
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
|
|
||||||
audit = load_audit_module()
|
|
||||||
migrations = [
|
|
||||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
|
||||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
|
||||||
]
|
|
||||||
report = audit.build_report(
|
|
||||||
migrations,
|
|
||||||
baseline={"version": 1, "releases": []},
|
|
||||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
|
||||||
workspace_root=Path("/workspace"),
|
|
||||||
track="dev",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(report["track"], "dev")
|
|
||||||
self.assertEqual(report["strict_errors"], [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
95
tests/test_scheduling_privacy_contract.py
Normal file
95
tests/test_scheduling_privacy_contract.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||||
|
PolicySourceStep,
|
||||||
|
SchedulingParticipantPrivacyDecision,
|
||||||
|
SchedulingParticipantPrivacyPolicy,
|
||||||
|
SchedulingParticipantPrivacyRequest,
|
||||||
|
scheduling_participant_privacy_policy,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _PrivacyPolicy:
|
||||||
|
def resolve_scheduling_participant_visibility(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SchedulingParticipantPrivacyRequest,
|
||||||
|
) -> SchedulingParticipantPrivacyDecision:
|
||||||
|
del session
|
||||||
|
return SchedulingParticipantPrivacyDecision(
|
||||||
|
effective_visibility="aggregates_only",
|
||||||
|
reason=f"Participant roster is restricted for {request.scheduling_request_id}.",
|
||||||
|
details={"requested_visibility": request.requested_visibility},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingPrivacyContractTests(unittest.TestCase):
|
||||||
|
def test_capability_name_and_request_context_are_stable(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||||
|
"policy.schedulingParticipantPrivacy",
|
||||||
|
)
|
||||||
|
request = SchedulingParticipantPrivacyRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scheduling_request_id="request-1",
|
||||||
|
participant_id="participant-1",
|
||||||
|
actor_user_id="user-1",
|
||||||
|
requested_visibility="names_and_statuses",
|
||||||
|
context={"request_status": "open"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(request.requested_visibility, "names_and_statuses")
|
||||||
|
self.assertEqual(request.context["request_status"], "open")
|
||||||
|
|
||||||
|
def test_decision_serializes_policy_provenance(self) -> None:
|
||||||
|
decision = SchedulingParticipantPrivacyDecision(
|
||||||
|
effective_visibility="aggregates_only",
|
||||||
|
reason="Tenant privacy policy restricts the participant roster.",
|
||||||
|
source_path=(
|
||||||
|
PolicySourceStep(
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
label="Tenant",
|
||||||
|
applied_fields=("scheduling_participant_visibility",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
details={"requested_visibility": "names_and_statuses"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = decision.to_dict()
|
||||||
|
self.assertEqual(payload["effective_visibility"], "aggregates_only")
|
||||||
|
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
|
||||||
|
self.assertEqual(payload["details"]["requested_visibility"], "names_and_statuses")
|
||||||
|
|
||||||
|
def test_optional_provider_resolves_through_platform_registry(self) -> None:
|
||||||
|
provider = _PrivacyPolicy()
|
||||||
|
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="policy_test",
|
||||||
|
name="Policy test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
self.assertIs(scheduling_participant_privacy_policy(registry), provider)
|
||||||
|
|
||||||
|
def test_optional_provider_lookup_does_not_choose_a_fallback(self) -> None:
|
||||||
|
self.assertIsNone(scheduling_participant_privacy_policy(None))
|
||||||
|
self.assertIsNone(scheduling_participant_privacy_policy(PlatformRegistry()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
|
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
@@ -32,6 +32,7 @@ export default function App() {
|
|||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||||
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
const [reloginMessage, setReloginMessage] = useState("");
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchPlatformStatus(settings);
|
const response = await fetchPlatformStatus(settings);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setBackendReachable(true);
|
||||||
setMaintenanceMode(response.maintenance_mode);
|
setMaintenanceMode(response.maintenance_mode);
|
||||||
if (response.i18n) {
|
if (response.i18n) {
|
||||||
setSystemLanguages({
|
setSystemLanguages({
|
||||||
@@ -100,7 +102,10 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
|
if (!cancelled) {
|
||||||
|
setBackendReachable(false);
|
||||||
|
setMaintenanceMode({ enabled: false, message: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +126,13 @@ export default function App() {
|
|||||||
async function bootstrapAuth() {
|
async function bootstrapAuth() {
|
||||||
try {
|
try {
|
||||||
const shellAuth = await fetchShellAuth(settings);
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
setBackendReachable(true);
|
||||||
|
setAuth(normalizeAuthInfo(shellAuth));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBackendReachable(isApiError(error));
|
||||||
const cleared = { ...settings, accessToken: "" };
|
const cleared = { ...settings, accessToken: "" };
|
||||||
setSettings(cleared);
|
setSettings(cleared);
|
||||||
saveApiSettings(cleared);
|
saveApiSettings(cleared);
|
||||||
@@ -243,6 +252,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const sessionInfo = await fetchSession(settings);
|
const sessionInfo = await fetchSession(settings);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setBackendReachable(true);
|
||||||
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
||||||
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
||||||
const shellAuth = await fetchShellAuth(settings);
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
@@ -252,7 +262,10 @@ export default function App() {
|
|||||||
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
||||||
: normalizeAuthInfo(shellAuth));
|
: normalizeAuthInfo(shellAuth));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (!cancelled && !isApiError(error)) {
|
||||||
|
setBackendReachable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// A background refresh must not log the user out on a transient network error.
|
// A background refresh must not log the user out on a transient network error.
|
||||||
@@ -273,7 +286,7 @@ export default function App() {
|
|||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
<section className="public-card">
|
<section className="public-card">
|
||||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||||
@@ -293,7 +306,7 @@ export default function App() {
|
|||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
@@ -324,7 +337,7 @@ export default function App() {
|
|||||||
moduleTranslations={moduleTranslations}>
|
moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
|
||||||
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
import SegmentedControl from "./SegmentedControl";
|
import SegmentedControl from "./SegmentedControl";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
|
|||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
sizeBytes?: number | null;
|
sizeBytes?: number | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
linkedToCampaign?: boolean | null;
|
||||||
archiveGroup?: string | null;
|
archiveGroup?: string | null;
|
||||||
archiveLabel?: string | null;
|
archiveLabel?: string | null;
|
||||||
protected?: boolean | null;
|
protected?: boolean | null;
|
||||||
@@ -159,9 +160,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
|
|||||||
const contentType = formatContentType(attachment.contentType);
|
const contentType = formatContentType(attachment.contentType);
|
||||||
const size = formatBytes(attachment.sizeBytes);
|
const size = formatBytes(attachment.sizeBytes);
|
||||||
const hasMeta = Boolean(contentType || size);
|
const hasMeta = Boolean(contentType || size);
|
||||||
|
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
|
||||||
|
const linkStateClass = attachment.linkedToCampaign === true ?
|
||||||
|
" is-linked" :
|
||||||
|
attachment.linkedToCampaign === false ?
|
||||||
|
" is-unlinked" :
|
||||||
|
"";
|
||||||
return (
|
return (
|
||||||
<div className="message-display-attachment-row">
|
<div className={`message-display-attachment-row${linkStateClass}`}>
|
||||||
<Paperclip size={14} aria-hidden="true" />
|
<LinkIcon size={14} aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
||||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
|||||||
|
|
||||||
type ToggleSwitchProps = {
|
type ToggleSwitchProps = {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
|
activeLabel?: ReactNode;
|
||||||
|
inactiveLabel?: ReactNode;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?: (checked: boolean) => void;
|
onChange?: (checked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||||
|
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
|
||||||
|
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
||||||
|
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||||
return (
|
return (
|
||||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={inputLabel}
|
||||||
onChange={(event) => onChange?.(event.target.checked)}
|
onChange={(event) => onChange?.(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
|
{hasStateLabels && inactiveLabel !== undefined &&
|
||||||
|
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
|
||||||
|
{renderedInactiveLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
||||||
|
{hasStateLabels && activeLabel !== undefined &&
|
||||||
|
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
|
||||||
|
{renderedActiveLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
{!hasStateLabels &&
|
||||||
<span className="toggle-switch-copy">
|
<span className="toggle-switch-copy">
|
||||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||||
</span>
|
</span>
|
||||||
|
}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import { CredentialFields } from "../CredentialPanel";
|
import { CredentialFields } from "../CredentialPanel";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
import DismissibleAlert from "../DismissibleAlert";
|
||||||
import FormField from "../FormField";
|
import FormField from "../FormField";
|
||||||
import SegmentedControl from "../SegmentedControl";
|
import SegmentedControl from "../SegmentedControl";
|
||||||
import ToggleSwitch from "../ToggleSwitch";
|
|
||||||
|
|
||||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ export type MailServerFolderLookupResult = {
|
|||||||
details?: Record<string, unknown> | null;
|
details?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
export type MailServerSettingsSection = "smtp" | "imap";
|
||||||
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||||
|
|
||||||
export type MailServerSettingsPanelProps = {
|
export type MailServerSettingsPanelProps = {
|
||||||
@@ -72,30 +71,11 @@ export type MailServerSettingsPanelProps = {
|
|||||||
imapActionDisabled?: boolean;
|
imapActionDisabled?: boolean;
|
||||||
smtpTestLabel?: string;
|
smtpTestLabel?: string;
|
||||||
imapTestLabel?: string;
|
imapTestLabel?: string;
|
||||||
folderLookupLabel?: string;
|
|
||||||
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
||||||
onTestSmtp?: () => void;
|
onTestSmtp?: () => void;
|
||||||
onTestImap?: () => void;
|
onTestImap?: () => void;
|
||||||
onLookupFolders?: () => void;
|
|
||||||
smtpTestResult?: MailServerConnectionTestResult | null;
|
smtpTestResult?: MailServerConnectionTestResult | null;
|
||||||
imapTestResult?: MailServerConnectionTestResult | null;
|
imapTestResult?: MailServerConnectionTestResult | null;
|
||||||
folderLookupResult?: MailServerFolderLookupResult | null;
|
|
||||||
onUseDetectedFolder?: () => void;
|
|
||||||
useDetectedFolderDisabled?: boolean;
|
|
||||||
mockToggle?: {
|
|
||||||
label?: string;
|
|
||||||
checked: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
};
|
|
||||||
append?: {
|
|
||||||
enabled: boolean;
|
|
||||||
folder: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
folderDisabled?: boolean;
|
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
|
||||||
onFolderChange: (folder: string) => void;
|
|
||||||
};
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
floatingResults?: boolean;
|
floatingResults?: boolean;
|
||||||
@@ -202,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
|
|||||||
return values.some((value) => String(value ?? "").trim() !== "");
|
return values.some((value) => String(value ?? "").trim() !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMailServerSettingsActiveSection(
|
||||||
|
activeSection: MailServerSettingsSection,
|
||||||
|
initialSection: MailServerSettingsSection,
|
||||||
|
previousInitialSection: MailServerSettingsSection,
|
||||||
|
visibleSectionIds: readonly MailServerSettingsSection[])
|
||||||
|
: MailServerSettingsSection {
|
||||||
|
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||||
|
const initialSectionVisible = visibleSectionIds.includes(initialSection);
|
||||||
|
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
|
||||||
|
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
|
||||||
|
return activeSection;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MailServerSettingsPanel({
|
export default function MailServerSettingsPanel({
|
||||||
smtp,
|
smtp,
|
||||||
imap,
|
imap,
|
||||||
@@ -223,18 +216,11 @@ export default function MailServerSettingsPanel({
|
|||||||
imapActionDisabled = imapServerDisabled,
|
imapActionDisabled = imapServerDisabled,
|
||||||
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
||||||
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
||||||
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
|
|
||||||
busyAction = null,
|
busyAction = null,
|
||||||
onTestSmtp,
|
onTestSmtp,
|
||||||
onTestImap,
|
onTestImap,
|
||||||
onLookupFolders,
|
|
||||||
smtpTestResult = null,
|
smtpTestResult = null,
|
||||||
imapTestResult = null,
|
imapTestResult = null,
|
||||||
folderLookupResult = null,
|
|
||||||
onUseDetectedFolder,
|
|
||||||
useDetectedFolderDisabled = false,
|
|
||||||
mockToggle,
|
|
||||||
append,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
floatingResults = false,
|
floatingResults = false,
|
||||||
@@ -254,35 +240,31 @@ export default function MailServerSettingsPanel({
|
|||||||
const imapSecurity = stringValue(imap.security, "tls");
|
const imapSecurity = stringValue(imap.security, "tls");
|
||||||
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
||||||
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
||||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
|
||||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
|
||||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
|
||||||
const appendTargetHelp = append ?
|
|
||||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
|
||||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
|
||||||
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
|
||||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
|
||||||
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
||||||
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
||||||
const fallbackSection = sections[0]?.id ?? "smtp";
|
const visibleSectionIds = sections.map((section) => section.id);
|
||||||
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
|
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||||
const sectionKey = sections.map((section) => section.id).join("|");
|
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
|
||||||
|
const sectionKey = visibleSectionIds.join("|");
|
||||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
||||||
|
const previousInitialSectionRef = useRef(initialSection);
|
||||||
const showSectionSwitcher = sections.length > 1;
|
const showSectionSwitcher = sections.length > 1;
|
||||||
const showServerFields = mode !== "credentials";
|
const showServerFields = mode !== "credentials";
|
||||||
const showCredentialFields = mode !== "server";
|
const showCredentialFields = mode !== "server";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sections.some((section) => section.id === activeSection)) {
|
const nextSection = resolveMailServerSettingsActiveSection(
|
||||||
setActiveSection(resolvedInitialSection);
|
activeSection,
|
||||||
return;
|
initialSection,
|
||||||
}
|
previousInitialSectionRef.current,
|
||||||
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
|
visibleSectionIds
|
||||||
setActiveSection(initialSection);
|
);
|
||||||
}
|
previousInitialSectionRef.current = initialSection;
|
||||||
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
|
if (nextSection !== activeSection) setActiveSection(nextSection);
|
||||||
|
}, [activeSection, initialSection, sectionKey]);
|
||||||
|
|
||||||
|
|
||||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||||
@@ -324,10 +306,10 @@ export default function MailServerSettingsPanel({
|
|||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
{showServerFields &&
|
{showServerFields &&
|
||||||
<>
|
<>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{showCredentialFields &&
|
{showCredentialFields &&
|
||||||
@@ -336,9 +318,7 @@ export default function MailServerSettingsPanel({
|
|||||||
onChange={patchSmtpCredentials}
|
onChange={patchSmtpCredentials}
|
||||||
disabled={smtpCredentialFieldsDisabled}
|
disabled={smtpCredentialFieldsDisabled}
|
||||||
savedPassword={smtpPasswordSaved}
|
savedPassword={smtpPasswordSaved}
|
||||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
|
||||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
|
||||||
headingClassName="mail-server-field-heading" />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{onTestSmtp &&
|
{onTestSmtp &&
|
||||||
@@ -355,10 +335,10 @@ export default function MailServerSettingsPanel({
|
|||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
{showServerFields &&
|
{showServerFields &&
|
||||||
<>
|
<>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{showCredentialFields &&
|
{showCredentialFields &&
|
||||||
@@ -367,9 +347,7 @@ export default function MailServerSettingsPanel({
|
|||||||
onChange={patchImapCredentials}
|
onChange={patchImapCredentials}
|
||||||
disabled={imapCredentialFieldsDisabled}
|
disabled={imapCredentialFieldsDisabled}
|
||||||
savedPassword={imapPasswordSaved}
|
savedPassword={imapPasswordSaved}
|
||||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
|
||||||
headingClassName="mail-server-field-heading" />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{onTestImap &&
|
{onTestImap &&
|
||||||
@@ -381,41 +359,6 @@ export default function MailServerSettingsPanel({
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
{activeSection === "advanced" &&
|
|
||||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
|
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
|
||||||
{mockToggle &&
|
|
||||||
<div className="mail-server-field-span mail-server-toggle-row">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
|
|
||||||
checked={mockToggle.checked}
|
|
||||||
disabled={disabled || mockToggle.disabled}
|
|
||||||
onChange={mockToggle.onChange} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
||||||
{append &&
|
|
||||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
|
||||||
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
|
|
||||||
<div className="field-with-action mail-server-folder-field">
|
|
||||||
<input
|
|
||||||
value={appendTargetFolder}
|
|
||||||
disabled={appendTargetDisabled}
|
|
||||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
|
||||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
|
||||||
|
|
||||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -437,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
|
|||||||
export function MailServerFolderLookupResultView({
|
export function MailServerFolderLookupResultView({
|
||||||
result,
|
result,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onUseDetected
|
onUseDetected,
|
||||||
|
compact = true,
|
||||||
|
floatingFailures = false
|
||||||
|
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
|
||||||
|
|
||||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = result.folders ?? [];
|
const folders = result.folders ?? [];
|
||||||
return (
|
return (
|
||||||
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||||
<p>{result.message}</p>
|
<p>{result.message}</p>
|
||||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
||||||
|
|||||||
@@ -860,6 +860,7 @@ export function DataGridRowActions({
|
|||||||
return (
|
return (
|
||||||
<div className="data-grid-row-actions">
|
<div className="data-grid-row-actions">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="data-grid-row-action is-add"
|
className="data-grid-row-action is-add"
|
||||||
aria-label={translatedAddLabel}
|
aria-label={translatedAddLabel}
|
||||||
@@ -870,6 +871,7 @@ export function DataGridRowActions({
|
|||||||
<Plus size={16} aria-hidden="true" />
|
<Plus size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="data-grid-row-action is-reorder"
|
className="data-grid-row-action is-reorder"
|
||||||
aria-label={translatedMoveUpLabel}
|
aria-label={translatedMoveUpLabel}
|
||||||
@@ -880,6 +882,7 @@ export function DataGridRowActions({
|
|||||||
<ArrowUp size={16} aria-hidden="true" />
|
<ArrowUp size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="data-grid-row-action is-reorder"
|
className="data-grid-row-action is-reorder"
|
||||||
aria-label={translatedMoveDownLabel}
|
aria-label={translatedMoveDownLabel}
|
||||||
@@ -890,6 +893,7 @@ export function DataGridRowActions({
|
|||||||
<ArrowDown size={16} aria-hidden="true" />
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
className="data-grid-row-action is-remove"
|
className="data-grid-row-action is-remove"
|
||||||
aria-label={translatedRemoveLabel}
|
aria-label={translatedRemoveLabel}
|
||||||
@@ -917,6 +921,7 @@ export function DataGridEmptyAction({
|
|||||||
return (
|
return (
|
||||||
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="data-grid-row-action is-add"
|
className="data-grid-row-action is-add"
|
||||||
aria-label={translatedLabel}
|
aria-label={translatedLabel}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Props = {
|
|||||||
publicMode?: boolean;
|
publicMode?: boolean;
|
||||||
navItems?: PlatformNavItem[];
|
navItems?: PlatformNavItem[];
|
||||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||||
|
backendReachable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppShell({
|
export default function AppShell({
|
||||||
@@ -23,7 +24,8 @@ export default function AppShell({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
publicMode = false,
|
publicMode = false,
|
||||||
navItems = [],
|
navItems = [],
|
||||||
maintenanceMode
|
maintenanceMode,
|
||||||
|
backendReachable = true
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export default function AppShell({
|
|||||||
<div className="app-shell public-shell">
|
<div className="app-shell public-shell">
|
||||||
<IconRail compact auth={auth} navItems={navItems} />
|
<IconRail compact auth={auth} navItems={navItems} />
|
||||||
<div className="app-main public-main">
|
<div className="app-main public-main">
|
||||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
||||||
<main className="public-content">{children}</main>
|
<main className="public-content">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +45,7 @@ export default function AppShell({
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<IconRail auth={auth} navItems={navItems} />
|
<IconRail auth={auth} navItems={navItems} />
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
||||||
<BreadcrumbBar pathname={location.pathname} />
|
<BreadcrumbBar pathname={location.pathname} />
|
||||||
<main className="app-content">{children}</main>
|
<main className="app-content">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ type Props = {
|
|||||||
onSettingsChange: (settings: ApiSettings) => void;
|
onSettingsChange: (settings: ApiSettings) => void;
|
||||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
||||||
|
backendReachable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) {
|
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode, backendReachable = true }: Props) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestNavigation } = useUnsavedChanges();
|
||||||
const [accountOpen, setAccountOpen] = useState(false);
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
@@ -161,7 +162,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
{maintenanceMode?.enabled &&
|
{!backendReachable ?
|
||||||
|
<div
|
||||||
|
className="backend-offline-topbar-alert"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
title="System not reachable / offline!">
|
||||||
|
System not reachable / offline!
|
||||||
|
</div> :
|
||||||
|
maintenanceMode?.enabled &&
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="maintenance-topbar-link"
|
className="maintenance-topbar-link"
|
||||||
|
|||||||
@@ -1564,20 +1564,31 @@
|
|||||||
box-shadow: var(--shadow-thumb);
|
box-shadow: var(--shadow-thumb);
|
||||||
transition: transform .16s ease;
|
transition: transform .16s ease;
|
||||||
}
|
}
|
||||||
.toggle-switch-input:checked + .toggle-switch-track {
|
.toggle-switch-input:checked ~ .toggle-switch-track {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
|
.toggle-switch-input:checked ~ .toggle-switch-track .toggle-switch-thumb {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:focus-visible + .toggle-switch-track {
|
.toggle-switch-input:focus-visible ~ .toggle-switch-track {
|
||||||
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
|
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:disabled + .toggle-switch-track {
|
.toggle-switch-input:disabled ~ .toggle-switch-track {
|
||||||
filter: grayscale(.15);
|
filter: grayscale(.15);
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
|
.toggle-switch-state-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-switch-state-label[data-selected="true"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.toggle-switch-copy {
|
.toggle-switch-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1949,6 +1960,21 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-alert {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-alert .alert-message > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-alert .alert-message > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-floating-stack {
|
.alert-floating-stack {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 131px;
|
top: 131px;
|
||||||
@@ -2489,6 +2515,18 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row > svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row.is-linked > svg {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row.is-unlinked > svg {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.message-display-attachment-row > span {
|
.message-display-attachment-row > span {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@@ -2580,9 +2618,9 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.mail-server-segmented-control {
|
.mail-server-segmented-control {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: min(420px, 100%);
|
width: min(320px, 100%);
|
||||||
}
|
}
|
||||||
.mail-server-settings-view {
|
.mail-server-settings-view {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2610,7 +2648,7 @@
|
|||||||
letter-spacing: .01em;
|
letter-spacing: .01em;
|
||||||
}
|
}
|
||||||
.form-grid.compact.mail-server-form-grid {
|
.form-grid.compact.mail-server-form-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.mail-server-form-grid {
|
.mail-server-form-grid {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -2668,7 +2706,7 @@
|
|||||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.mail-server-segmented-control { width: min(320px, 100%); }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.credential-panel-grid { grid-template-columns: 1fr; }
|
.credential-panel-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -36,8 +36,11 @@
|
|||||||
.titlebar-notification-button { position: relative; }
|
.titlebar-notification-button { position: relative; }
|
||||||
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
||||||
.account-pill { color: var(--text); }
|
.account-pill { color: var(--text); }
|
||||||
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); cursor: pointer; z-index: 1; }
|
.maintenance-topbar-link,
|
||||||
|
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; }
|
||||||
|
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
|
||||||
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
||||||
|
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
|
||||||
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
||||||
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.language-menu { min-width: 210px; }
|
.language-menu { min-width: 210px; }
|
||||||
|
|||||||
32
webui/tests/data-grid-actions.test.tsx
Normal file
32
webui/tests/data-grid-actions.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
function assertEqual<T>(actual: T, expected: T, message: string): void {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
function buttonCount(markup: string): number {
|
||||||
|
return markup.match(/<button\b/g)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonSubmittingButtonCount(markup: string): number {
|
||||||
|
return markup.match(/<button\b[^>]*\btype="button"/g)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowActions = renderToStaticMarkup(
|
||||||
|
<form>
|
||||||
|
<DataGridRowActions onAddBelow={noop} onRemove={noop} onMoveUp={noop} onMoveDown={noop} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(rowActions), 4, "row actions render the expected controls");
|
||||||
|
assertEqual(nonSubmittingButtonCount(rowActions), 4, "row actions do not submit an enclosing form");
|
||||||
|
|
||||||
|
const emptyAction = renderToStaticMarkup(
|
||||||
|
<form>
|
||||||
|
<DataGridEmptyAction onAdd={noop} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||||
|
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||||
@@ -16,12 +16,34 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
|
const allMailServerSections = ["smtp", "imap"] as const;
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", allMailServerSections),
|
||||||
|
"imap",
|
||||||
|
"user-selected mail settings section is not reset to initialSection"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("smtp", "imap", "smtp", allMailServerSections),
|
||||||
|
"imap",
|
||||||
|
"changed initialSection is applied when parent intentionally changes it"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", ["smtp"] as const),
|
||||||
|
"smtp",
|
||||||
|
"active mail settings section falls back when hidden by visibleSections"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("smtp", "imap", "imap", ["smtp"] as const),
|
||||||
|
"smtp",
|
||||||
|
"hidden initialSection falls back to the first visible section"
|
||||||
|
);
|
||||||
|
|
||||||
const savedPassword = renderToStaticMarkup(
|
const savedPassword = renderToStaticMarkup(
|
||||||
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||||
);
|
);
|
||||||
@@ -75,20 +97,14 @@ const settingsPanel = renderToStaticMarkup(
|
|||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
onTestSmtp={noop}
|
onTestSmtp={noop}
|
||||||
onTestImap={noop}
|
onTestImap={noop}
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server heading is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server field is rendered");
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "credentials heading is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.username.84c29015"), "username field is rendered");
|
||||||
|
assert(settingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "password field is rendered");
|
||||||
|
assert(settingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "timeout field is rendered with SMTP server settings");
|
||||||
|
assert(!settingsPanel.includes("i18n:govoplan-core.advanced.4d064726"), "advanced section tab is not rendered");
|
||||||
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
||||||
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
||||||
|
|
||||||
@@ -102,77 +118,45 @@ const imapSettingsPanel = renderToStaticMarkup(
|
|||||||
imapPasswordSaved
|
imapPasswordSaved
|
||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
onTestImap={noop}
|
onTestImap={noop}
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server heading is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server field is rendered");
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "IMAP credentials heading is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.username.84c29015"), "IMAP username field is rendered");
|
||||||
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "IMAP password field is rendered");
|
||||||
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "IMAP timeout field is rendered with IMAP server settings");
|
||||||
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
||||||
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
|
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
|
||||||
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
||||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||||
|
|
||||||
const advancedSettingsPanel = renderToStaticMarkup(
|
const folderLookupError = renderToStaticMarkup(
|
||||||
<MailServerSettingsPanel
|
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} />
|
||||||
initialSection="advanced"
|
);
|
||||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
assert(folderLookupError.includes("compact-alert"), "folder lookup errors use the compact shared alert style");
|
||||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
assert(folderLookupError.includes("warning"), "folder lookup errors are warnings, not page-level danger alerts");
|
||||||
onSmtpChange={noop}
|
assert(folderLookupError.includes("IMAP lookup timed out"), "folder lookup error message is rendered");
|
||||||
onImapChange={noop}
|
|
||||||
onLookupFolders={noop}
|
const floatingFolderLookupError = renderToStaticMarkup(
|
||||||
folderLookupResult={{
|
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} floatingFailures />
|
||||||
|
);
|
||||||
|
assert(floatingFolderLookupError.includes("alert-floating"), "folder lookup errors can be rendered in the floating alert stack");
|
||||||
|
|
||||||
|
const folderLookupSuccess = renderToStaticMarkup(
|
||||||
|
<MailServerFolderLookupResultView
|
||||||
|
result={{
|
||||||
ok: true,
|
ok: true,
|
||||||
protocol: "imap",
|
protocol: "imap",
|
||||||
message: "Folders loaded",
|
message: "Folders loaded",
|
||||||
detected_sent_folder: "Sent",
|
detected_sent_folder: "Sent",
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||||
}}
|
}}
|
||||||
onUseDetectedFolder={noop}
|
onUseDetected={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.append_target_folder.0aaacc0c"), "append target folder is the advanced IMAP folder field");
|
assert(folderLookupSuccess.includes("compact-alert"), "folder lookup success uses the compact shared alert style");
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is rendered near append target folder");
|
assert(folderLookupSuccess.includes("Folders loaded"), "folder lookup success message is rendered");
|
||||||
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
|
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is rendered near append target folder");
|
|
||||||
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
|
|
||||||
|
|
||||||
const disabledAppendFolderPanel = renderToStaticMarkup(
|
|
||||||
<MailServerSettingsPanel
|
|
||||||
initialSection="advanced"
|
|
||||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
|
||||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
|
||||||
onSmtpChange={noop}
|
|
||||||
onImapChange={noop}
|
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
append={{
|
|
||||||
enabled: true,
|
|
||||||
folder: "Sent",
|
|
||||||
folderDisabled: true,
|
|
||||||
onEnabledChange: noop,
|
|
||||||
onFolderChange: noop
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
|
|
||||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
|
|
||||||
|
|
||||||
const smtpServerOnlyPanel = renderToStaticMarkup(
|
const smtpServerOnlyPanel = renderToStaticMarkup(
|
||||||
<MailServerSettingsPanel
|
<MailServerSettingsPanel
|
||||||
@@ -185,7 +169,7 @@ const smtpServerOnlyPanel = renderToStaticMarkup(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
|
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
|
||||||
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused server panel hides credential fields");
|
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused server panel hides credential fields");
|
||||||
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
|
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
|
||||||
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
|
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
|
||||||
|
|
||||||
@@ -202,7 +186,7 @@ const imapCredentialsOnlyPanel = renderToStaticMarkup(
|
|||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused credentials panel renders credential fields");
|
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused credentials panel renders credential fields");
|
||||||
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
|
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
|
||||||
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
|
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
|
||||||
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
|
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
"tests/data-grid-actions.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user