Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c2ec4ec17 | ||
|
|
e84fb631f0 | ||
|
|
46ba69fbfe | ||
|
|
432f8bbd02 | ||
|
|
049ea80385 | ||
|
|
3259741765 | ||
|
|
be411e4ca8 | ||
|
|
75c9488f66 | ||
|
|
0e03f6a70c | ||
|
|
1351338590 | ||
|
|
21052accee |
@@ -100,3 +100,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
|
||||
```
|
||||
|
||||
## Git-source WebUI package
|
||||
|
||||
The repository root exposes `@govoplan/committee-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/committee-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.
|
||||
|
||||
+29
-3
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"name": "@govoplan/committee",
|
||||
"version": "0.1.16",
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Committee platform module seed.",
|
||||
"type": "module",
|
||||
"peerDependencies": {}
|
||||
"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"
|
||||
},
|
||||
"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/committee.css": "./webui/src/styles/committee.css"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-committee"
|
||||
version = "0.1.16"
|
||||
version = "0.1.21"
|
||||
description = "GovOPlaN committee governance and formal-decision integration module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.16",
|
||||
"govoplan-access>=0.1.16",
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
|
||||
|
||||
COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
actor_ids: tuple[str, ...]
|
||||
object_kind: str | None
|
||||
object_id: str | None
|
||||
decision_id: str | None
|
||||
|
||||
|
||||
class CommitteeDsarProvider:
|
||||
provider_id = "committee"
|
||||
module_id = "committee"
|
||||
|
||||
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 ()
|
||||
records: list[DsarRecordRef] = []
|
||||
revisions = db.query(CommitteeWorkspaceRevision).filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids),
|
||||
)
|
||||
events = db.query(CommitteeWorkspaceEvent).filter(
|
||||
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids),
|
||||
)
|
||||
decisions = db.query(CommitteeDecisionProjection).filter(
|
||||
CommitteeDecisionProjection.tenant_id == tenant_id,
|
||||
CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids),
|
||||
)
|
||||
if selectors.object_kind:
|
||||
revisions = revisions.filter(
|
||||
CommitteeWorkspaceRevision.object_kind == selectors.object_kind
|
||||
)
|
||||
events = events.filter(
|
||||
CommitteeWorkspaceEvent.object_kind == selectors.object_kind
|
||||
)
|
||||
if selectors.object_id:
|
||||
revisions = revisions.filter(
|
||||
CommitteeWorkspaceRevision.object_id == selectors.object_id
|
||||
)
|
||||
events = events.filter(
|
||||
CommitteeWorkspaceEvent.object_id == selectors.object_id
|
||||
)
|
||||
if selectors.object_kind == "meeting":
|
||||
decisions = decisions.filter(
|
||||
CommitteeDecisionProjection.meeting_id == selectors.object_id
|
||||
)
|
||||
elif selectors.object_kind in {"agenda", "agenda_item"}:
|
||||
decisions = decisions.filter(
|
||||
CommitteeDecisionProjection.agenda_item_id == selectors.object_id
|
||||
)
|
||||
elif selectors.object_kind == "decision":
|
||||
decisions = decisions.filter(
|
||||
CommitteeDecisionProjection.decision_id == selectors.object_id
|
||||
)
|
||||
if selectors.decision_id:
|
||||
decisions = decisions.filter(
|
||||
CommitteeDecisionProjection.decision_id == selectors.decision_id
|
||||
)
|
||||
|
||||
records.extend(
|
||||
_revision_attribution(row)
|
||||
for row in _limited(
|
||||
revisions,
|
||||
CommitteeWorkspaceRevision.recorded_at,
|
||||
CommitteeWorkspaceRevision.id,
|
||||
label="workspace revision attribution",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_event_attribution(row)
|
||||
for row in _limited(
|
||||
events,
|
||||
CommitteeWorkspaceEvent.occurred_at,
|
||||
CommitteeWorkspaceEvent.id,
|
||||
label="workspace event attribution",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_decision_attribution(row)
|
||||
for row in _limited(
|
||||
decisions,
|
||||
CommitteeDecisionProjection.recorded_at,
|
||||
CommitteeDecisionProjection.id,
|
||||
label="Decision projection attribution",
|
||||
)
|
||||
)
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Committee DSAR combined result limit exceeded; narrow the selectors."
|
||||
)
|
||||
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]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Committee DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"committee:retain:{record.resource_type}:{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 "Committee attribution is immutable evidence.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Committee DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind != "retain":
|
||||
raise ValueError("Committee DSAR publishes retain-only actions.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Committee workspace, event, and Decision attribution remains "
|
||||
"immutable institutional evidence."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("committee.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"membership_id": _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("committee.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
),
|
||||
"identity_id": _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("committee.identity"),
|
||||
references.get("identity.id"),
|
||||
),
|
||||
"actor_id": _coalesce(
|
||||
references.get("committee.actor"),
|
||||
references.get("committee.changed_by"),
|
||||
),
|
||||
"object_kind": _coalesce(
|
||||
references.get("committee.object_kind"),
|
||||
references.get("committee.kind"),
|
||||
),
|
||||
"object_id": _coalesce(
|
||||
references.get("committee.object"),
|
||||
references.get("committee.object_id"),
|
||||
),
|
||||
"decision_id": _coalesce(
|
||||
references.get("committee.decision"),
|
||||
references.get("committee.decision_id"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
actor_ids = tuple(
|
||||
dict.fromkeys(
|
||||
value
|
||||
for value in (
|
||||
_optional_string(values["account_id"]),
|
||||
_prefixed("account", values["account_id"]),
|
||||
_optional_string(values["membership_id"]),
|
||||
_prefixed("membership", values["membership_id"]),
|
||||
_optional_string(values["identity_id"]),
|
||||
_prefixed("identity", values["identity_id"]),
|
||||
)
|
||||
if value
|
||||
)
|
||||
)
|
||||
direct_actor = _optional_string(values["actor_id"])
|
||||
if direct_actor:
|
||||
if actor_ids and direct_actor not in actor_ids:
|
||||
return None
|
||||
if not actor_ids:
|
||||
actor_ids = (direct_actor,)
|
||||
if not actor_ids:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
actor_ids=actor_ids,
|
||||
object_kind=_optional_string(values["object_kind"]),
|
||||
object_id=_optional_string(values["object_id"]),
|
||||
decision_id=_optional_string(values["decision_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="committee",
|
||||
module_id="committee",
|
||||
resource_type="committee_workspace_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="committee_workspace_accountability",
|
||||
title="Committee workspace revision attribution",
|
||||
data={
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"revision": row.revision,
|
||||
"parent_kind": row.parent_kind,
|
||||
"parent_id": row.parent_id,
|
||||
"state": row.state,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_workspace_revision",
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Committee workspace attribution is immutable evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="committee",
|
||||
module_id="committee",
|
||||
resource_type="committee_event_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="committee_workspace_accountability",
|
||||
title="Committee lifecycle event attribution",
|
||||
data={
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"object_revision": row.object_revision,
|
||||
"event_id": row.event_id,
|
||||
"event_type": row.event_type,
|
||||
"occurred_at": _iso(row.occurred_at),
|
||||
},
|
||||
observed_at=_aware(row.occurred_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Committee event attribution is immutable evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="committee",
|
||||
module_id="committee",
|
||||
resource_type="committee_decision_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="committee_decision_accountability",
|
||||
title="Committee fallback Decision attribution",
|
||||
data={
|
||||
"decision_id": row.decision_id,
|
||||
"revision": row.revision,
|
||||
"meeting_id": row.meeting_id,
|
||||
"agenda_item_id": row.agenda_item_id,
|
||||
"state": row.state,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_decision_projection",
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Committee fallback Decision attribution is immutable evidence."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _limited(query, first, second, *, label: str):
|
||||
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
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 _prefixed(prefix: str, value: object) -> str | None:
|
||||
normalized = _optional_string(value)
|
||||
return f"{prefix}:{normalized}" if normalized else None
|
||||
|
||||
|
||||
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("Committee DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
_RESOURCE_TYPES = {
|
||||
"committee_workspace_actor_attribution",
|
||||
"committee_event_actor_attribution",
|
||||
"committee_decision_actor_attribution",
|
||||
}
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "committee" or record.module_id != "committee":
|
||||
raise ValueError("Committee DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||
raise ValueError("Committee DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "committee" or action.module_id != "committee":
|
||||
raise ValueError("Committee DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("committee:retain:"):
|
||||
raise ValueError("Committee DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -23,6 +24,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
@@ -45,6 +47,10 @@ from govoplan_committee.backend.ballots import (
|
||||
CommitteeBallotFinalizer,
|
||||
)
|
||||
from govoplan_committee.backend.db import models as committee_models
|
||||
from govoplan_committee.backend.dsar_provider import (
|
||||
COMMITTEE_DSAR_CAPABILITY,
|
||||
CommitteeDsarProvider,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CAPABILITY_COMMITTEE_WORKSPACE,
|
||||
SqlCommitteeWorkspace,
|
||||
@@ -53,7 +59,7 @@ from govoplan_core.db.base import Base
|
||||
|
||||
MODULE_ID = "committee"
|
||||
MODULE_NAME = "Committee"
|
||||
MODULE_VERSION = "0.1.16"
|
||||
MODULE_VERSION = "0.1.21"
|
||||
READ_SCOPE = "committee:workspace:read"
|
||||
WRITE_SCOPE = "committee:workspace:write"
|
||||
BALLOT_SCOPE = "committee:ballot:finalize"
|
||||
@@ -123,6 +129,10 @@ def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer:
|
||||
return CommitteeBallotFinalizer(context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> CommitteeDsarProvider:
|
||||
return CommitteeDsarProvider()
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_committee.backend.router import configure_registry, router
|
||||
|
||||
@@ -226,6 +236,7 @@ DOCUMENTATION = (
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
@@ -236,6 +247,7 @@ DOCUMENTATION = (
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"committee.navigation",
|
||||
@@ -262,6 +274,35 @@ DOCUMENTATION = (
|
||||
"generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle"
|
||||
],
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abgrenzung des Committee-Moduls",
|
||||
"summary": (
|
||||
"Arbeitsabläufe für Ausschüsse, Vorstände, Räte und Senate mit Sitzungen, Tagesordnungen, Protokollen, Beratung, Abstimmung, formellen Entscheidungsverweisen und Folgeaufgaben."
|
||||
),
|
||||
"body": (
|
||||
"Der dauerhafte Arbeitsbereich bewahrt unveränderliche Gremien, Sitzungen, Tagesordnungspunkte, gesteuerte "
|
||||
"Abstimmungsergebnisse, Protokolle und Lebenszyklusereignisse. Der Entscheidungspfad konstruiert daraus, aus "
|
||||
"einer wirksamen Mandatsauflösung, Genehmigung, versionierten Rechtsgrundlagen, Nachweisen, Begründung und "
|
||||
"beantragten oder beobachteten Wirkungen eine rekonstruierbare formelle Entscheidung. Committee führt keine "
|
||||
"generische Mandats- oder Entscheidungspersistenz; optionale Anbieter lösen diese Objekte auf und speichern "
|
||||
"sie, während eine geschützte lokale Projektion den begrenzten Rückfall bewahrt. An einen Anbieter gebundene "
|
||||
"Stimmabgaben werden über Adapterfähigkeiten abgeschlossen, ohne einzelne Stimmen aufzubewahren. Von Voting "
|
||||
"gelieferte Ergebnisse bewahren bereinigte Anbieterzusicherungen und stufen den nicht zertifizierten lokalen "
|
||||
"vertraulichen Referenzanbieter niemals zu einem zertifizierten Profil hoch."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"privacy_notes": [
|
||||
"Committee-Listen zeigen nur Datensätze, die im aktiven Mandanten- und Berechtigungskontext autorisiert sind.",
|
||||
"Anbietergestützte Abstimmungen speichern aggregierte Ergebnisnachweise, keine einzelnen vertraulichen Stimmen.",
|
||||
"Geschützte Entscheidungsbegründungen erfordern die besondere Berechtigung zum geschützten Lesen.",
|
||||
]
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
||||
@@ -289,6 +330,7 @@ DOCUMENTATION = (
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"committee.field.state",
|
||||
@@ -309,6 +351,36 @@ DOCUMENTATION = (
|
||||
"cancel_or_retire": "Stops future use while retaining institutional and audit evidence.",
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Committee-Felder und Folgen",
|
||||
"summary": (
|
||||
"Feldherkunft, Lebenszyklusbeschränkungen, optionale Anbieterverweise und Nachweisfolgen für Committee-Datensätze."
|
||||
),
|
||||
"body": (
|
||||
"Organisationseinheitsverweise eines Gremiums bestimmen den institutionellen Kontext, gewähren aber selbst "
|
||||
"keinen Zugriff. Sitzungszeiten begründen den Tagesordnungskontext. Gegenstands-, Decision-, Approval-, "
|
||||
"Records- und Abstimmungskennungen sind stabile modulübergreifende Verweise und bleiben nur optional, soweit "
|
||||
"der aktuelle Lebenszyklus dies erlaubt. Zustandsübergänge erzeugen neue unveränderliche Revisionen; endgültige "
|
||||
"Datensätze können nicht an Ort und Stelle bearbeitet werden. Der Abschluss einer Abstimmung oder die Annahme "
|
||||
"eines Protokolls erfordert den zugehörigen gesteuerten Nachweis. Die anbietergebundene Finalisierung importiert "
|
||||
"nur ein aggregiertes Ergebnis und Zusicherungsnachweise. Änderungsgründe bleiben mit jeder Revision erhalten. "
|
||||
"Abbruch, Rücknahme oder Außerbetriebnahme stoppt künftige Arbeit, löscht aber keine bestehenden Revisionen, "
|
||||
"Entscheidungen, Protokolle oder Prüfnachweise."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"save_revision": "Erzeugt eine neue unveränderliche Committee-Revision mit ihrem Änderungsgrund.",
|
||||
"change_lifecycle": "Ändert die künftig möglichen Aktionen; endgültige Zustände sind unveränderlich.",
|
||||
"finalize_ballot": "Importiert ein gesteuertes aggregiertes Ergebnis und Nachweise, ohne einzelne Stimmen aufzubewahren.",
|
||||
"cancel_or_retire": "Stoppt die künftige Nutzung und bewahrt institutionelle sowie Prüfnachweise.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -357,6 +429,17 @@ manifest = ModuleManifest(
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="meetings-decisions",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.meetings_decisions",
|
||||
icon="calendar",
|
||||
description="i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||
surface_ids=("committee.nav.committee", "committee.route.committee"),
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="committee.navigation",
|
||||
@@ -387,6 +470,7 @@ manifest = ModuleManifest(
|
||||
name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=COMMITTEE_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -418,6 +502,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
|
||||
CAPABILITY_COMMITTEE_WORKSPACE: _workspace,
|
||||
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer,
|
||||
COMMITTEE_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation(
|
||||
@@ -435,6 +520,14 @@ manifest = ModuleManifest(
|
||||
summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
COMMITTEE_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Committee data-subject request provider",
|
||||
summary=(
|
||||
"Exports minimized workspace, event, and fallback-Decision actor "
|
||||
"attribution without deliberation or minute payloads."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
@@ -460,7 +553,90 @@ manifest = ModuleManifest(
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
documentation=DOCUMENTATION,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="committee.workspace-layout",
|
||||
title="Committee workspace layout",
|
||||
summary="Find workspace actions and read consistently arranged content.",
|
||||
body="Reload and New body share the workspace-wide action bar at the upper right; Reload sits directly before New. Searching and selecting a body, meeting, or agenda item leaves those workspace actions in place. Item editing, ballots, and finalization remain scoped to the selected record. Disabled controls explain missing authority; administrators grant the existing management permissions rather than changing layouts.",
|
||||
layer="static",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "module_admin", "operator"),
|
||||
order=5,
|
||||
translations={"de": {
|
||||
"title": "Gremien: Aufbau des Arbeitsbereichs",
|
||||
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
|
||||
"body": "Neu laden und Neues Gremium stehen oben rechts in der gemeinsamen Arbeitsbereichsleiste; Neu laden steht unmittelbar vor Neu. Suche und Auswahl eines Gremiums, einer Sitzung oder eines Tagesordnungspunkts verändern diese Positionen nicht. Bearbeitung, Abstimmungen und Abschluss bleiben dem ausgewählten Datensatz zugeordnet. Deaktivierte Aktionen erklären fehlende Berechtigungen; Administratoren vergeben die bestehenden Verwaltungsrechte, statt das Layout zu ändern.",
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="committee.data-subject-requests",
|
||||
title="Committee data-subject requests",
|
||||
summary=(
|
||||
"Export minimized actor attribution from immutable Committee records "
|
||||
"without disclosing deliberation or ballot content."
|
||||
),
|
||||
body=(
|
||||
"Committee correlates exact account, membership, identity, or explicit "
|
||||
"actor identifiers inside the active tenant. Optional object-kind, "
|
||||
"object, or Decision identifiers only narrow an already verified actor "
|
||||
"search. Workspace results contain stable object, parent, revision, "
|
||||
"state, and timing context. Event and fallback-Decision results contain "
|
||||
"only lifecycle attribution and stable meeting or agenda references. "
|
||||
"Search text, minutes, deliberation payloads, individual ballots, "
|
||||
"request hashes, idempotency values, and referenced provider records "
|
||||
"are excluded and never traversed. All returned attribution is retained "
|
||||
"as immutable institutional evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "decisions", "voting", "records"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"committee.workspace",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"export_actor_attribution": (
|
||||
"Returns stable lifecycle context without institutional payloads."
|
||||
),
|
||||
"retain_committee_history": (
|
||||
"Preserves immutable meeting and Decision accountability evidence."
|
||||
),
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Datenschutzanfragen zu Committee",
|
||||
"summary": (
|
||||
"Minimierte Akteurszuordnungen aus unveränderlichen Committee-Datensätzen ausgeben, ohne Beratungs- oder Abstimmungsinhalte offenzulegen."
|
||||
),
|
||||
"body": (
|
||||
"Committee gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
|
||||
"Akteurskennungen ab. Optionale Objektart-, Objekt- oder Decision-Kennungen schränken nur eine bereits "
|
||||
"verifizierte Akteurssuche ein. Arbeitsbereichsergebnisse enthalten stabilen Objekt-, Eltern-, Revisions-, "
|
||||
"Zustands- und Zeitkontext. Ereignis- und Rückfall-Decision-Ergebnisse enthalten ausschließlich "
|
||||
"Lebenszykluszuordnungen sowie stabile Sitzungs- oder Tagesordnungsverweise. Suchtext, Protokolle, "
|
||||
"Beratungsinhalte, einzelne Stimmen, Anfrageprüfsummen, Idempotenzwerte und referenzierte Anbieterdatensätze "
|
||||
"werden ausgeschlossen und nie traversiert. Alle zurückgegebenen Zuordnungen bleiben unveränderliche "
|
||||
"institutionelle Nachweise."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"export_actor_attribution": "Gibt stabilen Lebenszykluskontext ohne institutionelle Inhalte zurück.",
|
||||
"retain_committee_history": "Bewahrt unveränderliche Verantwortungsnachweise zu Sitzungen und Decisions.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
*DOCUMENTATION,
|
||||
),
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
documentation_structured_translation_issues,
|
||||
localizable_documentation_metadata_keys,
|
||||
)
|
||||
from govoplan_committee.backend.manifest import manifest
|
||||
|
||||
|
||||
class CommitteeDocumentationTests(unittest.TestCase):
|
||||
def test_german_reference_documentation_is_complete(self) -> None:
|
||||
topics = manifest.documentation
|
||||
by_id = {topic.id: topic for topic in topics}
|
||||
self.assertEqual(len(topics), len(by_id), "Documentation topic IDs must be unique")
|
||||
self.assertLessEqual(
|
||||
{
|
||||
"committee.module-boundary",
|
||||
"committee.reference.fields-and-consequences",
|
||||
"committee.data-subject-requests",
|
||||
"committee.workspace-layout",
|
||||
},
|
||||
set(by_id),
|
||||
)
|
||||
self.assertLessEqual(
|
||||
{"user", "admin"},
|
||||
set(by_id["committee.workspace-layout"].documentation_types),
|
||||
)
|
||||
for topic in topics:
|
||||
translation = topic.translations.get("de", {})
|
||||
self.assertTrue(translation.get("title"), topic.id)
|
||||
self.assertTrue(translation.get("summary"), topic.id)
|
||||
self.assertTrue(translation.get("body"), topic.id)
|
||||
if localizable_documentation_metadata_keys(topic):
|
||||
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||
self.assertIn("de", topic.structured_translations, topic.id)
|
||||
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||
|
||||
kinds = {topic.metadata.get("kind") for topic in topics}
|
||||
self.assertIn("workflow", kinds)
|
||||
self.assertIn("reference", kinds)
|
||||
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
|
||||
self.assertTrue(workflow.conditions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_committee.backend.dsar_provider import (
|
||||
COMMITTEE_DSAR_CAPABILITY,
|
||||
CommitteeDsarProvider,
|
||||
)
|
||||
from govoplan_committee.backend.manifest import manifest
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: CommitteeDsarProvider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def capability_names(self):
|
||||
return (COMMITTEE_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
if name != COMMITTEE_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return "committee"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type("State", (), {"effective_modules": ("committee",)})()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
if name != COMMITTEE_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "committee"})(),)
|
||||
|
||||
|
||||
class CommitteeDsarProviderTests(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 = CommitteeDsarProvider()
|
||||
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(
|
||||
(
|
||||
CommitteeWorkspaceRevision(
|
||||
id="workspace-revision-1",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="meeting",
|
||||
object_id="meeting-1",
|
||||
revision=1,
|
||||
parent_kind="body",
|
||||
parent_id="body-1",
|
||||
state="scheduled",
|
||||
title="Private meeting title do not export",
|
||||
search_text="private search text do not export",
|
||||
recorded_at=NOW,
|
||||
payload={"minutes": "private minutes do not export"},
|
||||
changed_by="account-1",
|
||||
),
|
||||
CommitteeWorkspaceRevision(
|
||||
id="workspace-revision-other",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="meeting",
|
||||
object_id="meeting-other",
|
||||
revision=1,
|
||||
state="scheduled",
|
||||
title="Other meeting",
|
||||
search_text="other search text",
|
||||
recorded_at=NOW,
|
||||
payload={"private": "other payload"},
|
||||
changed_by="account-other",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
CommitteeWorkspaceEvent(
|
||||
id="event-1",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="meeting",
|
||||
object_id="meeting-1",
|
||||
object_revision=1,
|
||||
event_id="event-external-1",
|
||||
event_type="meeting.scheduled",
|
||||
occurred_at=NOW,
|
||||
actor_id="account-1",
|
||||
idempotency_key="event-idempotency-do-not-export",
|
||||
request_sha256="event-request-hash-do-not-export",
|
||||
payload={"secret": "event-payload-do-not-export"},
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
CommitteeDecisionProjection(
|
||||
id="decision-projection-1",
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
meeting_id="meeting-1",
|
||||
agenda_item_id="agenda-1",
|
||||
state="effective",
|
||||
recorded_at=NOW,
|
||||
payload={
|
||||
"reasoning": "decision-reasoning-do-not-export",
|
||||
"ballot": "individual-ballot-do-not-export",
|
||||
},
|
||||
changed_by="account-1",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _subject() -> DsarSubjectRef:
|
||||
return DsarSubjectRef(account_id="account-1")
|
||||
|
||||
def test_search_exports_minimized_committee_attribution(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"committee_workspace_actor_attribution",
|
||||
"committee_event_actor_attribution",
|
||||
"committee_decision_actor_attribution",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertIn("meeting-1", exported)
|
||||
self.assertIn("agenda-1", exported)
|
||||
for excluded in (
|
||||
"Private meeting title do not export",
|
||||
"private search text do not export",
|
||||
"private minutes do not export",
|
||||
"event-idempotency-do-not-export",
|
||||
"event-request-hash-do-not-export",
|
||||
"event-payload-do-not-export",
|
||||
"decision-reasoning-do-not-export",
|
||||
"individual-ballot-do-not-export",
|
||||
"meeting-other",
|
||||
):
|
||||
self.assertNotIn(excluded, exported)
|
||||
|
||||
def test_object_narrowing_and_actor_conflicts_fail_closed(self) -> None:
|
||||
narrowed = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={
|
||||
"committee.object_kind": "meeting",
|
||||
"committee.object": "meeting-1",
|
||||
},
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"committee.actor": "account-other"},
|
||||
),
|
||||
)
|
||||
object_only = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"committee.object": "meeting-1"}
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"committee_workspace_actor_attribution",
|
||||
"committee_event_actor_attribution",
|
||||
"committee_decision_actor_attribution",
|
||||
},
|
||||
{record.resource_type for record in narrowed},
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), object_only)
|
||||
|
||||
def test_erasure_is_retain_only(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
records=records,
|
||||
)
|
||||
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
actions=actions,
|
||||
request_id="dsar-committee-1",
|
||||
)
|
||||
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||
self.assertEqual(2, self.session.query(CommitteeWorkspaceRevision).count())
|
||||
|
||||
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||
self.assertIn(COMMITTEE_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-COMMITTEE-1",
|
||||
request_kind="access",
|
||||
subject=self._subject(),
|
||||
purpose="Committee attribution access request",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="operator-1",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=row.resource_revision,
|
||||
)
|
||||
self.assertEqual("searched", row.status)
|
||||
self.assertEqual(3, row.search_result["record_count"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/committee.css": "./src/styles/committee.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -4,24 +4,26 @@ import {
|
||||
ListPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Vote
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
import { ActionBlockerHint,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FilterBar,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -182,17 +184,18 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
||||
|
||||
return (
|
||||
<main className="committee-page">
|
||||
<div className="committee-shell">
|
||||
<div className="committee-toolbar">
|
||||
<form onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
|
||||
<WorkspaceFrame className="committee-shell" label="Committee workspace" interfaceId="committee.workspace" helpContextId="committee.page.workspace" helpModuleId="committee">
|
||||
<WorkspaceActionBar
|
||||
scope="workspace"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void refresh(), loading }}
|
||||
className="committee-toolbar"
|
||||
contextActions={<FilterBar as="form" surface="control" wrap="never" width="compact" onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
|
||||
</form>
|
||||
<Button variant="ghost" onClick={() => void refresh()} disabled={loading} disabledReason={loading ? COMMITTEE_INTERFACE_I18N.loading : undefined}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
</FilterBar>}
|
||||
createAction={<Button
|
||||
variant="primary"
|
||||
disabled={!canWrite || loading}
|
||||
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
|
||||
@@ -200,9 +203,9 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
New body
|
||||
</Button>
|
||||
<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />
|
||||
</div>
|
||||
</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />}
|
||||
/>
|
||||
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
||||
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
||||
@@ -262,7 +265,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
||||
|
||||
<section className="committee-detail" aria-label="Meeting workspace">
|
||||
{!selectedMeeting ? (
|
||||
<div className="committee-empty">Select or create a meeting.</div>
|
||||
<StatePanel size="fill" title="Meetings" description="Select or create a meeting." />
|
||||
) : (
|
||||
<>
|
||||
<div className="committee-detail-heading">
|
||||
@@ -310,7 +313,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
|
||||
{agendaItems.length === 0 ? <StatePanel size="inline" description="No agenda items." /> : null}
|
||||
</div>
|
||||
</WorkspaceSection>
|
||||
|
||||
@@ -373,7 +376,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceFrame>
|
||||
|
||||
{editor ? (
|
||||
<CommitteeRecordDialog
|
||||
@@ -410,7 +413,7 @@ function RecordList({ records, selectedId, onSelect, secondary }: {
|
||||
onSelect: (id: string) => void;
|
||||
secondary?: (record: CommitteeRecord) => string;
|
||||
}) {
|
||||
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
|
||||
if (records.length === 0) return <StatePanel size="inline" description="No records." />;
|
||||
return <div className="committee-record-list">{records.map((record) => (
|
||||
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
|
||||
<strong>{record.title}</strong>
|
||||
@@ -430,7 +433,7 @@ function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryActi
|
||||
detail: (record: CommitteeRecord) => string;
|
||||
secondaryAction?: (record: CommitteeRecord) => ReactNode;
|
||||
}) {
|
||||
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
|
||||
if (records.length === 0) return <StatePanel size="inline" description="No records." />;
|
||||
return <div className="committee-record-rows">{records.map((record) => (
|
||||
<div key={record.object_id}>
|
||||
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
import { FormGrid,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
@@ -176,7 +176,7 @@ export default function CommitteeRecordDialog({
|
||||
<div className="committee-record-form">
|
||||
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="committee-form-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.title}
|
||||
@@ -194,7 +194,7 @@ export default function CommitteeRecordDialog({
|
||||
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
|
||||
{kind === "body" ? (
|
||||
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
@@ -207,19 +207,19 @@ export default function CommitteeRecordDialog({
|
||||
) : null}
|
||||
|
||||
{kind === "meeting" ? (
|
||||
<div className="committee-form-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Starts">
|
||||
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ends">
|
||||
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
) : null}
|
||||
|
||||
{kind === "agenda_item" ? (
|
||||
<>
|
||||
<div className="committee-form-grid committee-form-grid-three">
|
||||
<FormGrid columns={3} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Position">
|
||||
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -234,10 +234,10 @@ export default function CommitteeRecordDialog({
|
||||
<FormField label="Subject ID">
|
||||
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
{draft.state === "decided" ? (
|
||||
<FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
|
||||
<input data-help-risk-reviewed="standard" value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
@@ -245,7 +245,7 @@ export default function CommitteeRecordDialog({
|
||||
|
||||
{kind === "vote" ? (
|
||||
<>
|
||||
<div className="committee-form-grid committee-form-grid-three">
|
||||
<FormGrid columns={3} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Method">
|
||||
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
|
||||
<option value="recorded">Recorded</option>
|
||||
@@ -259,7 +259,7 @@ export default function CommitteeRecordDialog({
|
||||
<FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -268,7 +268,7 @@ export default function CommitteeRecordDialog({
|
||||
</FormField>
|
||||
{draft.state === "closed" ? (
|
||||
<div className="committee-vote-result-fields">
|
||||
<div className="committee-form-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Votes cast">
|
||||
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -278,7 +278,7 @@ export default function CommitteeRecordDialog({
|
||||
<option value="not-met">Not met</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<div className="committee-count-grid">
|
||||
{choices.map((choice) => (
|
||||
<FormField key={choice} label={`${choice} votes`}>
|
||||
@@ -344,14 +344,14 @@ export default function CommitteeRecordDialog({
|
||||
|
||||
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
|
||||
return (
|
||||
<div className="committee-form-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
.committee-page,
|
||||
.committee-shell {
|
||||
.committee-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.committee-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.committee-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.committee-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(460px, 100%);
|
||||
flex: 1 1 460px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.committee-search input {
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.committee-alert {
|
||||
margin: 10px 16px 0;
|
||||
}
|
||||
@@ -231,17 +206,6 @@
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.committee-empty {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.committee-empty.compact {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.committee-record-dialog {
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-height: min(820px, calc(100vh - 32px));
|
||||
@@ -268,17 +232,11 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.committee-form-grid,
|
||||
.committee-count-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.committee-form-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.committee-count-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
@@ -291,22 +249,13 @@
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
@media (max-width: 1100px) {
|
||||
.committee-workspace {
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.committee-toolbar {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.committee-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.committee-workspace {
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr);
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
@@ -328,8 +277,4 @@
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.committee-form-grid,
|
||||
.committee-form-grid-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user