Compare commits
23
Commits
v0.1.8
...
7eef52776c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eef52776c | ||
|
|
c50ce58ad8 | ||
|
|
344fc0077f | ||
|
|
28afc01371 | ||
|
|
1a29e75db4 | ||
|
|
57ec960f40 | ||
|
|
6388afdad8 | ||
|
|
1a0e90b22d | ||
|
|
7ad6f6328a | ||
|
|
c79a7124b7 | ||
|
|
abbef5a10b | ||
|
|
2f559e3f0b | ||
|
|
9b5418db78 | ||
|
|
1678602fd6 | ||
|
|
6e373dcdd6 | ||
|
|
d1c033edc7 | ||
|
|
b5cfba666c | ||
|
|
78b4afdec4 | ||
|
|
15596f0742 | ||
|
|
a2320fcb5d | ||
|
|
e6f7c45f0a | ||
|
|
8aa1943581 | ||
|
|
b9badc9153 |
@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
|
|||||||
("governance_templates", "admin_governance_templates"),
|
("governance_templates", "admin_governance_templates"),
|
||||||
("governance_template_assignments", "admin_governance_template_assignments"),
|
("governance_template_assignments", "admin_governance_template_assignments"),
|
||||||
)
|
)
|
||||||
|
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one())
|
if table_name not in _KNOWN_TABLE_NAMES:
|
||||||
|
raise RuntimeError(f"Unexpected table name: {table_name}")
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
|||||||
CORE_SCOPE_TABLE = "core_scopes"
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||||
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||||
|
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
if table_name not in _KNOWN_SCOPE_TABLES:
|
||||||
|
raise RuntimeError(f"Unexpected scope table name: {table_name}")
|
||||||
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _scope_tables(bind: sa.Connection) -> set[str]:
|
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
|
|||||||
down_revision = "8c9d0e1f2a3b"
|
down_revision = "8c9d0e1f2a3b"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -28,9 +29,10 @@ def upgrade() -> None:
|
|||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
# Reconcile only the empty create_all shape for the newly introduced tables.
|
# Reconcile only the empty create_all shape for the newly introduced tables.
|
||||||
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"):
|
for table_name in _RECONCILE_CREATE_ALL_TABLES:
|
||||||
if table_name in tables:
|
if table_name in tables:
|
||||||
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
||||||
op.drop_table(table_name)
|
op.drop_table(table_name)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def upgrade() -> None:
|
|||||||
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
||||||
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"),
|
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
||||||
|
|||||||
+4
-1
@@ -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():
|
||||||
|
|||||||
@@ -153,16 +153,24 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
||||||
|
|
||||||
Worker command:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,default \
|
--queues send_email,append_sent,notifications,calendar,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||||
|
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||||
|
crashes, and expired worker leases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||||
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
@@ -275,7 +283,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,default`
|
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
||||||
- WebUI through the Vite dev server
|
- WebUI through the Vite dev server
|
||||||
- durable local files under `runtime/production-like/files`
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# GovOPlaN Master Roadmap
|
# GovOPlaN Master Roadmap
|
||||||
|
|
||||||
This roadmap is the durable product north star and sequencing guide for
|
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||||
GovOPlaN as a modular platform for administrative operations. It keeps the
|
a modular platform for administrative operations. It translates the
|
||||||
product moving without turning every possible public-sector need into an
|
cross-product outcome horizons into dependency waves without turning every
|
||||||
immediate implementation track.
|
possible public-sector need into an immediate implementation track.
|
||||||
|
|
||||||
Use this document for product direction, sequencing, and module routing. Issues
|
Use this document for technical sequencing, module routing, and implementation
|
||||||
are the active backlog; this document is durable planning context and should be
|
gates. Issues are the active backlog; this document is durable architecture
|
||||||
mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
|
The meta repository's
|
||||||
|
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
|
service and operating configurations, connected outcome stories, and
|
||||||
|
capability horizons. Its product horizons are canonical; the waves below give
|
||||||
|
their technical dependency order. This Core roadmap remains the technical
|
||||||
|
sequencing and module-routing companion.
|
||||||
|
|
||||||
## Product Thesis
|
## Product Thesis
|
||||||
|
|
||||||
@@ -530,7 +538,11 @@ Before a module becomes release-included, it needs:
|
|||||||
- smoke test or permutation test
|
- smoke test or permutation test
|
||||||
- no required imports from optional modules
|
- no required imports from optional modules
|
||||||
|
|
||||||
## Priority Order Summary
|
## Technical Dependency Order Summary
|
||||||
|
|
||||||
|
When the meta roadmap selects one of these product programs, use this order to
|
||||||
|
respect shared-spine dependencies. The list is not an active schedule or a
|
||||||
|
decision to start every program horizontally.
|
||||||
|
|
||||||
1. Stabilize the platform spine.
|
1. Stabilize the platform spine.
|
||||||
2. Deliver permit-to-payment MVP.
|
2. Deliver permit-to-payment MVP.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ New backend code should import policy-owned retention behavior from
|
|||||||
`govoplan-policy` or request the capability, not add new implementation logic
|
`govoplan-policy` or request the capability, not add new implementation logic
|
||||||
to core.
|
to core.
|
||||||
|
|
||||||
|
The retention API DTOs live in `govoplan_core.privacy.schemas`.
|
||||||
|
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
|
||||||
|
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
|
||||||
|
platform contracts because admin, access compatibility, and policy routes expose
|
||||||
|
the same stable retention payload shape. The policy engine's internal
|
||||||
|
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
|
||||||
|
`govoplan-policy`, because they carry implementation validators and merge
|
||||||
|
behavior that are not generic API contracts.
|
||||||
|
|
||||||
|
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
|
||||||
|
matching compatibility DTOs only for its legacy admin surface. Admin overview
|
||||||
|
responses remain module-local because the same counters are exposed from
|
||||||
|
different menu contexts and are not yet a separately versioned platform API.
|
||||||
|
|
||||||
## Frontend Contract
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
|
|||||||
|
|
||||||
Current tag-only module repositories:
|
Current tag-only module repositories:
|
||||||
|
|
||||||
- `govoplan-addresses`
|
|
||||||
- `govoplan-appointments`
|
- `govoplan-appointments`
|
||||||
- `govoplan-cases`
|
- `govoplan-cases`
|
||||||
- `govoplan-connectors`
|
- `govoplan-connectors`
|
||||||
@@ -135,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`
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
||||||
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||||
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||||
|
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||||
|
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||||
|
| 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-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
|
||||||
|
|
||||||
@@ -138,6 +146,67 @@ adaptive form, not force a linear wizard.
|
|||||||
- Wizard shells remain available for assisted setup, first-run guidance,
|
- Wizard shells remain available for assisted setup, first-run guidance,
|
||||||
imports, discovery-heavy flows, and operational preflight workflows.
|
imports, discovery-heavy flows, and operational preflight workflows.
|
||||||
|
|
||||||
|
### DUE-008: Platform Theme Contract
|
||||||
|
|
||||||
|
Decision: the WebUI shell exposes a small, stable appearance contract based on
|
||||||
|
shared CSS tokens and persisted user preference selection.
|
||||||
|
|
||||||
|
- Core applies `system`, `light`, and `dark` preferences at the document root.
|
||||||
|
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
|
||||||
|
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
||||||
|
status colors, radii, shadows, and disabled-control colors.
|
||||||
|
- Modules must style new UI with these tokens and shared controls. Module-local
|
||||||
|
CSS may tune layout and spacing, but it must not introduce a separate
|
||||||
|
appearance system.
|
||||||
|
- Appearance controls live in user settings first. Tenant defaults and policy
|
||||||
|
enforcement can be added later without changing the token contract.
|
||||||
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
|
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 |
|
||||||
@@ -200,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?
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
|
|||||||
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
||||||
`pyzipper==0.4.0`
|
`pyzipper==0.4.0`
|
||||||
- upgraded the local audit environment to `pip==26.1.2`
|
- upgraded the local audit environment to `pip==26.1.2`
|
||||||
- removed the obsolete local `govoplan-module-multimailer` editable install
|
- removed the obsolete local mailer-module editable install
|
||||||
from the audit environment so the audit reflects the split module product
|
from the audit environment so the audit reflects the split module product
|
||||||
|
|
||||||
Post-remediation result:
|
Post-remediation result:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-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"
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
|
|||||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionUserInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
account_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
tenant_display_name: str | None = None
|
||||||
|
is_tenant_admin: bool = False
|
||||||
|
password_reset_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionResponse(BaseModel):
|
||||||
|
authenticated: bool = True
|
||||||
|
auth_method: Literal["session", "api_key"] = "session"
|
||||||
|
user: AuthSessionUserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
session_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdateRequest(BaseModel):
|
class ProfileUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -143,6 +165,40 @@ class PrincipalContextInfo(BaseModel):
|
|||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthShellResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
|
||||||
|
scopes: list[str]
|
||||||
|
principal: PrincipalContextInfo | None = None
|
||||||
|
profile_loaded: bool = False
|
||||||
|
roles_loaded: bool = False
|
||||||
|
groups_loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProfileResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRolesResponse(BaseModel):
|
||||||
|
roles: list[RoleInfo] = Field(default_factory=list)
|
||||||
|
roles_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthGroupsResponse(BaseModel):
|
||||||
|
groups: list[GroupInfo] = Field(default_factory=list)
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ from __future__ import annotations
|
|||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||||
|
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import configure_runtime
|
from govoplan_core.core.runtime import configure_runtime
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_core.db.session import configure_database
|
from govoplan_core.db.session import configure_database
|
||||||
@@ -13,7 +16,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
|
|||||||
configure_database(settings.database_url)
|
configure_database(settings.database_url)
|
||||||
|
|
||||||
celery = Celery(
|
celery = Celery(
|
||||||
"multimailer",
|
"govoplan",
|
||||||
broker=settings.redis_url,
|
broker=settings.redis_url,
|
||||||
backend=settings.redis_url,
|
backend=settings.redis_url,
|
||||||
)
|
)
|
||||||
@@ -21,21 +24,31 @@ celery = Celery(
|
|||||||
celery.conf.update(
|
celery.conf.update(
|
||||||
task_default_queue="default",
|
task_default_queue="default",
|
||||||
task_routes={
|
task_routes={
|
||||||
"multimailer.send_email": {"queue": "send_email"},
|
"govoplan.campaigns.send_email": {"queue": "send_email"},
|
||||||
"multimailer.append_sent": {"queue": "append_sent"},
|
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||||
|
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||||
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
|
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
task_reject_on_worker_lost=True,
|
task_reject_on_worker_lost=True,
|
||||||
|
beat_schedule={
|
||||||
|
"calendar-outbox-every-minute": {
|
||||||
|
"task": "govoplan.calendar.dispatch_outbox",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (None, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.ping")
|
@celery.task(name="govoplan.ping")
|
||||||
def ping():
|
def ping():
|
||||||
return "pong"
|
return "pong"
|
||||||
|
|
||||||
|
|
||||||
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
def _platform_registry() -> PlatformRegistry:
|
||||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||||
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||||
@@ -44,13 +57,36 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
|||||||
context = ModuleContext(registry=registry, settings=settings)
|
context = ModuleContext(registry=registry, settings=settings)
|
||||||
configure_runtime(context)
|
configure_runtime(context)
|
||||||
registry.configure_capability_context(context)
|
registry.configure_capability_context(context)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||||
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
||||||
raise RuntimeError("Campaign delivery task capability is invalid")
|
raise RuntimeError("Campaign delivery task capability is invalid")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
|
def _notification_dispatch() -> NotificationDispatchProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
|
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
if not isinstance(capability, NotificationDispatchProvider):
|
||||||
|
raise RuntimeError("Notification dispatch capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
if not isinstance(capability, CalendarOutboxProvider):
|
||||||
|
raise RuntimeError("Calendar outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||||
def send_email(self, job_id: str):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
|
|
||||||
@@ -65,7 +101,7 @@ def send_email(self, job_id: str):
|
|||||||
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
|
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
|
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
|
||||||
def append_sent(self, job_id: str):
|
def append_sent(self, job_id: str):
|
||||||
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
||||||
|
|
||||||
@@ -78,3 +114,38 @@ def append_sent(self, job_id: str):
|
|||||||
if getattr(exc, "temporary", None) is True:
|
if getattr(exc, "temporary", None) is True:
|
||||||
raise self.retry(exc=exc, countdown=300)
|
raise self.retry(exc=exc, countdown=300)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
|
||||||
|
def deliver_notification(self, notification_id: str):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
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)
|
||||||
|
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
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)
|
||||||
|
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
"""Drain durable Calendar operations; retry timing lives in the database."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _calendar_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
|
||||||
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from govoplan_core.core.module_installer import (
|
from govoplan_core.core.module_installer import (
|
||||||
ModuleInstallerError,
|
ModuleInstallerError,
|
||||||
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
|
|||||||
update_module_installer_request,
|
update_module_installer_request,
|
||||||
update_module_installer_daemon_status,
|
update_module_installer_daemon_status,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.module_installer_notifications import (
|
||||||
|
build_runtime_notification_registry,
|
||||||
|
emit_module_installer_notification,
|
||||||
|
installer_notification_body,
|
||||||
|
installer_notification_priority,
|
||||||
|
installer_notification_subject,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
|
|||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
||||||
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
||||||
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
||||||
@@ -95,11 +101,43 @@ def main() -> int:
|
|||||||
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
||||||
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
||||||
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _build_parser().parse_args()
|
||||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||||
try:
|
try:
|
||||||
|
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
|
for handler in (
|
||||||
|
_handle_license_command,
|
||||||
|
_handle_catalog_command,
|
||||||
|
_handle_runtime_command,
|
||||||
|
_handle_queue_command,
|
||||||
|
_handle_run_history_command,
|
||||||
|
):
|
||||||
|
result = handler(args=args, runtime_dir=runtime_dir)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return _handle_install_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.issue_license:
|
if args.issue_license:
|
||||||
|
return _issue_license_command(args)
|
||||||
|
if args.validate_license is not None:
|
||||||
|
return _validate_license_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_license_command(args: argparse.Namespace) -> int:
|
||||||
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
||||||
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
||||||
if not args.license_signing_key_id or not args.license_signing_private_key:
|
if not args.license_signing_key_id or not args.license_signing_private_key:
|
||||||
@@ -134,7 +172,9 @@ def main() -> int:
|
|||||||
output_format=args.format,
|
output_format=args.format,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_license is not None:
|
|
||||||
|
|
||||||
|
def _validate_license_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||||
result = module_license_diagnostics(
|
result = module_license_diagnostics(
|
||||||
path,
|
path,
|
||||||
@@ -144,7 +184,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.sign_package_catalog:
|
if args.sign_package_catalog:
|
||||||
|
return _sign_package_catalog_command(args)
|
||||||
|
if args.validate_package_catalog is not None:
|
||||||
|
return _validate_package_catalog_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
||||||
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
||||||
path = sign_module_package_catalog(
|
path = sign_module_package_catalog(
|
||||||
@@ -155,7 +206,9 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_package_catalog is not None:
|
|
||||||
|
|
||||||
|
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
||||||
result = validate_module_package_catalog(
|
result = validate_module_package_catalog(
|
||||||
path,
|
path,
|
||||||
@@ -165,11 +218,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") else 1
|
return 0 if result.get("valid") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.daemon_status:
|
if args.daemon_status:
|
||||||
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.daemon or args.daemon_once:
|
if args.daemon or args.daemon_once:
|
||||||
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_requests:
|
if args.list_requests:
|
||||||
_print_result({
|
_print_result({
|
||||||
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
||||||
@@ -186,13 +246,13 @@ def main() -> int:
|
|||||||
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.enqueue_supervised:
|
if args.enqueue_supervised:
|
||||||
request = queue_module_installer_request(
|
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
|
||||||
runtime_dir=runtime_dir,
|
|
||||||
requested_by="cli",
|
|
||||||
options=_request_options_from_args(args),
|
|
||||||
)
|
|
||||||
_print_result(request, output_format=args.format)
|
_print_result(request, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_runs:
|
if args.list_runs:
|
||||||
_print_result({
|
_print_result({
|
||||||
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
||||||
@@ -206,6 +266,11 @@ def main() -> int:
|
|||||||
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.rollback:
|
if args.rollback:
|
||||||
|
return _rollback_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
result = rollback_module_install_run(
|
result = rollback_module_install_run(
|
||||||
run_id=args.rollback,
|
run_id=args.rollback,
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
@@ -217,6 +282,8 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -224,6 +291,21 @@ def main() -> int:
|
|||||||
desired = saved_desired_enabled_modules(session, configured)
|
desired = saved_desired_enabled_modules(session, configured)
|
||||||
plan = saved_module_install_plan(session)
|
plan = saved_module_install_plan(session)
|
||||||
if args.supervise:
|
if args.supervise:
|
||||||
|
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
if args.apply or args.dry_run:
|
||||||
|
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = supervise_module_install_plan(
|
result = supervise_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -248,7 +330,17 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
if args.apply or args.dry_run:
|
|
||||||
|
|
||||||
|
def _apply_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = run_module_install_plan(
|
result = run_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -271,6 +363,16 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
|
|
||||||
maintenance = saved_maintenance_mode(session)
|
maintenance = saved_maintenance_mode(session)
|
||||||
@@ -286,9 +388,6 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_preflight(preflight.as_dict(), output_format=args.format)
|
_print_preflight(preflight.as_dict(), output_format=args.format)
|
||||||
return 0 if preflight.allowed else 1
|
return 0 if preflight.allowed else 1
|
||||||
except ModuleInstallerError as exc:
|
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _default_webui_root() -> Path:
|
def _default_webui_root() -> Path:
|
||||||
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
request_id = str(request["request_id"])
|
request_id = str(request["request_id"])
|
||||||
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
|
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
|
||||||
try:
|
try:
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
||||||
request_context=_request_context(request),
|
request_context=_request_context(request),
|
||||||
)
|
)
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"result": result.as_dict(),
|
"result": result.as_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(
|
||||||
|
updated_request,
|
||||||
|
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
|
||||||
|
tenant_id = str(request.get("tenant_id") or "")
|
||||||
|
if not tenant_id:
|
||||||
|
return
|
||||||
|
registry = build_runtime_notification_registry(settings)
|
||||||
|
if registry is None:
|
||||||
|
return
|
||||||
|
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
emitted = emit_module_installer_notification(
|
||||||
|
session=session,
|
||||||
|
registry=registry,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
request=request,
|
||||||
|
event_kind=event_kind,
|
||||||
|
subject=installer_notification_subject(event_kind, request),
|
||||||
|
body_text=installer_notification_body(event_kind, request),
|
||||||
|
recipient_id=str(request.get("requested_by") or "") or None,
|
||||||
|
priority=installer_notification_priority(status_value),
|
||||||
|
)
|
||||||
|
if emitted:
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
||||||
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
|
|||||||
context: dict[str, object] = {
|
context: dict[str, object] = {
|
||||||
"request_id": request.get("request_id"),
|
"request_id": request.get("request_id"),
|
||||||
"requested_by": request.get("requested_by"),
|
"requested_by": request.get("requested_by"),
|
||||||
|
"tenant_id": request.get("tenant_id"),
|
||||||
}
|
}
|
||||||
if request.get("retry_of"):
|
if request.get("retry_of"):
|
||||||
context["retry_of"] = request["retry_of"]
|
context["retry_of"] = request["retry_of"]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Calendar capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRequest:
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
status: str = "CONFIRMED"
|
||||||
|
transparency: str = "OPAQUE"
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
related_to: tuple[Mapping[str, object], ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRef:
|
||||||
|
id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarSchedulingProvider(Protocol):
|
||||||
|
def list_freebusy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
calendar_ids: Sequence[str] | None = None,
|
||||||
|
) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarOutboxProvider(Protocol):
|
||||||
|
"""Stable worker boundary for Calendar-owned external side effects."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, CalendarSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
@@ -8,8 +8,6 @@ from pathlib import Path
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import (
|
from govoplan_core.core.module_package_catalog import (
|
||||||
_canonical_catalog_bytes,
|
_canonical_catalog_bytes,
|
||||||
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
_load_private_key,
|
_load_private_key,
|
||||||
_parse_trusted_keys,
|
_parse_trusted_keys,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||||
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
|||||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationCatalogValidationState:
|
||||||
|
packages: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ConfigurationModuleRequirement:
|
class ConfigurationModuleRequirement:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
|
|||||||
configured = source is not None and _catalog_source_exists(source)
|
configured = source is not None and _catalog_source_exists(source)
|
||||||
if source is not None and not _catalog_source_exists(source):
|
if source is not None and not _catalog_source_exists(source):
|
||||||
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
|
read_state = _configuration_catalog_default_read_state()
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
|
||||||
packages = _normalize_catalog_packages(payload)
|
|
||||||
channel = _catalog_channel(payload)
|
|
||||||
sequence = _catalog_sequence(payload)
|
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
|
||||||
freshness = _catalog_freshness_state(payload)
|
read_state = state.read_state
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||||
if signature_state.get("fatal"):
|
policy_error = _configuration_catalog_policy_error(
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
|
source,
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
configured=configured,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
|
state=state,
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
require_trusted=effective_require_trusted,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
|
approved_channels=effective_approved_channels,
|
||||||
if not freshness["valid"]:
|
)
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
|
if policy_error is not None:
|
||||||
if not replay["valid"]:
|
return policy_error
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
|
|
||||||
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
if not signature_state["signed"]:
|
|
||||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
|
||||||
elif not signature_state["trusted"]:
|
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
|
||||||
return _validation_result(
|
return _validation_result(
|
||||||
source,
|
source,
|
||||||
valid=True,
|
valid=True,
|
||||||
configured=configured,
|
configured=configured,
|
||||||
read_state=read_state,
|
read_state=state.read_state,
|
||||||
packages=packages,
|
packages=state.packages,
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
warnings=_configuration_catalog_warnings(state),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _ConfigurationCatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
|
channel = _catalog_channel(payload)
|
||||||
|
sequence = _catalog_sequence(payload)
|
||||||
|
return _ConfigurationCatalogValidationState(
|
||||||
|
packages=_normalize_catalog_packages(payload),
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
warnings=warnings,
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
|
read_state=read_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"]),
|
||||||
|
)
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=(
|
||||||
|
f"Configuration package catalog channel {state.channel!r} is not approved. "
|
||||||
|
f"Approved channels: {', '.join(approved_channels)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
|
||||||
|
)
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.freshness["error"]),
|
||||||
|
)
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.replay["error"]),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_invalid_result(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
error: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return _validation_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
read_state=state.read_state,
|
||||||
|
packages=(),
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
|
||||||
|
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_default_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
|
||||||
|
|
||||||
|
|
||||||
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
||||||
payload = _read_catalog_payload(path)
|
payload = _read_catalog_payload(path)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
|
|||||||
return {"packages": []}, metadata
|
return {"packages": []}, metadata
|
||||||
if isinstance(source, str) and _is_http_url(source):
|
if isinstance(source, str) and _is_http_url(source):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(source, timeout=15) as response:
|
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return json.loads(body), metadata
|
return json.loads(body), metadata
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
metadata["cache_used"] = True
|
metadata["cache_used"] = True
|
||||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationChangeSafetyState:
|
||||||
|
field: ConfigurationFieldSafety
|
||||||
|
missing_scopes: tuple[str, ...]
|
||||||
|
blockers: tuple[str, ...]
|
||||||
|
warnings: tuple[str, ...]
|
||||||
|
approval_required: bool
|
||||||
|
approval_satisfied: bool
|
||||||
|
|
||||||
|
|
||||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="module_management.desired_enabled",
|
key="module_management.desired_enabled",
|
||||||
@@ -339,6 +349,26 @@ def plan_configuration_change(
|
|||||||
) -> ConfigurationChangeSafetyPlan:
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
field = classify_configuration_field(key)
|
field = classify_configuration_field(key)
|
||||||
if field is None:
|
if field is None:
|
||||||
|
return _unknown_configuration_plan(key)
|
||||||
|
if not include_env_only and not field.ui_managed:
|
||||||
|
return _deployment_managed_configuration_plan(key, field)
|
||||||
|
state = _configuration_change_safety_state(
|
||||||
|
field,
|
||||||
|
actor_scopes=actor_scopes,
|
||||||
|
value=value,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
approval_count=approval_count,
|
||||||
|
)
|
||||||
|
return _configuration_change_safety_plan(
|
||||||
|
field,
|
||||||
|
state=state,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -346,7 +376,9 @@ def plan_configuration_change(
|
|||||||
blockers=("unknown_configuration_field",),
|
blockers=("unknown_configuration_field",),
|
||||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||||
)
|
)
|
||||||
if not include_env_only and not field.ui_managed:
|
|
||||||
|
|
||||||
|
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -356,6 +388,17 @@ def plan_configuration_change(
|
|||||||
blockers=("deployment_managed",),
|
blockers=("deployment_managed",),
|
||||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_state(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
actor_scopes: tuple[str, ...] | list[str],
|
||||||
|
value: object,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
approval_count: int,
|
||||||
|
) -> _ConfigurationChangeSafetyState:
|
||||||
blockers: list[str] = []
|
blockers: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
||||||
@@ -377,24 +420,41 @@ def plan_configuration_change(
|
|||||||
blockers.append("env_only_secret")
|
blockers.append("env_only_secret")
|
||||||
if field.rollback_history_required:
|
if field.rollback_history_required:
|
||||||
warnings.append("rollback_history_required")
|
warnings.append("rollback_history_required")
|
||||||
return ConfigurationChangeSafetyPlan(
|
return _ConfigurationChangeSafetyState(
|
||||||
key=field.key,
|
|
||||||
allowed=not blockers,
|
|
||||||
field=field,
|
field=field,
|
||||||
risk=field.risk,
|
|
||||||
missing_scopes=missing_scopes,
|
missing_scopes=missing_scopes,
|
||||||
dry_run_required=field.dry_run_required,
|
blockers=tuple(dict.fromkeys(blockers)),
|
||||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
warnings=tuple(dict.fromkeys(warnings)),
|
||||||
approval_required=approval_required,
|
approval_required=approval_required,
|
||||||
approval_satisfied=approval_satisfied,
|
approval_satisfied=approval_satisfied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_plan(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
state: _ConfigurationChangeSafetyState,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
|
return ConfigurationChangeSafetyPlan(
|
||||||
|
key=field.key,
|
||||||
|
allowed=not state.blockers,
|
||||||
|
field=field,
|
||||||
|
risk=field.risk,
|
||||||
|
missing_scopes=state.missing_scopes,
|
||||||
|
dry_run_required=field.dry_run_required,
|
||||||
|
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||||
|
approval_required=state.approval_required,
|
||||||
|
approval_satisfied=state.approval_satisfied,
|
||||||
maintenance_required=field.maintenance_required,
|
maintenance_required=field.maintenance_required,
|
||||||
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
||||||
rollback_history_required=field.rollback_history_required,
|
rollback_history_required=field.rollback_history_required,
|
||||||
secret_handling=field.secret_handling,
|
secret_handling=field.secret_handling,
|
||||||
audit_event=field.audit_event,
|
audit_event=field.audit_event,
|
||||||
policy_explanation=_policy_explanation(field),
|
policy_explanation=_policy_explanation(field),
|
||||||
blockers=tuple(dict.fromkeys(blockers)),
|
blockers=state.blockers,
|
||||||
warnings=tuple(dict.fromkeys(warnings)),
|
warnings=state.warnings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
@@ -72,6 +72,25 @@ class ConfigValidationResult:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RuntimeProfile:
|
||||||
|
name: str
|
||||||
|
production: bool
|
||||||
|
production_like: bool
|
||||||
|
local: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _ConfigIssueCollector:
|
||||||
|
strict: bool
|
||||||
|
issues: list[ConfigIssue]
|
||||||
|
|
||||||
|
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||||
|
if self.strict and level == "warning":
|
||||||
|
level = "error"
|
||||||
|
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||||
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
|
|||||||
strict: bool = False,
|
strict: bool = False,
|
||||||
) -> ConfigValidationResult:
|
) -> ConfigValidationResult:
|
||||||
env = dict(os.environ if environ is None else environ)
|
env = dict(os.environ if environ is None else environ)
|
||||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
runtime = _runtime_profile(env, profile=profile)
|
||||||
issues: list[ConfigIssue] = []
|
collector = _ConfigIssueCollector(strict=strict, issues=[])
|
||||||
|
_validate_app_env(env, runtime, collector)
|
||||||
|
_validate_database_settings(env, runtime, collector)
|
||||||
|
_validate_master_key(env, runtime, collector)
|
||||||
|
_validate_enabled_modules(env, runtime, collector)
|
||||||
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
|
_validate_cors_settings(env, runtime, collector)
|
||||||
|
_validate_file_storage_settings(env, runtime, collector)
|
||||||
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
|
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
||||||
|
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||||
local = clean_profile in _LOCAL_PROFILES and not production_like
|
return _RuntimeProfile(
|
||||||
|
name=clean_profile,
|
||||||
|
production=production,
|
||||||
|
production_like=production_like,
|
||||||
|
local=clean_profile in _LOCAL_PROFILES and not production_like,
|
||||||
|
)
|
||||||
|
|
||||||
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
|
||||||
if strict and level == "warning":
|
|
||||||
level = "error"
|
|
||||||
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
|
||||||
|
|
||||||
|
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
app_env = _clean(env.get("APP_ENV"))
|
app_env = _clean(env.get("APP_ENV"))
|
||||||
if not app_env and production_like:
|
if not app_env and runtime.production_like:
|
||||||
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
||||||
elif app_env.lower() in {"dev", "test", "local"} and production_like:
|
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
||||||
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
database_url = _clean(env.get("DATABASE_URL"))
|
database_url = _clean(env.get("DATABASE_URL"))
|
||||||
if not database_url:
|
if not database_url:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||||
else:
|
return
|
||||||
backend = _database_backend(database_url)
|
backend = _database_backend(database_url)
|
||||||
if backend is None:
|
if backend is None:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||||
elif backend == "sqlite" and production_like:
|
return
|
||||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
if backend == "sqlite" and runtime.production_like:
|
||||||
elif backend != "postgresql" and production:
|
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||||
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
elif backend != "postgresql" and runtime.production:
|
||||||
|
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||||
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||||
if not master_key and not local:
|
if not master_key and not runtime.local:
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
||||||
elif master_key:
|
return
|
||||||
|
if not master_key:
|
||||||
|
return
|
||||||
error = _master_key_error(master_key)
|
error = _master_key_error(master_key)
|
||||||
if error:
|
if error:
|
||||||
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||||
if not enabled_modules and production_like:
|
if not enabled_modules and runtime.production_like:
|
||||||
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||||
elif "access" not in enabled_modules and production_like:
|
elif "access" not in enabled_modules and runtime.production_like:
|
||||||
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
||||||
elif enabled_modules and "admin" not in enabled_modules and production_like:
|
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
|
||||||
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||||
|
|
||||||
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
|
|
||||||
if celery_enabled and not _clean(env.get("REDIS_URL")):
|
|
||||||
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
|
||||||
|
|
||||||
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
||||||
|
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||||
|
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||||
|
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||||
|
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||||
|
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||||
|
|
||||||
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
|
||||||
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
|
||||||
|
|
||||||
|
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||||
if production_like and not cors_origins:
|
if runtime.production_like and not cors_origins:
|
||||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||||
elif "*" in cors_origins and production_like:
|
elif "*" in cors_origins and runtime.production_like:
|
||||||
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||||
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||||
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||||
if storage_backend == "local":
|
if storage_backend == "local":
|
||||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
_validate_local_file_storage(env, runtime, collector)
|
||||||
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
|
||||||
elif production:
|
|
||||||
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
|
||||||
elif storage_backend == "s3":
|
elif storage_backend == "s3":
|
||||||
|
_validate_s3_file_storage(env, collector)
|
||||||
|
else:
|
||||||
|
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
|
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
|
||||||
|
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||||
|
elif runtime.production:
|
||||||
|
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
|
||||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
||||||
if not _clean(env.get(key)):
|
if not _clean(env.get(key)):
|
||||||
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||||
else:
|
|
||||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||||
if production and catalog_source:
|
if not runtime.production or not catalog_source:
|
||||||
|
return
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
||||||
|
|
||||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
|
||||||
|
|
||||||
|
|
||||||
def _self_hosted_env_template(master_key: str) -> str:
|
def _self_hosted_env_template(master_key: str) -> str:
|
||||||
@@ -220,7 +274,8 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
@@ -263,7 +318,8 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
@@ -299,8 +355,8 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
Fernet(candidate)
|
Fernet(candidate)
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
raw = None
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(candidate)
|
raw = base64.b64decode(candidate)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -309,6 +365,6 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||||
try:
|
try:
|
||||||
Fernet(base64.urlsafe_b64encode(raw))
|
Fernet(base64.urlsafe_b64encode(raw))
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||||
return None
|
return None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
|
|
||||||
|
|
||||||
|
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_module_installer_notification(
|
||||||
|
*,
|
||||||
|
session: object,
|
||||||
|
registry: object | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
request: Mapping[str, object],
|
||||||
|
event_kind: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
recipient_id: str | None = None,
|
||||||
|
priority: int = 5,
|
||||||
|
) -> bool:
|
||||||
|
if not tenant_id:
|
||||||
|
return False
|
||||||
|
provider = notification_dispatch_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
request_id = str(request.get("request_id") or "")
|
||||||
|
if not request_id:
|
||||||
|
return False
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="core",
|
||||||
|
source_resource_type="module_install_request",
|
||||||
|
source_resource_id=request_id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if recipient_id else None,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
|
||||||
|
priority=priority,
|
||||||
|
payload={
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": request.get("status"),
|
||||||
|
"run_id": _result_run_id(request),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
|
||||||
|
"retry_of": request.get("retry_of"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_notification_registry(settings: object) -> object | None:
|
||||||
|
try:
|
||||||
|
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
|
||||||
|
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
|
||||||
|
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||||
|
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
|
||||||
|
if "notifications" not in enabled_modules:
|
||||||
|
return None
|
||||||
|
registry = build_platform_registry(enabled_modules)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
|
||||||
|
return registry
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
prefixes = {
|
||||||
|
"queued": "Module installer request queued",
|
||||||
|
"running": "Module installer request started",
|
||||||
|
"completed": "Module installer request completed",
|
||||||
|
"failed": "Module installer request failed",
|
||||||
|
"cancelled": "Module installer request cancelled",
|
||||||
|
}
|
||||||
|
prefix = prefixes.get(status, "Module installer request updated")
|
||||||
|
return ": ".join((prefix, request_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_status_sentence(request_id: str, status: str) -> str:
|
||||||
|
return " ".join(("Installer request", request_id, "is", status))
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
error = str(request.get("error") or result.get("error") or "").strip()
|
||||||
|
sentence = _installer_status_sentence(request_id, status)
|
||||||
|
if error:
|
||||||
|
return ". Error: ".join((sentence, error))
|
||||||
|
run_id = _result_run_id(request)
|
||||||
|
if run_id:
|
||||||
|
return ". Run: ".join((sentence, run_id))
|
||||||
|
return sentence + "."
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_priority(status: str) -> int:
|
||||||
|
if status == "failed":
|
||||||
|
return 20
|
||||||
|
if status in {"completed", "cancelled"}:
|
||||||
|
return 8
|
||||||
|
if status == "running":
|
||||||
|
return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def _result_run_id(request: Mapping[str, object]) -> str | None:
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
|
||||||
|
return str(run_id) if run_id else None
|
||||||
@@ -7,12 +7,11 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
||||||
@@ -257,17 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not url.startswith(("https://", "http://")):
|
|
||||||
raise ValueError("Trusted license key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _NormalizedModuleInstallPlanItem:
|
||||||
|
module_id: str
|
||||||
|
action: str
|
||||||
|
source: str
|
||||||
|
catalog: Mapping[str, object] | None
|
||||||
|
status: str
|
||||||
|
python_package: str | None
|
||||||
|
python_ref: str | None
|
||||||
|
webui_package: str | None
|
||||||
|
webui_ref: str | None
|
||||||
|
artifact_integrity: Mapping[str, object] | None
|
||||||
|
data_safety_acknowledged: bool
|
||||||
|
destroy_data: bool
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModuleInstallPlan:
|
class ModuleInstallPlan:
|
||||||
items: tuple[ModuleInstallPlanItem, ...] = ()
|
items: tuple[ModuleInstallPlanItem, ...] = ()
|
||||||
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
|
|||||||
def normalize_module_install_plan_item(
|
def normalize_module_install_plan_item(
|
||||||
item: Mapping[str, object] | ModuleInstallPlanItem,
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
) -> ModuleInstallPlanItem:
|
) -> ModuleInstallPlanItem:
|
||||||
|
normalized = _normalized_module_install_plan_item(item)
|
||||||
|
_validate_module_install_plan_item(normalized)
|
||||||
|
return ModuleInstallPlanItem(
|
||||||
|
module_id=normalized.module_id,
|
||||||
|
action=normalized.action,
|
||||||
|
source=normalized.source,
|
||||||
|
catalog=normalized.catalog,
|
||||||
|
python_package=normalized.python_package,
|
||||||
|
python_ref=normalized.python_ref,
|
||||||
|
webui_package=normalized.webui_package,
|
||||||
|
webui_ref=normalized.webui_ref,
|
||||||
|
artifact_integrity=normalized.artifact_integrity,
|
||||||
|
data_safety_acknowledged=normalized.data_safety_acknowledged,
|
||||||
|
destroy_data=normalized.destroy_data,
|
||||||
|
status=normalized.status,
|
||||||
|
notes=normalized.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_module_install_plan_item(
|
||||||
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
|
) -> _NormalizedModuleInstallPlanItem:
|
||||||
if isinstance(item, ModuleInstallPlanItem):
|
if isinstance(item, ModuleInstallPlanItem):
|
||||||
raw = item.as_dict()
|
raw = item.as_dict()
|
||||||
elif isinstance(item, Mapping):
|
elif isinstance(item, Mapping):
|
||||||
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
|
|||||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||||
notes = _clean_optional_string(raw.get("notes"))
|
notes = _clean_optional_string(raw.get("notes"))
|
||||||
|
return _NormalizedModuleInstallPlanItem(
|
||||||
if action not in INSTALL_PLAN_ACTIONS:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
|
|
||||||
if status not in INSTALL_PLAN_STATUSES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
|
||||||
if source not in INSTALL_PLAN_SOURCES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
|
||||||
if action in {"install", "update"} and not python_ref:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
|
||||||
if action == "uninstall" and not python_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
|
||||||
if action != "uninstall" and destroy_data:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
|
||||||
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
|
||||||
if action == "uninstall" and webui_ref and not webui_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
|
||||||
if python_ref:
|
|
||||||
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
|
|
||||||
if webui_ref:
|
|
||||||
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
|
|
||||||
|
|
||||||
return ModuleInstallPlanItem(
|
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
action=action,
|
action=action,
|
||||||
source=source,
|
source=source,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
|
status=status,
|
||||||
python_package=python_package,
|
python_package=python_package,
|
||||||
python_ref=python_ref,
|
python_ref=python_ref,
|
||||||
webui_package=webui_package,
|
webui_package=webui_package,
|
||||||
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
|
|||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
data_safety_acknowledged=data_safety_acknowledged,
|
data_safety_acknowledged=data_safety_acknowledged,
|
||||||
destroy_data=destroy_data,
|
destroy_data=destroy_data,
|
||||||
status=status,
|
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action not in INSTALL_PLAN_ACTIONS:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
|
||||||
|
if item.status not in INSTALL_PLAN_STATUSES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
|
||||||
|
if item.source not in INSTALL_PLAN_SOURCES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
|
||||||
|
_validate_module_install_plan_item_requirements(item)
|
||||||
|
_validate_module_install_plan_item_refs(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action in {"install", "update"} and not item.python_ref:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
|
||||||
|
if item.action == "uninstall" and not item.python_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
|
||||||
|
if item.action != "uninstall" and item.destroy_data:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
|
||||||
|
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||||
|
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.python_ref:
|
||||||
|
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
|
||||||
|
if item.webui_ref:
|
||||||
|
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
|
||||||
|
|
||||||
|
|
||||||
def plan_desired_enabled_modules(
|
def plan_desired_enabled_modules(
|
||||||
requested_enabled: Iterable[str],
|
requested_enabled: Iterable[str],
|
||||||
available: Mapping[str, ModuleManifest],
|
available: Mapping[str, ModuleManifest],
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||||
|
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||||
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CatalogValidationState:
|
||||||
|
modules: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
def module_package_catalog(
|
def module_package_catalog(
|
||||||
path: Path | str | None = None,
|
path: Path | str | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
|
|||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
||||||
return {
|
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
|
||||||
"valid": False,
|
|
||||||
"configured": True,
|
|
||||||
"path": str(catalog_source),
|
|
||||||
"source": str(catalog_source),
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": False,
|
|
||||||
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog does not exist: {catalog_source}",
|
|
||||||
}
|
|
||||||
warnings: list[str] = []
|
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
|
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
|
||||||
|
except Exception as exc:
|
||||||
|
return _catalog_error_result(catalog_source, error=str(exc))
|
||||||
|
policy_error = _catalog_policy_error(
|
||||||
|
catalog_source,
|
||||||
|
state,
|
||||||
|
require_trusted=effective_require_trusted,
|
||||||
|
approved_channels=effective_approved_channels,
|
||||||
|
)
|
||||||
|
if policy_error is not None:
|
||||||
|
return policy_error
|
||||||
|
return _valid_catalog_result(catalog_source, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _CatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
modules = _normalize_catalog_modules(payload)
|
modules = _normalize_catalog_modules(payload)
|
||||||
channel = _catalog_channel(payload)
|
channel = _catalog_channel(payload)
|
||||||
sequence = _catalog_sequence(payload)
|
sequence = _catalog_sequence(payload)
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
return _CatalogValidationState(
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
modules=modules,
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
|
||||||
freshness = _catalog_freshness_state(payload)
|
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
if signature_state.get("fatal"):
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
|
|
||||||
}
|
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
|
|
||||||
}
|
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
|
|
||||||
}
|
|
||||||
if not freshness["valid"]:
|
|
||||||
return _invalid_catalog_result(
|
|
||||||
catalog_source,
|
|
||||||
modules=(),
|
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
read_state=read_state,
|
read_state=read_state,
|
||||||
error=str(freshness["error"]),
|
|
||||||
)
|
)
|
||||||
if not replay["valid"]:
|
|
||||||
|
|
||||||
|
def _catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
state: _CatalogValidationState,
|
||||||
|
*,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _invalid_catalog_state_result(
|
||||||
|
source,
|
||||||
|
state,
|
||||||
|
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
|
||||||
return _invalid_catalog_result(
|
return _invalid_catalog_result(
|
||||||
catalog_source,
|
source,
|
||||||
modules=(),
|
modules=(),
|
||||||
channel=channel,
|
channel=None,
|
||||||
sequence=sequence,
|
sequence=None,
|
||||||
generated_at=generated_at,
|
generated_at=None,
|
||||||
not_before=not_before,
|
not_before=None,
|
||||||
expires_at=expires_at,
|
expires_at=None,
|
||||||
signature_state=signature_state,
|
signature_state=_unsigned_catalog_signature_state(),
|
||||||
read_state=read_state,
|
read_state=_default_catalog_read_state(),
|
||||||
error=str(replay["error"]),
|
error=error,
|
||||||
)
|
)
|
||||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
warnings.extend(_catalog_interface_warnings(modules))
|
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
|
||||||
if not signature_state["signed"]:
|
return _invalid_catalog_result(
|
||||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
source,
|
||||||
elif not signature_state["trusted"]:
|
modules=(),
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
read_state=state.read_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
|
"configured": source is not None and _catalog_source_exists(source),
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
"path": str(source) if source is not None else None,
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
"source": str(source) if source is not None else None,
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
"source_type": _catalog_source_type(source),
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
"cache_used": bool(state.read_state.get("cache_used")),
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
|
||||||
"modules": list(modules),
|
"modules": list(state.modules),
|
||||||
"channel": channel,
|
"channel": state.channel,
|
||||||
"sequence": sequence,
|
"sequence": state.sequence,
|
||||||
"generated_at": generated_at,
|
"generated_at": state.generated_at,
|
||||||
"not_before": not_before,
|
"not_before": state.not_before,
|
||||||
"expires_at": expires_at,
|
"expires_at": state.expires_at,
|
||||||
"signed": signature_state["signed"],
|
"signed": state.signature_state["signed"],
|
||||||
"trusted": signature_state["trusted"],
|
"trusted": state.signature_state["trusted"],
|
||||||
"key_id": signature_state["key_id"],
|
"key_id": state.signature_state["key_id"],
|
||||||
"warnings": warnings,
|
"warnings": _catalog_validation_warnings(state),
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
warnings.extend(_catalog_interface_warnings(state.modules))
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _default_catalog_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
|
||||||
|
|
||||||
|
|
||||||
|
def _unsigned_catalog_signature_state() -> dict[str, object]:
|
||||||
|
return {"signed": False, "trusted": False, "key_id": None}
|
||||||
|
|
||||||
|
|
||||||
def sign_module_package_catalog(
|
def sign_module_package_catalog(
|
||||||
*,
|
*,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
|
|||||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||||
cache_path = _configured_catalog_cache_path()
|
cache_path = _configured_catalog_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body, False
|
return body, False
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8"), True
|
return cache_path.read_text(encoding="utf-8"), True
|
||||||
raise
|
raise
|
||||||
@@ -935,7 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _is_http_url(value: str) -> bool:
|
def _is_http_url(value: str) -> bool:
|
||||||
return value.startswith(("https://", "http://"))
|
return is_http_url(value)
|
||||||
|
|
||||||
|
|
||||||
def _invalid_catalog_result(
|
def _invalid_catalog_result(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NotificationDispatchRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
event_kind: str
|
||||||
|
channel: str = "inbox"
|
||||||
|
recipient: str | None = None
|
||||||
|
recipient_type: str | None = None
|
||||||
|
recipient_id: str | None = None
|
||||||
|
recipient_label: str | None = None
|
||||||
|
subject: str | None = None
|
||||||
|
body_text: str | None = None
|
||||||
|
body_html: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
priority: int = 0
|
||||||
|
not_before_at: datetime | None = None
|
||||||
|
payload: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class NotificationDispatchProvider(Protocol):
|
||||||
|
def enqueue_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: NotificationDispatchRequest,
|
||||||
|
*,
|
||||||
|
enqueue_delivery: bool = True,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
return capability if isinstance(capability, NotificationDispatchProvider) else None
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
|
||||||
|
|
||||||
|
|
||||||
|
class PollCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Poll capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRequest:
|
||||||
|
label: str
|
||||||
|
key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollCreateCommand:
|
||||||
|
title: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
description: str | None = None
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
workflow_state: str | None = None
|
||||||
|
workflow_steps: tuple[Mapping[str, object], ...] = ()
|
||||||
|
allow_anonymous: bool = False
|
||||||
|
allow_response_update: bool = True
|
||||||
|
min_choices: int = 1
|
||||||
|
max_choices: int | None = None
|
||||||
|
opens_at: datetime | None = None
|
||||||
|
closes_at: datetime | None = None
|
||||||
|
options: tuple[PollOptionRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollUpdateCommand:
|
||||||
|
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
allow_anonymous: bool
|
||||||
|
allow_response_update: bool
|
||||||
|
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)
|
||||||
|
class PollInvitationCommand:
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRequest:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollSubmitResponseCommand:
|
||||||
|
respondent_id: str
|
||||||
|
respondent_label: str | None = None
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRef:
|
||||||
|
id: str
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollRef:
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
options: tuple[PollOptionRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRef:
|
||||||
|
id: 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)
|
||||||
|
class PollResponseRef:
|
||||||
|
invitation_id: str | None
|
||||||
|
submitted_at: datetime
|
||||||
|
# Optional for backwards compatibility with existing providers. A
|
||||||
|
# consumer can use it to reconcile an authenticated response without
|
||||||
|
# trusting client-supplied invitation metadata.
|
||||||
|
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
|
||||||
|
class PollSchedulingProvider(Protocol):
|
||||||
|
def create_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
command: PollCreateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollUpdateCommand,
|
||||||
|
) -> 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 close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str | None,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_workflow_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
workflow_state: str,
|
||||||
|
workflow_steps: Sequence[Mapping[str, object]],
|
||||||
|
context_module: str,
|
||||||
|
context_resource_type: str,
|
||||||
|
context_resource_id: str,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
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
|
||||||
|
class PollResponseSubmissionProvider(Protocol):
|
||||||
|
"""Optional extension for modules that collect responses through Poll."""
|
||||||
|
|
||||||
|
def submit_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollSubmitResponseCommand,
|
||||||
|
) -> PollResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, PollSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_submission_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseSubmissionProvider | None:
|
||||||
|
provider = poll_scheduling_provider(registry)
|
||||||
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
@@ -186,46 +186,20 @@ class PlatformRegistry:
|
|||||||
def validate(self) -> RegistrySnapshot:
|
def validate(self) -> RegistrySnapshot:
|
||||||
ordered = tuple(self._topologically_sorted())
|
ordered = tuple(self._topologically_sorted())
|
||||||
available_capabilities = set(self._capability_factories)
|
available_capabilities = set(self._capability_factories)
|
||||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
|
||||||
for manifest in ordered:
|
for manifest in ordered:
|
||||||
_validate_manifest_shape(manifest)
|
_validate_manifest_shape(manifest)
|
||||||
for dependency in manifest.dependencies:
|
_validate_manifest_relationships(
|
||||||
if dependency not in self._manifests:
|
manifest,
|
||||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
known_modules=self._manifests,
|
||||||
for capability in manifest.required_capabilities:
|
available_capabilities=available_capabilities,
|
||||||
if capability not in available_capabilities:
|
)
|
||||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
for dependency in manifest.optional_dependencies:
|
|
||||||
if dependency == manifest.id:
|
|
||||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
|
||||||
for permission in manifest.permissions:
|
|
||||||
if permission.module_id != manifest.id:
|
|
||||||
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
|
||||||
if not _SCOPE_RE.match(permission.scope):
|
|
||||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
|
||||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
|
||||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
|
||||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
|
||||||
if permission.scope in seen_permissions:
|
|
||||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
|
||||||
seen_permissions[permission.scope] = permission
|
|
||||||
|
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
known_scopes = set(seen_permissions)
|
|
||||||
for manifest in ordered:
|
|
||||||
for template in manifest.role_templates:
|
|
||||||
for scope in template.permissions:
|
|
||||||
if scope in {"*", "tenant:*", "system:*"}:
|
|
||||||
continue
|
|
||||||
if _WILDCARD_RE.match(scope):
|
|
||||||
continue
|
|
||||||
if scope not in known_scopes:
|
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
|
||||||
|
|
||||||
return RegistrySnapshot(
|
return RegistrySnapshot(
|
||||||
manifests=ordered,
|
manifests=ordered,
|
||||||
permissions=tuple(seen_permissions.values()),
|
permissions=tuple(permissions.values()),
|
||||||
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
||||||
nav_items=self.nav_items(),
|
nav_items=self.nav_items(),
|
||||||
)
|
)
|
||||||
@@ -299,7 +273,80 @@ def _attribute_delete_veto_issue(
|
|||||||
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_relationships(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
*,
|
||||||
|
known_modules: Mapping[str, ModuleManifest],
|
||||||
|
available_capabilities: set[str],
|
||||||
|
) -> None:
|
||||||
|
for dependency in manifest.dependencies:
|
||||||
|
if dependency not in known_modules:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||||
|
for capability in manifest.required_capabilities:
|
||||||
|
if capability not in available_capabilities:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||||
|
for dependency in manifest.optional_dependencies:
|
||||||
|
if dependency == manifest.id:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
|
||||||
|
permissions: dict[str, PermissionDefinition] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
for permission in manifest.permissions:
|
||||||
|
_validate_manifest_permission(manifest, permission, permissions)
|
||||||
|
permissions[permission.scope] = permission
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_permission(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
permission: PermissionDefinition,
|
||||||
|
seen_permissions: Mapping[str, PermissionDefinition],
|
||||||
|
) -> None:
|
||||||
|
if permission.module_id != manifest.id:
|
||||||
|
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
||||||
|
if not _SCOPE_RE.match(permission.scope):
|
||||||
|
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||||
|
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||||
|
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
||||||
|
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
||||||
|
if permission.scope in seen_permissions:
|
||||||
|
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role_template_scopes(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
*,
|
||||||
|
known_scopes: set[str],
|
||||||
|
) -> None:
|
||||||
|
for manifest in manifests:
|
||||||
|
for template in manifest.role_templates:
|
||||||
|
for scope in template.permissions:
|
||||||
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
|
continue
|
||||||
|
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
||||||
|
if scope in {"*", "tenant:*", "system:*"}:
|
||||||
|
return True
|
||||||
|
if _WILDCARD_RE.match(scope):
|
||||||
|
return True
|
||||||
|
return scope in known_scopes
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||||
|
_validate_manifest_identity(manifest)
|
||||||
|
_validate_manifest_contract_lists(manifest)
|
||||||
|
_validate_manifest_overlaps(manifest)
|
||||||
|
_validate_manifest_migration_spec(manifest)
|
||||||
|
_validate_manifest_frontend(manifest)
|
||||||
|
for item in manifest.nav_items:
|
||||||
|
_validate_nav_item(manifest.id, item)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||||
if not _MODULE_ID_RE.match(manifest.id):
|
if not _MODULE_ID_RE.match(manifest.id):
|
||||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||||
if not manifest.name.strip():
|
if not manifest.name.strip():
|
||||||
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||||
if overlap:
|
if overlap:
|
||||||
joined = ", ".join(sorted(overlap))
|
joined = ", ".join(sorted(overlap))
|
||||||
@@ -328,13 +380,18 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
joined = ", ".join(sorted(capability_overlap))
|
joined = ", ".join(sorted(capability_overlap))
|
||||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
||||||
if manifest.migration_spec is not None:
|
if manifest.migration_spec is not None:
|
||||||
if manifest.migration_spec.module_id != manifest.id:
|
if manifest.migration_spec.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
||||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
||||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||||
|
|
||||||
if manifest.frontend is not None:
|
|
||||||
|
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||||
|
if manifest.frontend is None:
|
||||||
|
return
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
if frontend.module_id != manifest.id:
|
if frontend.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||||
@@ -347,15 +404,16 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
if not route.path.startswith("/"):
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
|
|
||||||
if not route.component.strip():
|
|
||||||
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
|
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
|
||||||
for item in manifest.nav_items:
|
|
||||||
_validate_nav_item(manifest.id, item)
|
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
|
||||||
|
if not component.strip():
|
||||||
|
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
_context: ModuleContext | None = None
|
_context: ModuleContext | None = None
|
||||||
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
|
|||||||
|
|
||||||
def get_registry() -> object | None:
|
def get_registry() -> object | None:
|
||||||
return _context.registry if _context is not None else None
|
return _context.registry if _context is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleSettingsProxy:
|
||||||
|
def __init__(self, runtime: ModuleRuntimeState) -> None:
|
||||||
|
self._runtime = runtime
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return getattr(self._runtime.get_settings(), name)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleRuntimeState:
|
||||||
|
def __init__(self, module_name: str) -> None:
|
||||||
|
self.module_name = module_name
|
||||||
|
self.settings = ModuleSettingsProxy(self)
|
||||||
|
self._registry: object | None = None
|
||||||
|
self._settings: object | None = None
|
||||||
|
|
||||||
|
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
|
||||||
|
if registry is not None:
|
||||||
|
self._registry = registry
|
||||||
|
if settings is not None:
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
def clear_runtime(self) -> None:
|
||||||
|
self._registry = None
|
||||||
|
self._settings = None
|
||||||
|
|
||||||
|
def get_registry(self) -> object | None:
|
||||||
|
return self._registry
|
||||||
|
|
||||||
|
def get_settings(self) -> object:
|
||||||
|
if self._settings is not None:
|
||||||
|
return self._settings
|
||||||
|
try:
|
||||||
|
from govoplan_core.settings import settings as legacy_settings
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
|
||||||
|
return legacy_settings
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
ChangeOperation = Literal["created", "updated", "deleted"]
|
||||||
|
|
||||||
|
|
||||||
|
def object_state(obj: object) -> Any:
|
||||||
|
return inspect(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||||
|
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def previous_value(obj: object, attr_name: str) -> str | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if attr_name not in state.attrs:
|
||||||
|
return None
|
||||||
|
history = state.attrs[attr_name].history
|
||||||
|
if not history.has_changes() or not history.deleted:
|
||||||
|
return None
|
||||||
|
value = history.deleted[0]
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
|
||||||
|
resource_id = getattr(obj, "id", None)
|
||||||
|
if resource_id:
|
||||||
|
return str(resource_id)
|
||||||
|
resource_id = new_id()
|
||||||
|
setattr(obj, "id", resource_id)
|
||||||
|
return resource_id
|
||||||
|
|
||||||
|
|
||||||
|
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if state.pending:
|
||||||
|
return "created"
|
||||||
|
if state.deleted:
|
||||||
|
return "deleted"
|
||||||
|
if not has_attr_changes(state, changed_attrs):
|
||||||
|
return None
|
||||||
|
return "updated"
|
||||||
|
|
||||||
|
|
||||||
|
def operation_for_soft_deletable(
|
||||||
|
obj: object,
|
||||||
|
*,
|
||||||
|
changed_attrs: tuple[str, ...],
|
||||||
|
deleted_attr: str = "deleted_at",
|
||||||
|
) -> ChangeOperation | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if state.pending:
|
||||||
|
return "created"
|
||||||
|
if not has_attr_changes(state, changed_attrs):
|
||||||
|
return None
|
||||||
|
if deleted_attr in state.attrs:
|
||||||
|
history = state.attrs[deleted_attr].history
|
||||||
|
if history.has_changes():
|
||||||
|
if any(value is not None for value in history.added):
|
||||||
|
return "deleted"
|
||||||
|
if any(value is not None for value in history.deleted):
|
||||||
|
return "created"
|
||||||
|
return "updated"
|
||||||
+234
-102
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -24,6 +26,8 @@ from govoplan_core.server.config import ManifestFactory
|
|||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Historic development databases could be created partly through Alembic and
|
# Historic development databases could be created partly through Alembic and
|
||||||
# partly through Base.metadata.create_all(). In that state Alembic still says
|
# partly through Base.metadata.create_all(). In that state Alembic still says
|
||||||
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
|
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
|
||||||
@@ -44,6 +48,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
|
|||||||
MIGRATION_TRACK_RELEASE = "release"
|
MIGRATION_TRACK_RELEASE = "release"
|
||||||
MIGRATION_TRACK_DEV = "dev"
|
MIGRATION_TRACK_DEV = "dev"
|
||||||
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
|
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
|
||||||
|
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
_NAMESPACE_TABLE_RENAMES = (
|
_NAMESPACE_TABLE_RENAMES = (
|
||||||
("tenants", "tenancy_tenants"),
|
("tenants", "tenancy_tenants"),
|
||||||
@@ -164,6 +169,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LegacyCreateAllSchemaState:
|
||||||
|
current: str | None
|
||||||
|
has_no_revision: bool
|
||||||
|
has_file_storage: bool
|
||||||
|
has_file_folders: bool
|
||||||
|
has_create_all_hierarchical_schema: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MigrationResult:
|
class MigrationResult:
|
||||||
previous_revision: str | None
|
previous_revision: str | None
|
||||||
@@ -231,10 +245,7 @@ def run_registered_module_migration_tasks(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
|
active_phases = _normalized_migration_task_phases(phases)
|
||||||
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
|
|
||||||
if invalid_phases:
|
|
||||||
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
|
|
||||||
url = database_url or settings.database_url
|
url = database_url or settings.database_url
|
||||||
registry = _registered_module_registry(
|
registry = _registered_module_registry(
|
||||||
database_url=url,
|
database_url=url,
|
||||||
@@ -242,75 +253,81 @@ def run_registered_module_migration_tasks(
|
|||||||
manifest_factories=manifest_factories,
|
manifest_factories=manifest_factories,
|
||||||
)
|
)
|
||||||
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
||||||
ordered_ids = tuple(dict.fromkeys([
|
ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
|
||||||
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
|
|
||||||
*manifests.keys(),
|
|
||||||
]))
|
|
||||||
records: list[dict[str, object]] = []
|
records: list[dict[str, object]] = []
|
||||||
database = get_database()
|
database = get_database()
|
||||||
with database.SessionLocal() as session:
|
with database.SessionLocal() as session:
|
||||||
for phase in active_phases:
|
for phase in active_phases:
|
||||||
|
for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
|
||||||
|
_run_registered_module_migration_task(
|
||||||
|
session,
|
||||||
|
manifest=manifest,
|
||||||
|
task=task,
|
||||||
|
database_url=url,
|
||||||
|
dry_run=dry_run,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
||||||
|
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
|
||||||
|
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
|
||||||
|
if invalid_phases:
|
||||||
|
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
|
||||||
|
return active_phases
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_migration_task_module_ids(
|
||||||
|
migration_module_order: tuple[str, ...] | list[str] | None,
|
||||||
|
manifests: Mapping[str, object],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
return tuple(dict.fromkeys([
|
||||||
|
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
|
||||||
|
*manifests.keys(),
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_phase_migration_tasks(
|
||||||
|
phase: str,
|
||||||
|
*,
|
||||||
|
ordered_ids: tuple[str, ...],
|
||||||
|
manifests: Mapping[str, object],
|
||||||
|
) -> Iterable[tuple[object, object]]:
|
||||||
for module_id in ordered_ids:
|
for module_id in ordered_ids:
|
||||||
manifest = manifests.get(module_id)
|
manifest = manifests.get(module_id)
|
||||||
if manifest is None or manifest.migration_spec is None:
|
migration_spec = getattr(manifest, "migration_spec", None)
|
||||||
|
if manifest is None or migration_spec is None:
|
||||||
continue
|
continue
|
||||||
for task in manifest.migration_spec.migration_tasks:
|
for task in migration_spec.migration_tasks:
|
||||||
if task.phase != phase:
|
if task.phase == phase:
|
||||||
continue
|
yield manifest, task
|
||||||
record: dict[str, object] = {
|
|
||||||
"module_id": manifest.id,
|
|
||||||
"task_id": task.task_id,
|
def _run_registered_module_migration_task(
|
||||||
"phase": task.phase,
|
session: object,
|
||||||
"summary": task.summary,
|
*,
|
||||||
"task_version": task.task_version,
|
manifest: object,
|
||||||
"safety": task.safety,
|
task: object,
|
||||||
"idempotent": task.idempotent,
|
database_url: str,
|
||||||
"dry_run": dry_run,
|
dry_run: bool,
|
||||||
}
|
records: list[dict[str, object]],
|
||||||
if task.timeout_seconds is not None:
|
) -> None:
|
||||||
record["timeout_seconds"] = task.timeout_seconds
|
record = _migration_task_record(manifest, task, dry_run=dry_run)
|
||||||
if not task.idempotent:
|
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
|
||||||
record.update({"status": "blocked", "message": "Task is not idempotent."})
|
|
||||||
records.append(record)
|
|
||||||
raise ModuleMigrationTaskExecutionError(
|
|
||||||
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
|
|
||||||
records=tuple(records),
|
|
||||||
)
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
||||||
records.append(record)
|
records.append(record)
|
||||||
continue
|
return
|
||||||
if task.executor is None:
|
normalized = _execute_module_migration_task(
|
||||||
record.update({"status": "blocked", "message": "Task has no executor."})
|
session,
|
||||||
records.append(record)
|
manifest=manifest,
|
||||||
raise ModuleMigrationTaskExecutionError(
|
task=task,
|
||||||
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
|
record=record,
|
||||||
records=tuple(records),
|
records=records,
|
||||||
)
|
database_url=database_url,
|
||||||
context = ModuleMigrationTaskContext(
|
|
||||||
module_id=manifest.id,
|
|
||||||
task_id=task.task_id,
|
|
||||||
phase=task.phase,
|
|
||||||
database_url=url,
|
|
||||||
target_version=manifest.version,
|
|
||||||
session=session,
|
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
metadata=task.metadata,
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
result = task.executor(context)
|
|
||||||
normalized = _normalize_migration_task_result(result)
|
|
||||||
except Exception as exc:
|
|
||||||
session.rollback()
|
|
||||||
record.update({
|
|
||||||
"status": "blocked",
|
|
||||||
"message": f"{type(exc).__name__}: {exc}",
|
|
||||||
})
|
|
||||||
records.append(record)
|
|
||||||
raise ModuleMigrationTaskExecutionError(
|
|
||||||
f"Module migration task {manifest.id}/{task.task_id} failed.",
|
|
||||||
records=tuple(records),
|
|
||||||
) from exc
|
|
||||||
record.update({
|
record.update({
|
||||||
"status": normalized.status,
|
"status": normalized.status,
|
||||||
"message": normalized.message,
|
"message": normalized.message,
|
||||||
@@ -324,7 +341,102 @@ def run_registered_module_migration_tasks(
|
|||||||
records=tuple(records),
|
records=tuple(records),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return tuple(records)
|
|
||||||
|
|
||||||
|
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
|
||||||
|
record: dict[str, object] = {
|
||||||
|
"module_id": manifest.id,
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"phase": task.phase,
|
||||||
|
"summary": task.summary,
|
||||||
|
"task_version": task.task_version,
|
||||||
|
"safety": task.safety,
|
||||||
|
"idempotent": task.idempotent,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
if task.timeout_seconds is not None:
|
||||||
|
record["timeout_seconds"] = task.timeout_seconds
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_migration_task_can_run(
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> None:
|
||||||
|
if not task.idempotent:
|
||||||
|
_block_migration_task(
|
||||||
|
manifest,
|
||||||
|
task,
|
||||||
|
record,
|
||||||
|
records,
|
||||||
|
message="Task is not idempotent.",
|
||||||
|
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
|
||||||
|
)
|
||||||
|
if not dry_run and task.executor is None:
|
||||||
|
_block_migration_task(
|
||||||
|
manifest,
|
||||||
|
task,
|
||||||
|
record,
|
||||||
|
records,
|
||||||
|
message="Task has no executor.",
|
||||||
|
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_migration_task(
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
record.update({"status": "blocked", "message": message})
|
||||||
|
records.append(record)
|
||||||
|
raise ModuleMigrationTaskExecutionError(
|
||||||
|
error,
|
||||||
|
records=tuple(records),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_module_migration_task(
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
database_url: str,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> ModuleMigrationTaskResult:
|
||||||
|
context = ModuleMigrationTaskContext(
|
||||||
|
module_id=manifest.id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
phase=task.phase,
|
||||||
|
database_url=database_url,
|
||||||
|
target_version=manifest.version,
|
||||||
|
session=session,
|
||||||
|
dry_run=dry_run,
|
||||||
|
metadata=task.metadata,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return _normalize_migration_task_result(task.executor(context))
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
record.update({
|
||||||
|
"status": "blocked",
|
||||||
|
"message": f"{type(exc).__name__}: {exc}",
|
||||||
|
})
|
||||||
|
records.append(record)
|
||||||
|
raise ModuleMigrationTaskExecutionError(
|
||||||
|
f"Module migration task {manifest.id}/{task.task_id} failed.",
|
||||||
|
records=tuple(records),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
|
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
|
||||||
@@ -521,19 +633,28 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _row_count(connection, table_name: str) -> int:
|
def _row_count(connection, table_name: str) -> int:
|
||||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
quoted = _quoted_table_name(connection, table_name)
|
||||||
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
return int(connection.execute(statement).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _drop_table(connection, table_name: str) -> None:
|
def _drop_table(connection, table_name: str) -> None:
|
||||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
quoted = _quoted_table_name(connection, table_name)
|
||||||
connection.execute(text(f"DROP TABLE {quoted}"))
|
statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
def _rename_table(connection, old_name: str, new_name: str) -> None:
|
def _rename_table(connection, old_name: str, new_name: str) -> None:
|
||||||
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
|
quoted_old = _quoted_table_name(connection, old_name)
|
||||||
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
|
quoted_new = _quoted_table_name(connection, new_name)
|
||||||
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
|
statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted_table_name(connection, table_name: str) -> str:
|
||||||
|
if not _SQL_IDENTIFIER_RE.fullmatch(table_name):
|
||||||
|
raise ValueError(f"Unsafe table identifier: {table_name!r}")
|
||||||
|
return connection.dialect.identifier_preparer.quote(table_name)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
|
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
|
||||||
@@ -707,7 +828,8 @@ def reconcile_covered_alembic_dependency_heads(
|
|||||||
for revision_id in current:
|
for revision_id in current:
|
||||||
try:
|
try:
|
||||||
revision = script.get_revision(revision_id)
|
revision = script.get_revision(revision_id)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
|
||||||
continue
|
continue
|
||||||
ancestors = {
|
ancestors = {
|
||||||
item.revision
|
item.revision
|
||||||
@@ -741,15 +863,34 @@ def reconcile_legacy_create_all_schema(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
url = database_url or settings.database_url
|
url = database_url or settings.database_url
|
||||||
engine = create_engine(url)
|
state = _legacy_create_all_schema_state(url)
|
||||||
|
target = _legacy_create_all_reconciliation_target(state)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
if target == REVISION_HIERARCHICAL_SETTINGS:
|
||||||
|
_backfill_user_lock_state_for_create_all_schema(url)
|
||||||
|
|
||||||
|
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
|
||||||
|
engine = create_engine(database_url)
|
||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
heads = MigrationContext.configure(connection).get_current_heads()
|
heads = MigrationContext.configure(connection).get_current_heads()
|
||||||
current = heads[0] if len(heads) == 1 else None
|
|
||||||
has_no_revision = len(heads) == 0
|
|
||||||
schema = inspect(connection)
|
schema = inspect(connection)
|
||||||
tables = set(schema.get_table_names())
|
tables = set(schema.get_table_names())
|
||||||
|
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_create_all_schema_state_from_inspection(
|
||||||
|
heads: tuple[str, ...],
|
||||||
|
schema: object,
|
||||||
|
tables: set[str],
|
||||||
|
) -> _LegacyCreateAllSchemaState:
|
||||||
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
||||||
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
||||||
for table in _FILE_STORAGE_TABLES
|
for table in _FILE_STORAGE_TABLES
|
||||||
@@ -759,39 +900,30 @@ def reconcile_legacy_create_all_schema(
|
|||||||
"file_folders",
|
"file_folders",
|
||||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||||
)
|
)
|
||||||
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
|
return _LegacyCreateAllSchemaState(
|
||||||
finally:
|
current=heads[0] if len(heads) == 1 else None,
|
||||||
engine.dispose()
|
has_no_revision=len(heads) == 0,
|
||||||
|
has_file_storage=has_file_storage,
|
||||||
|
has_file_folders=has_file_folders,
|
||||||
|
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
|
||||||
|
)
|
||||||
|
|
||||||
target: str | None = None
|
|
||||||
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
elif current == REVISION_AUTH_RBAC and has_file_storage:
|
|
||||||
target = REVISION_FILE_STORAGE
|
|
||||||
elif current == REVISION_FILE_STORAGE and has_file_folders:
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
|
|
||||||
# Development DBs may be stamped at 4e after earlier create_all
|
|
||||||
# reconciliation even though later tables/columns were already created
|
|
||||||
# by a newer model set. Skip only when that complete known schema is
|
|
||||||
# present, then let newer migrations such as mail credential usernames
|
|
||||||
# run normally.
|
|
||||||
_backfill_user_lock_state_for_create_all_schema(url)
|
|
||||||
target = REVISION_HIERARCHICAL_SETTINGS
|
|
||||||
elif has_no_revision and has_create_all_hierarchical_schema:
|
|
||||||
_backfill_user_lock_state_for_create_all_schema(url)
|
|
||||||
target = REVISION_HIERARCHICAL_SETTINGS
|
|
||||||
elif has_no_revision and has_file_storage and has_file_folders:
|
|
||||||
# This is the other create_all-only development shape. The strict
|
|
||||||
# column checks above ensure that we only stamp a complete known schema.
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
|
|
||||||
if target is None:
|
def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
|
||||||
|
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
|
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
|
||||||
|
return REVISION_FILE_STORAGE
|
||||||
|
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
|
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
|
||||||
|
return REVISION_HIERARCHICAL_SETTINGS
|
||||||
|
if state.has_no_revision and state.has_create_all_hierarchical_schema:
|
||||||
|
return REVISION_HIERARCHICAL_SETTINGS
|
||||||
|
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
return None
|
return None
|
||||||
|
|
||||||
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_database(
|
def migrate_database(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import time
|
||||||
|
import weakref
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.engine import Connection, Engine
|
||||||
|
from sqlalchemy.engine.interfaces import ExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class QueryMetrics:
|
||||||
|
query_count: int = 0
|
||||||
|
total_ms: float = 0.0
|
||||||
|
slowest_ms: float = 0.0
|
||||||
|
error_count: int = 0
|
||||||
|
|
||||||
|
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
|
||||||
|
self.query_count += 1
|
||||||
|
self.total_ms += elapsed_ms
|
||||||
|
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
|
||||||
|
if failed:
|
||||||
|
self.error_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
|
||||||
|
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
|
||||||
|
_START_STACK_KEY = "govoplan_query_metric_starts"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def collect_query_metrics() -> Iterator[QueryMetrics]:
|
||||||
|
metrics = QueryMetrics()
|
||||||
|
token = _current_metrics.set(metrics)
|
||||||
|
try:
|
||||||
|
yield metrics
|
||||||
|
finally:
|
||||||
|
_current_metrics.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_query_metrics() -> QueryMetrics | None:
|
||||||
|
return _current_metrics.get()
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_engine(engine: Engine) -> Engine:
|
||||||
|
if engine in _instrumented_engines:
|
||||||
|
return engine
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
|
||||||
|
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
|
||||||
|
event.listen(engine, "handle_error", _handle_error)
|
||||||
|
_instrumented_engines.add(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _before_cursor_execute(
|
||||||
|
conn: Connection,
|
||||||
|
_cursor: object,
|
||||||
|
_statement: str,
|
||||||
|
_parameters: object,
|
||||||
|
_context: ExecutionContext,
|
||||||
|
_executemany: bool,
|
||||||
|
) -> None:
|
||||||
|
if current_query_metrics() is None:
|
||||||
|
return
|
||||||
|
starts = conn.info.setdefault(_START_STACK_KEY, [])
|
||||||
|
starts.append(time.perf_counter())
|
||||||
|
|
||||||
|
|
||||||
|
def _after_cursor_execute(
|
||||||
|
conn: Connection,
|
||||||
|
_cursor: object,
|
||||||
|
_statement: str,
|
||||||
|
_parameters: object,
|
||||||
|
_context: ExecutionContext,
|
||||||
|
_executemany: bool,
|
||||||
|
) -> None:
|
||||||
|
_record_elapsed(conn, failed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_error(exception_context: object) -> None:
|
||||||
|
conn = getattr(exception_context, "connection", None)
|
||||||
|
if isinstance(conn, Connection):
|
||||||
|
_record_elapsed(conn, failed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
|
||||||
|
metrics = current_query_metrics()
|
||||||
|
if metrics is None:
|
||||||
|
return
|
||||||
|
starts = conn.info.get(_START_STACK_KEY)
|
||||||
|
if not starts:
|
||||||
|
return
|
||||||
|
started_at = starts.pop()
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
metrics.record(elapsed_ms, failed=failed)
|
||||||
@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.query_metrics import instrument_engine
|
||||||
|
|
||||||
|
|
||||||
def default_connect_args(database_url: str) -> dict[str, Any]:
|
def default_connect_args(database_url: str) -> dict[str, Any]:
|
||||||
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||||
@@ -22,13 +24,13 @@ def create_database_engine(
|
|||||||
merged_connect_args = dict(default_connect_args(database_url))
|
merged_connect_args = dict(default_connect_args(database_url))
|
||||||
if connect_args:
|
if connect_args:
|
||||||
merged_connect_args.update(connect_args)
|
merged_connect_args.update(connect_args)
|
||||||
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs)
|
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHandle:
|
class DatabaseHandle:
|
||||||
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
self.engine = engine or create_database_engine(database_url)
|
self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
|
||||||
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
def session(self) -> Session:
|
def session(self) -> Session:
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_split_transport_credentials(value: object) -> object:
|
||||||
|
"""Move legacy transport username/password fields into credentials."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
data = dict(value)
|
||||||
|
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
||||||
|
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
transport = data.get(protocol)
|
||||||
|
if not isinstance(transport, dict):
|
||||||
|
continue
|
||||||
|
next_transport = dict(transport)
|
||||||
|
next_credentials = dict(credentials.get(protocol) or {})
|
||||||
|
for field in ("username", "password"):
|
||||||
|
if field in next_transport and field not in next_credentials:
|
||||||
|
next_credentials[field] = next_transport[field]
|
||||||
|
next_transport.pop(field, None)
|
||||||
|
next_transport.pop("enabled", None)
|
||||||
|
data[protocol] = next_transport
|
||||||
|
if next_credentials:
|
||||||
|
credentials[protocol] = next_credentials
|
||||||
|
if credentials:
|
||||||
|
data["credentials"] = credentials
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
||||||
if security == TransportSecurity.TLS or security == "tls":
|
if security == TransportSecurity.TLS or security == "tls":
|
||||||
return 465
|
return 465
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Compatibility facade for privacy retention policy.
|
"""Compatibility facade for privacy retention policy.
|
||||||
|
|
||||||
Policy-owned behavior is provided by ``govoplan-policy`` through the
|
Policy-owned behavior is provided by ``govoplan-policy`` through the
|
||||||
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
|
|||||||
without importing policy implementation code from core.
|
without importing policy implementation code from core.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
|
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.privacy.schemas import (
|
||||||
|
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
|
||||||
|
RETENTION_POLICY_FIELD_KEYS,
|
||||||
|
default_allow_lower_level_limits as default_allow_lower_level_limits,
|
||||||
|
normalize_allow_lower_level_limits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||||
RETENTION_DAY_KEYS = (
|
|
||||||
"raw_campaign_json_retention_days",
|
|
||||||
"generated_eml_retention_days",
|
|
||||||
"stored_report_detail_retention_days",
|
|
||||||
"mock_mailbox_retention_days",
|
|
||||||
"audit_detail_retention_days",
|
|
||||||
)
|
|
||||||
RETENTION_POLICY_FIELD_KEYS = (
|
|
||||||
"store_raw_campaign_json",
|
|
||||||
*RETENTION_DAY_KEYS,
|
|
||||||
"audit_detail_level",
|
|
||||||
)
|
|
||||||
|
|
||||||
_DEFAULT_POLICY = {
|
_DEFAULT_POLICY = {
|
||||||
"store_raw_campaign_json": True,
|
"store_raw_campaign_json": True,
|
||||||
"raw_campaign_json_retention_days": None,
|
"raw_campaign_json_retention_days": None,
|
||||||
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
for key in RETENTION_POLICY_FIELD_KEYS:
|
for key in RETENTION_POLICY_FIELD_KEYS:
|
||||||
if key in raw:
|
if key in raw:
|
||||||
payload[key] = raw[key]
|
payload[key] = raw[key]
|
||||||
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
|
payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
|
||||||
if value in (None, ""):
|
|
||||||
return default_allow_lower_level_limits() if fill_defaults else None
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ValueError("allow_lower_level_limits must be an object")
|
|
||||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
|
||||||
for key, allowed in value.items():
|
|
||||||
clean_key = str(key)
|
|
||||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
|
||||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
|
||||||
normalized[clean_key] = bool(allowed)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
|
||||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def privacy_policy_from_settings(item: object) -> Any:
|
def privacy_policy_from_settings(item: object) -> Any:
|
||||||
service = _runtime_service()
|
service = _runtime_service()
|
||||||
if service is not None:
|
if service is not None:
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
RETENTION_DAY_KEYS = (
|
||||||
|
"raw_campaign_json_retention_days",
|
||||||
|
"generated_eml_retention_days",
|
||||||
|
"stored_report_detail_retention_days",
|
||||||
|
"mock_mailbox_retention_days",
|
||||||
|
"audit_detail_retention_days",
|
||||||
|
)
|
||||||
|
RETENTION_POLICY_FIELD_KEYS = (
|
||||||
|
"store_raw_campaign_json",
|
||||||
|
*RETENTION_DAY_KEYS,
|
||||||
|
"audit_detail_level",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||||
|
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return default_allow_lower_level_limits() if fill_defaults else None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("allow_lower_level_limits must be an object")
|
||||||
|
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||||
|
for key, allowed in value.items():
|
||||||
|
clean_key = str(key)
|
||||||
|
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||||
|
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||||
|
normalized[clean_key] = bool(allowed)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicyItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
store_raw_campaign_json: bool = True
|
||||||
|
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||||
|
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||||
|
|
||||||
|
@field_validator("allow_lower_level_limits", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||||
|
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
store_raw_campaign_json: bool | None = None
|
||||||
|
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
||||||
|
allow_lower_level_limits: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
@field_validator("allow_lower_level_limits", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||||
|
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RETENTION_DAY_KEYS",
|
||||||
|
"RETENTION_POLICY_FIELD_KEYS",
|
||||||
|
"PrivacyRetentionPolicyItem",
|
||||||
|
"PrivacyRetentionPolicyPatchItem",
|
||||||
|
"default_allow_lower_level_limits",
|
||||||
|
"normalize_allow_lower_level_limits",
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HttpFetchResponse:
|
||||||
|
status: int
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
def text(self, encoding: str = "utf-8") -> str:
|
||||||
|
return self.body.decode(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def is_http_url(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
validate_http_url(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_http_url(value: str, *, label: str = "URL") -> str:
|
||||||
|
parsed = urllib.parse.urlparse(str(value).strip())
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError(f"{label} must not include embedded credentials.")
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
label: str = "URL",
|
||||||
|
method: str = "GET",
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
) -> HttpFetchResponse:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
validate_http_url(url, label=label),
|
||||||
|
headers=dict(headers or {}),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
|
return HttpFetchResponse(
|
||||||
|
status=int(getattr(response, "status", 0)),
|
||||||
|
headers=dict(response.headers.items()),
|
||||||
|
body=response.read(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http_text(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
label: str = "URL",
|
||||||
|
method: str = "GET",
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
) -> str:
|
||||||
|
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)
|
||||||
@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
|
|||||||
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
||||||
if legacy.startswith("system:")
|
if legacy.startswith("system:")
|
||||||
)
|
)
|
||||||
|
LEGACY_SYSTEM_SCOPES = frozenset(
|
||||||
|
legacy
|
||||||
|
for legacy in LEGACY_TO_MODULE_SCOPES
|
||||||
|
if legacy.startswith("system:")
|
||||||
|
)
|
||||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
|||||||
|
|
||||||
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||||
granted = list(scopes)
|
granted = list(scopes)
|
||||||
if required in MODULE_SYSTEM_SCOPES:
|
if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
|
||||||
return "*" in granted or "system:*" in granted or any(
|
return "*" in granted or "system:*" in granted or any(
|
||||||
scope != "tenant:*" and scopes_grant([scope], alias)
|
scope != "tenant:*" and scopes_grant([scope], alias)
|
||||||
for scope in granted
|
for scope in granted
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PermissionDefinition:
|
class PermissionDefinition:
|
||||||
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
|
|||||||
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
|
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
|
||||||
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
|
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
|
||||||
|
|
||||||
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
|
||||||
# Only names that are no longer canonical remain runtime aliases. Canonical
|
|
||||||
# permissions keep their narrow meaning; the Alembic migration expands old
|
|
||||||
# role records once so upgraded installations do not lose prior access.
|
|
||||||
"campaign:write": frozenset({
|
|
||||||
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
|
|
||||||
"recipients:read", "recipients:write", "recipients:import",
|
|
||||||
}),
|
|
||||||
"attachments:read": frozenset({"files:read", "files:download"}),
|
|
||||||
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
|
||||||
"admin:users": frozenset({
|
|
||||||
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
|
|
||||||
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
|
|
||||||
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
|
|
||||||
}),
|
|
||||||
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
|
|
||||||
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
|
||||||
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
|
|
||||||
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
|
||||||
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
|
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
|
||||||
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
|
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
|
||||||
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
|
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
||||||
|
# Only names that are no longer canonical remain runtime aliases. Canonical
|
||||||
|
# permissions keep their narrow meaning; migrations expand old role records
|
||||||
|
# once so upgraded installations do not lose prior access.
|
||||||
|
"campaign:write": frozenset({
|
||||||
|
"campaign:create",
|
||||||
|
"campaign:update",
|
||||||
|
"campaign:copy",
|
||||||
|
"campaign:archive",
|
||||||
|
"campaign:delete",
|
||||||
|
"campaign:share",
|
||||||
|
"recipients:read",
|
||||||
|
"recipients:write",
|
||||||
|
"recipients:import",
|
||||||
|
}),
|
||||||
|
"attachments:read": frozenset({"files:read", "files:download"}),
|
||||||
|
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
||||||
|
"admin:users": frozenset({
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:users:create",
|
||||||
|
"admin:users:update",
|
||||||
|
"admin:users:suspend",
|
||||||
|
"admin:groups:read",
|
||||||
|
"admin:groups:write",
|
||||||
|
"admin:groups:manage_members",
|
||||||
|
"admin:roles:read",
|
||||||
|
"admin:roles:write",
|
||||||
|
"admin:roles:assign",
|
||||||
|
}),
|
||||||
|
"admin:users:write": frozenset({
|
||||||
|
"admin:users:create",
|
||||||
|
"admin:users:update",
|
||||||
|
"admin:users:suspend",
|
||||||
|
"admin:roles:assign",
|
||||||
|
"admin:groups:manage_members",
|
||||||
|
}),
|
||||||
|
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
||||||
|
"admin:settings": frozenset({
|
||||||
|
"admin:settings:read",
|
||||||
|
"admin:settings:write",
|
||||||
|
"admin:api_keys:read",
|
||||||
|
"admin:api_keys:create",
|
||||||
|
"admin:api_keys:revoke",
|
||||||
|
}),
|
||||||
|
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
||||||
|
"system:access:write": frozenset({
|
||||||
|
"system:access:assign",
|
||||||
|
"system:roles:assign",
|
||||||
|
"system:accounts:create",
|
||||||
|
"system:accounts:update",
|
||||||
|
"system:accounts:suspend",
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
|
|||||||
try:
|
try:
|
||||||
Fernet(candidate)
|
Fernet(candidate)
|
||||||
return candidate
|
return candidate
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
raw = None
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(candidate)
|
raw = base64.b64decode(candidate)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
|
|||||||
|
|
||||||
|
|
||||||
def create_app(config: GovoplanServerConfig | str | None = None):
|
def create_app(config: GovoplanServerConfig | str | None = None):
|
||||||
if isinstance(config, str) or config is None:
|
server_config = _server_config(config)
|
||||||
server_config = load_server_config(config)
|
_configure_app_database(server_config)
|
||||||
else:
|
registry, available_modules = _server_module_registry(server_config)
|
||||||
server_config = config
|
api_router = _server_api_router(server_config, registry)
|
||||||
|
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
|
||||||
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
|
||||||
if database_url:
|
|
||||||
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
|
|
||||||
configure_database(str(database_url), dispose_previous=dispose_previous)
|
|
||||||
|
|
||||||
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
|
|
||||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
|
||||||
startup_available_modules = available_module_manifests(
|
|
||||||
server_config.manifest_factories,
|
|
||||||
enabled_modules=candidate_modules,
|
|
||||||
ignore_load_errors=True,
|
|
||||||
)
|
|
||||||
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
|
|
||||||
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
|
|
||||||
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
|
|
||||||
api_router = APIRouter(prefix=server_config.api_prefix)
|
|
||||||
|
|
||||||
for router in server_config.base_routers:
|
|
||||||
api_router.include_router(router)
|
|
||||||
|
|
||||||
lifecycle = ModuleLifecycleManager(
|
|
||||||
registry=registry,
|
|
||||||
available_modules=available_modules,
|
|
||||||
settings=server_config.settings,
|
|
||||||
api_prefix=server_config.api_prefix,
|
|
||||||
manifest_factories=tuple(server_config.manifest_factories),
|
|
||||||
module_context_data=server_config.module_context_data,
|
|
||||||
)
|
|
||||||
lifecycle.configure_runtime()
|
lifecycle.configure_runtime()
|
||||||
|
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
|
||||||
|
|
||||||
for router in server_config.post_module_routers:
|
|
||||||
api_router.include_router(router)
|
|
||||||
|
|
||||||
for contribution in server_config.extra_routers:
|
|
||||||
if contribution.should_include(server_config.settings, registry):
|
|
||||||
api_router.include_router(contribution.router)
|
|
||||||
|
|
||||||
validate_no_route_collisions(api_router, owner="server startup routes")
|
|
||||||
|
|
||||||
app = create_govoplan_app(
|
app = create_govoplan_app(
|
||||||
title=server_config.title,
|
title=server_config.title,
|
||||||
version=server_config.version,
|
version=server_config.version,
|
||||||
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
|
||||||
|
if isinstance(config, str) or config is None:
|
||||||
|
return load_server_config(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
|
||||||
|
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
||||||
|
if database_url:
|
||||||
|
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
|
||||||
|
configure_database(str(database_url), dispose_previous=dispose_previous)
|
||||||
|
|
||||||
|
|
||||||
|
def _server_module_registry(server_config: GovoplanServerConfig):
|
||||||
|
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
|
||||||
|
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||||
|
startup_available_modules = available_module_manifests(
|
||||||
|
server_config.manifest_factories,
|
||||||
|
enabled_modules=candidate_modules,
|
||||||
|
ignore_load_errors=True,
|
||||||
|
)
|
||||||
|
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
|
||||||
|
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
|
||||||
|
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
|
||||||
|
return registry, available_modules
|
||||||
|
|
||||||
|
|
||||||
|
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
|
||||||
|
api_router = APIRouter(prefix=server_config.api_prefix)
|
||||||
|
for router in server_config.base_routers:
|
||||||
|
api_router.include_router(router)
|
||||||
|
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||||
|
for router in server_config.post_module_routers:
|
||||||
|
api_router.include_router(router)
|
||||||
|
for contribution in server_config.extra_routers:
|
||||||
|
if contribution.should_include(server_config.settings, registry):
|
||||||
|
api_router.include_router(contribution.router)
|
||||||
|
validate_no_route_collisions(api_router, owner="server startup routes")
|
||||||
|
return api_router
|
||||||
|
|
||||||
|
|
||||||
|
def _server_lifecycle(
|
||||||
|
server_config: GovoplanServerConfig,
|
||||||
|
*,
|
||||||
|
registry,
|
||||||
|
available_modules,
|
||||||
|
) -> ModuleLifecycleManager:
|
||||||
|
return ModuleLifecycleManager(
|
||||||
|
registry=registry,
|
||||||
|
available_modules=available_modules,
|
||||||
|
settings=server_config.settings,
|
||||||
|
api_prefix=server_config.api_prefix,
|
||||||
|
manifest_factories=tuple(server_config.manifest_factories),
|
||||||
|
module_context_data=server_config.module_context_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
|
|||||||
ManifestFactory = Callable[[], ModuleManifest]
|
ManifestFactory = Callable[[], ModuleManifest]
|
||||||
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
|
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
|
||||||
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
|
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
|
||||||
|
DEFAULT_TRUSTED_IMPORT_PREFIXES = ("govoplan_core", "govoplan_", "app")
|
||||||
|
TRUSTED_IMPORT_PREFIXES_ENV = "GOVOPLAN_TRUSTED_IMPORT_PREFIXES"
|
||||||
|
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
||||||
|
_ATTRIBUTE_PATH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
|
|||||||
|
|
||||||
|
|
||||||
def import_object(path: str) -> Any:
|
def import_object(path: str) -> Any:
|
||||||
module_name, separator, attribute = path.partition(":")
|
module_name, attribute = validate_object_path(path)
|
||||||
if not separator:
|
_validate_trusted_import_prefix(module_name, trusted_import_prefixes())
|
||||||
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
|
module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
|
||||||
module = import_module(module_name)
|
|
||||||
value: Any = module
|
value: Any = module
|
||||||
for part in attribute.split("."):
|
for part in attribute.split("."):
|
||||||
value = getattr(value, part)
|
value = getattr(value, part)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_object_path(path: str) -> tuple[str, str]:
|
||||||
|
module_name, separator, attribute = path.strip().partition(":")
|
||||||
|
if not separator:
|
||||||
|
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
|
||||||
|
if not _MODULE_PATH_RE.fullmatch(module_name):
|
||||||
|
raise ValueError(f"Object path module is not a valid Python module path: {path!r}")
|
||||||
|
if not _ATTRIBUTE_PATH_RE.fullmatch(attribute) or any(part.startswith("_") for part in attribute.split(".")):
|
||||||
|
raise ValueError(f"Object path attribute is not a public attribute path: {path!r}")
|
||||||
|
return module_name, attribute
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_import_prefixes() -> tuple[str, ...]:
|
||||||
|
configured = os.getenv(TRUSTED_IMPORT_PREFIXES_ENV, "").strip()
|
||||||
|
if not configured:
|
||||||
|
return DEFAULT_TRUSTED_IMPORT_PREFIXES
|
||||||
|
prefixes = tuple(item.strip() for item in configured.split(",") if item.strip())
|
||||||
|
return prefixes or DEFAULT_TRUSTED_IMPORT_PREFIXES
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_trusted_import_prefix(module_name: str, prefixes: Iterable[str]) -> None:
|
||||||
|
if any(_module_matches_prefix(module_name, prefix) for prefix in prefixes):
|
||||||
|
return
|
||||||
|
raise ValueError(
|
||||||
|
f"Object path module {module_name!r} is outside trusted import prefixes. "
|
||||||
|
f"Set {TRUSTED_IMPORT_PREFIXES_ENV} to allow custom deployment config modules."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_matches_prefix(module_name: str, prefix: str) -> bool:
|
||||||
|
cleaned = prefix.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
if cleaned.endswith((".", "_")):
|
||||||
|
return module_name.startswith(cleaned)
|
||||||
|
return module_name == cleaned or module_name.startswith(f"{cleaned}.")
|
||||||
|
|
||||||
|
|
||||||
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
|
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
|
||||||
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
|
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from collections.abc import AsyncIterator, Callable, Iterable
|
from collections.abc import AsyncIterator, Callable, Iterable
|
||||||
from contextlib import AbstractAsyncContextManager
|
from contextlib import AbstractAsyncContextManager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -9,9 +12,19 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||||
|
|
||||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||||
|
logger = logging.getLogger("govoplan.request")
|
||||||
|
|
||||||
|
|
||||||
|
def _slow_request_threshold_ms() -> float:
|
||||||
|
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return 500.0
|
||||||
|
|
||||||
|
|
||||||
def create_govoplan_app(
|
def create_govoplan_app(
|
||||||
@@ -26,6 +39,7 @@ def create_govoplan_app(
|
|||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||||
app.state.govoplan_registry = registry
|
app.state.govoplan_registry = registry
|
||||||
|
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def request_correlation_context(request: Request, call_next):
|
async def request_correlation_context(request: Request, call_next):
|
||||||
@@ -39,6 +53,42 @@ def create_govoplan_app(
|
|||||||
response.headers["X-Correlation-ID"] = correlation_id
|
response.headers["X-Correlation-ID"] = correlation_id
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def slow_request_logging(request: Request, call_next):
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
with collect_query_metrics() as db_metrics:
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
|
||||||
|
logger.warning(
|
||||||
|
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
elapsed_ms,
|
||||||
|
db_metrics.query_count,
|
||||||
|
db_metrics.total_ms,
|
||||||
|
db_metrics.slowest_ms,
|
||||||
|
db_metrics.error_count,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
|
||||||
|
logger.warning(
|
||||||
|
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
elapsed_ms,
|
||||||
|
db_metrics.query_count,
|
||||||
|
db_metrics.total_ms,
|
||||||
|
db_metrics.slowest_ms,
|
||||||
|
db_metrics.error_count,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
app.middleware("http")(conditional_json_get_middleware)
|
app.middleware("http")(conditional_json_get_middleware)
|
||||||
|
|
||||||
origins = [item.strip() for item in cors_origins if item.strip()]
|
origins = [item.strip() for item in cors_origins if item.strip()]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import importlib
|
|||||||
from govoplan_core.core.discovery import discover_module_manifests
|
from govoplan_core.core.discovery import discover_module_manifests
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
from govoplan_core.server.config import validate_object_path
|
||||||
|
|
||||||
ManifestFactory = Callable[[], ModuleManifest]
|
ManifestFactory = Callable[[], ModuleManifest]
|
||||||
|
|
||||||
@@ -74,8 +75,10 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
|
|||||||
|
|
||||||
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
||||||
target = _BUILTIN_MANIFESTS[module_id]
|
target = _BUILTIN_MANIFESTS[module_id]
|
||||||
module_name, function_name = target.split(":", 1)
|
module_name, function_name = validate_object_path(target)
|
||||||
module = importlib.import_module(module_name)
|
if module_name != _BUILTIN_MANIFESTS[module_id].split(":", 1)[0]:
|
||||||
|
raise RegistryError(f"Built-in module manifest target changed unexpectedly: {module_id}")
|
||||||
|
module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
|
||||||
factory = getattr(module, function_name)
|
factory = getattr(module, function_name)
|
||||||
manifest = factory()
|
manifest = factory()
|
||||||
if not isinstance(manifest, ModuleManifest):
|
if not isinstance(manifest, ModuleManifest):
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ class Settings(BaseSettings):
|
|||||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES")
|
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||||
|
|
||||||
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
|
||||||
s3_region: str = Field(default="garage", alias="S3_REGION")
|
s3_region: str = Field(default="garage", alias="S3_REGION")
|
||||||
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID")
|
s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
|
||||||
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
|
s3_secret_access_key: str = Field(default="govoplan-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
|
||||||
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
|
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
|
||||||
|
|
||||||
# Managed file storage. Development defaults to local filesystem storage;
|
# Managed file storage. Development defaults to local filesystem storage;
|
||||||
@@ -41,19 +41,24 @@ class Settings(BaseSettings):
|
|||||||
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
|
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
|
||||||
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
|
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
|
||||||
|
|
||||||
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME")
|
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
|
||||||
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
||||||
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
|
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
|
||||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||||
|
|
||||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||||
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES")
|
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||||
|
calendar_outbox_terminal_retention_days: int = Field(
|
||||||
|
default=90,
|
||||||
|
ge=0,
|
||||||
|
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
|
)
|
||||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||||
|
|
||||||
# Development bootstrap only. Do not use this in production.
|
# Development bootstrap only. Do not use this in production.
|
||||||
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY")
|
dev_bootstrap_api_key: str | None = Field(default="dev-govoplan-api-key", alias="DEV_BOOTSTRAP_API_KEY")
|
||||||
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
|
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
|
||||||
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
|
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
|
||||||
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
|
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+238
-5
@@ -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,
|
||||||
@@ -364,12 +386,79 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
json={"email": "admin@example.local", "password": "test-admin"},
|
json={"email": "admin@example.local", "password": "test-admin"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
csrf = response.cookies.get("msm_csrf")
|
csrf = response.cookies.get("govoplan_csrf")
|
||||||
self.assertTrue(csrf)
|
self.assertTrue(csrf)
|
||||||
|
|
||||||
|
session_summary = self.client.get("/api/v1/auth/session")
|
||||||
|
self.assertEqual(session_summary.status_code, 200, session_summary.text)
|
||||||
|
session_payload = session_summary.json()
|
||||||
|
self.assertTrue(session_payload["authenticated"])
|
||||||
|
self.assertEqual(session_payload["auth_method"], "session")
|
||||||
|
self.assertNotIn("scopes", session_payload)
|
||||||
|
self.assertNotIn("roles", session_payload)
|
||||||
|
self.assertNotIn("groups", session_payload)
|
||||||
|
|
||||||
|
shell_summary = self.client.get("/api/v1/auth/shell")
|
||||||
|
self.assertEqual(shell_summary.status_code, 200, shell_summary.text)
|
||||||
|
shell_payload = shell_summary.json()
|
||||||
|
self.assertEqual(shell_payload["principal"]["auth_method"], "session")
|
||||||
|
self.assertFalse(shell_payload["profile_loaded"])
|
||||||
|
self.assertFalse(shell_payload["roles_loaded"])
|
||||||
|
self.assertFalse(shell_payload["groups_loaded"])
|
||||||
|
self.assertIsInstance(shell_payload["scopes"], list)
|
||||||
|
self.assertTrue(shell_payload["scopes"])
|
||||||
|
self.assertTrue(shell_payload["tenants"])
|
||||||
|
self.assertTrue(all(membership["roles"] == [] for membership in shell_payload["tenants"]))
|
||||||
|
self.assertNotIn("roles", shell_payload)
|
||||||
|
self.assertNotIn("groups", shell_payload)
|
||||||
|
self.assertNotIn("available_languages", shell_payload)
|
||||||
|
self.assertNotIn("default_language", shell_payload)
|
||||||
|
|
||||||
|
profile_summary = self.client.get("/api/v1/auth/profile")
|
||||||
|
self.assertEqual(profile_summary.status_code, 200, profile_summary.text)
|
||||||
|
profile_payload = profile_summary.json()
|
||||||
|
self.assertTrue(profile_payload["profile_loaded"])
|
||||||
|
self.assertEqual(profile_payload["user"]["id"], session_payload["user"]["id"])
|
||||||
|
self.assertIn("available_languages", profile_payload)
|
||||||
|
self.assertIn("default_language", profile_payload)
|
||||||
|
self.assertNotIn("roles", profile_payload)
|
||||||
|
self.assertNotIn("groups", profile_payload)
|
||||||
|
|
||||||
|
roles_summary = self.client.get("/api/v1/auth/roles")
|
||||||
|
self.assertEqual(roles_summary.status_code, 200, roles_summary.text)
|
||||||
|
roles_payload = roles_summary.json()
|
||||||
|
self.assertTrue(roles_payload["roles_loaded"])
|
||||||
|
self.assertTrue(roles_payload["roles"])
|
||||||
|
self.assertNotIn("groups", roles_payload)
|
||||||
|
|
||||||
|
groups_summary = self.client.get("/api/v1/auth/groups")
|
||||||
|
self.assertEqual(groups_summary.status_code, 200, groups_summary.text)
|
||||||
|
groups_payload = groups_summary.json()
|
||||||
|
self.assertTrue(groups_payload["groups_loaded"])
|
||||||
|
self.assertIsInstance(groups_payload["groups"], list)
|
||||||
|
self.assertNotIn("roles", groups_payload)
|
||||||
|
|
||||||
|
api_key_session = self.client.get("/api/v1/auth/session", headers={"X-API-Key": "test-api-key"})
|
||||||
|
self.assertEqual(api_key_session.status_code, 200, api_key_session.text)
|
||||||
|
self.assertEqual(api_key_session.json()["auth_method"], "api_key")
|
||||||
|
|
||||||
|
api_key_shell = self.client.get("/api/v1/auth/shell", headers={"X-API-Key": "test-api-key"})
|
||||||
|
self.assertEqual(api_key_shell.status_code, 200, api_key_shell.text)
|
||||||
|
api_key_shell_payload = api_key_shell.json()
|
||||||
|
self.assertEqual(api_key_shell_payload["principal"]["auth_method"], "api_key")
|
||||||
|
self.assertFalse(api_key_shell_payload["profile_loaded"])
|
||||||
|
self.assertFalse(api_key_shell_payload["roles_loaded"])
|
||||||
|
self.assertFalse(api_key_shell_payload["groups_loaded"])
|
||||||
|
self.assertIsInstance(api_key_shell_payload["scopes"], list)
|
||||||
|
|
||||||
me = self.client.get("/api/v1/auth/me")
|
me = self.client.get("/api/v1/auth/me")
|
||||||
self.assertEqual(me.status_code, 200, me.text)
|
self.assertEqual(me.status_code, 200, me.text)
|
||||||
me_payload = me.json()
|
me_payload = me.json()
|
||||||
|
self.assertEqual(session_payload["user"]["id"], me_payload["user"]["id"])
|
||||||
|
self.assertEqual(session_payload["tenant"]["id"], me_payload["tenant"]["id"])
|
||||||
|
self.assertEqual(shell_payload["user"]["id"], me_payload["user"]["id"])
|
||||||
|
self.assertEqual(shell_payload["tenant"]["id"], me_payload["tenant"]["id"])
|
||||||
|
self.assertTrue(me_payload["profile_loaded"])
|
||||||
self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"])
|
self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"])
|
||||||
self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"])
|
self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"])
|
||||||
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
|
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
|
||||||
@@ -423,6 +512,20 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
imap_host="mock.imap",
|
imap_host="mock.imap",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bootstrapped = self.client.get(
|
||||||
|
f"/api/v1/mail/profiles/{profile_id}/mailbox/bootstrap",
|
||||||
|
headers=headers,
|
||||||
|
params={"folder": "INBOX", "limit": 2},
|
||||||
|
)
|
||||||
|
self.assertEqual(bootstrapped.status_code, 200, bootstrapped.text)
|
||||||
|
bootstrap_payload = bootstrapped.json()
|
||||||
|
self.assertEqual(bootstrap_payload["folder"], "INBOX")
|
||||||
|
self.assertTrue(bootstrap_payload["folders"]["ok"])
|
||||||
|
self.assertFalse(bootstrap_payload["folders"]["from_cache"])
|
||||||
|
self.assertFalse(bootstrap_payload["messages"]["from_cache"])
|
||||||
|
self.assertEqual(bootstrap_payload["messages"]["total_count"], 3)
|
||||||
|
self.assertEqual(len(bootstrap_payload["messages"]["messages"]), 2)
|
||||||
|
|
||||||
listed = self.client.get(
|
listed = self.client.get(
|
||||||
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
|
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -433,6 +536,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["folder"], "INBOX")
|
self.assertEqual(payload["folder"], "INBOX")
|
||||||
self.assertEqual(payload["total_count"], 3)
|
self.assertEqual(payload["total_count"], 3)
|
||||||
self.assertEqual(len(payload["messages"]), 2)
|
self.assertEqual(len(payload["messages"]), 2)
|
||||||
|
self.assertTrue(payload["from_cache"])
|
||||||
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
|
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
|
||||||
self.assertTrue(payload["cursor_stable"])
|
self.assertTrue(payload["cursor_stable"])
|
||||||
self.assertFalse(payload["full"])
|
self.assertFalse(payload["full"])
|
||||||
@@ -449,6 +553,14 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(next_payload["messages"]), 1)
|
self.assertEqual(len(next_payload["messages"]), 1)
|
||||||
self.assertFalse(next_payload["next_cursor"])
|
self.assertFalse(next_payload["next_cursor"])
|
||||||
|
|
||||||
|
cached_next_page = self.client.get(
|
||||||
|
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
|
||||||
|
headers=headers,
|
||||||
|
params={"folder": "INBOX", "cursor": payload["next_cursor"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(cached_next_page.status_code, 200, cached_next_page.text)
|
||||||
|
self.assertTrue(cached_next_page.json()["from_cache"])
|
||||||
|
|
||||||
stale_cursor = encode_keyset_cursor(
|
stale_cursor = encode_keyset_cursor(
|
||||||
"mail.mailbox.messages.v1",
|
"mail.mailbox.messages.v1",
|
||||||
fingerprint=keyset_query_fingerprint(
|
fingerprint=keyset_query_fingerprint(
|
||||||
@@ -739,7 +851,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
|
schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
|
||||||
self.assertEqual(schema.status_code, 200, schema.text)
|
self.assertEqual(schema.status_code, 200, schema.text)
|
||||||
self.assertEqual(schema.json()["title"], "MultiMailer Campaign")
|
self.assertEqual(schema.json()["title"], "GovOPlaN Campaign")
|
||||||
|
|
||||||
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
|
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
|
||||||
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
|
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
|
||||||
@@ -3269,7 +3381,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"invoices/archive/202605-010001-report.XLSX",
|
"invoices/archive/202605-010001-report.XLSX",
|
||||||
"invoices/202605-010001-90100010-9601741.XLSX",
|
"invoices/202605-010001-90100010-9601741.XLSX",
|
||||||
})
|
})
|
||||||
self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths))
|
self.assertFalse(any("govoplan-managed-build" in value for value in resolved_paths))
|
||||||
|
|
||||||
jobs = self.client.get(
|
jobs = self.client.get(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs",
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
||||||
@@ -3342,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"]
|
||||||
@@ -5558,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",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||||
|
def test_worker_commits_provider_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.dispatch_due.return_value = {
|
||||||
|
"processed": 1,
|
||||||
|
"succeeded": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"operations": [{"id": "operation-1", "status": "succeeded"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
|
||||||
|
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||||
|
):
|
||||||
|
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||||
|
|
||||||
|
provider.dispatch_due.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(result["succeeded"], 1)
|
||||||
|
|
||||||
|
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
|
||||||
|
{"queue": "calendar"},
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
|
||||||
|
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
|
||||||
|
self.assertEqual(schedule["schedule"], 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
|
||||||
|
|
||||||
|
|
||||||
|
class HttpFetchTests(unittest.TestCase):
|
||||||
|
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
|
||||||
|
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
|
||||||
|
self.assertTrue(is_http_url("http://example.test/catalog.json"))
|
||||||
|
|
||||||
|
def test_validate_http_url_rejects_non_http_urls_and_embedded_credentials(self) -> None:
|
||||||
|
for value in (
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"/relative/catalog.json",
|
||||||
|
"https://user@example.test/catalog.json",
|
||||||
|
"https://user:secret@example.test/catalog.json",
|
||||||
|
):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertFalse(is_http_url(value))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_http_url(value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.server.config import import_object, validate_object_path
|
||||||
|
from govoplan_core.server.registry import available_module_manifests
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTrustBoundaryTests(unittest.TestCase):
|
||||||
|
def test_object_path_validation_rejects_invalid_shapes(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "module:attribute"):
|
||||||
|
validate_object_path("govoplan_core.server.default_config")
|
||||||
|
with self.assertRaisesRegex(ValueError, "valid Python module"):
|
||||||
|
validate_object_path("os;system:path")
|
||||||
|
with self.assertRaisesRegex(ValueError, "public attribute"):
|
||||||
|
validate_object_path("govoplan_core.server.default_config:_private")
|
||||||
|
|
||||||
|
def test_import_object_allows_default_govoplan_config_path(self) -> None:
|
||||||
|
value = import_object("govoplan_core.server.default_config:get_server_config")
|
||||||
|
self.assertTrue(callable(value))
|
||||||
|
|
||||||
|
def test_import_object_rejects_untrusted_stdlib_module(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside trusted import prefixes"):
|
||||||
|
import_object("os:path")
|
||||||
|
|
||||||
|
def test_import_object_allows_explicit_custom_prefix(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-config-import-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "custom_config.py").write_text("value = 42\n", encoding="utf-8")
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {"GOVOPLAN_TRUSTED_IMPORT_PREFIXES": "custom_config"}):
|
||||||
|
self.assertEqual(import_object("custom_config:value"), 42)
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(root))
|
||||||
|
sys.modules.pop("custom_config", None)
|
||||||
|
|
||||||
|
def test_builtin_manifest_imports_are_loaded_from_fixed_targets(self) -> None:
|
||||||
|
manifests = available_module_manifests(enabled_modules=("access",), ignore_load_errors=True)
|
||||||
|
if "access" in manifests:
|
||||||
|
self.assertEqual(manifests["access"].id, "access")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,11 +3,24 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
|
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
|
||||||
|
from govoplan_core.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
class InstallConfigTests(unittest.TestCase):
|
class InstallConfigTests(unittest.TestCase):
|
||||||
|
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
|
||||||
|
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
|
||||||
|
self.assertEqual(
|
||||||
|
Settings(
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
|
||||||
|
).calendar_outbox_terminal_retention_days,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||||
|
|
||||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||||
|
|
||||||
@@ -68,8 +81,10 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||||
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.mail.config import normalize_split_transport_credentials
|
||||||
|
|
||||||
|
|
||||||
|
class MailConfigTests(unittest.TestCase):
|
||||||
|
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
|
||||||
|
payload = normalize_split_transport_credentials(
|
||||||
|
{
|
||||||
|
"smtp": {"host": "smtp.example.test", "username": "smtp-user", "password": "smtp-secret"},
|
||||||
|
"imap": {"host": "imap.example.test", "enabled": True, "username": "imap-user"},
|
||||||
|
"credentials": {"smtp": {"username": "existing"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"smtp": {"host": "smtp.example.test"},
|
||||||
|
"imap": {"host": "imap.example.test"},
|
||||||
|
"credentials": {
|
||||||
|
"smtp": {"username": "existing", "password": "smtp-secret"},
|
||||||
|
"imap": {"username": "imap-user"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+57
-22
@@ -14,7 +14,6 @@ import tempfile
|
|||||||
import textwrap
|
import textwrap
|
||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
import urllib.error
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -35,6 +34,7 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
from govoplan_core.core.events import event_context
|
from govoplan_core.core.events import event_context
|
||||||
|
from govoplan_core.core import module_installer as module_installer_module
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
ModuleInstallPlan,
|
ModuleInstallPlan,
|
||||||
@@ -227,7 +227,7 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
||||||
self.assertEqual(manifests["campaigns"].dependencies, ())
|
self.assertEqual(manifests["campaigns"].dependencies, ())
|
||||||
self.assertTrue(manifests["campaigns"].required_capabilities)
|
self.assertTrue(manifests["campaigns"].required_capabilities)
|
||||||
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
|
||||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||||
self.assertTrue(manifests["dashboard"].required_capabilities)
|
self.assertTrue(manifests["dashboard"].required_capabilities)
|
||||||
self.assertEqual(manifests["docs"].dependencies, ())
|
self.assertEqual(manifests["docs"].dependencies, ())
|
||||||
@@ -272,14 +272,16 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||||
|
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
|
||||||
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
||||||
|
|
||||||
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
||||||
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
|
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
|
||||||
|
legacy_admin_import_pattern = re.compile(r"api/admin(?=[\"'#?]|$)")
|
||||||
self.assertFalse((webui_src / "api" / "admin.ts").exists())
|
self.assertFalse((webui_src / "api" / "admin.ts").exists())
|
||||||
for path in webui_src.rglob("*.ts*"):
|
for path in webui_src.rglob("*.ts*"):
|
||||||
with self.subTest(path=path.relative_to(webui_src)):
|
with self.subTest(path=path.relative_to(webui_src)):
|
||||||
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
|
self.assertIsNone(legacy_admin_import_pattern.search(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
|
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
|
||||||
from govoplan_admin.backend.db.models import GovernanceTemplate
|
from govoplan_admin.backend.db.models import GovernanceTemplate
|
||||||
@@ -2474,7 +2476,8 @@ finally:
|
|||||||
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
||||||
|
|
||||||
def fake_run(*_args, **kwargs):
|
def fake_run(*_args, **kwargs):
|
||||||
if kwargs.get("shell"):
|
argv = tuple(_args[0]) if _args else ()
|
||||||
|
if kwargs.get("shell") or argv == ("restart-fails",):
|
||||||
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
|
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
@@ -2562,6 +2565,43 @@ finally:
|
|||||||
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
|
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
|
||||||
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
|
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
|
||||||
|
settings = _settings(root)
|
||||||
|
configure_database(settings.database_url)
|
||||||
|
database = get_database()
|
||||||
|
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
|
||||||
|
backup_command = f"{sys.executable} -c {shlex.quote('print(1)')} && {sys.executable} -c {shlex.quote('print(2)')}"
|
||||||
|
|
||||||
|
with database.session() as session:
|
||||||
|
save_maintenance_mode(session, MaintenanceMode(enabled=True))
|
||||||
|
plan = save_module_install_plan(session, [{
|
||||||
|
"module_id": "files",
|
||||||
|
"action": "install",
|
||||||
|
"python_package": "govoplan-files",
|
||||||
|
"python_ref": "govoplan-files==0.1.4",
|
||||||
|
}])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Database backup command failed"):
|
||||||
|
run_module_install_plan(
|
||||||
|
session=session,
|
||||||
|
plan=plan,
|
||||||
|
available=available_module_manifests(),
|
||||||
|
current_enabled=("tenancy", "access"),
|
||||||
|
desired_enabled=("tenancy", "access"),
|
||||||
|
database_url="postgresql://db.example.invalid/govoplan",
|
||||||
|
runtime_dir=root / "installer",
|
||||||
|
migrate_database=True,
|
||||||
|
database_backup_command=backup_command,
|
||||||
|
database_restore_command="restore-database",
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected = module_installer_module._run_operator_command("DATABASE_URL=postgres://db.example.invalid/govoplan pg_dump")
|
||||||
|
self.assertEqual(2, rejected.returncode)
|
||||||
|
self.assertIn("Environment assignments", rejected.stderr)
|
||||||
|
|
||||||
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
|
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
|
||||||
settings = _settings(root)
|
settings = _settings(root)
|
||||||
@@ -2654,11 +2694,13 @@ finally:
|
|||||||
queued = queue_module_installer_request(
|
queued = queue_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
requested_by="user-1",
|
requested_by="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
|
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
|
||||||
)
|
)
|
||||||
request_id = str(queued["request_id"])
|
request_id = str(queued["request_id"])
|
||||||
|
|
||||||
self.assertEqual("queued", queued["status"])
|
self.assertEqual("queued", queued["status"])
|
||||||
|
self.assertEqual("tenant-1", queued["tenant_id"])
|
||||||
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
|
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
|
||||||
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
|
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
|
||||||
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
|
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
|
||||||
@@ -2675,7 +2717,7 @@ finally:
|
|||||||
self.assertEqual("completed", updated["status"])
|
self.assertEqual("completed", updated["status"])
|
||||||
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
|
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
|
||||||
|
|
||||||
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1")
|
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1", tenant_id="tenant-1")
|
||||||
cancelled = cancel_module_installer_request(
|
cancelled = cancel_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=str(cancellable["request_id"]),
|
request_id=str(cancellable["request_id"]),
|
||||||
@@ -2691,6 +2733,7 @@ finally:
|
|||||||
)
|
)
|
||||||
self.assertEqual("queued", retry["status"])
|
self.assertEqual("queued", retry["status"])
|
||||||
self.assertEqual(cancellable["request_id"], retry["retry_of"])
|
self.assertEqual(cancellable["request_id"], retry["retry_of"])
|
||||||
|
self.assertEqual("tenant-1", retry["tenant_id"])
|
||||||
|
|
||||||
settings = _settings(root)
|
settings = _settings(root)
|
||||||
configure_database(settings.database_url)
|
configure_database(settings.database_url)
|
||||||
@@ -3030,11 +3073,11 @@ finally:
|
|||||||
"version_min": "0.1.0",
|
"version_min": "0.1.0",
|
||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["campaigns"]["requires_interfaces"])
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
|
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
||||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||||
self.assertEqual("0.1.7", modules["files"]["version"])
|
self.assertEqual("0.1.8", modules["files"]["version"])
|
||||||
self.assertIn("@v0.1.7", modules["files"]["python_ref"])
|
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
|
||||||
|
|
||||||
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
||||||
@@ -3067,16 +3110,6 @@ finally:
|
|||||||
)
|
)
|
||||||
body = signed_path.read_text(encoding="utf-8")
|
body = signed_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
class _Response:
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read(self) -> bytes:
|
|
||||||
return body.encode("utf-8")
|
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
|
||||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
|
||||||
@@ -3085,11 +3118,11 @@ finally:
|
|||||||
}
|
}
|
||||||
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
|
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
|
||||||
with patch.dict(os.environ, env):
|
with patch.dict(os.environ, env):
|
||||||
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()):
|
with patch("govoplan_core.core.module_package_catalog.fetch_http_text", return_value=body):
|
||||||
fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
|
fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
|
||||||
with patch(
|
with patch(
|
||||||
"govoplan_core.core.module_package_catalog.urllib.request.urlopen",
|
"govoplan_core.core.module_package_catalog.fetch_http_text",
|
||||||
side_effect=urllib.error.URLError("offline"),
|
side_effect=OSError("offline"),
|
||||||
):
|
):
|
||||||
cached = validate_module_package_catalog(trusted_keys=trusted_keys)
|
cached = validate_module_package_catalog(trusted_keys=trusted_keys)
|
||||||
|
|
||||||
@@ -3800,12 +3833,14 @@ finally:
|
|||||||
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
|
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
|
||||||
|
|
||||||
def test_module_route_factories_receive_runtime_settings(self) -> None:
|
def test_module_route_factories_receive_runtime_settings(self) -> None:
|
||||||
app, settings = self._app_for_modules(("files", "mail"))
|
app, settings = self._app_for_modules(("calendar", "files", "mail"))
|
||||||
self.assertIsNotNone(app)
|
self.assertIsNotNone(app)
|
||||||
|
|
||||||
|
from govoplan_calendar.backend.runtime import get_settings as get_calendar_settings
|
||||||
from govoplan_files.backend.runtime import get_settings as get_files_settings
|
from govoplan_files.backend.runtime import get_settings as get_files_settings
|
||||||
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
|
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
|
||||||
|
|
||||||
|
self.assertIs(settings, get_calendar_settings())
|
||||||
self.assertIs(settings, get_files_settings())
|
self.assertIs(settings, get_files_settings())
|
||||||
self.assertIs(settings, get_mail_settings())
|
self.assertIs(settings, get_mail_settings())
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from govoplan_core.privacy.schemas import (
|
||||||
|
RETENTION_POLICY_FIELD_KEYS,
|
||||||
|
PrivacyRetentionPolicyItem,
|
||||||
|
PrivacyRetentionPolicyPatchItem,
|
||||||
|
default_allow_lower_level_limits,
|
||||||
|
normalize_allow_lower_level_limits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacySchemaContractTests(unittest.TestCase):
|
||||||
|
def test_default_allow_lower_level_limits_cover_every_retention_field(self) -> None:
|
||||||
|
self.assertEqual({key: True for key in RETENTION_POLICY_FIELD_KEYS}, default_allow_lower_level_limits())
|
||||||
|
|
||||||
|
def test_policy_item_fills_missing_lower_level_limits(self) -> None:
|
||||||
|
item = PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
|
||||||
|
|
||||||
|
self.assertFalse(item.allow_lower_level_limits["audit_detail_level"])
|
||||||
|
self.assertTrue(item.allow_lower_level_limits["store_raw_campaign_json"])
|
||||||
|
|
||||||
|
def test_policy_patch_keeps_lower_level_limits_sparse(self) -> None:
|
||||||
|
patch = PrivacyRetentionPolicyPatchItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
|
||||||
|
|
||||||
|
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
|
||||||
|
self.assertIsNone(normalize_allow_lower_level_limits("", fill_defaults=False))
|
||||||
|
|
||||||
|
def test_unknown_lower_level_limit_field_is_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"unknown": True}})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||||
|
from govoplan_core.db.session import create_database_engine
|
||||||
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
|
|
||||||
|
|
||||||
|
class QueryMetricsTests(TestCase):
|
||||||
|
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
|
||||||
|
engine = create_database_engine("sqlite:///:memory:")
|
||||||
|
try:
|
||||||
|
with collect_query_metrics() as metrics:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertEqual(1, connection.execute(text("select 1")).scalar_one())
|
||||||
|
|
||||||
|
self.assertEqual(1, metrics.query_count)
|
||||||
|
self.assertGreaterEqual(metrics.total_ms, 0.0)
|
||||||
|
self.assertGreaterEqual(metrics.slowest_ms, 0.0)
|
||||||
|
self.assertEqual(0, metrics.error_count)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_slow_request_log_includes_query_metrics(self) -> None:
|
||||||
|
engine = create_database_engine("sqlite:///:memory:")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/query")
|
||||||
|
def query_route() -> dict[str, bool]:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
connection.execute(text("select 1")).scalar_one()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {"GOVOPLAN_SLOW_REQUEST_MS": "0.001"}):
|
||||||
|
app = create_govoplan_app(
|
||||||
|
title="query metrics test",
|
||||||
|
version="0",
|
||||||
|
registry=PlatformRegistry(),
|
||||||
|
api_router=router,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client, self.assertLogs("govoplan.request", level="WARNING") as logs:
|
||||||
|
response = client.get("/query")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
output = "\n".join(logs.output)
|
||||||
|
self.assertIn("db_query_count=1", output)
|
||||||
|
self.assertIn("db_time_ms=", output)
|
||||||
|
self.assertIn("db_slowest_ms=", output)
|
||||||
|
self.assertIn("db_error_count=0", output)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.core.sqlalchemy_change_tracking import (
|
||||||
|
ensure_object_id,
|
||||||
|
operation_for_object,
|
||||||
|
operation_for_soft_deletable,
|
||||||
|
previous_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleRecord(Base):
|
||||||
|
__tablename__ = "example_records"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlalchemyChangeTrackingTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_pending_object_is_created_and_can_receive_id(self) -> None:
|
||||||
|
record = ExampleRecord(id="", name="Draft", deleted_at=None)
|
||||||
|
self.session.add(record)
|
||||||
|
|
||||||
|
self.assertEqual(ensure_object_id(record, lambda: "record-1"), "record-1")
|
||||||
|
self.assertEqual(record.id, "record-1")
|
||||||
|
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "created")
|
||||||
|
|
||||||
|
def test_changed_object_reports_update_and_previous_value(self) -> None:
|
||||||
|
record = ExampleRecord(id="record-1", name="Before", deleted_at=None)
|
||||||
|
self.session.add(record)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
record.name = "After"
|
||||||
|
|
||||||
|
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "updated")
|
||||||
|
self.assertEqual(previous_value(record, "name"), "Before")
|
||||||
|
|
||||||
|
def test_soft_delete_and_restore_are_classified(self) -> None:
|
||||||
|
deleted_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
|
||||||
|
record = ExampleRecord(id="record-1", name="Record", deleted_at=None)
|
||||||
|
self.session.add(record)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
record.deleted_at = deleted_at
|
||||||
|
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "deleted")
|
||||||
|
|
||||||
|
self.session.flush()
|
||||||
|
record.deleted_at = None
|
||||||
|
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "created")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+72
-3
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"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/admin-webui": "file:../../govoplan-admin/webui",
|
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
@@ -18,9 +19,11 @@
|
|||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
|
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
@@ -60,6 +63,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-addresses/webui": {
|
||||||
|
"name": "@govoplan/addresses-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"../../govoplan-admin/webui": {
|
"../../govoplan-admin/webui": {
|
||||||
"name": "@govoplan/admin-webui",
|
"name": "@govoplan/admin-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
@@ -228,6 +247,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-notifications/webui": {
|
||||||
|
"name": "@govoplan/notifications-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"../../govoplan-ops/webui": {
|
"../../govoplan-ops/webui": {
|
||||||
"name": "@govoplan/ops-webui",
|
"name": "@govoplan/ops-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
@@ -282,6 +320,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-scheduling/webui": {
|
||||||
|
"name": "@govoplan/scheduling-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1010,6 +1067,10 @@
|
|||||||
"resolved": "../../govoplan-access/webui",
|
"resolved": "../../govoplan-access/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/addresses-webui": {
|
||||||
|
"resolved": "../../govoplan-addresses/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/admin-webui": {
|
"node_modules/@govoplan/admin-webui": {
|
||||||
"resolved": "../../govoplan-admin/webui",
|
"resolved": "../../govoplan-admin/webui",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1046,6 +1107,10 @@
|
|||||||
"resolved": "../../govoplan-mail/webui",
|
"resolved": "../../govoplan-mail/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/notifications-webui": {
|
||||||
|
"resolved": "../../govoplan-notifications/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/ops-webui": {
|
"node_modules/@govoplan/ops-webui": {
|
||||||
"resolved": "../../govoplan-ops/webui",
|
"resolved": "../../govoplan-ops/webui",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1058,6 +1123,10 @@
|
|||||||
"resolved": "../../govoplan-policy/webui",
|
"resolved": "../../govoplan-policy/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/scheduling-webui": {
|
||||||
|
"resolved": "../../govoplan-scheduling/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+7
-2
@@ -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",
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"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: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"
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||||
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
@@ -36,9 +39,11 @@
|
|||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
|
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"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",
|
||||||
|
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
|
||||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
||||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
||||||
|
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
||||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
|
||||||
|
const normalized = source.replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
function assertIncludes(fragment, message) {
|
||||||
|
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
|
||||||
|
"drag enter/over must prevent the browser default and stop propagation"
|
||||||
|
);
|
||||||
|
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
|
||||||
|
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
|
||||||
|
assertIncludes(
|
||||||
|
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
|
||||||
|
"drag leave must prevent the browser default and stop propagation"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
|
||||||
|
"drop must prevent the browser default and stop propagation before resolving files"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
|
||||||
|
"disabled drops must route the disabled rejection reason"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
|
||||||
|
"drop must route the event DataTransfer through the shared resolver"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
|
||||||
|
"resolved files and empty drops must be routed through their explicit callbacks"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
".catch(() => onRejectedDrop?.(\"unreadable\"))",
|
||||||
|
"resolver failures must route the unreadable rejection reason"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("FileDropZone event and result routing structure is intact.");
|
||||||
@@ -3,7 +3,9 @@ import { spawnSync } from "node:child_process";
|
|||||||
const packageByModule = {
|
const packageByModule = {
|
||||||
access: "@govoplan/access-webui",
|
access: "@govoplan/access-webui",
|
||||||
admin: "@govoplan/admin-webui",
|
admin: "@govoplan/admin-webui",
|
||||||
|
addresses: "@govoplan/addresses-webui",
|
||||||
audit: "@govoplan/audit-webui",
|
audit: "@govoplan/audit-webui",
|
||||||
|
calendar: "@govoplan/calendar-webui",
|
||||||
campaigns: "@govoplan/campaign-webui",
|
campaigns: "@govoplan/campaign-webui",
|
||||||
dashboard: "@govoplan/dashboard-webui",
|
dashboard: "@govoplan/dashboard-webui",
|
||||||
docs: "@govoplan/docs-webui",
|
docs: "@govoplan/docs-webui",
|
||||||
@@ -12,16 +14,19 @@ const packageByModule = {
|
|||||||
mail: "@govoplan/mail-webui",
|
mail: "@govoplan/mail-webui",
|
||||||
organizations: "@govoplan/organizations-webui",
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
policy: "@govoplan/policy-webui"
|
policy: "@govoplan/policy-webui",
|
||||||
|
scheduling: "@govoplan/scheduling-webui"
|
||||||
};
|
};
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
{ name: "core-only", modules: [] },
|
{ name: "core-only", modules: [] },
|
||||||
{ name: "access-only", modules: ["access"] },
|
{ name: "access-only", modules: ["access"] },
|
||||||
{ name: "admin-only", modules: ["admin"] },
|
{ name: "admin-only", modules: ["admin"] },
|
||||||
|
{ name: "addresses-only", modules: ["addresses"] },
|
||||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||||
|
{ name: "calendar-only", modules: ["calendar"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
{ name: "organizations-only", modules: ["organizations"] },
|
{ name: "organizations-only", modules: ["organizations"] },
|
||||||
@@ -29,8 +34,10 @@ const cases = [
|
|||||||
{ name: "campaign-only", modules: ["campaigns"] },
|
{ name: "campaign-only", modules: ["campaigns"] },
|
||||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||||
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] }
|
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
+122
-30
@@ -1,9 +1,9 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
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 { fetchMe, 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, AuthTenant, AuthTenantMembership, 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";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
@@ -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("");
|
||||||
|
|
||||||
@@ -47,9 +48,9 @@ export default function App() {
|
|||||||
saveApiSettings(next);
|
saveApiSettings(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuth(next: AuthInfo | null, accessToken?: string) {
|
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
|
||||||
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
||||||
setAuth(next ? normalizeAuthInfo(next) : null);
|
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
|
||||||
if (accessToken !== undefined) {
|
if (accessToken !== undefined) {
|
||||||
setSettings(nextSettings);
|
setSettings(nextSettings);
|
||||||
saveApiSettings(nextSettings);
|
saveApiSettings(nextSettings);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +122,17 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setCheckingSession(true);
|
setCheckingSession(true);
|
||||||
fetchMe(settings).
|
|
||||||
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
|
async function bootstrapAuth() {
|
||||||
catch(() => {
|
try {
|
||||||
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
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);
|
||||||
@@ -128,10 +140,12 @@ export default function App() {
|
|||||||
setPlatformModules(null);
|
setPlatformModules(null);
|
||||||
setRemoteWebModules([]);
|
setRemoteWebModules([]);
|
||||||
}
|
}
|
||||||
}).
|
} finally {
|
||||||
finally(() => {
|
|
||||||
if (!cancelled) setCheckingSession(false);
|
if (!cancelled) setCheckingSession(false);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrapAuth();
|
||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
@@ -180,11 +194,25 @@ export default function App() {
|
|||||||
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
|
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
|
||||||
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
|
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
|
||||||
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
|
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
|
||||||
if (preferences.theme === "system") {
|
|
||||||
delete root.dataset.theme;
|
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
||||||
} else {
|
const applyTheme = () => {
|
||||||
root.dataset.theme = preferences.theme;
|
const resolvedTheme = preferences.theme === "system" ?
|
||||||
|
systemDarkQuery?.matches ? "dark" : "light" :
|
||||||
|
preferences.theme;
|
||||||
|
root.dataset.theme = resolvedTheme;
|
||||||
|
root.dataset.themePreference = preferences.theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
if (preferences.theme !== "system" || !systemDarkQuery) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemDarkQuery.addEventListener("change", applyTheme);
|
||||||
|
return () => {
|
||||||
|
systemDarkQuery.removeEventListener("change", applyTheme);
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
auth?.user.ui_preferences?.compact_tables,
|
auth?.user.ui_preferences?.compact_tables,
|
||||||
auth?.user.ui_preferences?.show_inline_help_hints,
|
auth?.user.ui_preferences?.show_inline_help_hints,
|
||||||
@@ -208,8 +236,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
|
|
||||||
|
const currentAuth = auth;
|
||||||
|
let cancelled = false;
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
let lastRefreshAt = 0;
|
let lastRefreshAt = 0;
|
||||||
|
let lastShellRefreshAt = Date.now();
|
||||||
|
|
||||||
async function refreshVisibleSession() {
|
async function refreshVisibleSession() {
|
||||||
if (document.visibilityState === "hidden" || inFlight) return;
|
if (document.visibilityState === "hidden" || inFlight) return;
|
||||||
@@ -219,8 +250,22 @@ export default function App() {
|
|||||||
inFlight = true;
|
inFlight = true;
|
||||||
lastRefreshAt = now;
|
lastRefreshAt = now;
|
||||||
try {
|
try {
|
||||||
setAuth(normalizeAuthInfo(await fetchMe(settings)));
|
const sessionInfo = await fetchSession(settings);
|
||||||
} catch {
|
if (cancelled) return;
|
||||||
|
setBackendReachable(true);
|
||||||
|
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
||||||
|
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
||||||
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
|
if (cancelled) return;
|
||||||
|
lastShellRefreshAt = Date.now();
|
||||||
|
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
|
||||||
|
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
||||||
|
: normalizeAuthInfo(shellAuth));
|
||||||
|
}
|
||||||
|
} 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.
|
||||||
@@ -230,6 +275,7 @@ export default function App() {
|
|||||||
window.addEventListener("focus", refreshVisibleSession);
|
window.addEventListener("focus", refreshVisibleSession);
|
||||||
document.addEventListener("visibilitychange", refreshVisibleSession);
|
document.addEventListener("visibilitychange", refreshVisibleSession);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
window.removeEventListener("focus", refreshVisibleSession);
|
window.removeEventListener("focus", refreshVisibleSession);
|
||||||
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
document.removeEventListener("visibilitychange", refreshVisibleSession);
|
||||||
};
|
};
|
||||||
@@ -240,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>
|
||||||
@@ -260,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>
|
||||||
@@ -291,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 />} />
|
||||||
@@ -327,18 +373,40 @@ export default function App() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthPayload = Partial<AuthInfo> & {
|
type AuthPayload = AuthUpdate;
|
||||||
principal?: AuthInfo["principal"];
|
|
||||||
user?: Partial<AuthUser> | null;
|
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
|
||||||
tenant?: AuthTenant | null;
|
if (!current) return next;
|
||||||
active_tenant?: AuthTenant | null;
|
const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
|
||||||
tenants?: AuthTenantMembership[] | null;
|
const currentActiveTenant = current.active_tenant ?? current.tenant;
|
||||||
|
const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...next,
|
||||||
|
user: next.user ? { ...current.user, ...next.user } : current.user,
|
||||||
|
tenant: nextActiveTenant ?? current.tenant,
|
||||||
|
active_tenant: nextActiveTenant ?? currentActiveTenant,
|
||||||
|
tenants: next.tenants ?? current.tenants,
|
||||||
|
scopes: next.scopes ?? current.scopes,
|
||||||
|
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
|
||||||
|
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
|
||||||
|
principal: next.principal === undefined ? current.principal : next.principal,
|
||||||
|
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
|
||||||
|
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
|
||||||
|
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
|
||||||
|
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
|
||||||
|
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
|
||||||
|
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
||||||
const principal = response.principal ?? null;
|
const principal = response.principal ?? null;
|
||||||
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
|
||||||
const user = normalizeAuthUser(response.user, principal);
|
const user = normalizeAuthUser(response.user, principal);
|
||||||
|
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
|
||||||
|
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
|
||||||
|
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
|
||||||
|
|
||||||
if (!activeTenant) {
|
if (!activeTenant) {
|
||||||
throw new Error("Authentication response did not include an active tenant.");
|
throw new Error("Authentication response did not include an active tenant.");
|
||||||
@@ -356,12 +424,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
|
|||||||
roles: response.roles ?? [],
|
roles: response.roles ?? [],
|
||||||
groups: response.groups ?? [],
|
groups: response.groups ?? [],
|
||||||
principal,
|
principal,
|
||||||
available_languages: response.available_languages ?? [],
|
available_languages: response.available_languages,
|
||||||
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
|
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
|
||||||
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
|
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
|
||||||
|
profile_loaded: profileLoaded,
|
||||||
|
roles_loaded: rolesLoaded,
|
||||||
|
groups_loaded: groupsLoaded
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFullProfilePayload(response: AuthPayload): boolean {
|
||||||
|
return Boolean(
|
||||||
|
response.default_language ||
|
||||||
|
response.available_languages ||
|
||||||
|
response.enabled_language_codes ||
|
||||||
|
response.roles ||
|
||||||
|
response.groups
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
|
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
|
||||||
if (user?.id && user.account_id) {
|
if (user?.id && user.account_id) {
|
||||||
return {
|
return {
|
||||||
@@ -396,6 +477,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
|
||||||
|
const activeTenant = auth.active_tenant ?? auth.tenant;
|
||||||
|
if (sessionInfo.user.id !== auth.user.id) return false;
|
||||||
|
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
|
||||||
|
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
|
||||||
|
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
|
||||||
|
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
|
||||||
|
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||||
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ApiSettings } from "../types";
|
||||||
|
import { apiFetch, apiGetList } from "./client";
|
||||||
|
|
||||||
|
export type PermissionItem = {
|
||||||
|
scope: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
level: "tenant" | "system";
|
||||||
|
system_template_id?: string | null;
|
||||||
|
system_required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOverview = {
|
||||||
|
active_tenant_id: string;
|
||||||
|
active_tenant_name: string;
|
||||||
|
tenant_count?: number | null;
|
||||||
|
system_account_count?: number | null;
|
||||||
|
system_group_template_count?: number | null;
|
||||||
|
system_role_template_count?: number | null;
|
||||||
|
user_count: number;
|
||||||
|
active_user_count: number;
|
||||||
|
group_count: number;
|
||||||
|
role_count: number;
|
||||||
|
active_api_key_count: number;
|
||||||
|
capabilities: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantAdminItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
default_locale: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
effective_governance: Record<string, boolean>;
|
||||||
|
is_active: boolean;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/overview");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||||
|
return apiGetList<PermissionItem, "permissions">(settings, "/api/v1/admin/permissions", "permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||||
|
return apiGetList<TenantAdminItem, "tenants">(settings, "/api/v1/admin/tenants", "tenants");
|
||||||
|
}
|
||||||
+25
-5
@@ -1,4 +1,4 @@
|
|||||||
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types";
|
import type { ApiSettings, AuthGroupsInfo, AuthInfo, AuthProfileInfo, AuthRolesInfo, AuthSessionInfo, AuthShellInfo, LoginResponse, UserUiPreferences } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
|
|||||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
|
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
|
export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
|
||||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
|
return apiFetch<AuthSessionInfo>(settings, "/api/v1/auth/session", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchShellAuth(settings: ApiSettings): Promise<AuthShellInfo> {
|
||||||
|
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/shell", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthProfile(settings: ApiSettings): Promise<AuthProfileInfo> {
|
||||||
|
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthRoles(settings: ApiSettings): Promise<AuthRolesInfo> {
|
||||||
|
return apiFetch<AuthRolesInfo>(settings, "/api/v1/auth/roles", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthGroups(settings: ApiSettings): Promise<AuthGroupsInfo> {
|
||||||
|
return apiFetch<AuthGroupsInfo>(settings, "/api/v1/auth/groups", { cache: "no-store" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthShellInfo> {
|
||||||
|
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/switch-tenant", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ tenant_id: tenantId })
|
body: JSON.stringify({ tenant_id: tenantId })
|
||||||
});
|
});
|
||||||
@@ -35,8 +55,8 @@ export async function updateProfile(
|
|||||||
enabled_language_codes?: string[] | null;
|
enabled_language_codes?: string[] | null;
|
||||||
ui_preferences?: Partial<UserUiPreferences> | null;
|
ui_preferences?: Partial<UserUiPreferences> | null;
|
||||||
}
|
}
|
||||||
): Promise<AuthInfo> {
|
): Promise<AuthProfileInfo> {
|
||||||
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
|
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-4
@@ -1,9 +1,9 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
|
|
||||||
const STORAGE_KEY = "multimailer.apiSettings";
|
const STORAGE_KEY = "govoplan.apiSettings";
|
||||||
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"];
|
const LEGACY_STORAGE_KEYS: string[] = [];
|
||||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
const SESSION_STORAGE_KEY = "govoplan.session";
|
||||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
|
||||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||||
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
|
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
|
||||||
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
|
|||||||
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
|
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiQueryValue = string | number | boolean | null | undefined;
|
||||||
|
export type ApiQueryParams = Record<string, ApiQueryValue | readonly ApiQueryValue[]>;
|
||||||
|
|
||||||
|
function queryValue(value: ApiQueryValue): string | null {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiQuery(params: ApiQueryParams = {}): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, rawValue] of Object.entries(params)) {
|
||||||
|
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = queryValue(value);
|
||||||
|
if (normalized !== null) search.append(key, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = search.toString();
|
||||||
|
return query ? `?${query}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPath(path: string, params: ApiQueryParams = {}): string {
|
||||||
|
const query = apiQuery(params);
|
||||||
|
if (!query) return path;
|
||||||
|
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetList<TItem, K extends string>(
|
||||||
|
settings: ApiSettings,
|
||||||
|
path: string,
|
||||||
|
key: K,
|
||||||
|
params: ApiQueryParams = {}
|
||||||
|
): Promise<TItem[]> {
|
||||||
|
const response = await apiFetch<Record<K, TItem[] | null | undefined>>(settings, apiPath(path, params));
|
||||||
|
return response[key] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPost<TResponse>(settings: ApiSettings, path: string, init: Omit<RequestInit, "method"> = {}): Promise<TResponse> {
|
||||||
|
return apiFetch<TResponse>(settings, path, { ...init, method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPostJson<TResponse, TPayload = unknown>(
|
||||||
|
settings: ApiSettings,
|
||||||
|
path: string,
|
||||||
|
payload: TPayload,
|
||||||
|
init: Omit<RequestInit, "method" | "body"> = {}
|
||||||
|
): Promise<TResponse> {
|
||||||
|
return apiPost<TResponse>(settings, path, { ...init, body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
export function loadApiSettings(): ApiSettings {
|
export function loadApiSettings(): ApiSettings {
|
||||||
const storedBaseUrl = loadStoredSetting("baseUrl");
|
const storedBaseUrl = loadStoredSetting("baseUrl");
|
||||||
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);
|
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type {
|
||||||
|
MailImapTransportSettings,
|
||||||
|
MailProfilePatternKey,
|
||||||
|
MailProfilePolicy,
|
||||||
|
MailProfileScope,
|
||||||
|
MailSecurity,
|
||||||
|
MailServerProfile,
|
||||||
|
MailServerProfileCredentials,
|
||||||
|
MailTransportCredentials,
|
||||||
|
MailTransportSettings
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MailCredentialPolicy,
|
||||||
|
MailProfilePatternKey,
|
||||||
|
MailProfilePolicy,
|
||||||
|
MailProfileScope,
|
||||||
|
MailSecurity,
|
||||||
|
MailServerProfile
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export type MailSmtpTestPayload = MailTransportSettings;
|
||||||
|
export type MailImapTestPayload = MailImapTransportSettings;
|
||||||
|
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
||||||
|
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
||||||
|
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||||
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
|
export type MailConnectionTestResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "smtp" | "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderResponse = {
|
||||||
|
name: string;
|
||||||
|
flags?: string[];
|
||||||
|
message_count?: number | null;
|
||||||
|
unseen_count?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderListResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
protocol: "imap";
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
security?: MailSecurity | string | null;
|
||||||
|
message: string;
|
||||||
|
folders: MailImapFolderResponse[];
|
||||||
|
detected_sent_folder?: string | null;
|
||||||
|
from_cache?: boolean;
|
||||||
|
refreshing?: boolean;
|
||||||
|
indexed_at?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mailProfilePatternKeys = [
|
||||||
|
"smtp_hosts",
|
||||||
|
"imap_hosts",
|
||||||
|
"envelope_senders",
|
||||||
|
"from_headers",
|
||||||
|
"recipient_domains"
|
||||||
|
] as const satisfies readonly MailProfilePatternKey[];
|
||||||
|
|
||||||
|
export const mailProfilePolicyLimitKeys = [
|
||||||
|
"allowed_profile_ids",
|
||||||
|
"allow_user_profiles",
|
||||||
|
"allow_group_profiles",
|
||||||
|
"smtp_credentials.inherit",
|
||||||
|
"imap_credentials.inherit",
|
||||||
|
"whitelist.smtp_hosts",
|
||||||
|
"whitelist.imap_hosts",
|
||||||
|
"whitelist.envelope_senders",
|
||||||
|
"whitelist.from_headers",
|
||||||
|
"whitelist.recipient_domains",
|
||||||
|
"blacklist.smtp_hosts",
|
||||||
|
"blacklist.imap_hosts",
|
||||||
|
"blacklist.envelope_senders",
|
||||||
|
"blacklist.from_headers",
|
||||||
|
"blacklist.recipient_domains"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
|
||||||
|
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
|
||||||
|
|
||||||
|
export type MailPolicySourceStep = {
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
applied_fields?: string[];
|
||||||
|
policy?: MailProfilePolicy | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfilePolicyResponse = {
|
||||||
|
scope_type: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
policy: MailProfilePolicy;
|
||||||
|
effective_policy?: MailProfilePolicy | null;
|
||||||
|
parent_policy?: MailProfilePolicy | null;
|
||||||
|
effective_policy_sources?: MailPolicySourceStep[];
|
||||||
|
parent_policy_sources?: MailPolicySourceStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfilePayload = {
|
||||||
|
name: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope_type?: MailProfileScope;
|
||||||
|
scope_id?: string | null;
|
||||||
|
smtp: MailSmtpTestPayload;
|
||||||
|
imap?: MailImapTestPayload | null;
|
||||||
|
credentials?: MailServerProfileCredentialsPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxMessage = {
|
||||||
|
id: string;
|
||||||
|
kind: "smtp" | "imap_append" | string;
|
||||||
|
created_at: string;
|
||||||
|
envelope_from?: string | null;
|
||||||
|
envelope_recipients?: string[];
|
||||||
|
subject?: string | null;
|
||||||
|
from_header?: string | null;
|
||||||
|
to_header?: string | null;
|
||||||
|
cc_header?: string | null;
|
||||||
|
bcc_header?: string | null;
|
||||||
|
message_id?: string | null;
|
||||||
|
size_bytes?: number;
|
||||||
|
body_preview?: string | null;
|
||||||
|
attachment_count?: number;
|
||||||
|
folder?: string | null;
|
||||||
|
raw_eml?: string | null;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockMailboxMessageResponse = {
|
||||||
|
message: MockMailboxMessage;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch, apiPath } from "./client";
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyFieldKey =
|
export type PrivacyRetentionPolicyFieldKey =
|
||||||
| "store_raw_campaign_json"
|
| "store_raw_campaign_json"
|
||||||
@@ -78,13 +78,6 @@ export type RetentionRunResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function retentionPolicyQuery(scopeId?: string | null): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (scopeId) params.set("scope_id", scopeId);
|
|
||||||
const suffix = params.toString();
|
|
||||||
return suffix ? `?${suffix}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPrivacyRetentionPolicy(
|
export function getPrivacyRetentionPolicy(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
scope: PrivacyRetentionPolicyScope,
|
scope: PrivacyRetentionPolicyScope,
|
||||||
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
|
|||||||
): Promise<PrivacyRetentionPolicyScopeResponse> {
|
): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`
|
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +96,7 @@ export function explainPrivacyRetentionPolicy(
|
|||||||
): Promise<PrivacyRetentionPolicyExplainResponse> {
|
): Promise<PrivacyRetentionPolicyExplainResponse> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}`
|
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain`, { scope_id: scopeId })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +109,7 @@ export function updatePrivacyRetentionPolicy(
|
|||||||
): Promise<PrivacyRetentionPolicyScopeResponse> {
|
): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`,
|
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId }),
|
||||||
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
|
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import FormField from "./FormField";
|
||||||
|
import PasswordField from "./PasswordField";
|
||||||
|
|
||||||
|
export type CredentialValues = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CredentialFieldsProps = {
|
||||||
|
values: CredentialValues;
|
||||||
|
onChange: (patch: Partial<CredentialValues>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
usernameDisabled?: boolean;
|
||||||
|
passwordDisabled?: boolean;
|
||||||
|
savedPassword?: boolean;
|
||||||
|
savedPasswordPlaceholder?: string;
|
||||||
|
heading?: ReactNode;
|
||||||
|
headingClassName?: string;
|
||||||
|
usernameLabel?: ReactNode;
|
||||||
|
passwordLabel?: ReactNode;
|
||||||
|
usernamePlaceholder?: string;
|
||||||
|
passwordPlaceholder?: string;
|
||||||
|
usernameAutoComplete?: string;
|
||||||
|
passwordAutoComplete?: string;
|
||||||
|
showUsername?: boolean;
|
||||||
|
showPassword?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CredentialPanelProps = CredentialFieldsProps & {
|
||||||
|
className?: string;
|
||||||
|
gridClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CredentialFields({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
usernameDisabled = disabled,
|
||||||
|
passwordDisabled = disabled,
|
||||||
|
savedPassword = false,
|
||||||
|
savedPasswordPlaceholder = "••••••••",
|
||||||
|
heading,
|
||||||
|
headingClassName = "credential-panel-heading",
|
||||||
|
usernameLabel = "i18n:govoplan-core.username.84c29015",
|
||||||
|
passwordLabel = "i18n:govoplan-core.password.8be3c943",
|
||||||
|
usernamePlaceholder,
|
||||||
|
passwordPlaceholder,
|
||||||
|
usernameAutoComplete = "username",
|
||||||
|
passwordAutoComplete = "new-password",
|
||||||
|
showUsername = true,
|
||||||
|
showPassword = true
|
||||||
|
}: CredentialFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{heading && <div className={headingClassName}>{heading}</div>}
|
||||||
|
{showUsername &&
|
||||||
|
<FormField label={usernameLabel}>
|
||||||
|
<input
|
||||||
|
value={stringValue(values.username)}
|
||||||
|
disabled={usernameDisabled}
|
||||||
|
autoComplete={usernameAutoComplete}
|
||||||
|
placeholder={usernamePlaceholder}
|
||||||
|
onChange={(event) => onChange({ username: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
}
|
||||||
|
{showPassword &&
|
||||||
|
<FormField label={passwordLabel}>
|
||||||
|
<PasswordField
|
||||||
|
value={stringValue(values.password)}
|
||||||
|
disabled={passwordDisabled}
|
||||||
|
saved={savedPassword}
|
||||||
|
savedPlaceholder={savedPasswordPlaceholder}
|
||||||
|
placeholder={passwordPlaceholder}
|
||||||
|
autoComplete={passwordAutoComplete}
|
||||||
|
onValueChange={(password) => onChange({ password })} />
|
||||||
|
</FormField>
|
||||||
|
}
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CredentialPanel({
|
||||||
|
className = "",
|
||||||
|
gridClassName = "",
|
||||||
|
children,
|
||||||
|
...fieldProps
|
||||||
|
}: CredentialPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className={`credential-panel ${className}`.trim()}>
|
||||||
|
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
|
||||||
|
<CredentialFields {...fieldProps} />
|
||||||
|
</div>
|
||||||
|
{children && <div className="credential-panel-extra">{children}</div>}
|
||||||
|
</div>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import HoverTooltip from "./HoverTooltip";
|
||||||
|
|
||||||
|
export type DisabledActionTooltipProps = {
|
||||||
|
reason?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
|
||||||
|
if (!reason) return <>{children}</>;
|
||||||
|
return (
|
||||||
|
<HoverTooltip
|
||||||
|
content={reason}
|
||||||
|
tone="danger"
|
||||||
|
className={`disabled-action-tooltip ${className}`.trim()}
|
||||||
|
triggerTabIndex={0}>
|
||||||
|
{children}
|
||||||
|
</HoverTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
|
||||||
import { UploadCloud } from "lucide-react";
|
import { UploadCloud } from "lucide-react";
|
||||||
|
import { resolveDroppedFiles } from "./resolveDroppedFiles";
|
||||||
|
|
||||||
|
type RejectedDropReason = "disabled" | "empty" | "unreadable";
|
||||||
|
|
||||||
export type FileDropZoneProps = {
|
export type FileDropZoneProps = {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
|
|||||||
note?: ReactNode;
|
note?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
inputLabel?: string;
|
inputLabel?: string;
|
||||||
|
onRejectedDrop?: (reason: RejectedDropReason) => void;
|
||||||
onFiles: (files: File[]) => void | Promise<void>;
|
onFiles: (files: File[]) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ export default function FileDropZone({
|
|||||||
note,
|
note,
|
||||||
className = "",
|
className = "",
|
||||||
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
||||||
|
onRejectedDrop,
|
||||||
onFiles
|
onFiles
|
||||||
}: FileDropZoneProps) {
|
}: FileDropZoneProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -52,6 +57,13 @@ export default function FileDropZone({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
|
||||||
|
if (!interactionDisabled) setDragActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -70,15 +82,29 @@ export default function FileDropZone({
|
|||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragOver={(event) => {
|
onDragEnter={prepareFileDrag}
|
||||||
|
onDragOver={prepareFileDrag}
|
||||||
|
onDragLeave={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!interactionDisabled) setDragActive(true);
|
event.stopPropagation();
|
||||||
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||||
|
setDragActive(false);
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDragActive(false)}
|
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
|
if (interactionDisabled) {
|
||||||
|
onRejectedDrop?.("disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void resolveDroppedFiles(event.dataTransfer).then((files) => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
onRejectedDrop?.("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleFiles(files);
|
||||||
|
}).catch(() => onRejectedDrop?.("unreadable"));
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{showProgress ?
|
{showProgress ?
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
|
export type HoverTooltipTone = "default" | "danger";
|
||||||
|
|
||||||
|
type TooltipPosition = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
arrowLeft: number;
|
||||||
|
placement: "top" | "bottom";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HoverTooltipProps = {
|
||||||
|
content: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
tone?: HoverTooltipTone;
|
||||||
|
ariaLabel?: string;
|
||||||
|
triggerTabIndex?: number;
|
||||||
|
openDelayMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 12;
|
||||||
|
const TRIGGER_GAP = 10;
|
||||||
|
|
||||||
|
const BASE_TOOLTIP_STYLE: CSSProperties = {
|
||||||
|
position: "fixed",
|
||||||
|
zIndex: 20000,
|
||||||
|
width: "max-content",
|
||||||
|
maxWidth: "min(320px, calc(100vw - 48px))",
|
||||||
|
borderRadius: 7,
|
||||||
|
boxShadow: "var(--shadow-popover)",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
padding: "9px 10px",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
pointerEvents: "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
if (max < min) return min;
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
|
||||||
|
if (tone === "danger") {
|
||||||
|
return {
|
||||||
|
border: "1px solid var(--border-danger-soft)",
|
||||||
|
background: "var(--danger-muted-bg)",
|
||||||
|
color: "var(--danger-text-tooltip)",
|
||||||
|
fontWeight: 700
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
border: "1px solid var(--line-dark)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontWeight: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
|
||||||
|
if (tone === "danger") {
|
||||||
|
return {
|
||||||
|
borderColor: "var(--border-danger-soft)",
|
||||||
|
background: "var(--danger-muted-bg)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
borderColor: "var(--line-dark)",
|
||||||
|
background: "var(--surface)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverTooltip({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
tone = "default",
|
||||||
|
ariaLabel,
|
||||||
|
triggerTabIndex,
|
||||||
|
openDelayMs = 350
|
||||||
|
}: HoverTooltipProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const tooltipId = useId();
|
||||||
|
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const openTimerRef = useRef<number | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState<TooltipPosition | null>(null);
|
||||||
|
|
||||||
|
const clearOpenTimer = useCallback(() => {
|
||||||
|
if (openTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(openTimerRef.current);
|
||||||
|
openTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openWithDelay = useCallback(() => {
|
||||||
|
if (!content) return;
|
||||||
|
clearOpenTimer();
|
||||||
|
openTimerRef.current = window.setTimeout(() => {
|
||||||
|
openTimerRef.current = null;
|
||||||
|
setIsOpen(true);
|
||||||
|
}, openDelayMs);
|
||||||
|
}, [clearOpenTimer, content, openDelayMs]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
clearOpenTimer();
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [clearOpenTimer]);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const tooltip = tooltipRef.current;
|
||||||
|
if (!trigger || !tooltip) return;
|
||||||
|
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const tooltipRect = tooltip.getBoundingClientRect();
|
||||||
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
||||||
|
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
|
||||||
|
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
|
||||||
|
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
|
||||||
|
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
|
||||||
|
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
|
||||||
|
const top = hasRoomAbove
|
||||||
|
? topCandidate
|
||||||
|
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
|
||||||
|
placement: hasRoomAbove ? "top" : "bottom"
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
const frame = window.requestAnimationFrame(updatePosition);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
|
||||||
|
const handleScrollOrResize = () => updatePosition();
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") close();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScrollOrResize, true);
|
||||||
|
window.addEventListener("resize", handleScrollOrResize);
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScrollOrResize, true);
|
||||||
|
window.removeEventListener("resize", handleScrollOrResize);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [close, isOpen, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => clearOpenTimer, [clearOpenTimer]);
|
||||||
|
|
||||||
|
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
|
||||||
|
const tooltipStyle: CSSProperties = {
|
||||||
|
...BASE_TOOLTIP_STYLE,
|
||||||
|
...tooltipStyleForTone(tone),
|
||||||
|
top: position?.top ?? -9999,
|
||||||
|
left: position?.left ?? -9999,
|
||||||
|
opacity: position ? 1 : 0
|
||||||
|
};
|
||||||
|
const arrowColors = arrowStyleForTone(tone);
|
||||||
|
const arrowStyle: CSSProperties = position?.placement === "bottom"
|
||||||
|
? {
|
||||||
|
position: "absolute",
|
||||||
|
left: position.arrowLeft,
|
||||||
|
top: 0,
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderLeft: "1px solid",
|
||||||
|
borderTop: "1px solid",
|
||||||
|
...arrowColors,
|
||||||
|
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "absolute",
|
||||||
|
left: position?.arrowLeft ?? 16,
|
||||||
|
top: "100%",
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderRight: "1px solid",
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
...arrowColors,
|
||||||
|
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={triggerRef}
|
||||||
|
className={className}
|
||||||
|
tabIndex={triggerTabIndex}
|
||||||
|
aria-label={translatedAriaLabel}
|
||||||
|
aria-describedby={isOpen ? tooltipId : undefined}
|
||||||
|
onMouseEnter={openWithDelay}
|
||||||
|
onMouseLeave={close}
|
||||||
|
onFocus={openWithDelay}
|
||||||
|
onBlur={close}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{isOpen && typeof document !== "undefined" && createPortal(
|
||||||
|
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
|
||||||
|
{typeof content === "string" ? translateText(content) : content}
|
||||||
|
<span aria-hidden="true" style={arrowStyle} />
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,4 +1,5 @@
|
|||||||
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, KeyboardEvent } from "react";
|
import type { CSSProperties, KeyboardEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
|
|||||||
value: MailboxAddress[];
|
value: MailboxAddress[];
|
||||||
onChange?: (value: MailboxAddress[]) => void;
|
onChange?: (value: MailboxAddress[]) => void;
|
||||||
onAddressAdded?: (address: MailboxAddress) => void;
|
onAddressAdded?: (address: MailboxAddress) => void;
|
||||||
|
onSuggestionQueryChange?: (query: string) => void;
|
||||||
suggestions?: MailboxAddress[];
|
suggestions?: MailboxAddress[];
|
||||||
allowMultiple?: boolean;
|
allowMultiple?: boolean;
|
||||||
clearOnAdd?: boolean;
|
clearOnAdd?: boolean;
|
||||||
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onAddressAdded,
|
onAddressAdded,
|
||||||
|
onSuggestionQueryChange,
|
||||||
suggestions = [],
|
suggestions = [],
|
||||||
allowMultiple = true,
|
allowMultiple = true,
|
||||||
clearOnAdd = false,
|
clearOnAdd = false,
|
||||||
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
|
|||||||
compact = false,
|
compact = false,
|
||||||
showAddButton
|
showAddButton
|
||||||
}: EmailAddressInputProps) {
|
}: EmailAddressInputProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
|
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
|
||||||
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
|
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
|
||||||
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
|
|||||||
const [dialogEmail, setDialogEmail] = useState("");
|
const [dialogEmail, setDialogEmail] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||||
|
const lastSuggestionQueryRef = useRef<string | null>(null);
|
||||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const canUseAddButton = showAddButton ?? allowMultiple;
|
const canUseAddButton = showAddButton ?? allowMultiple;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = entryText.trim();
|
||||||
|
if (query === "" && lastSuggestionQueryRef.current === null) return;
|
||||||
|
if (query === lastSuggestionQueryRef.current) return;
|
||||||
|
lastSuggestionQueryRef.current = query;
|
||||||
|
onSuggestionQueryChange?.(query);
|
||||||
|
}, [entryText, onSuggestionQueryChange]);
|
||||||
|
|
||||||
const filteredSuggestions = useMemo(() => {
|
const filteredSuggestions = useMemo(() => {
|
||||||
const query = entryText.trim().toLowerCase();
|
const query = entryText.trim().toLowerCase();
|
||||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||||
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
|
|||||||
if (event.key === "Escape") setDialogOpen(false);
|
if (event.key === "Escape") setDialogOpen(false);
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
|
<h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
|
||||||
<label>
|
<label>
|
||||||
<span>i18n:govoplan-core.name.709a2322</span>
|
<span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
|
||||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>i18n:govoplan-core.email_address.c94d3175</span>
|
<span>{translateText("i18n:govoplan-core.email_address.c94d3175")}</span>
|
||||||
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
|
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
|
||||||
</label>
|
</label>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button>
|
<Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
|
||||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
<Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
|
|||||||
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
|
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
|
||||||
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
||||||
<div className="email-chip-list" aria-live="polite">
|
<div className="email-chip-list" aria-live="polite">
|
||||||
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
|
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
|
||||||
{normalizedValue.map((address) => {
|
{normalizedValue.map((address) => {
|
||||||
const valid = isValidEmailAddress(address.email);
|
const valid = isValidEmailAddress(address.email);
|
||||||
return (
|
return (
|
||||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
|
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("i18n:govoplan-core.invalid_email_address.9e4ee6d7")}>
|
||||||
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
||||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||||
{!disabled &&
|
{!disabled &&
|
||||||
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleTextKeyDown}
|
onKeyDown={handleTextKeyDown}
|
||||||
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
|
placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
|
||||||
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
|
aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
|
||||||
|
|
||||||
{canUseAddButton &&
|
{canUseAddButton &&
|
||||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}>
|
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
|
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
|
||||||
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
|
<div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
|
||||||
{filteredSuggestions.map((item) =>
|
{filteredSuggestions.map((item) =>
|
||||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||||
<span>{addressDisplayName(item)}</span>
|
<span>{addressDisplayName(item)}</span>
|
||||||
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{error && <p className="form-help danger-text">{error}</p>}
|
{error && <p className="form-help danger-text">{translateText(error)}</p>}
|
||||||
{addressDialog}
|
{addressDialog}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,188 +1,20 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
import HoverTooltip from "../HoverTooltip";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
|
||||||
|
|
||||||
type InlineHelpProps = {
|
type InlineHelpProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TooltipPosition = {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
arrowLeft: number;
|
|
||||||
placement: "top" | "bottom";
|
|
||||||
};
|
|
||||||
|
|
||||||
const OPEN_DELAY_MS = 350;
|
|
||||||
const VIEWPORT_MARGIN = 12;
|
|
||||||
const TRIGGER_GAP = 10;
|
|
||||||
|
|
||||||
const tooltipBaseStyle: CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
zIndex: 20000,
|
|
||||||
width: "max-content",
|
|
||||||
maxWidth: "min(320px, calc(100vw - 48px))",
|
|
||||||
border: "1px solid var(--line-dark)",
|
|
||||||
borderRadius: 7,
|
|
||||||
background: "var(--surface)",
|
|
||||||
boxShadow: "var(--shadow-popover)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
padding: "9px 10px",
|
|
||||||
whiteSpace: "normal",
|
|
||||||
pointerEvents: "none"
|
|
||||||
};
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
if (max < min) return min;
|
|
||||||
return Math.min(Math.max(value, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
|
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
|
||||||
const tooltipId = useId();
|
|
||||||
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
|
||||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const openTimerRef = useRef<number | null>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [position, setPosition] = useState<TooltipPosition | null>(null);
|
|
||||||
|
|
||||||
const clearOpenTimer = useCallback(() => {
|
|
||||||
if (openTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(openTimerRef.current);
|
|
||||||
openTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openWithDelay = useCallback(() => {
|
|
||||||
clearOpenTimer();
|
|
||||||
openTimerRef.current = window.setTimeout(() => {
|
|
||||||
openTimerRef.current = null;
|
|
||||||
setIsOpen(true);
|
|
||||||
}, OPEN_DELAY_MS);
|
|
||||||
}, [clearOpenTimer]);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
clearOpenTimer();
|
|
||||||
setIsOpen(false);
|
|
||||||
}, [clearOpenTimer]);
|
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
|
||||||
const trigger = triggerRef.current;
|
|
||||||
const tooltip = tooltipRef.current;
|
|
||||||
if (!trigger || !tooltip) return;
|
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const tooltipRect = tooltip.getBoundingClientRect();
|
|
||||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
|
||||||
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
|
|
||||||
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
|
|
||||||
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
|
|
||||||
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
|
|
||||||
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
|
|
||||||
const top = hasRoomAbove ?
|
|
||||||
topCandidate :
|
|
||||||
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
|
|
||||||
|
|
||||||
setPosition({
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
|
|
||||||
placement: hasRoomAbove ? "top" : "bottom"
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setPosition(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
const frame = window.requestAnimationFrame(updatePosition);
|
|
||||||
return () => window.cancelAnimationFrame(frame);
|
|
||||||
}, [isOpen, updatePosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return undefined;
|
|
||||||
|
|
||||||
const handleScrollOrResize = () => updatePosition();
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") close();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScrollOrResize, true);
|
|
||||||
window.addEventListener("resize", handleScrollOrResize);
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScrollOrResize, true);
|
|
||||||
window.removeEventListener("resize", handleScrollOrResize);
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [close, isOpen, updatePosition]);
|
|
||||||
|
|
||||||
useEffect(() => clearOpenTimer, [clearOpenTimer]);
|
|
||||||
|
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
|
|
||||||
const tooltipStyle: CSSProperties = {
|
|
||||||
...tooltipBaseStyle,
|
|
||||||
top: position?.top ?? -9999,
|
|
||||||
left: position?.left ?? -9999,
|
|
||||||
opacity: position ? 1 : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
left: position.arrowLeft,
|
|
||||||
top: 0,
|
|
||||||
width: 9,
|
|
||||||
height: 9,
|
|
||||||
borderLeft: "1px solid var(--line-dark)",
|
|
||||||
borderTop: "1px solid var(--line-dark)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
|
||||||
} :
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
left: position?.arrowLeft ?? 16,
|
|
||||||
top: "100%",
|
|
||||||
width: 9,
|
|
||||||
height: 9,
|
|
||||||
borderRight: "1px solid var(--line-dark)",
|
|
||||||
borderBottom: "1px solid var(--line-dark)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HoverTooltip
|
||||||
<span
|
content={children}
|
||||||
ref={triggerRef}
|
|
||||||
className={`inline-help ${className}`.trim()}
|
className={`inline-help ${className}`.trim()}
|
||||||
tabIndex={-1}
|
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
|
||||||
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
|
triggerTabIndex={-1}>
|
||||||
aria-describedby={isOpen ? tooltipId : undefined}
|
|
||||||
onMouseEnter={openWithDelay}
|
|
||||||
onMouseLeave={close}
|
|
||||||
onFocus={openWithDelay}
|
|
||||||
onBlur={close}>
|
|
||||||
|
|
||||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||||
</span>
|
</HoverTooltip>
|
||||||
{isOpen && createPortal(
|
);
|
||||||
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
|
|
||||||
{typeof children === "string" ? translateText(children) : children}
|
|
||||||
<span aria-hidden="true" style={arrowStyle} />
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
|
import { CredentialFields } from "../CredentialPanel";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
import DismissibleAlert from "../DismissibleAlert";
|
||||||
import FormField from "../FormField";
|
import FormField from "../FormField";
|
||||||
import PasswordField from "../PasswordField";
|
|
||||||
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,6 +47,9 @@ export type MailServerFolderLookupResult = {
|
|||||||
details?: Record<string, unknown> | null;
|
details?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailServerSettingsSection = "smtp" | "imap";
|
||||||
|
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||||
|
|
||||||
export type MailServerSettingsPanelProps = {
|
export type MailServerSettingsPanelProps = {
|
||||||
smtp: MailServerSmtpSettings;
|
smtp: MailServerSmtpSettings;
|
||||||
imap: MailServerImapSettings;
|
imap: MailServerImapSettings;
|
||||||
@@ -69,40 +71,22 @@ 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;
|
||||||
initialSection?: MailServerSettingsSection;
|
initialSection?: MailServerSettingsSection;
|
||||||
|
visibleSections?: readonly MailServerSettingsSection[];
|
||||||
|
mode?: MailServerSettingsMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||||
const securityOptions = mailServerSecurityOptions;
|
const securityOptions = mailServerSecurityOptions;
|
||||||
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
|
||||||
|
|
||||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||||
if (security === "tls") return 465;
|
if (security === "tls") return 465;
|
||||||
@@ -198,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,
|
||||||
@@ -219,22 +216,17 @@ 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,
|
||||||
initialSection = "smtp"
|
initialSection = "smtp",
|
||||||
|
visibleSections,
|
||||||
|
mode = "all"
|
||||||
}: MailServerSettingsPanelProps) {
|
}: MailServerSettingsPanelProps) {
|
||||||
const smtpFieldsDisabled = disabled || smtpDisabled;
|
const smtpFieldsDisabled = disabled || smtpDisabled;
|
||||||
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
||||||
@@ -248,17 +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 allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||||
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 [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
|
||||||
const sections: {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 sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
||||||
|
const visibleSectionIds = sections.map((section) => section.id);
|
||||||
|
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||||
|
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
|
||||||
|
const sectionKey = visibleSectionIds.join("|");
|
||||||
|
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
||||||
|
const previousInitialSectionRef = useRef(initialSection);
|
||||||
|
const showSectionSwitcher = sections.length > 1;
|
||||||
|
const showServerFields = mode !== "credentials";
|
||||||
|
const showCredentialFields = mode !== "server";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSection = resolveMailServerSettingsActiveSection(
|
||||||
|
activeSection,
|
||||||
|
initialSection,
|
||||||
|
previousInitialSectionRef.current,
|
||||||
|
visibleSectionIds
|
||||||
|
);
|
||||||
|
previousInitialSectionRef.current = initialSection;
|
||||||
|
if (nextSection !== activeSection) setActiveSection(nextSection);
|
||||||
|
}, [activeSection, initialSection, sectionKey]);
|
||||||
|
|
||||||
|
|
||||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||||
@@ -283,6 +289,7 @@ export default function MailServerSettingsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||||
|
{showSectionSwitcher &&
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
className="mail-server-segmented-control"
|
className="mail-server-segmented-control"
|
||||||
size="equal"
|
size="equal"
|
||||||
@@ -291,27 +298,28 @@ export default function MailServerSettingsPanel({
|
|||||||
onChange={setActiveSection}
|
onChange={setActiveSection}
|
||||||
options={sections}
|
options={sections}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="mail-server-settings-view">
|
<div className="mail-server-settings-view">
|
||||||
{activeSection === "smtp" &&
|
{activeSection === "smtp" &&
|
||||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
|
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
{showServerFields &&
|
||||||
<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.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.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>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
<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>
|
||||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
</>
|
||||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
}
|
||||||
<PasswordField
|
{showCredentialFields &&
|
||||||
value={stringValue(smtpCredentialValues.password)}
|
<CredentialFields
|
||||||
|
values={smtpCredentialValues}
|
||||||
|
onChange={patchSmtpCredentials}
|
||||||
disabled={smtpCredentialFieldsDisabled}
|
disabled={smtpCredentialFieldsDisabled}
|
||||||
saved={smtpPasswordSaved}
|
savedPassword={smtpPasswordSaved}
|
||||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
|
||||||
autoComplete="new-password"
|
}
|
||||||
onValueChange={(password) => patchSmtpCredentials({ password })} />
|
|
||||||
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
{onTestSmtp &&
|
{onTestSmtp &&
|
||||||
<div className="button-row compact-actions mail-server-actions">
|
<div className="button-row compact-actions mail-server-actions">
|
||||||
@@ -325,22 +333,22 @@ export default function MailServerSettingsPanel({
|
|||||||
{activeSection === "imap" &&
|
{activeSection === "imap" &&
|
||||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
|
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
{showServerFields &&
|
||||||
<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.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.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>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
<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>
|
||||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
</>
|
||||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
}
|
||||||
<PasswordField
|
{showCredentialFields &&
|
||||||
value={stringValue(imapCredentialValues.password)}
|
<CredentialFields
|
||||||
|
values={imapCredentialValues}
|
||||||
|
onChange={patchImapCredentials}
|
||||||
disabled={imapCredentialFieldsDisabled}
|
disabled={imapCredentialFieldsDisabled}
|
||||||
saved={imapPasswordSaved}
|
savedPassword={imapPasswordSaved}
|
||||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||||
autoComplete="new-password"
|
}
|
||||||
onValueChange={(password) => patchImapCredentials({ password })} />
|
|
||||||
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
{onTestImap &&
|
{onTestImap &&
|
||||||
<div className="button-row compact-actions mail-server-actions">
|
<div className="button-row compact-actions mail-server-actions">
|
||||||
@@ -351,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>);
|
||||||
|
|
||||||
@@ -407,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>}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
|
||||||
|
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
|
||||||
|
type FileSystemDirectoryHandleLike = {
|
||||||
|
kind: "directory";
|
||||||
|
values?: () => AsyncIterable<FileSystemHandleLike>;
|
||||||
|
};
|
||||||
|
type WebKitFileEntryLike = {
|
||||||
|
isFile: true;
|
||||||
|
isDirectory: false;
|
||||||
|
file: (
|
||||||
|
success: (file: File) => void,
|
||||||
|
error?: (error: DOMException) => void
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
|
||||||
|
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
|
||||||
|
type DropItemWithFileHandles = DataTransferItem & {
|
||||||
|
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
|
||||||
|
webkitGetAsEntry?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a browser file drop exactly once, preferring the standard file list
|
||||||
|
* before progressively trying item and directory-drag compatibility APIs.
|
||||||
|
*/
|
||||||
|
export async function resolveDroppedFiles(
|
||||||
|
dataTransfer: Pick<DataTransfer, "files" | "items">
|
||||||
|
): Promise<File[]> {
|
||||||
|
const directFiles = Array.from(dataTransfer.files);
|
||||||
|
if (directFiles.length > 0) return directFiles;
|
||||||
|
|
||||||
|
const items = Array.from(dataTransfer.items).filter(
|
||||||
|
(item) => item.kind === "file"
|
||||||
|
) as DropItemWithFileHandles[];
|
||||||
|
const itemFiles = items
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
if (itemFiles.length > 0) return itemFiles;
|
||||||
|
|
||||||
|
const handleFiles = await filesFromDataTransferHandles(items);
|
||||||
|
if (handleFiles.length > 0) return handleFiles;
|
||||||
|
|
||||||
|
return filesFromWebKitEntries(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromDataTransferHandles(
|
||||||
|
items: DropItemWithFileHandles[]
|
||||||
|
): Promise<File[]> {
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
let handle: FileSystemHandleLike | null | undefined;
|
||||||
|
try {
|
||||||
|
handle = await item.getAsFileSystemHandle?.();
|
||||||
|
} catch {
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
if (!handle) continue;
|
||||||
|
files.push(...await filesFromFileSystemHandle(handle));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromFileSystemHandle(
|
||||||
|
handle: FileSystemHandleLike
|
||||||
|
): Promise<File[]> {
|
||||||
|
if (handle.kind === "file") return [await handle.getFile()];
|
||||||
|
const values = handle.values?.();
|
||||||
|
if (!values) return [];
|
||||||
|
const files: File[] = [];
|
||||||
|
for await (const child of values) {
|
||||||
|
files.push(...await filesFromFileSystemHandle(child));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromWebKitEntries(
|
||||||
|
items: DropItemWithFileHandles[]
|
||||||
|
): Promise<File[]> {
|
||||||
|
const entries = items.flatMap((item) => {
|
||||||
|
const entry = item.webkitGetAsEntry?.() as unknown;
|
||||||
|
return isWebKitEntryLike(entry) ? [entry] : [];
|
||||||
|
});
|
||||||
|
const files = await Promise.all(entries.map(fileFromWebKitEntry));
|
||||||
|
return files.filter((file): file is File => Boolean(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as { isFile?: unknown; isDirectory?: unknown };
|
||||||
|
return (
|
||||||
|
typeof entry.isFile === "boolean" &&
|
||||||
|
typeof entry.isDirectory === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
|
||||||
|
if (!entry.isFile) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ type ColumnResizeState = {
|
|||||||
behavior: DataGridResizeBehavior;
|
behavior: DataGridResizeBehavior;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_PREFIX = "multimailer.datagrid.";
|
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||||
const FILTER_POPOVER_WIDTH = 320;
|
const FILTER_POPOVER_WIDTH = 320;
|
||||||
const FILTER_POPOVER_MARGIN = 12;
|
const FILTER_POPOVER_MARGIN = 12;
|
||||||
const MIN_HEADER_LABEL_WIDTH = 72;
|
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PasswordField from "../../components/PasswordField";
|
import PasswordField from "../../components/PasswordField";
|
||||||
@@ -8,15 +8,16 @@ import Button from "../../components/Button";
|
|||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { apiFetch } from "../../api/client";
|
import { apiFetch } from "../../api/client";
|
||||||
import { updateProfile } from "../../api/auth";
|
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
|
||||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
import SegmentedControl from "../../components/SegmentedControl";
|
||||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
|
||||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications";
|
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||||
|
|
||||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||||
compact_tables: false,
|
compact_tables: false,
|
||||||
@@ -32,8 +33,8 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
|||||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||||
];
|
];
|
||||||
|
|
||||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
|
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||||
return [
|
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||||
{
|
{
|
||||||
title: "i18n:govoplan-core.account.f967543b",
|
title: "i18n:govoplan-core.account.f967543b",
|
||||||
items: [
|
items: [
|
||||||
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
|
|||||||
items: [
|
items: [
|
||||||
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
|
||||||
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
|
||||||
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
|
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
|
||||||
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
|
|
||||||
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
for (const section of contributedSections) {
|
||||||
|
const groupId = section.group || "ui";
|
||||||
|
const title = section.groupTitle || (groupId === "account" ? "i18n:govoplan-core.account.f967543b" : groupId === "ui" ? "i18n:govoplan-core.ui_settings.9e9cc5ea" : groupId);
|
||||||
|
let group = groups.find((item) => item.title === title);
|
||||||
|
if (!group) {
|
||||||
|
group = { title, items: [] };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.items.push({ id: section.id, label: section.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
group.items.sort((left, right) => {
|
||||||
|
const leftId = "id" in left ? left.id : "";
|
||||||
|
const rightId = "id" in right ? right.id : "";
|
||||||
|
return (sectionOrder(leftId, contributedSections) - sectionOrder(rightId, contributedSections)) || left.label.localeCompare(right.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({
|
export default function SettingsPage({
|
||||||
@@ -64,19 +83,26 @@ export default function SettingsPage({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) {
|
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;}) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestNavigation } = useUnsavedChanges();
|
||||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
|
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]);
|
const contributedSections = useMemo(
|
||||||
const requestedSection = searchParams.get("section") as SettingsSection | null;
|
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||||
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
|
[auth, settingsSectionCapabilities]
|
||||||
|
);
|
||||||
|
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||||
|
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||||
|
const requestedSection = searchParams.get("section");
|
||||||
|
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
||||||
|
const activeContributedSection = contributedSections.find((section) => section.id === active) ?? null;
|
||||||
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
|
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState("");
|
const [testResult, setTestResult] = useState("");
|
||||||
@@ -118,12 +144,30 @@ export default function SettingsPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
|
if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
next.set("section", "interface");
|
next.set("section", "interface");
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
}
|
}
|
||||||
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]);
|
}, [availableSectionIds, requestedSection, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.profile_loaded) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchAuthProfile(settings)
|
||||||
|
.then((next) => {if (!cancelled) onAuthChange(next);})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [auth.profile_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.roles_loaded) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchAuthRoles(settings)
|
||||||
|
.then((next) => {if (!cancelled) onAuthChange(next);})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [auth.roles_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfileName(auth.user.display_name || "");
|
setProfileName(auth.user.display_name || "");
|
||||||
@@ -329,12 +373,16 @@ export default function SettingsPage({
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-core.theme.a797e309">
|
<FormField label="i18n:govoplan-core.theme.a797e309">
|
||||||
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
|
<SegmentedControl
|
||||||
{UI_THEME_OPTIONS.map((item) =>
|
options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
|
||||||
<option key={item.value} value={item.value}>{item.label}</option>
|
value={theme}
|
||||||
)}
|
onChange={setTheme}
|
||||||
</select>
|
role="group"
|
||||||
|
size="equal"
|
||||||
|
width="fill"
|
||||||
|
ariaLabel="i18n:govoplan-core.theme.a797e309" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<ThemePreview theme={theme} />
|
||||||
<dl className="detail-list compact-detail-list">
|
<dl className="detail-list compact-detail-list">
|
||||||
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
|
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
|
||||||
@@ -413,24 +461,15 @@ export default function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
{active === "notifications" &&
|
{activeContributedSection &&
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
activeContributedSection.render({
|
||||||
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
|
settings,
|
||||||
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
|
auth,
|
||||||
<div className="placeholder-stack">
|
onAuthChange,
|
||||||
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
|
activeSection: active,
|
||||||
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
|
availableSections: availableSectionIds,
|
||||||
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
|
selectSection
|
||||||
</div>
|
})
|
||||||
</Card>
|
|
||||||
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
|
|
||||||
<div className="placeholder-stack">
|
|
||||||
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
|
|
||||||
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
|
|
||||||
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -439,8 +478,30 @@ export default function SettingsPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
|
function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
|
||||||
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
|
return Boolean(section && sections.has(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseSettingsContribution(auth: AuthInfo, section: SettingsSectionContribution): boolean {
|
||||||
|
if (section.allOf?.some((scope) => !hasScope(auth, scope))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (section.anyOf && !hasAnyScope(auth, section.anyOf)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionOrder(sectionId: string, contributedSections: SettingsSectionContribution[]): number {
|
||||||
|
const builtInOrder: Record<string, number> = {
|
||||||
|
profile: 10,
|
||||||
|
"mail-profiles": 20,
|
||||||
|
"file-connectors": 30,
|
||||||
|
interface: 10,
|
||||||
|
workspace: 20,
|
||||||
|
"local-connection": 30
|
||||||
|
};
|
||||||
|
return builtInOrder[sectionId] ?? contributedSections.find((section) => section.id === sectionId)?.order ?? 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
|
||||||
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
|||||||
function themeLabel(value: UserUiTheme): string {
|
function themeLabel(value: UserUiTheme): string {
|
||||||
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
|
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemePreview({ theme }: {theme: UserUiTheme;}) {
|
||||||
|
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
|
||||||
|
return (
|
||||||
|
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
|
||||||
|
{variants.map((variant) =>
|
||||||
|
<div key={variant} className="theme-preview" data-preview-theme={variant}>
|
||||||
|
<div className="theme-preview-header">
|
||||||
|
<span>{themeLabel(variant)}</span>
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
<div className="theme-preview-body">
|
||||||
|
<strong>GovOPlaN</strong>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
+27
-1
@@ -3,6 +3,26 @@ export * from "./types";
|
|||||||
export * from "./api/client";
|
export * from "./api/client";
|
||||||
export * from "./api/auth";
|
export * from "./api/auth";
|
||||||
export * from "./api/platform";
|
export * from "./api/platform";
|
||||||
|
export * from "./api/adminCommon";
|
||||||
|
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
|
||||||
|
export type {
|
||||||
|
MailConnectionTestResponse,
|
||||||
|
MailImapFolderListResponse,
|
||||||
|
MailImapFolderResponse,
|
||||||
|
MailImapTestPayload,
|
||||||
|
MailPolicySourceStep,
|
||||||
|
MailProfilePatternRules,
|
||||||
|
MailProfilePolicyLimitKey,
|
||||||
|
MailProfilePolicyLimitPermissions,
|
||||||
|
MailProfilePolicyResponse,
|
||||||
|
MailServerProfileCredentialsPayload,
|
||||||
|
MailServerProfileListResponse,
|
||||||
|
MailServerProfilePayload,
|
||||||
|
MailSmtpTestPayload,
|
||||||
|
MailTransportCredentialsPayload,
|
||||||
|
MockMailboxMessage,
|
||||||
|
MockMailboxMessageResponse
|
||||||
|
} from "./api/mailContracts";
|
||||||
export * from "./api/privacyRetention";
|
export * from "./api/privacyRetention";
|
||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
@@ -32,8 +52,12 @@ export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
|||||||
export { default as ConnectionTree } from "./components/ConnectionTree";
|
export { default as ConnectionTree } from "./components/ConnectionTree";
|
||||||
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
|
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
|
||||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||||
|
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
||||||
|
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
|
||||||
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||||
export { default as Dialog } from "./components/Dialog";
|
export { default as Dialog } from "./components/Dialog";
|
||||||
|
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||||
|
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||||
@@ -44,6 +68,8 @@ export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
|
|||||||
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
|
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
|
||||||
export { default as GuidedReviewList } from "./components/GuidedReviewList";
|
export { default as GuidedReviewList } from "./components/GuidedReviewList";
|
||||||
export type { GuidedReviewItem } from "./components/GuidedReviewList";
|
export type { GuidedReviewItem } from "./components/GuidedReviewList";
|
||||||
|
export { default as HoverTooltip } from "./components/HoverTooltip";
|
||||||
|
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
|
||||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||||
export { default as ExplorerTree } from "./components/ExplorerTree";
|
export { default as ExplorerTree } from "./components/ExplorerTree";
|
||||||
@@ -71,7 +97,7 @@ export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard"
|
|||||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
||||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
||||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types";
|
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
|
||||||
import IconRail from "./IconRail";
|
import IconRail from "./IconRail";
|
||||||
import Titlebar from "./Titlebar";
|
import Titlebar from "./Titlebar";
|
||||||
import BreadcrumbBar from "./BreadcrumbBar";
|
import BreadcrumbBar from "./BreadcrumbBar";
|
||||||
@@ -9,10 +9,11 @@ type Props = {
|
|||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo | null;
|
auth: AuthInfo | null;
|
||||||
onSettingsChange: (settings: ApiSettings) => void;
|
onSettingsChange: (settings: ApiSettings) => void;
|
||||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
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>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
|
|||||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
|
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
||||||
|
|
||||||
export default function HelpMenu() {
|
export default function HelpMenu() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
@@ -50,8 +52,11 @@ export default function HelpMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDocs(type: "user" | "admin") {
|
function openDocs(type: "user" | "admin") {
|
||||||
if (!docsAvailable) return;
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
if (!docsAvailable) {
|
||||||
|
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const params = new URLSearchParams({ type });
|
const params = new URLSearchParams({ type });
|
||||||
params.set("context", helpContext.id);
|
params.set("context", helpContext.id);
|
||||||
navigate(`/docs?${params.toString()}`);
|
navigate(`/docs?${params.toString()}`);
|
||||||
@@ -79,10 +84,10 @@ export default function HelpMenu() {
|
|||||||
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
|
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
|
||||||
</button>
|
</button>
|
||||||
<hr />
|
<hr />
|
||||||
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
||||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
||||||
</button>
|
</button>
|
||||||
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||||
</button>
|
</button>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -96,6 +101,11 @@ export default function HelpMenu() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
|
||||||
|
const params = new URLSearchParams({ type, context: context.id });
|
||||||
|
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Settings } from "lucide-react";
|
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||||
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
|||||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||||
|
|
||||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||||
|
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
|
||||||
|
|
||||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||||
return [...navItems].
|
return [...navItems].
|
||||||
@@ -22,14 +23,11 @@ export default function IconRail({
|
|||||||
compact = false,
|
compact = false,
|
||||||
auth = null,
|
auth = null,
|
||||||
navItems = []
|
navItems = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const items = visibleNavItems(auth, navItems);
|
const items = visibleNavItems(auth, navItems);
|
||||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||||
|
const [expanded, setExpanded] = useState(() => loadRailExpanded());
|
||||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
@@ -46,15 +44,27 @@ export default function IconRail({
|
|||||||
});
|
});
|
||||||
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
setExpanded((current) => {
|
||||||
|
const next = !current;
|
||||||
|
saveRailExpanded(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
|
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
|
||||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigate(target);
|
navigate(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const railExpanded = !compact && expanded;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
<aside className={`icon-rail ${compact ? "compact" : ""} ${railExpanded ? "expanded" : ""}`}>
|
||||||
|
<div className="icon-rail-header">
|
||||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!compact &&
|
{!compact &&
|
||||||
<>
|
<>
|
||||||
@@ -62,9 +72,11 @@ export default function IconRail({
|
|||||||
{items.map(({ to, label, icon: Icon }) => {
|
{items.map(({ to, label, icon: Icon }) => {
|
||||||
const target = rememberedTargets[to] ?? to;
|
const target = rememberedTargets[to] ?? to;
|
||||||
const active = modulePathActive(location.pathname, to);
|
const active = modulePathActive(location.pathname, to);
|
||||||
|
const renderedLabel = translateText(label);
|
||||||
return (
|
return (
|
||||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
|
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
|
||||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
||||||
|
<span className="icon-nav-label">{renderedLabel}</span>
|
||||||
</NavLink>);
|
</NavLink>);
|
||||||
|
|
||||||
})}
|
})}
|
||||||
@@ -72,7 +84,17 @@ export default function IconRail({
|
|||||||
<div className="icon-rail-bottom">
|
<div className="icon-rail-bottom">
|
||||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
||||||
<Settings size={20} />
|
<Settings size={20} />
|
||||||
|
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-nav-item icon-rail-toggle"
|
||||||
|
aria-label={railExpanded ? "Collapse navigation" : "Expand navigation"}
|
||||||
|
title={railExpanded ? "Collapse navigation" : "Expand navigation"}
|
||||||
|
onClick={toggleExpanded}>
|
||||||
|
{railExpanded ? <PanelLeftClose size={20} aria-hidden="true" /> : <PanelLeftOpen size={20} aria-hidden="true" />}
|
||||||
|
<span className="icon-nav-label">{railExpanded ? "Collapse" : "Expand"}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
|
|
||||||
// Remembered navigation is a convenience only.
|
// Remembered navigation is a convenience only.
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function loadRailExpanded(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(RAIL_EXPANDED_STORAGE_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRailExpanded(expanded: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(RAIL_EXPANDED_STORAGE_KEY, expanded ? "true" : "false");
|
||||||
|
} catch {
|
||||||
|
// Rail width is a presentation preference only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
|
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
|
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
|
||||||
import HelpMenu from "./HelpMenu";
|
import HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
import LoginModal from "../features/auth/LoginModal";
|
import LoginModal from "../features/auth/LoginModal";
|
||||||
import DismissibleAlert from "../components/DismissibleAlert";
|
import DismissibleAlert from "../components/DismissibleAlert";
|
||||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||||
|
import { apiFetch, isApiError } from "../api/client";
|
||||||
import { logout, switchTenant } from "../api/auth";
|
import { logout, switchTenant } from "../api/auth";
|
||||||
import { hasAnyScope } from "../utils/permissions";
|
import { hasAnyScope } from "../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import { usePlatformModules } from "../platform/ModuleContext";
|
||||||
|
|
||||||
|
type NotificationSummary = {
|
||||||
|
unread: number;
|
||||||
|
show_unread_badge?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo | null;
|
auth: AuthInfo | null;
|
||||||
onSettingsChange: (settings: ApiSettings) => void;
|
onSettingsChange: (settings: ApiSettings) => void;
|
||||||
onAuthChange: (auth: AuthInfo | 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);
|
||||||
@@ -26,9 +34,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
|
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
|
||||||
const [tenantError, setTenantError] = useState("");
|
const [tenantError, setTenantError] = useState("");
|
||||||
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||||
const accountRef = useRef<HTMLDivElement>(null);
|
const accountRef = useRef<HTMLDivElement>(null);
|
||||||
const tenantRef = useRef<HTMLDivElement>(null);
|
const tenantRef = useRef<HTMLDivElement>(null);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
|
||||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||||
@@ -41,6 +51,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
"system:tenants:suspend"]
|
"system:tenants:suspend"]
|
||||||
);
|
);
|
||||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||||
|
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
||||||
|
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onPointerDown(event: MouseEvent) {
|
function onPointerDown(event: MouseEvent) {
|
||||||
@@ -56,6 +68,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth || !notificationsAvailable) {
|
||||||
|
setUnreadNotificationCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
async function refreshNotificationSummary() {
|
||||||
|
try {
|
||||||
|
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
|
||||||
|
if (!cancelled) {
|
||||||
|
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUnreadNotificationCount(0);
|
||||||
|
}
|
||||||
|
if (!isApiError(error, 401, 403, 404)) {
|
||||||
|
console.error("Failed to load notification summary", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshNotificationSummary();
|
||||||
|
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
|
||||||
|
window.addEventListener("focus", refreshNotificationSummary);
|
||||||
|
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
window.removeEventListener("focus", refreshNotificationSummary);
|
||||||
|
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||||
|
};
|
||||||
|
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
function handleLogin(response: LoginResponse) {
|
function handleLogin(response: LoginResponse) {
|
||||||
onAuthChange(response, "");
|
onAuthChange(response, "");
|
||||||
}
|
}
|
||||||
@@ -109,9 +156,21 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
navigate("/admin?section=system-settings");
|
navigate("/admin?section=system-settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNotificationCenter() {
|
||||||
|
navigate("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
@@ -159,8 +218,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
<div className="titlebar-spacer" />
|
<div className="titlebar-spacer" />
|
||||||
|
|
||||||
<HelpMenu />
|
|
||||||
<LanguageMenu />
|
<LanguageMenu />
|
||||||
|
<HelpMenu />
|
||||||
|
|
||||||
|
{auth && notificationsAvailable &&
|
||||||
|
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||||
|
<Bell size={18} />
|
||||||
|
{unreadNotificationCount > 0 &&
|
||||||
|
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
|
||||||
|
{notificationBadgeLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="context-menu-wrap" ref={accountRef}>
|
<div className="context-menu-wrap" ref={accountRef}>
|
||||||
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Activity, Building2, CalendarDays, ClipboardPenLine, FileText, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -39,9 +39,12 @@ type RemoteAssetManifest = {
|
|||||||
const iconByName: Record<string, LucideIcon> = {
|
const iconByName: Record<string, LucideIcon> = {
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
admin: Shield,
|
admin: Shield,
|
||||||
|
bell: Bell,
|
||||||
|
"book-user": BookUser,
|
||||||
building: Building2,
|
building: Building2,
|
||||||
"building-2": Building2,
|
"building-2": Building2,
|
||||||
calendar: CalendarDays,
|
calendar: CalendarDays,
|
||||||
|
"calendar-clock": CalendarClock,
|
||||||
campaign: Mails,
|
campaign: Mails,
|
||||||
"clipboard-pen-line": ClipboardPenLine,
|
"clipboard-pen-line": ClipboardPenLine,
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
@@ -51,9 +54,12 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
form: Form,
|
form: Form,
|
||||||
"layout-template": LayoutTemplate,
|
"layout-template": LayoutTemplate,
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
|
notifications: Bell,
|
||||||
|
organizations: Building2,
|
||||||
operator: RadioTower,
|
operator: RadioTower,
|
||||||
"radio-tower": RadioTower,
|
"radio-tower": RadioTower,
|
||||||
reports: FileText,
|
reports: ClipboardPenLine,
|
||||||
|
templates: LayoutTemplate,
|
||||||
users: Users
|
users: Users
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +144,7 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
|
|||||||
let promise = remoteModuleCache.get(cacheKey);
|
let promise = remoteModuleCache.get(cacheKey);
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
promise = loadRemoteWebModule(module).catch((error) => {
|
promise = loadRemoteWebModule(module).catch((error) => {
|
||||||
console.warn(`GovOPlaN remote module ${module.id} was not loaded:`, error);
|
console.warn("GovOPlaN remote module was not loaded:", module.id, error);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
remoteModuleCache.set(cacheKey, promise);
|
remoteModuleCache.set(cacheKey, promise);
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
|
radial-gradient(circle at 18% 18%, var(--accent-auth-glow), transparent 24rem),
|
||||||
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
|
radial-gradient(circle at 78% 14%, var(--blue-auth-glow), transparent 22rem),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
.public-card {
|
.public-card {
|
||||||
width: min(720px, 100%);
|
width: min(720px, 100%);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 42px 48px;
|
padding: 42px 48px;
|
||||||
@@ -134,9 +134,9 @@
|
|||||||
.titlebar-link:hover,
|
.titlebar-link:hover,
|
||||||
.account-pill:hover,
|
.account-pill:hover,
|
||||||
.context-menu-wrap:focus-within .account-pill {
|
.context-menu-wrap:focus-within .account-pill {
|
||||||
background: rgba(0,0,0,.055);
|
background: var(--titlebar-hover-bg);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04);
|
box-shadow: inset 0 0 0 1px rgba(var(--shadow-color-rgb), .04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
|
|
||||||
.dropdown-item:hover,
|
.dropdown-item:hover,
|
||||||
.dropdown-item.active {
|
.dropdown-item.active {
|
||||||
background: rgba(239, 107, 58, .10);
|
background: var(--accent-hover-bg);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; }
|
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
|
||||||
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: #d6eee9; color: #34796d; }
|
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
|
||||||
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; }
|
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
|
||||||
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; }
|
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
|
||||||
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; }
|
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
|
||||||
.status-inactive, .status-locked { background: #e7e4df; color: #666; }
|
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
|
||||||
|
|||||||
+292
-141
@@ -19,9 +19,9 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
border: 2px solid #8c8881;
|
border: 2px solid var(--loading-line);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255,255,255,.7);
|
background: var(--loading-envelope-bg);
|
||||||
animation: loading-envelope-float .74s ease-in-out infinite alternate;
|
animation: loading-envelope-float .74s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
.loading-envelope::before,
|
.loading-envelope::before,
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
top: 1px;
|
top: 1px;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-top: 2px solid #8c8881;
|
border-top: 2px solid var(--loading-line);
|
||||||
}
|
}
|
||||||
.loading-envelope::before {
|
.loading-envelope::before {
|
||||||
left: 1px;
|
left: 1px;
|
||||||
@@ -64,19 +64,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 1px solid #c9c5bd;
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(#ffffff, #f2f1ef);
|
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
|
box-shadow: var(--shadow-control-strong);
|
||||||
padding: 5px 8px 5px 11px;
|
padding: 5px 8px 5px 11px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.email-chip.invalid {
|
.email-chip.invalid {
|
||||||
border-color: #c96b63;
|
border-color: var(--danger-border);
|
||||||
background: #f6e3df;
|
background: var(--danger-soft);
|
||||||
color: #873c35;
|
color: var(--danger-text-deep);
|
||||||
}
|
}
|
||||||
.email-chip-main {
|
.email-chip-main {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -99,14 +99,14 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0,0,0,.08);
|
background: var(--hover-tint);
|
||||||
color: #57534d;
|
color: var(--control-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.email-chip-remove:hover { background: rgba(0,0,0,.15); }
|
.email-chip-remove:hover { background: var(--hover-tint-strong); }
|
||||||
.email-chip-empty {
|
.email-chip-empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px 10px;
|
gap: 8px 10px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -224,14 +224,40 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
.password-field-toggle:hover {
|
.password-field-toggle:hover {
|
||||||
background: rgba(0, 0, 0, .06);
|
background: var(--hover-tint-soft);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
.password-field-toggle:focus-visible {
|
.password-field-toggle:focus-visible {
|
||||||
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
|
outline: var(--focus-outline);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.credential-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-panel-heading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 2px 0 -4px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-panel-extra {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.color-picker-field,
|
.color-picker-field,
|
||||||
.date-field,
|
.date-field,
|
||||||
.time-field {
|
.time-field {
|
||||||
@@ -261,9 +287,9 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
|
box-shadow: var(--shadow-inset-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-trigger,
|
.color-picker-trigger,
|
||||||
@@ -287,7 +313,7 @@
|
|||||||
.color-picker-trigger:focus-visible,
|
.color-picker-trigger:focus-visible,
|
||||||
.date-field-trigger:hover:not(:disabled),
|
.date-field-trigger:hover:not(:disabled),
|
||||||
.date-field-trigger:focus-visible {
|
.date-field-trigger:focus-visible {
|
||||||
background: rgba(0,0,0,.08);
|
background: var(--hover-tint);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +333,10 @@
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: 0 16px 34px rgba(0,0,0,.18);
|
box-shadow: var(--shadow-menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-grid {
|
.color-picker-grid {
|
||||||
@@ -322,10 +348,10 @@
|
|||||||
.color-picker-grid button {
|
.color-picker-grid button {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 1px solid rgba(0,0,0,.2);
|
border: 1px solid var(--black-border-soft);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
|
box-shadow: var(--shadow-inset-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-grid button.is-selected {
|
.color-picker-grid button.is-selected {
|
||||||
@@ -355,7 +381,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -401,9 +427,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-field-grid button.is-selected {
|
.date-field-grid button.is-selected {
|
||||||
border-color: #5aa99b;
|
border-color: var(--success-border);
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +452,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-overview-link {
|
.admin-overview-link {
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -436,7 +462,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-overview-link:hover {
|
.admin-overview-link:hover {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-color: var(--line-dark);
|
border-color: var(--line-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +493,7 @@
|
|||||||
grid-template-columns: minmax(0, 1fr) 170px;
|
grid-template-columns: minmax(0, 1fr) 170px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -551,7 +577,7 @@
|
|||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -616,7 +642,7 @@
|
|||||||
.module-install-preflight {
|
.module-install-preflight {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -657,7 +683,7 @@
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(255,255,255,.58);
|
background: var(--panel-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-install-preflight-issue strong {
|
.module-install-preflight-issue strong {
|
||||||
@@ -686,9 +712,9 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
min-height: 86px;
|
min-height: 86px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255,255,255,.6);
|
background: var(--panel-glass);
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +769,7 @@
|
|||||||
grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
|
grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -818,7 +844,7 @@
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -976,9 +1002,9 @@
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-muted, #f6f7f9);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-selection-item {
|
.admin-selection-item {
|
||||||
@@ -992,7 +1018,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-selection-item:hover {
|
.admin-selection-item:hover {
|
||||||
background: var(--surface, #fff);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-selection-item.disabled {
|
.admin-selection-item.disabled {
|
||||||
@@ -1036,8 +1062,8 @@
|
|||||||
|
|
||||||
.guided-config-sidebar {
|
.guided-config-sidebar {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: #f6f5f3;
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guided-config-sidebar .stepper {
|
.guided-config-sidebar .stepper {
|
||||||
@@ -1109,7 +1135,7 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.advanced-options-panel {
|
.advanced-options-panel {
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1274,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-blocker-technical {
|
.action-blocker-technical {
|
||||||
background: rgba(255,255,255,.56);
|
background: var(--panel-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guided-review-list {
|
.guided-review-list {
|
||||||
@@ -1271,7 +1297,7 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
border-left-color: var(--line-dark);
|
border-left-color: var(--line-dark);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -1327,7 +1353,7 @@
|
|||||||
|
|
||||||
.guided-config-sidebar {
|
.guided-config-sidebar {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guided-config-sidebar .stepper {
|
.guided-config-sidebar .stepper {
|
||||||
@@ -1350,19 +1376,19 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
|
box-shadow: var(--shadow-control-surface);
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
}
|
}
|
||||||
.email-address-editor:focus-within {
|
.email-address-editor:focus-within {
|
||||||
border-color: #9bb7d3;
|
border-color: var(--input-border-focus);
|
||||||
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8);
|
box-shadow: var(--focus-ring), var(--shadow-inset-highlight-strong);
|
||||||
}
|
}
|
||||||
.email-address-editor.has-error {
|
.email-address-editor.has-error {
|
||||||
border-color: #c96b63;
|
border-color: var(--danger-border);
|
||||||
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13);
|
box-shadow: var(--danger-focus-ring-soft);
|
||||||
}
|
}
|
||||||
.email-address-editor .email-chip-list {
|
.email-address-editor .email-chip-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1402,10 +1428,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 1px solid #c7c2b8;
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(#fff, #f0efec);
|
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
|
||||||
color: #4f4b46;
|
color: var(--control-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -1413,8 +1439,8 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.email-address-plus:hover {
|
.email-address-plus:hover {
|
||||||
border-color: #aaa49a;
|
border-color: var(--control-border-hover);
|
||||||
background: linear-gradient(#fff, #e9e7e3);
|
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
|
||||||
}
|
}
|
||||||
.email-address-popover {
|
.email-address-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1424,9 +1450,9 @@
|
|||||||
width: min(360px, calc(100vw - 64px));
|
width: min(360px, calc(100vw - 64px));
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow-popover);
|
box-shadow: var(--shadow-popover);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
@@ -1449,10 +1475,10 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
box-shadow: 0 8px 24px var(--hover-tint);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
.email-address-suggestions button {
|
.email-address-suggestions button {
|
||||||
@@ -1521,10 +1547,10 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 1px solid #aeb4bb;
|
border: 1px solid var(--toggle-track-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #cfd4da;
|
background: var(--toggle-track-bg);
|
||||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
|
box-shadow: var(--shadow-control-inset-strong);
|
||||||
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
|
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
|
||||||
}
|
}
|
||||||
.toggle-switch-thumb {
|
.toggle-switch-thumb {
|
||||||
@@ -1534,24 +1560,35 @@
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.22);
|
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: #0d6efd;
|
border-color: var(--primary);
|
||||||
background: #0d6efd;
|
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: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12);
|
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;
|
||||||
@@ -1604,7 +1641,7 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #686560;
|
color: var(--text-subtle);
|
||||||
background: var(--line-dark);
|
background: var(--line-dark);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -1627,7 +1664,7 @@
|
|||||||
.policy-table {
|
.policy-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1646,7 +1683,7 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
.policy-row:first-child {
|
.policy-row:first-child {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
@@ -1712,7 +1749,7 @@
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: min(320px, calc(100vw - 48px));
|
max-width: min(320px, calc(100vw - 48px));
|
||||||
transform: translate(-50%, 3px);
|
transform: translate(-50%, 3px);
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow-popover);
|
box-shadow: var(--shadow-popover);
|
||||||
@@ -1734,8 +1771,8 @@
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-right: 1px solid var(--line-dark);
|
border-right: var(--border-line-dark);
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
transform: translate(-50%, -5px) rotate(45deg);
|
transform: translate(-50%, -5px) rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -1767,8 +1804,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-input-missing {
|
.field-input-missing {
|
||||||
border-color: #b42318 !important;
|
border-color: var(--danger-border-deep) !important;
|
||||||
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important;
|
box-shadow: var(--danger-focus-ring) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabled-action-tooltip {
|
.disabled-action-tooltip {
|
||||||
@@ -1776,19 +1813,27 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled-action-tooltip:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-action-tooltip:focus-visible > .btn {
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
|
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
|
||||||
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
|
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: calc(100% + 10px);
|
bottom: calc(100% + 10px);
|
||||||
z-index: 80;
|
z-index: 20000;
|
||||||
width: min(280px, 72vw);
|
width: min(280px, 72vw);
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
border: 1px solid rgba(180, 35, 24, .24);
|
border: 1px solid var(--border-danger-soft);
|
||||||
border-radius: var(--radius-sm, 8px);
|
border-radius: var(--radius-sm, 8px);
|
||||||
color: #7a241c;
|
color: var(--danger-text-tooltip);
|
||||||
background: #fff7f5;
|
background: var(--danger-muted-bg);
|
||||||
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18));
|
box-shadow: var(--shadow-popover);
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1819,7 +1864,7 @@
|
|||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border-radius: var(--radius-lg, 18px);
|
border-radius: var(--radius-lg, 18px);
|
||||||
background: rgba(255, 255, 255, 0.00);
|
background: var(--transparent-surface);
|
||||||
backdrop-filter: blur(1.5px);
|
backdrop-filter: blur(1.5px);
|
||||||
margin: -10px;
|
margin: -10px;
|
||||||
}
|
}
|
||||||
@@ -1829,11 +1874,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12));
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--text, #172033);
|
color: var(--text);
|
||||||
background: rgba(255, 255, 255, 0.86);
|
background: var(--panel-glass-strong);
|
||||||
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12));
|
box-shadow: var(--shadow-soft);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -1853,21 +1898,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border: 1px solid #c9c3b9;
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(#ffffff, #f1efeb);
|
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
|
||||||
color: #4f4a43;
|
color: var(--control-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
box-shadow: var(--shadow-control);
|
||||||
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
|
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||||
}
|
}
|
||||||
.card-collapse-toggle:hover {
|
.card-collapse-toggle:hover {
|
||||||
border-color: #aaa299;
|
border-color: var(--control-border-pressed);
|
||||||
background: linear-gradient(#ffffff, #e8e5df);
|
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-pressed));
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
box-shadow: var(--shadow-control-hover);
|
||||||
}
|
}
|
||||||
.card-collapse-toggle:focus-visible {
|
.card-collapse-toggle:focus-visible {
|
||||||
outline: 3px solid rgba(82, 130, 177, .22);
|
outline: 3px solid var(--focus-ring-color);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.card-collapse-toggle svg {
|
.card-collapse-toggle svg {
|
||||||
@@ -1907,7 +1952,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
box-shadow: var(--shadow-dismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-dismissible .alert-message {
|
.alert-dismissible .alert-message {
|
||||||
@@ -1915,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;
|
||||||
@@ -1932,9 +1992,9 @@
|
|||||||
top: auto;
|
top: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.14);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
|
box-shadow: var(--shadow-floating);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,7 +2013,7 @@
|
|||||||
.alert-dismiss:hover,
|
.alert-dismiss:hover,
|
||||||
.alert-dismiss:focus-visible {
|
.alert-dismiss:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 255, 255, 0.45);
|
background: var(--panel-glass-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2015,7 +2075,7 @@
|
|||||||
.explorer-tree-node-wrap:hover,
|
.explorer-tree-node-wrap:hover,
|
||||||
.explorer-tree-node-wrap:focus-within,
|
.explorer-tree-node-wrap:focus-within,
|
||||||
.explorer-tree-node-wrap.is-active {
|
.explorer-tree-node-wrap.is-active {
|
||||||
background: rgba(13, 110, 253, .08);
|
background: var(--primary-soft);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2029,14 +2089,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-heading {
|
.file-tree-heading {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -2054,7 +2114,7 @@
|
|||||||
.file-tree-space + .file-tree-space {
|
.file-tree-space + .file-tree-space {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-node-wrap {
|
.file-tree-node-wrap {
|
||||||
@@ -2143,8 +2203,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-node-wrap.is-selected .file-tree-select {
|
.file-tree-node-wrap.is-selected .file-tree-select {
|
||||||
background: #0d6efd;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-children {
|
.file-tree-children {
|
||||||
@@ -2159,10 +2219,10 @@
|
|||||||
justify-content: start;
|
justify-content: start;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #c9c3b9;
|
border: var(--border-line-dark);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #c9c3b9;
|
background: var(--line-dark);
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
box-shadow: var(--shadow-control);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-size-content {
|
.segmented-control-size-content {
|
||||||
@@ -2186,18 +2246,18 @@
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: linear-gradient(#ffffff, #f1efeb);
|
background: linear-gradient(var(--surface), var(--panel));
|
||||||
color: #4f4a43;
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
box-shadow: var(--shadow-control);
|
||||||
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-option + .segmented-control-option {
|
.segmented-control-option + .segmented-control-option {
|
||||||
border-left: 1px solid #c9c3b9;
|
border-left: var(--border-line-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-option:first-child {
|
.segmented-control-option:first-child {
|
||||||
@@ -2210,13 +2270,13 @@
|
|||||||
|
|
||||||
.segmented-control-option:hover,
|
.segmented-control-option:hover,
|
||||||
.segmented-control-option:focus-visible {
|
.segmented-control-option:focus-visible {
|
||||||
background: linear-gradient(#ffffff, #e8e5df);
|
background: linear-gradient(var(--surface), var(--bar));
|
||||||
color: #4f4a43;
|
color: var(--text-strong);
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
box-shadow: var(--shadow-control-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-option:focus-visible {
|
.segmented-control-option:focus-visible {
|
||||||
outline: 3px solid rgba(82, 130, 177, .22);
|
outline: 3px solid var(--focus-ring-color);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2224,7 +2284,7 @@
|
|||||||
.segmented-control-option.active {
|
.segmented-control-option.active {
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
|
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-option.is-active:hover,
|
.segmented-control-option.is-active:hover,
|
||||||
@@ -2232,7 +2292,7 @@
|
|||||||
.segmented-control-option.active:hover,
|
.segmented-control-option.active:hover,
|
||||||
.segmented-control-option.active:focus-visible {
|
.segmented-control-option.active:focus-visible {
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
|
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .14), var(--shadow-inset-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control-option:disabled {
|
.segmented-control-option:disabled {
|
||||||
@@ -2240,6 +2300,84 @@
|
|||||||
opacity: .55;
|
opacity: .55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-preview-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview {
|
||||||
|
--preview-bg: var(--theme-preview-light-bg);
|
||||||
|
--preview-bar: var(--theme-preview-light-bar);
|
||||||
|
--preview-surface: var(--theme-preview-light-surface);
|
||||||
|
--preview-line: var(--theme-preview-light-line);
|
||||||
|
--preview-text: var(--theme-preview-light-text);
|
||||||
|
--preview-muted: var(--theme-preview-light-muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 112px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--preview-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview[data-preview-theme="dark"] {
|
||||||
|
--preview-bg: var(--theme-preview-dark-bg);
|
||||||
|
--preview-bar: var(--theme-preview-dark-bar);
|
||||||
|
--preview-surface: var(--theme-preview-dark-surface);
|
||||||
|
--preview-line: var(--theme-preview-dark-line);
|
||||||
|
--preview-text: var(--theme-preview-dark-text);
|
||||||
|
--preview-muted: var(--theme-preview-dark-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--preview-line);
|
||||||
|
background: var(--preview-bar);
|
||||||
|
color: var(--preview-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-header i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-body {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--preview-surface);
|
||||||
|
color: var(--preview-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-body strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-body span {
|
||||||
|
display: block;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--preview-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-body span:last-child {
|
||||||
|
width: 68%;
|
||||||
|
background: var(--preview-muted);
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
.message-display-panel {
|
.message-display-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -2327,7 +2465,7 @@
|
|||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -2342,7 +2480,7 @@
|
|||||||
|
|
||||||
.message-display-html-frame {
|
.message-display-html-frame {
|
||||||
height: 420px;
|
height: 420px;
|
||||||
background: #fff;
|
background: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-display-attachments {
|
.message-display-attachments {
|
||||||
@@ -2372,11 +2510,23 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
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;
|
||||||
@@ -2413,7 +2563,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
@@ -2468,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;
|
||||||
@@ -2498,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;
|
||||||
@@ -2514,7 +2664,7 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.mail-server-toggle-row {
|
.mail-server-toggle-row {
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -2544,7 +2694,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
@@ -2556,9 +2706,10 @@
|
|||||||
.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; }
|
||||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
|
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
|
||||||
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
|
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
|
||||||
.mail-server-segmented-control { width: 100%; }
|
.mail-server-segmented-control { width: 100%; }
|
||||||
@@ -2588,20 +2739,20 @@
|
|||||||
.file-drop-zone:hover,
|
.file-drop-zone:hover,
|
||||||
.file-drop-zone:focus-visible,
|
.file-drop-zone:focus-visible,
|
||||||
.file-drop-zone.is-active {
|
.file-drop-zone.is-active {
|
||||||
border-color: #0d6efd;
|
border-color: var(--primary);
|
||||||
background: rgba(13, 110, 253, .08);
|
background: var(--primary-soft);
|
||||||
}
|
}
|
||||||
.file-drop-zone:focus-visible {
|
.file-drop-zone:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
|
box-shadow: var(--primary-focus-ring);
|
||||||
}
|
}
|
||||||
.file-drop-zone[aria-disabled="true"] {
|
.file-drop-zone[aria-disabled="true"] {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: .65;
|
opacity: .65;
|
||||||
}
|
}
|
||||||
.file-drop-zone.is-busy {
|
.file-drop-zone.is-busy {
|
||||||
border-color: #0d6efd;
|
border-color: var(--primary);
|
||||||
background: rgba(13, 110, 253, .08);
|
background: var(--primary-soft);
|
||||||
cursor: progress;
|
cursor: progress;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -2613,7 +2764,7 @@
|
|||||||
width: 58px;
|
width: 58px;
|
||||||
height: 58px;
|
height: 58px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0);
|
background: conic-gradient(var(--primary) var(--file-drop-progress), var(--primary-soft-strong) 0);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -2625,14 +2776,14 @@
|
|||||||
inset: 6px;
|
inset: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12);
|
box-shadow: var(--primary-inset-ring);
|
||||||
}
|
}
|
||||||
.file-drop-progress > span {
|
.file-drop-progress > span {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.file-drop-progress.is-indeterminate {
|
.file-drop-progress.is-indeterminate {
|
||||||
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%);
|
background: conic-gradient(var(--primary) 0 32%, var(--primary-soft-strong) 32% 100%);
|
||||||
animation: file-drop-progress-spin .85s linear infinite;
|
animation: file-drop-progress-spin .85s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes file-drop-progress-spin {
|
@keyframes file-drop-progress-spin {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user