Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8059c8afc | ||
|
|
3d33d7a217 | ||
|
|
ac2f47bd7a | ||
|
|
eaca78803d | ||
|
|
127b6ee5d0 | ||
|
|
b5b629b357 | ||
|
|
5a906185a6 | ||
|
|
d44ed78e5c | ||
|
|
da29de71e5 |
@@ -13,3 +13,21 @@ Mail, Postbox, and future chat providers are composed inside the one Messages
|
|||||||
overlay while retaining their independent delivery and authority semantics.
|
overlay while retaining their independent delivery and authority semantics.
|
||||||
|
|
||||||
See [Quick Access architecture](docs/QUICK_ACCESS.md).
|
See [Quick Access architecture](docs/QUICK_ACCESS.md).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/quick-access-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/quick-access-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
+26
-2
@@ -25,6 +25,12 @@ override that decision, but may configure any still-available item. Stale
|
|||||||
preferences are retained and diagnosed so uninstalling and reinstalling a
|
preferences are retained and diagnosed so uninstalling and reinstalling a
|
||||||
contributing module does not silently discard a user's arrangement.
|
contributing module does not silently discard a user's arrangement.
|
||||||
|
|
||||||
|
The effective API reports each item's `availability_state`,
|
||||||
|
`availability_source`, and `order_source`. It also returns every unavailable
|
||||||
|
stored id as a `stale_preferences` entry with its system, tenant, or user
|
||||||
|
source. Administration and support tooling can therefore explain a result
|
||||||
|
without reading or copying any contributing module's state.
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
The initial stable categories are Work, Calendar, Messages, and Files. Messages
|
The initial stable categories are Work, Calendar, Messages, and Files. Messages
|
||||||
@@ -37,5 +43,23 @@ owner module.
|
|||||||
|
|
||||||
Quick Access is not an authorization boundary. Every contribution keeps its
|
Quick Access is not an authorization boundary. Every contribution keeps its
|
||||||
own permission requirements and View surface. Full-page routes remain the
|
own permission requirements and View surface. Full-page routes remain the
|
||||||
canonical fallback. Disabling this module removes the rail without making any
|
canonical fallback. Launch-context version 2 contains only the tenant/account
|
||||||
domain state unavailable through its owning module.
|
identity, a reference-contract-version-1 active object, acting-assignment
|
||||||
|
identifiers, temporal selection, exact View identity, View recommendations or
|
||||||
|
focus, and a safe return route. Cross-tenant object references are discarded
|
||||||
|
and unknown versions fail closed. View focus applies only when at least one
|
||||||
|
focused tool is currently enabled, context-compatible, and authorized; a
|
||||||
|
recommendation only changes ordering and emphasis. The rail identifies an
|
||||||
|
active View focus and offers **All available tools** as a temporary escape to
|
||||||
|
the complete permission-derived catalogue. This does not change the active
|
||||||
|
View, persist an override, or expose a tool that failed installation,
|
||||||
|
entitlement, policy, preference, context, surface, or permission checks. If no
|
||||||
|
focused tool is eligible, the rail explains that stale focus and falls back to
|
||||||
|
the permission-derived catalogue instead of becoming empty.
|
||||||
|
|
||||||
|
Each renderer performs owner-side reads and effects and explicitly reports
|
||||||
|
either a result-contract-version-1 completion with a typed owner reference or
|
||||||
|
a cancellation reason. Closing the drawer is not completion. The host may
|
||||||
|
listen for that correlated result while its unsaved page state remains mounted.
|
||||||
|
Disabling this module removes the rail without making any domain state
|
||||||
|
unavailable through its owning module.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/quick-access-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/quick-access.css": "./webui/src/styles/quick-access.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-quick-access"
|
name = "govoplan-quick-access"
|
||||||
version = "0.1.18"
|
version = "0.1.21"
|
||||||
description = "Governed configurable Quick Access rail for GovOPlaN."
|
description = "Governed configurable Quick Access rail for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Quick Access module."""
|
"""GovOPlaN Quick Access module."""
|
||||||
|
|
||||||
__version__ = "0.1.18"
|
__version__ = "0.1.21"
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_quick_access.backend.db.models import QuickAccessProfile
|
||||||
|
|
||||||
|
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY = dsar_capability_name("quick_access")
|
||||||
|
_MAX_PREFERENCES = 1_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str
|
||||||
|
profile_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class QuickAccessDsarProvider:
|
||||||
|
provider_id = "quick_access"
|
||||||
|
module_id = "quick_access"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
personal_query = db.query(QuickAccessProfile).filter(
|
||||||
|
QuickAccessProfile.scope_type == "user",
|
||||||
|
QuickAccessProfile.tenant_id == tenant_id,
|
||||||
|
QuickAccessProfile.scope_id == selectors.account_id,
|
||||||
|
)
|
||||||
|
attribution_query = db.query(QuickAccessProfile).filter(
|
||||||
|
QuickAccessProfile.scope_type == "tenant",
|
||||||
|
QuickAccessProfile.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
QuickAccessProfile.created_by == selectors.account_id,
|
||||||
|
QuickAccessProfile.updated_by == selectors.account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selectors.profile_id:
|
||||||
|
personal_query = personal_query.filter(
|
||||||
|
QuickAccessProfile.id == selectors.profile_id
|
||||||
|
)
|
||||||
|
attribution_query = attribution_query.filter(
|
||||||
|
QuickAccessProfile.id == selectors.profile_id
|
||||||
|
)
|
||||||
|
|
||||||
|
records = [_personal_record(row) for row in personal_query.all()]
|
||||||
|
records.extend(
|
||||||
|
_attribution_record(row, selectors.account_id)
|
||||||
|
for row in attribution_query.all()
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
raise ValueError("Quick Access DSAR requires one corroborated account.")
|
||||||
|
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
if record.resource_type == "quick_access_tenant_attribution":
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
"quick_access:retain:quick_access_tenant_attribution:"
|
||||||
|
f"{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Tenant Quick Access policy attribution is accountability evidence.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile = (
|
||||||
|
db.query(QuickAccessProfile)
|
||||||
|
.filter(
|
||||||
|
QuickAccessProfile.id == record.resource_id,
|
||||||
|
QuickAccessProfile.scope_type == "user",
|
||||||
|
QuickAccessProfile.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
profile is None
|
||||||
|
or profile.scope_id != selectors.account_id
|
||||||
|
or (selectors.profile_id and profile.id != selectors.profile_id)
|
||||||
|
):
|
||||||
|
actions.append(_manual_action(record))
|
||||||
|
continue
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
"quick_access:delete:quick_access_personal_profile:"
|
||||||
|
f"{profile.id}:r{profile.revision}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="delete",
|
||||||
|
resource_type="quick_access_personal_profile",
|
||||||
|
resource_id=profile.id,
|
||||||
|
title="Delete personal Quick Access profile",
|
||||||
|
rationale=(
|
||||||
|
"The profile contains only account-owned presentation "
|
||||||
|
"preferences and can be recreated from governed defaults."
|
||||||
|
),
|
||||||
|
executable=True,
|
||||||
|
irreversible=True,
|
||||||
|
metadata={
|
||||||
|
"account_id": profile.scope_id,
|
||||||
|
"revision": profile.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
raise ValueError("Quick Access DSAR requires one corroborated account.")
|
||||||
|
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if not action.executable or action.kind != "delete":
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Tenant Quick Access policy attribution remains "
|
||||||
|
"institutional accountability evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if action.resource_type != "quick_access_personal_profile":
|
||||||
|
raise ValueError("Unsupported executable Quick Access DSAR action.")
|
||||||
|
|
||||||
|
profile = (
|
||||||
|
db.query(QuickAccessProfile)
|
||||||
|
.filter(
|
||||||
|
QuickAccessProfile.id == action.resource_id,
|
||||||
|
QuickAccessProfile.scope_type == "user",
|
||||||
|
QuickAccessProfile.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if profile is None:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="unchanged",
|
||||||
|
summary="The personal Quick Access profile was already absent.",
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
expected_account = str(action.metadata.get("account_id") or "")
|
||||||
|
expected_revision = action.metadata.get("revision")
|
||||||
|
if (
|
||||||
|
profile.scope_id != selectors.account_id
|
||||||
|
or expected_account != selectors.account_id
|
||||||
|
or (selectors.profile_id and profile.id != selectors.profile_id)
|
||||||
|
):
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"The profile is not owned by the corroborated subject "
|
||||||
|
"account and selector context."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if expected_revision != profile.revision:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"The profile changed after the erasure plan; create a "
|
||||||
|
"new plan before deleting it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile_id = profile.id
|
||||||
|
db.delete(profile)
|
||||||
|
db.flush()
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="executed",
|
||||||
|
summary=(
|
||||||
|
"Deleted the personal Quick Access profile without "
|
||||||
|
"changing tenant policy, tools, or domain data."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id, "profile_id": profile_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
account_id = _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("quick_access.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
)
|
||||||
|
profile_id = _coalesce(
|
||||||
|
references.get("quick_access.profile"),
|
||||||
|
references.get("quick_access.profile_id"),
|
||||||
|
)
|
||||||
|
if _CONFLICT in (account_id, profile_id):
|
||||||
|
return None
|
||||||
|
normalized_account = _optional_string(account_id)
|
||||||
|
if normalized_account is None:
|
||||||
|
return None
|
||||||
|
return _SubjectSelectors(
|
||||||
|
account_id=normalized_account,
|
||||||
|
profile_id=_optional_string(profile_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _personal_record(profile: QuickAccessProfile) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="quick_access",
|
||||||
|
module_id="quick_access",
|
||||||
|
resource_type="quick_access_personal_profile",
|
||||||
|
resource_id=profile.id,
|
||||||
|
category="personal_interface_preference",
|
||||||
|
title="Personal Quick Access profile",
|
||||||
|
data={
|
||||||
|
"revision": profile.revision,
|
||||||
|
"category_preferences": _preference_projection(
|
||||||
|
profile.category_preferences
|
||||||
|
),
|
||||||
|
"tool_preferences": _preference_projection(profile.tool_preferences),
|
||||||
|
},
|
||||||
|
observed_at=_aware(profile.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attribution_record(
|
||||||
|
profile: QuickAccessProfile,
|
||||||
|
account_id: str,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
activities = []
|
||||||
|
if profile.created_by == account_id:
|
||||||
|
activities.append("created_tenant_quick_access_policy")
|
||||||
|
if profile.updated_by == account_id:
|
||||||
|
activities.append("updated_tenant_quick_access_policy")
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="quick_access",
|
||||||
|
module_id="quick_access",
|
||||||
|
resource_type="quick_access_tenant_attribution",
|
||||||
|
resource_id=profile.id,
|
||||||
|
category="operator_accountability_evidence",
|
||||||
|
title="Tenant Quick Access policy attribution",
|
||||||
|
data={
|
||||||
|
"activities": activities,
|
||||||
|
"revision": profile.revision,
|
||||||
|
"created_at": _iso(profile.created_at),
|
||||||
|
"updated_at": _iso(profile.updated_at),
|
||||||
|
},
|
||||||
|
observed_at=_aware(profile.updated_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Tenant policy authorship is retained as institutional "
|
||||||
|
"accountability evidence; policy preferences are excluded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preference_projection(value: object) -> dict[str, dict[str, object]]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("Quick Access preference payload must be an object.")
|
||||||
|
if len(value) > _MAX_PREFERENCES:
|
||||||
|
raise ValueError(
|
||||||
|
"Quick Access preference limit exceeded; review the stored profile."
|
||||||
|
)
|
||||||
|
projected: dict[str, dict[str, object]] = {}
|
||||||
|
for raw_key, raw_entry in value.items():
|
||||||
|
key = str(raw_key)
|
||||||
|
if not key or len(key) > 255 or not isinstance(raw_entry, Mapping):
|
||||||
|
raise ValueError("Quick Access preference entry is invalid.")
|
||||||
|
entry: dict[str, object] = {}
|
||||||
|
if isinstance(raw_entry.get("enabled"), bool):
|
||||||
|
entry["enabled"] = raw_entry["enabled"]
|
||||||
|
if isinstance(raw_entry.get("forced"), bool):
|
||||||
|
entry["forced"] = raw_entry["forced"]
|
||||||
|
order = raw_entry.get("order")
|
||||||
|
if isinstance(order, int) and not isinstance(order, bool):
|
||||||
|
entry["order"] = order
|
||||||
|
projected[key] = entry
|
||||||
|
return projected
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_action(record: DsarRecordRef) -> DsarErasureActionRef:
|
||||||
|
return DsarErasureActionRef(
|
||||||
|
action_id=f"quick_access:manual_review:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id="quick_access",
|
||||||
|
module_id="quick_access",
|
||||||
|
kind="manual_review",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Review {record.title}",
|
||||||
|
rationale=(
|
||||||
|
"The selected profile is absent or no longer belongs to the exact "
|
||||||
|
"tenant, account, and narrowing selectors."
|
||||||
|
),
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Quick Access DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "quick_access" or record.module_id != "quick_access":
|
||||||
|
raise ValueError("Quick Access DSAR cannot plan a foreign provider record.")
|
||||||
|
if (
|
||||||
|
record.resource_type
|
||||||
|
not in {
|
||||||
|
"quick_access_personal_profile",
|
||||||
|
"quick_access_tenant_attribution",
|
||||||
|
}
|
||||||
|
or not record.resource_id
|
||||||
|
):
|
||||||
|
raise ValueError("Quick Access DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "quick_access" or action.module_id != "quick_access":
|
||||||
|
raise ValueError("Quick Access DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("quick_access:"):
|
||||||
|
raise ValueError("Quick Access DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["QUICK_ACCESS_DSAR_CAPABILITY", "QuickAccessDsarProvider"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'quick-access.data-subject-requests': {'consequence_classes': {'delete_personal_profile': 'Entfernt '
|
||||||
|
'persönliche '
|
||||||
|
'Overrides; '
|
||||||
|
'Reged '
|
||||||
|
'Defaults '
|
||||||
|
'gelten '
|
||||||
|
'wieder.',
|
||||||
|
'export_personal_profile': 'Gibt '
|
||||||
|
'begrenzte '
|
||||||
|
'Schienenpräferenzen '
|
||||||
|
'zurück, '
|
||||||
|
'niemals '
|
||||||
|
'werkzeugeigene '
|
||||||
|
'Daten.',
|
||||||
|
'retain_tenant_attribution': 'Preserves '
|
||||||
|
'minimiert '
|
||||||
|
'Mandant-Politik '
|
||||||
|
'Rechenschaftspflicht '
|
||||||
|
'Nachweise.'}},
|
||||||
|
'quick-access.user': {'steps': ['Öffnen Sie eine Kategorie auf der richtigen Schiene und wählen '
|
||||||
|
'Sie ein verfügbares kompaktes Werkzeug.',
|
||||||
|
'Füllen Sie die begrenzte Aktion des Eigentümermoduls aus oder '
|
||||||
|
'stornieren Sie sie ausdrücklich.',
|
||||||
|
'Folgen Sie dem Owning-Page-Link, wenn die Aufgabe die kompakte '
|
||||||
|
'Oberfläche übersteigt.']}}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_quick_access.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -11,6 +14,8 @@ from govoplan_core.core.module_guards import (
|
|||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -25,11 +30,15 @@ from govoplan_core.core.provider_governance import declared_module_architecture
|
|||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_quick_access.backend.db import models as quick_access_models
|
from govoplan_quick_access.backend.db import models as quick_access_models
|
||||||
|
from govoplan_quick_access.backend.dsar_provider import (
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY,
|
||||||
|
QuickAccessDsarProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "quick_access"
|
MODULE_ID = "quick_access"
|
||||||
MODULE_NAME = "Quick Access"
|
MODULE_NAME = "Quick Access"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.21"
|
||||||
|
|
||||||
READ_SCOPE = "quick_access:profile:read"
|
READ_SCOPE = "quick_access:profile:read"
|
||||||
WRITE_SCOPE = "quick_access:profile:write"
|
WRITE_SCOPE = "quick_access:profile:write"
|
||||||
@@ -111,7 +120,68 @@ def _router(context: ModuleContext):
|
|||||||
return create_router(context.registry)
|
return create_router(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> QuickAccessDsarProvider:
|
||||||
|
return QuickAccessDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="quick-access.data-subject-requests",
|
||||||
|
title="Quick Access data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Export or delete personal rail preferences while retaining "
|
||||||
|
"institutional Quick Access policy."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Quick Access contributes the user profile owned by the exact account "
|
||||||
|
"in the active tenant. The access package contains bounded category and "
|
||||||
|
"tool availability and ordering preferences but never follows a tool "
|
||||||
|
"into Mail, Postbox, Tasks, Files, or another owner module. Tenant policy "
|
||||||
|
"records are included only as minimized creation or update attribution "
|
||||||
|
"when the subject account performed that action; their preference payload "
|
||||||
|
"is excluded and their attribution is retained as institutional evidence. "
|
||||||
|
"System-wide policy is outside tenant-scoped requests. Erasure deletes "
|
||||||
|
"only the selected personal profile after owner and revision checks. The "
|
||||||
|
"effective rail then falls back to current module, system, and tenant "
|
||||||
|
"defaults, including all locked or forced items. A repeated execution is "
|
||||||
|
"unchanged and no tool or domain data is modified."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "tenant_admin", "operator", "auditor"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen für Schnellzugriff",
|
||||||
|
"summary": "Persönliche Leisteneinstellungen exportieren oder löschen, während institutionelle Schnellzugriffsrichtlinien erhalten bleiben.",
|
||||||
|
"body": (
|
||||||
|
"Schnellzugriff trägt das Benutzerprofil bei, das dem genauen Konto im aktiven Mandanten gehört. Das Auskunftspaket enthält begrenzte Einstellungen zu Verfügbarkeit und Reihenfolge von Kategorien und Werkzeugen, folgt einem Werkzeug jedoch niemals in Mail, Postbox, Tasks, Files oder ein anderes Besitzermodul. "
|
||||||
|
"Mandantenrichtlinien werden nur mit minimierter Erstellungs- oder Änderungszuordnung aufgenommen, wenn das betroffene Konto die Handlung ausgeführt hat; ihr Einstellungsinhalt bleibt ausgeschlossen und die Zuordnung bleibt als institutioneller Nachweis erhalten. Systemweite Richtlinien liegen außerhalb mandantenbezogener Anfragen. "
|
||||||
|
"Eine Löschung entfernt nach Eigentums- und Revisionsprüfung ausschließlich das ausgewählte persönliche Profil. Die wirksame Leiste fällt anschließend auf die aktuellen Modul-, System- und Mandantenvorgaben einschließlich aller gesperrten oder erzwungenen Einträge zurück. Wiederholte Ausführung verändert nichts und keine Werkzeug- oder Fachdaten werden geändert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
related_modules=("core", "access", "views"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"quick_access.rail",
|
||||||
|
"quick_access.settings.personal",
|
||||||
|
"quick_access.admin.tenant",
|
||||||
|
"privacy.data-subject-requests",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_personal_profile": (
|
||||||
|
"Returns bounded rail preferences, never tool-owned data."
|
||||||
|
),
|
||||||
|
"delete_personal_profile": (
|
||||||
|
"Removes personal overrides; governed defaults apply again."
|
||||||
|
),
|
||||||
|
"retain_tenant_attribution": (
|
||||||
|
"Preserves minimized tenant-policy accountability evidence."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="quick-access.user",
|
id="quick-access.user",
|
||||||
title="Quick Access rail",
|
title="Quick Access rail",
|
||||||
@@ -121,10 +191,24 @@ DOCUMENTATION = (
|
|||||||
"Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. "
|
"Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. "
|
||||||
"Personal settings can reorder or hide items that remain available under system, tenant, "
|
"Personal settings can reorder or hide items that remain available under system, tenant, "
|
||||||
"permission, and View policy. Every item retains a link to its complete owning page. "
|
"permission, and View policy. Every item retains a link to its complete owning page. "
|
||||||
|
"When a View focuses the rail, the rail names that View and offers a temporary All available tools escape that "
|
||||||
|
"shows the complete permission-derived catalogue without changing the View or personal settings. If none of a "
|
||||||
|
"View's focused tools is currently available, Quick Access explains the stale focus and falls back to that catalogue. "
|
||||||
|
"Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, "
|
||||||
|
"temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and "
|
||||||
|
"before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an "
|
||||||
|
"explicit cancellation; closing the drawer does not imply success. The overlay preserves unsaved host-page work, "
|
||||||
|
"and the complete owning page remains the fallback for work that exceeds the compact surface."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user",),
|
audience=("user",),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("quick_access", "access"),
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Quick Access architecture",
|
label="Quick Access architecture",
|
||||||
@@ -140,15 +224,29 @@ DOCUMENTATION = (
|
|||||||
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. "
|
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. "
|
||||||
"Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. "
|
"Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. "
|
||||||
"Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. "
|
"Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. "
|
||||||
|
"Wenn eine Ansicht die Leiste fokussiert, benennt die Leiste diese Ansicht und bietet voruebergehend alle "
|
||||||
|
"verfuegbaren Werkzeuge an, ohne Ansicht oder persoenliche Einstellungen zu aendern. Ist keines der fokussierten "
|
||||||
|
"Werkzeuge verfuegbar, wird der veraltete Fokus erklaert und der berechtigungsabgeleitete Katalog angezeigt. "
|
||||||
|
"Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
|
||||||
|
"Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen "
|
||||||
|
"und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem "
|
||||||
|
"Besitzerverweis oder einen ausdruecklichen Abbruch; das Schliessen gilt nicht als Erfolg. Die Einblendung "
|
||||||
|
"erhaelt ungespeicherte Arbeit auf der Ausgangsseite, die vollstaendige Besitzerseite bleibt das Ausweichziel."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"quick_access.rail",
|
"quick_access.rail",
|
||||||
"quick_access.drawer",
|
"quick_access.drawer",
|
||||||
"quick_access.settings.personal",
|
"quick_access.settings.personal",
|
||||||
]
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open a category on the right rail and choose an available compact tool.",
|
||||||
|
"Complete or explicitly cancel the bounded owner-module action.",
|
||||||
|
"Follow the owning-page link when the task exceeds the compact surface.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -158,7 +256,14 @@ DOCUMENTATION = (
|
|||||||
body=(
|
body=(
|
||||||
"The catalogue follows installed module registrations. System settings constrain tenants; "
|
"The catalogue follows installed module registrations. System settings constrain tenants; "
|
||||||
"tenant settings constrain users. An item may remain available, be blocked, or be forced. "
|
"tenant settings constrain users. An item may remain available, be blocked, or be forced. "
|
||||||
|
"Effective entries identify the system, tenant, user, or module source of availability and ordering; "
|
||||||
|
"preferences for retired entries retain their scope provenance. "
|
||||||
"Views and permissions form additional ceilings and Quick Access never grants access to domain data. "
|
"Views and permissions form additional ceilings and Quick Access never grants access to domain data. "
|
||||||
|
"A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized "
|
||||||
|
"tools participate. The All available tools escape only restores that permission-derived set for the current session; "
|
||||||
|
"it never broadens authorization or persists an override. Workflow receives that same presentation from the exact resolved View revision. Launch-context "
|
||||||
|
"version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant "
|
||||||
|
"active-object and result references are rejected."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
@@ -170,7 +275,14 @@ DOCUMENTATION = (
|
|||||||
"body": (
|
"body": (
|
||||||
"Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, "
|
"Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, "
|
||||||
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
|
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
|
||||||
|
"Effektive Eintraege nennen System, Mandant, Benutzer oder Modul als Quelle fuer Verfuegbarkeit und Reihenfolge; "
|
||||||
|
"Einstellungen fuer entfernte Eintraege behalten ihren Ebenennachweis. "
|
||||||
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. "
|
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. "
|
||||||
|
"Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb "
|
||||||
|
"der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Alle verfuegbaren Werkzeuge stellt nur diese "
|
||||||
|
"berechtigungsabgeleitete Menge fuer die laufende Sitzung wieder her und speichert keine Umgehung. Workflow verwendet dieselbe Darstellung aus "
|
||||||
|
"der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 "
|
||||||
|
"lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -199,10 +311,25 @@ manifest = ModuleManifest(
|
|||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="quick_access.runtime", version="1.0.0"),
|
ModuleInterfaceProvider(name="quick_access.runtime", version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name="quick_access.preferences", version="1.0.0"),
|
ModuleInterfaceProvider(name="quick_access.preferences", version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=QUICK_ACCESS_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
|
capability_factories={QUICK_ACCESS_DSAR_CAPABILITY: _dsar_provider},
|
||||||
|
capability_documentation={
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Quick Access data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports and deletes personal Quick Access preferences while "
|
||||||
|
"retaining minimized tenant-policy attribution."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
package_name="@govoplan/quick-access-webui",
|
package_name="@govoplan/quick-access-webui",
|
||||||
@@ -301,6 +428,11 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class CatalogueCategoryResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CatalogueToolResponse(BaseModel):
|
class CatalogueToolResponse(BaseModel):
|
||||||
|
contract_version: str
|
||||||
id: str
|
id: str
|
||||||
module_id: str
|
module_id: str
|
||||||
category_id: str
|
category_id: str
|
||||||
@@ -55,6 +56,10 @@ class CatalogueToolResponse(BaseModel):
|
|||||||
order: int
|
order: int
|
||||||
default_enabled: bool
|
default_enabled: bool
|
||||||
modes: list[str]
|
modes: list[str]
|
||||||
|
availability: str
|
||||||
|
accepted_reference_kinds: list[str]
|
||||||
|
returned_reference_kinds: list[str]
|
||||||
|
help_context_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CatalogueResponse(BaseModel):
|
class CatalogueResponse(BaseModel):
|
||||||
@@ -66,17 +71,30 @@ class EffectiveToolResponse(CatalogueToolResponse):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
forced: bool
|
forced: bool
|
||||||
locked_by: str | None = None
|
locked_by: str | None = None
|
||||||
|
availability_state: Literal["available", "forced", "blocked"]
|
||||||
|
availability_source: Literal["module", "system", "tenant", "user"]
|
||||||
|
order_source: Literal["module", "system", "tenant", "user"]
|
||||||
|
|
||||||
|
|
||||||
class EffectiveCategoryResponse(CatalogueCategoryResponse):
|
class EffectiveCategoryResponse(CatalogueCategoryResponse):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
forced: bool
|
forced: bool
|
||||||
locked_by: str | None = None
|
locked_by: str | None = None
|
||||||
|
availability_state: Literal["available", "forced", "blocked"]
|
||||||
|
availability_source: Literal["module", "system", "tenant", "user"]
|
||||||
|
order_source: Literal["module", "system", "tenant", "user"]
|
||||||
tools: list[EffectiveToolResponse]
|
tools: list[EffectiveToolResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class EffectiveStalePreferenceResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: Literal["category", "tool"]
|
||||||
|
source: Literal["system", "tenant", "user"]
|
||||||
|
|
||||||
|
|
||||||
class EffectiveQuickAccessResponse(BaseModel):
|
class EffectiveQuickAccessResponse(BaseModel):
|
||||||
categories: list[EffectiveCategoryResponse]
|
categories: list[EffectiveCategoryResponse]
|
||||||
|
stale_preferences: list[EffectiveStalePreferenceResponse] = Field(default_factory=list)
|
||||||
diagnostics: list[str] = Field(default_factory=list)
|
diagnostics: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from govoplan_quick_access.backend.schemas import (
|
|||||||
CatalogueToolResponse,
|
CatalogueToolResponse,
|
||||||
EffectiveCategoryResponse,
|
EffectiveCategoryResponse,
|
||||||
EffectiveQuickAccessResponse,
|
EffectiveQuickAccessResponse,
|
||||||
|
EffectiveStalePreferenceResponse,
|
||||||
EffectiveToolResponse,
|
EffectiveToolResponse,
|
||||||
PreferenceEntry,
|
PreferenceEntry,
|
||||||
ProfileResponse,
|
ProfileResponse,
|
||||||
@@ -91,6 +92,7 @@ def build_catalogue(
|
|||||||
continue
|
continue
|
||||||
tools.append(
|
tools.append(
|
||||||
CatalogueToolResponse(
|
CatalogueToolResponse(
|
||||||
|
contract_version=tool.contract_version,
|
||||||
id=tool.id,
|
id=tool.id,
|
||||||
module_id=tool.module_id,
|
module_id=tool.module_id,
|
||||||
category_id=tool.category_id,
|
category_id=tool.category_id,
|
||||||
@@ -104,6 +106,10 @@ def build_catalogue(
|
|||||||
order=tool.order,
|
order=tool.order,
|
||||||
default_enabled=tool.default_enabled,
|
default_enabled=tool.default_enabled,
|
||||||
modes=list(tool.modes),
|
modes=list(tool.modes),
|
||||||
|
availability=tool.availability,
|
||||||
|
accepted_reference_kinds=list(tool.accepted_reference_kinds),
|
||||||
|
returned_reference_kinds=list(tool.returned_reference_kinds),
|
||||||
|
help_context_id=tool.help_context_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tools.sort(key=lambda item: (item.category_id, item.order, item.id))
|
tools.sort(key=lambda item: (item.category_id, item.order, item.id))
|
||||||
@@ -236,6 +242,7 @@ def resolve_effective(
|
|||||||
("user", get_profile(session, scope_type="user", tenant_id=tenant_id, scope_id=account_id)),
|
("user", get_profile(session, scope_type="user", tenant_id=tenant_id, scope_id=account_id)),
|
||||||
)
|
)
|
||||||
diagnostics: list[str] = []
|
diagnostics: list[str] = []
|
||||||
|
stale_preferences = _stale_preferences(profiles, catalogue)
|
||||||
category_states: dict[str, _EffectiveState] = {}
|
category_states: dict[str, _EffectiveState] = {}
|
||||||
for category in catalogue.categories:
|
for category in catalogue.categories:
|
||||||
state = _EffectiveState(enabled=True, order=category.order)
|
state = _EffectiveState(enabled=True, order=category.order)
|
||||||
@@ -254,6 +261,11 @@ def resolve_effective(
|
|||||||
state.apply(entry, source=source)
|
state.apply(entry, source=source)
|
||||||
category_state = category_states.get(tool.category_id)
|
category_state = category_states.get(tool.category_id)
|
||||||
enabled = state.enabled and bool(category_state and category_state.enabled)
|
enabled = state.enabled and bool(category_state and category_state.enabled)
|
||||||
|
availability_source = (
|
||||||
|
category_state.availability_source
|
||||||
|
if state.enabled and category_state and not category_state.enabled
|
||||||
|
else state.availability_source
|
||||||
|
)
|
||||||
tool_payload = tool.model_dump()
|
tool_payload = tool.model_dump()
|
||||||
tool_payload["order"] = state.order
|
tool_payload["order"] = state.order
|
||||||
tools_by_category.setdefault(tool.category_id, []).append(
|
tools_by_category.setdefault(tool.category_id, []).append(
|
||||||
@@ -262,6 +274,9 @@ def resolve_effective(
|
|||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
forced=state.forced,
|
forced=state.forced,
|
||||||
locked_by=state.locked_by,
|
locked_by=state.locked_by,
|
||||||
|
availability_state=_availability_state(enabled, state.forced),
|
||||||
|
availability_source=availability_source,
|
||||||
|
order_source=state.order_source,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,12 +296,16 @@ def resolve_effective(
|
|||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
forced=state.forced,
|
forced=state.forced,
|
||||||
locked_by=state.locked_by,
|
locked_by=state.locked_by,
|
||||||
|
availability_state=_availability_state(enabled, state.forced),
|
||||||
|
availability_source=state.availability_source,
|
||||||
|
order_source=state.order_source,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
categories.sort(key=lambda item: (item.order, item.id))
|
categories.sort(key=lambda item: (item.order, item.id))
|
||||||
return EffectiveQuickAccessResponse(
|
return EffectiveQuickAccessResponse(
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
stale_preferences=stale_preferences,
|
||||||
diagnostics=list(dict.fromkeys(diagnostics)),
|
diagnostics=list(dict.fromkeys(diagnostics)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -297,16 +316,20 @@ class _EffectiveState:
|
|||||||
order: int
|
order: int
|
||||||
forced: bool = False
|
forced: bool = False
|
||||||
locked_by: str | None = None
|
locked_by: str | None = None
|
||||||
|
availability_source: str = "module"
|
||||||
|
order_source: str = "module"
|
||||||
|
|
||||||
def apply(self, entry: PreferenceEntry | None, *, source: str) -> None:
|
def apply(self, entry: PreferenceEntry | None, *, source: str) -> None:
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return
|
return
|
||||||
if entry.order is not None:
|
if entry.order is not None:
|
||||||
self.order = entry.order
|
self.order = entry.order
|
||||||
|
self.order_source = source
|
||||||
if self.locked_by is not None:
|
if self.locked_by is not None:
|
||||||
return
|
return
|
||||||
if entry.enabled is not None:
|
if entry.enabled is not None:
|
||||||
self.enabled = entry.enabled
|
self.enabled = entry.enabled
|
||||||
|
self.availability_source = source
|
||||||
if source != "user" and entry.enabled is False:
|
if source != "user" and entry.enabled is False:
|
||||||
self.forced = False
|
self.forced = False
|
||||||
self.locked_by = source
|
self.locked_by = source
|
||||||
@@ -314,6 +337,38 @@ class _EffectiveState:
|
|||||||
self.enabled = True
|
self.enabled = True
|
||||||
self.forced = True
|
self.forced = True
|
||||||
self.locked_by = source
|
self.locked_by = source
|
||||||
|
self.availability_source = source
|
||||||
|
|
||||||
|
|
||||||
|
def _availability_state(enabled: bool, forced: bool) -> str:
|
||||||
|
if not enabled:
|
||||||
|
return "blocked"
|
||||||
|
return "forced" if forced else "available"
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_preferences(
|
||||||
|
profiles: tuple[tuple[str, QuickAccessProfile | None], ...],
|
||||||
|
catalogue: CatalogueResponse,
|
||||||
|
) -> list[EffectiveStalePreferenceResponse]:
|
||||||
|
category_ids = {item.id for item in catalogue.categories}
|
||||||
|
tool_ids = {item.id for item in catalogue.tools}
|
||||||
|
stale: list[EffectiveStalePreferenceResponse] = []
|
||||||
|
for source, profile in profiles:
|
||||||
|
if profile is None:
|
||||||
|
continue
|
||||||
|
for kind, field, known_ids in (
|
||||||
|
("category", "category_preferences", category_ids),
|
||||||
|
("tool", "tool_preferences", tool_ids),
|
||||||
|
):
|
||||||
|
values = getattr(profile, field, {})
|
||||||
|
if not isinstance(values, Mapping):
|
||||||
|
continue
|
||||||
|
stale.extend(
|
||||||
|
EffectiveStalePreferenceResponse(id=str(item_id), kind=kind, source=source)
|
||||||
|
for item_id in values
|
||||||
|
if str(item_id) not in known_ids
|
||||||
|
)
|
||||||
|
return sorted(stale, key=lambda item: (item.source, item.kind, item.id))
|
||||||
|
|
||||||
|
|
||||||
def _profile_entry(
|
def _profile_entry(
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_quick_access.backend.db.models import QuickAccessProfile
|
||||||
|
from govoplan_quick_access.backend.dsar_provider import (
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY,
|
||||||
|
QuickAccessDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_quick_access.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: QuickAccessDsarProvider,
|
||||||
|
*,
|
||||||
|
active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (QUICK_ACCESS_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "quick_access"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("quick_access",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "quick_access"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != QUICK_ACCESS_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class QuickAccessDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = QuickAccessDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
QuickAccessProfile(
|
||||||
|
id="profile-personal",
|
||||||
|
scope_type="user",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="account-1",
|
||||||
|
scope_key="user:tenant-1:account-1",
|
||||||
|
category_preferences={"work": {"enabled": False}},
|
||||||
|
tool_preferences={"tasks.mine": {"order": 5}},
|
||||||
|
revision=3,
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
id="profile-tenant",
|
||||||
|
scope_type="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
scope_key="tenant:tenant-1",
|
||||||
|
category_preferences={
|
||||||
|
"private-policy-do-not-export": {"enabled": False}
|
||||||
|
},
|
||||||
|
tool_preferences={"tasks.mine": {"forced": True}},
|
||||||
|
revision=4,
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-other",
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
id="profile-other-account",
|
||||||
|
scope_type="user",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="account-other",
|
||||||
|
scope_key="user:tenant-1:account-other",
|
||||||
|
category_preferences={"calendar": {"enabled": False}},
|
||||||
|
tool_preferences={},
|
||||||
|
revision=2,
|
||||||
|
created_by="account-other",
|
||||||
|
updated_by="account-other",
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
id="profile-other-tenant",
|
||||||
|
scope_type="user",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
scope_id="account-1",
|
||||||
|
scope_key="user:tenant-2:account-1",
|
||||||
|
category_preferences={"files": {"enabled": False}},
|
||||||
|
tool_preferences={},
|
||||||
|
revision=2,
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
id="profile-system",
|
||||||
|
scope_type="system",
|
||||||
|
tenant_id=None,
|
||||||
|
scope_id=None,
|
||||||
|
scope_key="system:*",
|
||||||
|
category_preferences={"messages": {"enabled": False}},
|
||||||
|
tool_preferences={},
|
||||||
|
revision=2,
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_exports_personal_preferences_and_minimized_attribution(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"quick_access_personal_profile",
|
||||||
|
"quick_access_tenant_attribution",
|
||||||
|
],
|
||||||
|
[record.resource_type for record in records],
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertIn("tasks.mine", exported)
|
||||||
|
self.assertIn("created_tenant_quick_access_policy", exported)
|
||||||
|
self.assertNotIn("private-policy-do-not-export", exported)
|
||||||
|
self.assertNotIn("profile-other-account", exported)
|
||||||
|
self.assertNotIn("profile-other-tenant", exported)
|
||||||
|
self.assertNotIn("profile-system", exported)
|
||||||
|
|
||||||
|
def test_profile_reference_narrows_and_alias_conflicts_fail_closed(self) -> None:
|
||||||
|
narrowed = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"quick_access.profile": "profile-personal"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"quick_access.account": "account-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
no_account = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"quick_access.profile": "profile-personal"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["profile-personal"], [item.resource_id for item in narrowed])
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), no_account)
|
||||||
|
|
||||||
|
def test_erasure_deletes_only_personal_profile_and_is_idempotent(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["delete", "retain"], [action.kind for action in actions])
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["executed", "blocked"], [item.status for item in first])
|
||||||
|
self.assertEqual(["unchanged", "blocked"], [item.status for item in second])
|
||||||
|
self.assertIsNone(self.session.get(QuickAccessProfile, "profile-personal"))
|
||||||
|
self.assertIsNotNone(self.session.get(QuickAccessProfile, "profile-tenant"))
|
||||||
|
self.assertIsNotNone(self.session.get(QuickAccessProfile, "profile-system"))
|
||||||
|
|
||||||
|
def test_changed_and_foreign_resources_are_blocked(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
record = next(
|
||||||
|
item
|
||||||
|
for item in self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
if item.resource_type == "quick_access_personal_profile"
|
||||||
|
)
|
||||||
|
action = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(record,),
|
||||||
|
)[0]
|
||||||
|
self.session.get(QuickAccessProfile, "profile-personal").revision += 1
|
||||||
|
changed = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(action,),
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", changed[0].status)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="dashboard",
|
||||||
|
module_id="dashboard",
|
||||||
|
resource_type="quick_access_personal_profile",
|
||||||
|
resource_id="profile-personal",
|
||||||
|
category="preference",
|
||||||
|
title="Foreign profile",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="dashboard:delete:profile:profile-personal",
|
||||||
|
provider_id="dashboard",
|
||||||
|
module_id="dashboard",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="quick_access_personal_profile",
|
||||||
|
resource_id="profile-personal",
|
||||||
|
title="Delete profile",
|
||||||
|
rationale="Foreign action",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-QUICK-ACCESS-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[QUICK_ACCESS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
|
||||||
|
)
|
||||||
|
self.assertEqual(2, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-QUICK-ACCESS-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[QUICK_ACCESS_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||||
|
self.assertIn(QUICK_ACCESS_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY,
|
||||||
|
manifest.capability_documentation,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
QUICK_ACCESS_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "quick-access.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -12,6 +12,7 @@ from govoplan_core.core.modules import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_quick_access.backend.db.models import QuickAccessProfile
|
from govoplan_quick_access.backend.db.models import QuickAccessProfile
|
||||||
|
from govoplan_quick_access.backend.manifest import manifest
|
||||||
from govoplan_quick_access.backend.service import build_catalogue, resolve_effective
|
from govoplan_quick_access.backend.service import build_catalogue, resolve_effective
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,31 @@ class QuickAccessTests(unittest.TestCase):
|
|||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_documentation_has_localized_workflow_and_reference_baselines(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
all(
|
||||||
|
topic.translations.get("de", {}).get(field)
|
||||||
|
for field in ("title", "summary", "body")
|
||||||
|
)
|
||||||
|
for topic in topics.values()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = topics["quick-access.user"]
|
||||||
|
self.assertEqual("workflow", workflow.metadata["kind"])
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
condition.required_scopes or condition.any_scopes
|
||||||
|
for condition in workflow.conditions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"reference",
|
||||||
|
topics["quick-access.data-subject-requests"].metadata["kind"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_catalogue_is_derived_from_manifest_tools(self) -> None:
|
def test_catalogue_is_derived_from_manifest_tools(self) -> None:
|
||||||
catalogue = build_catalogue(registry_with_tools())
|
catalogue = build_catalogue(registry_with_tools())
|
||||||
|
|
||||||
@@ -136,6 +162,91 @@ class QuickAccessTests(unittest.TestCase):
|
|||||||
self.assertTrue(work.enabled)
|
self.assertTrue(work.enabled)
|
||||||
self.assertTrue(work.forced)
|
self.assertTrue(work.forced)
|
||||||
self.assertEqual("tenant", work.locked_by)
|
self.assertEqual("tenant", work.locked_by)
|
||||||
|
self.assertEqual("forced", work.availability_state)
|
||||||
|
self.assertEqual("tenant", work.availability_source)
|
||||||
|
|
||||||
|
def test_effective_resolution_reports_availability_and_order_provenance(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
QuickAccessProfile(
|
||||||
|
scope_type="system",
|
||||||
|
tenant_id=None,
|
||||||
|
scope_id=None,
|
||||||
|
scope_key="system:*",
|
||||||
|
category_preferences={},
|
||||||
|
tool_preferences={"example.work": {"enabled": True, "order": 40}},
|
||||||
|
revision=2,
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
scope_type="user",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="account-1",
|
||||||
|
scope_key="user:tenant-1:account-1",
|
||||||
|
category_preferences={},
|
||||||
|
tool_preferences={"example.work": {"order": 5}},
|
||||||
|
revision=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
effective = resolve_effective(
|
||||||
|
session,
|
||||||
|
registry=registry_with_tools(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
permission_checker=lambda _scope: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tool = next(item for item in effective.categories if item.id == "work").tools[0]
|
||||||
|
self.assertEqual("available", tool.availability_state)
|
||||||
|
self.assertEqual("system", tool.availability_source)
|
||||||
|
self.assertEqual("user", tool.order_source)
|
||||||
|
self.assertEqual(5, tool.order)
|
||||||
|
|
||||||
|
def test_effective_resolution_retains_stale_preferences_with_scope_provenance(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
QuickAccessProfile(
|
||||||
|
scope_type="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
scope_key="tenant:tenant-1",
|
||||||
|
category_preferences={"retired.category": {"enabled": False}},
|
||||||
|
tool_preferences={"retired.tool": {"order": 5}},
|
||||||
|
revision=2,
|
||||||
|
),
|
||||||
|
QuickAccessProfile(
|
||||||
|
scope_type="user",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_id="account-1",
|
||||||
|
scope_key="user:tenant-1:account-1",
|
||||||
|
category_preferences={},
|
||||||
|
tool_preferences={"missing.user-tool": {"enabled": True}},
|
||||||
|
revision=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
effective = resolve_effective(
|
||||||
|
session,
|
||||||
|
registry=registry_with_tools(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
permission_checker=lambda _scope: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
("retired.category", "category", "tenant"),
|
||||||
|
("retired.tool", "tool", "tenant"),
|
||||||
|
("missing.user-tool", "tool", "user"),
|
||||||
|
],
|
||||||
|
[(item.id, item.kind, item.source) for item in effective.stale_preferences],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/quick-access-webui",
|
"name": "@govoplan/quick-access-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type QuickAccessCategory = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessTool = {
|
export type QuickAccessTool = {
|
||||||
|
contract_version: "1";
|
||||||
id: string;
|
id: string;
|
||||||
module_id: string;
|
module_id: string;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
@@ -40,6 +41,10 @@ export type QuickAccessTool = {
|
|||||||
order: number;
|
order: number;
|
||||||
default_enabled: boolean;
|
default_enabled: boolean;
|
||||||
modes: string[];
|
modes: string[];
|
||||||
|
availability: "global" | "active_object";
|
||||||
|
accepted_reference_kinds: string[];
|
||||||
|
returned_reference_kinds: string[];
|
||||||
|
help_context_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessCatalogue = {
|
export type QuickAccessCatalogue = {
|
||||||
@@ -51,17 +56,28 @@ export type EffectiveQuickAccessTool = QuickAccessTool & {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forced: boolean;
|
forced: boolean;
|
||||||
locked_by?: string | null;
|
locked_by?: string | null;
|
||||||
|
availability_state: "available" | "forced" | "blocked";
|
||||||
|
availability_source: "module" | "system" | "tenant" | "user";
|
||||||
|
order_source: "module" | "system" | "tenant" | "user";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EffectiveQuickAccessCategory = QuickAccessCategory & {
|
export type EffectiveQuickAccessCategory = QuickAccessCategory & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forced: boolean;
|
forced: boolean;
|
||||||
locked_by?: string | null;
|
locked_by?: string | null;
|
||||||
|
availability_state: "available" | "forced" | "blocked";
|
||||||
|
availability_source: "module" | "system" | "tenant" | "user";
|
||||||
|
order_source: "module" | "system" | "tenant" | "user";
|
||||||
tools: EffectiveQuickAccessTool[];
|
tools: EffectiveQuickAccessTool[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EffectiveQuickAccess = {
|
export type EffectiveQuickAccess = {
|
||||||
categories: EffectiveQuickAccessCategory[];
|
categories: EffectiveQuickAccessCategory[];
|
||||||
|
stale_preferences: Array<{
|
||||||
|
id: string;
|
||||||
|
kind: "category" | "tool";
|
||||||
|
source: "system" | "tenant" | "user";
|
||||||
|
}>;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Files,
|
Files,
|
||||||
|
Grid2X2,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
ListFilter,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Settings2,
|
Settings2,
|
||||||
X,
|
X,
|
||||||
@@ -11,8 +13,13 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
|||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import {
|
import {
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
dispatchQuickAccessResult,
|
||||||
|
i18nMessage,
|
||||||
|
quickAccessLaunchState,
|
||||||
|
useGuardedNavigate,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
usePlatformUiCapabilities,
|
usePlatformUiCapabilities,
|
||||||
type QuickAccessRailProps,
|
type QuickAccessRailProps,
|
||||||
@@ -32,14 +39,16 @@ const iconByCategory: Record<string, LucideIcon> = {
|
|||||||
files: Files
|
files: Files
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRailProps) {
|
export default function QuickAccessRail({ settings, auth, tools, launchContext }: QuickAccessRailProps) {
|
||||||
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
|
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
|
||||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||||
|
const [showAllAvailable, setShowAllAvailable] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const drawerRef = useRef<HTMLDivElement>(null);
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
const contributions = usePlatformUiCapabilities<QuickAccessToolsUiCapability>("quickAccess.tools");
|
const contributions = usePlatformUiCapabilities<QuickAccessToolsUiCapability>("quickAccess.tools");
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const renderers = useMemo(
|
const renderers = useMemo(
|
||||||
@@ -47,12 +56,62 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
|
|||||||
[contributions]
|
[contributions]
|
||||||
);
|
);
|
||||||
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
|
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
|
||||||
const categories = useMemo(
|
const metadataById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
|
||||||
() => (effective?.categories ?? []).map((category) => ({
|
const focusedToolIds = useMemo(
|
||||||
|
() => new Set(launchContext.viewContext?.focusedToolIds ?? []),
|
||||||
|
[launchContext.viewContext?.focusedToolIds]
|
||||||
|
);
|
||||||
|
const recommendedToolIds = useMemo(
|
||||||
|
() => new Set(launchContext.viewContext?.recommendedToolIds ?? []),
|
||||||
|
[launchContext.viewContext?.recommendedToolIds]
|
||||||
|
);
|
||||||
|
const eligibleCategories = useMemo(() =>
|
||||||
|
(effective?.categories ?? [])
|
||||||
|
.filter((category) => category.enabled)
|
||||||
|
.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id))
|
tools: category.tools.filter((tool) => {
|
||||||
})).filter((category) => category.enabled && category.tools.length > 0),
|
if (!tool.enabled || !availableToolIds.has(tool.id)) return false;
|
||||||
[availableToolIds, effective]
|
const metadata = metadataById.get(tool.id);
|
||||||
|
if (metadata?.availability === "active_object" && !launchContext.activeObject) return false;
|
||||||
|
if (metadata?.acceptedReferenceKinds.length && launchContext.activeObject) {
|
||||||
|
const activeKind = `${launchContext.activeObject.ownerModule}.${launchContext.activeObject.kind}`;
|
||||||
|
if (!metadata.acceptedReferenceKinds.includes(activeKind)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
.filter((category) => category.tools.length > 0),
|
||||||
|
[availableToolIds, effective, launchContext.activeObject, metadataById]
|
||||||
|
);
|
||||||
|
const hasEligibleFocus = useMemo(
|
||||||
|
() => eligibleCategories.some((category) =>
|
||||||
|
category.tools.some((tool) => focusedToolIds.has(tool.id))
|
||||||
|
),
|
||||||
|
[eligibleCategories, focusedToolIds]
|
||||||
|
);
|
||||||
|
const hiddenByFocusCount = useMemo(
|
||||||
|
() => hasEligibleFocus
|
||||||
|
? eligibleCategories.reduce(
|
||||||
|
(count, category) => count + category.tools.filter((tool) => !focusedToolIds.has(tool.id)).length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
[eligibleCategories, focusedToolIds, hasEligibleFocus]
|
||||||
|
);
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
return eligibleCategories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
tools: category.tools.filter((tool) =>
|
||||||
|
!hasEligibleFocus || showAllAvailable || focusedToolIds.has(tool.id)
|
||||||
|
).sort((left, right) =>
|
||||||
|
Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id))
|
||||||
|
|| left.order - right.order
|
||||||
|
|| left.id.localeCompare(right.id)
|
||||||
|
)
|
||||||
|
})).filter((category) => category.enabled && category.tools.length > 0);
|
||||||
|
},
|
||||||
|
[eligibleCategories, focusedToolIds, hasEligibleFocus, recommendedToolIds, showAllAvailable]
|
||||||
);
|
);
|
||||||
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
|
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
|
||||||
|
|
||||||
@@ -104,6 +163,10 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
|
|||||||
setActiveCategoryId(null);
|
setActiveCategoryId(null);
|
||||||
}, [location.pathname, location.search]);
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowAllAvailable(false);
|
||||||
|
}, [launchContext.viewContext?.viewId, launchContext.viewContext?.revisionId]);
|
||||||
|
|
||||||
if (!loading && categories.length === 0 && !error) return null;
|
if (!loading && categories.length === 0 && !error) return null;
|
||||||
|
|
||||||
function closeDrawer(restoreFocus = true) {
|
function closeDrawer(restoreFocus = true) {
|
||||||
@@ -125,9 +188,42 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
|
|||||||
setActiveCategoryId(category.id);
|
setActiveCategoryId(category.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFullPage(path: string) {
|
||||||
|
closeDrawer(false);
|
||||||
|
navigate(path, { state: quickAccessLaunchState(launchContext) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleViewFocus() {
|
||||||
|
setShowAllAvailable((current) => !current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewName = launchContext.viewContext?.name?.trim()
|
||||||
|
|| translateText("i18n:govoplan-quick-access.current_view");
|
||||||
|
const viewFocusRequested = focusedToolIds.size > 0;
|
||||||
|
const canToggleViewFocus = hasEligibleFocus && hiddenByFocusCount > 0;
|
||||||
|
const viewModeLabel = showAllAvailable
|
||||||
|
? translateText("i18n:govoplan-quick-access.restore_view_focus")
|
||||||
|
: translateText("i18n:govoplan-quick-access.show_all_available");
|
||||||
|
const viewModeExplanation = !hasEligibleFocus
|
||||||
|
? translateText(i18nMessage("i18n:govoplan-quick-access.focus_unavailable", { value0: viewName }))
|
||||||
|
: showAllAvailable
|
||||||
|
? translateText(i18nMessage("i18n:govoplan-quick-access.showing_all_for_view", { value0: viewName }))
|
||||||
|
: translateText(i18nMessage("i18n:govoplan-quick-access.focused_by_view", { value0: viewName }));
|
||||||
|
const viewFocusMode = !viewFocusRequested
|
||||||
|
? "none"
|
||||||
|
: !hasEligibleFocus
|
||||||
|
? "unavailable"
|
||||||
|
: showAllAvailable
|
||||||
|
? "all"
|
||||||
|
: "focused";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="quick-access-rail" aria-label="i18n:govoplan-quick-access.quick_access">
|
<aside
|
||||||
|
className="quick-access-rail"
|
||||||
|
aria-label="i18n:govoplan-quick-access.quick_access"
|
||||||
|
data-view-focus-mode={viewFocusMode}
|
||||||
|
>
|
||||||
<div className="quick-access-rail-tools">
|
<div className="quick-access-rail-tools">
|
||||||
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
|
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
@@ -149,6 +245,20 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{canToggleViewFocus ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`quick-access-rail-button quick-access-view-mode-button${showAllAvailable ? " active" : ""}`}
|
||||||
|
title={viewModeLabel}
|
||||||
|
aria-label={viewModeLabel}
|
||||||
|
aria-pressed={showAllAvailable}
|
||||||
|
onClick={toggleViewFocus}
|
||||||
|
>
|
||||||
|
{showAllAvailable
|
||||||
|
? <ListFilter size={19} aria-hidden="true" />
|
||||||
|
: <Grid2X2 size={19} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
className="quick-access-rail-button quick-access-settings-link"
|
className="quick-access-rail-button quick-access-settings-link"
|
||||||
to="/settings?section=quick-access"
|
to="/settings?section=quick-access"
|
||||||
@@ -176,21 +286,75 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
|
|||||||
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
|
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
|
||||||
</header>
|
</header>
|
||||||
<div className="quick-access-drawer-content">
|
<div className="quick-access-drawer-content">
|
||||||
|
{viewFocusRequested ? (
|
||||||
|
<div className="quick-access-view-mode" role="status" data-view-focus-mode={viewFocusMode}>
|
||||||
|
<span>{viewModeExplanation}</span>
|
||||||
|
{canToggleViewFocus ? (
|
||||||
|
<button type="button" onClick={toggleViewFocus}>{viewModeLabel}</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
||||||
{activeCategory.tools.map((tool) => {
|
{activeCategory.tools.map((tool) => {
|
||||||
const renderer = renderers.get(tool.id);
|
const renderer = renderers.get(tool.id);
|
||||||
|
const metadata = metadataById.get(tool.id);
|
||||||
return (
|
return (
|
||||||
<section className="quick-access-tool" key={tool.id} data-tool-id={tool.id}>
|
<section
|
||||||
|
className={`quick-access-tool${recommendedToolIds.has(tool.id) ? " is-recommended" : ""}`}
|
||||||
|
key={tool.id}
|
||||||
|
data-tool-id={tool.id}
|
||||||
|
data-tool-contract-version={metadata?.contractVersion}
|
||||||
|
>
|
||||||
<div className="quick-access-tool-heading">
|
<div className="quick-access-tool-heading">
|
||||||
<div>
|
<div>
|
||||||
<strong>{translateText(tool.label)}</strong>
|
<strong>{translateText(tool.label)}</strong>
|
||||||
|
{recommendedToolIds.has(tool.id) ? (
|
||||||
|
<span className="quick-access-recommended">
|
||||||
|
{translateText("i18n:govoplan-quick-access.recommended_for_view")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{tool.description ? <small>{translateText(tool.description)}</small> : null}
|
{tool.description ? <small>{translateText(tool.description)}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
{tool.full_page_path ? <Link to={tool.full_page_path} onClick={() => closeDrawer(false)}>i18n:govoplan-quick-access.open_full_page</Link> : null}
|
{metadata?.helpContextId ? (
|
||||||
|
<DocumentationHelpLink reference={{ contextId: metadata.helpContextId, moduleId: tool.module_id }} />
|
||||||
|
) : null}
|
||||||
|
{tool.full_page_path ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-access-full-page-link"
|
||||||
|
onClick={() => openFullPage(tool.full_page_path!)}
|
||||||
|
>
|
||||||
|
i18n:govoplan-quick-access.open_full_page
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{renderer
|
{renderer
|
||||||
? renderer.render({ settings, auth, close: () => closeDrawer(), active: true })
|
? renderer.render({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
close: () => closeDrawer(),
|
||||||
|
active: true,
|
||||||
|
launchContext,
|
||||||
|
complete: (result) => {
|
||||||
|
if (!dispatchQuickAccessResult(tool.id, launchContext, result, metadata?.returnedReferenceKinds ?? [])) {
|
||||||
|
setError("i18n:govoplan-quick-access.invalid_result");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDrawer();
|
||||||
|
},
|
||||||
|
cancel: (reason = "user") => {
|
||||||
|
if (!dispatchQuickAccessResult(tool.id, launchContext, {
|
||||||
|
contractVersion: "1",
|
||||||
|
outcome: "cancelled",
|
||||||
|
reason
|
||||||
|
}, metadata?.returnedReferenceKinds ?? [])) {
|
||||||
|
setError("i18n:govoplan-quick-access.invalid_result");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDrawer();
|
||||||
|
}
|
||||||
|
})
|
||||||
: <p className="quick-access-tool-unavailable">i18n:govoplan-quick-access.compact_view_unavailable</p>}
|
: <p className="quick-access-tool-unavailable">i18n:govoplan-quick-access.compact_view_unavailable</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FormSection,
|
||||||
IconButton,
|
IconButton,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
@@ -31,6 +32,11 @@ type Draft = {
|
|||||||
tools: Record<string, QuickAccessPreference>;
|
tools: Record<string, QuickAccessPreference>;
|
||||||
};
|
};
|
||||||
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
|
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
|
||||||
|
type EffectiveProvenance = {
|
||||||
|
state: "available" | "forced" | "blocked";
|
||||||
|
availabilitySource: "module" | "system" | "tenant" | "user";
|
||||||
|
orderSource: "module" | "system" | "tenant" | "user";
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
|
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
|
||||||
|
|
||||||
@@ -185,6 +191,14 @@ export default function QuickAccessSettingsPanel({
|
|||||||
const lockedTools = new Map(
|
const lockedTools = new Map(
|
||||||
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
|
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
|
||||||
);
|
);
|
||||||
|
const categoryProvenance = new Map(
|
||||||
|
(effective?.categories ?? []).map((item) => [item.id, effectiveProvenance(item)])
|
||||||
|
);
|
||||||
|
const toolProvenance = new Map(
|
||||||
|
(effective?.categories ?? []).flatMap((category) =>
|
||||||
|
category.tools.map((item) => [item.id, effectiveProvenance(item)] as const)
|
||||||
|
)
|
||||||
|
);
|
||||||
const categoryIds = categories.map((item) => item.id);
|
const categoryIds = categories.map((item) => item.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,8 +209,8 @@ export default function QuickAccessSettingsPanel({
|
|||||||
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
|
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-access-settings-actions">
|
<div className="quick-access-settings-actions">
|
||||||
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
|
<Button variant="secondary" disabled={!dirty || saving} onClick={reset}><RotateCcw size={16} aria-hidden="true" />i18n:govoplan-quick-access.discard</Button>
|
||||||
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
|
<Button variant="primary" disabled={!dirty || saving || !canWrite} onClick={() => void save()}><Save size={16} aria-hidden="true" />i18n:govoplan-quick-access.save</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,9 +222,7 @@ export default function QuickAccessSettingsPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
|
||||||
<section className="quick-access-settings-section">
|
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.categories" contentClassName="quick-access-preference-list">
|
||||||
<h3>i18n:govoplan-quick-access.categories</h3>
|
|
||||||
<div className="quick-access-preference-list">
|
|
||||||
{categories.map((category, index) => (
|
{categories.map((category, index) => (
|
||||||
<PreferenceRow
|
<PreferenceRow
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -220,6 +232,7 @@ export default function QuickAccessSettingsPanel({
|
|||||||
preference={draft.categories[category.id]}
|
preference={draft.categories[category.id]}
|
||||||
isPersonal={isPersonal}
|
isPersonal={isPersonal}
|
||||||
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
|
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
|
||||||
|
provenance={categoryProvenance.get(category.id)}
|
||||||
disabled={!canWrite || saving}
|
disabled={!canWrite || saving}
|
||||||
onChange={(value) => setPreference("categories", category.id, value)}
|
onChange={(value) => setPreference("categories", category.id, value)}
|
||||||
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
|
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
|
||||||
@@ -228,12 +241,9 @@ export default function QuickAccessSettingsPanel({
|
|||||||
last={index === categories.length - 1}
|
last={index === categories.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</FormSection>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="quick-access-settings-section">
|
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.registered_tools" contentClassName="quick-access-preference-list">
|
||||||
<h3>i18n:govoplan-quick-access.registered_tools</h3>
|
|
||||||
<div className="quick-access-preference-list">
|
|
||||||
{categories.flatMap((category) => {
|
{categories.flatMap((category) => {
|
||||||
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
|
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
|
||||||
const toolIds = categoryTools.map((tool) => tool.id);
|
const toolIds = categoryTools.map((tool) => tool.id);
|
||||||
@@ -247,6 +257,7 @@ export default function QuickAccessSettingsPanel({
|
|||||||
defaultEnabled={tool.default_enabled}
|
defaultEnabled={tool.default_enabled}
|
||||||
isPersonal={isPersonal}
|
isPersonal={isPersonal}
|
||||||
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
|
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
|
||||||
|
provenance={toolProvenance.get(tool.id)}
|
||||||
disabled={!canWrite || saving}
|
disabled={!canWrite || saving}
|
||||||
onChange={(value) => setPreference("tools", tool.id, value)}
|
onChange={(value) => setPreference("tools", tool.id, value)}
|
||||||
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
|
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
|
||||||
@@ -257,8 +268,7 @@ export default function QuickAccessSettingsPanel({
|
|||||||
));
|
));
|
||||||
})}
|
})}
|
||||||
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
|
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
|
||||||
</div>
|
</FormSection>
|
||||||
</section>
|
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -273,6 +283,7 @@ function PreferenceRow({
|
|||||||
defaultEnabled = true,
|
defaultEnabled = true,
|
||||||
isPersonal,
|
isPersonal,
|
||||||
lockedBy,
|
lockedBy,
|
||||||
|
provenance,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@@ -287,6 +298,7 @@ function PreferenceRow({
|
|||||||
defaultEnabled?: boolean;
|
defaultEnabled?: boolean;
|
||||||
isPersonal: boolean;
|
isPersonal: boolean;
|
||||||
lockedBy?: string | null;
|
lockedBy?: string | null;
|
||||||
|
provenance?: EffectiveProvenance;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onChange: (value: QuickAccessPreference | null) => void;
|
onChange: (value: QuickAccessPreference | null) => void;
|
||||||
onMoveUp: () => void;
|
onMoveUp: () => void;
|
||||||
@@ -308,6 +320,15 @@ function PreferenceRow({
|
|||||||
})}
|
})}
|
||||||
</small>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
|
{provenance ? (
|
||||||
|
<small>
|
||||||
|
{i18nMessage("i18n:govoplan-quick-access.effective_provenance", {
|
||||||
|
value0: provenance.state,
|
||||||
|
value1: provenance.availabilitySource,
|
||||||
|
value2: provenance.orderSource
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-access-preference-controls">
|
<div className="quick-access-preference-controls">
|
||||||
{isPersonal ? (
|
{isPersonal ? (
|
||||||
@@ -343,6 +364,18 @@ function PreferenceRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function effectiveProvenance(item: {
|
||||||
|
availability_state: "available" | "forced" | "blocked";
|
||||||
|
availability_source: "module" | "system" | "tenant" | "user";
|
||||||
|
order_source: "module" | "system" | "tenant" | "user";
|
||||||
|
}): EffectiveProvenance {
|
||||||
|
return {
|
||||||
|
state: item.availability_state,
|
||||||
|
availabilitySource: item.availability_source,
|
||||||
|
orderSource: item.order_source
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
|
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
|
||||||
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
|
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen",
|
"i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen",
|
||||||
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
|
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
|
||||||
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
|
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
|
||||||
|
"i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen",
|
||||||
|
"i18n:govoplan-quick-access.current_view": "aktuelle Ansicht",
|
||||||
|
"i18n:govoplan-quick-access.show_all_available": "Alle verfügbaren Werkzeuge anzeigen",
|
||||||
|
"i18n:govoplan-quick-access.restore_view_focus": "Ansichtsfokus wiederherstellen",
|
||||||
|
"i18n:govoplan-quick-access.focused_by_view": "Durch die Ansicht {value0} fokussiert. Weitere berechtigte Werkzeuge bleiben verfügbar.",
|
||||||
|
"i18n:govoplan-quick-access.showing_all_for_view": "Alle für Sie verfügbaren Werkzeuge werden angezeigt; die Ansicht {value0} bleibt aktiv.",
|
||||||
|
"i18n:govoplan-quick-access.focus_unavailable": "Der Werkzeugfokus der Ansicht {value0} ist derzeit nicht verfügbar. Alle berechtigten Werkzeuge werden angezeigt.",
|
||||||
|
"i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.",
|
||||||
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
|
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
|
||||||
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
|
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
|
||||||
"i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.",
|
"i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.",
|
||||||
@@ -36,7 +44,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.",
|
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.",
|
||||||
"i18n:govoplan-quick-access.category.files": "Dateien",
|
"i18n:govoplan-quick-access.category.files": "Dateien",
|
||||||
"i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.",
|
"i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.",
|
||||||
"i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt"
|
"i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt",
|
||||||
|
"i18n:govoplan-quick-access.effective_provenance": "Effektiv {value0}; Verfügbarkeit von {value1}, Reihenfolge von {value2}"
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
"i18n:govoplan-quick-access.quick_access": "Quick Access",
|
"i18n:govoplan-quick-access.quick_access": "Quick Access",
|
||||||
@@ -45,6 +54,14 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-quick-access.loading": "Loading Quick Access",
|
"i18n:govoplan-quick-access.loading": "Loading Quick Access",
|
||||||
"i18n:govoplan-quick-access.open_full_page": "Open full page",
|
"i18n:govoplan-quick-access.open_full_page": "Open full page",
|
||||||
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
|
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
|
||||||
|
"i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View",
|
||||||
|
"i18n:govoplan-quick-access.current_view": "current View",
|
||||||
|
"i18n:govoplan-quick-access.show_all_available": "Show all available tools",
|
||||||
|
"i18n:govoplan-quick-access.restore_view_focus": "Restore View focus",
|
||||||
|
"i18n:govoplan-quick-access.focused_by_view": "Focused by the {value0} View. Other authorized tools remain available.",
|
||||||
|
"i18n:govoplan-quick-access.showing_all_for_view": "Showing all tools available to you; the {value0} View remains active.",
|
||||||
|
"i18n:govoplan-quick-access.focus_unavailable": "The {value0} View's tool focus is currently unavailable. All authorized tools are shown.",
|
||||||
|
"i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.",
|
||||||
"i18n:govoplan-quick-access.settings_title": "Quick Access",
|
"i18n:govoplan-quick-access.settings_title": "Quick Access",
|
||||||
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
|
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
|
||||||
"i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.",
|
"i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.",
|
||||||
@@ -73,6 +90,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.",
|
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.",
|
||||||
"i18n:govoplan-quick-access.category.files": "Files",
|
"i18n:govoplan-quick-access.category.files": "Files",
|
||||||
"i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.",
|
"i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.",
|
||||||
"i18n:govoplan-quick-access.locked_by_value": "Set by {value0}"
|
"i18n:govoplan-quick-access.locked_by_value": "Set by {value0}",
|
||||||
|
"i18n:govoplan-quick-access.effective_provenance": "Effective {value0}; availability from {value1}, order from {value2}"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default, quickAccessModule } from "./module";
|
export { default, quickAccessModule } from "./module";
|
||||||
|
export { default as QuickAccessRail } from "./components/QuickAccessRail";
|
||||||
export * from "./api/quickAccess";
|
export * from "./api/quickAccess";
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
export const quickAccessModule: PlatformWebModule = {
|
export const quickAccessModule: PlatformWebModule = {
|
||||||
id: "quick_access",
|
id: "quick_access",
|
||||||
label: "i18n:govoplan-quick-access.quick_access",
|
label: "i18n:govoplan-quick-access.quick_access",
|
||||||
version: "0.1.18",
|
version: "0.1.19",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["views", "policy"],
|
optionalDependencies: ["views", "policy"],
|
||||||
translations: generatedTranslations,
|
translations: generatedTranslations,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -55,13 +55,22 @@
|
|||||||
margin: auto 4px 6px;
|
margin: auto 4px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: auto 4px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode-button + .quick-access-settings-link {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-access-rail-loading {
|
.quick-access-rail-loading {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
margin: 11px auto;
|
margin: 11px auto;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-top-color: var(--accent);
|
border-top-color: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-round);
|
||||||
animation: quick-access-spin 0.8s linear infinite;
|
animation: quick-access-spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-left: var(--border-line);
|
border-left: var(--border-line);
|
||||||
box-shadow: -12px 0 30px rgb(0 0 0 / 18%);
|
box-shadow: var(--shadow-drawer-side);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +126,36 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 9px 12px 9px 16px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode button:hover,
|
||||||
|
.quick-access-view-mode button:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-access-tool {
|
.quick-access-tool {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -124,6 +163,21 @@
|
|||||||
padding: 14px 16px 16px;
|
padding: 14px 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-tool.is-recommended {
|
||||||
|
border-inline-start: 3px solid var(--accent);
|
||||||
|
padding-inline-start: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-recommended {
|
||||||
|
width: fit-content;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
padding: 1px 7px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-access-tool-heading,
|
.quick-access-tool-heading,
|
||||||
.quick-access-settings-heading,
|
.quick-access-settings-heading,
|
||||||
.quick-access-preference-row,
|
.quick-access-preference-row,
|
||||||
@@ -141,12 +195,25 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-access-tool-heading > a {
|
.quick-access-tool-heading > a,
|
||||||
|
.quick-access-full-page-link {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-full-page-link {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-full-page-link:hover,
|
||||||
|
.quick-access-full-page-link:focus-visible { text-decoration: underline; }
|
||||||
|
|
||||||
.quick-access-settings {
|
.quick-access-settings {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -242,6 +309,14 @@
|
|||||||
margin: 4px 6px 4px auto;
|
margin: 4px 6px 4px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode-button {
|
||||||
|
margin: 4px 3px 4px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode-button + .quick-access-settings-link {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-access-drawer {
|
.quick-access-drawer {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 50px;
|
bottom: 50px;
|
||||||
@@ -250,7 +325,12 @@
|
|||||||
height: min(70vh, 680px);
|
height: min(70vh, 680px);
|
||||||
border-top: var(--border-line);
|
border-top: var(--border-line);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
box-shadow: 0 -12px 30px rgb(0 0 0 / 18%);
|
box-shadow: var(--shadow-drawer-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-view-mode {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-access-preference-row {
|
.quick-access-preference-row {
|
||||||
|
|||||||
Reference in New Issue
Block a user