5 Commits
Author SHA1 Message Date
zemion bf25e88f77 docs: localize poll guidance
Module Package Release / publish-packages (push) Successful in 10s
2026-08-22 08:27:57 +02:00
zemion 02ec5423b9 feat(poll): add governed DSAR coverage 2026-08-21 12:15:56 +02:00
zemion 1891996f13 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:45 +02:00
zemion 8407f0830c Release v0.1.17
Module Package Release / publish-packages (push) Successful in 10s
2026-08-05 20:34:09 +02:00
zemion f02fd11ea8 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:12 +02:00
6 changed files with 900 additions and 6 deletions
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-poll"
version = "0.1.15"
version = "0.1.19"
description = "GovOPlaN lightweight poll and availability decision module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.15",
"govoplan-core>=0.1.18",
]
[tool.setuptools.packages.find]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.15"
__version__ = "0.1.19"
+506
View File
@@ -0,0 +1,506 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollParticipationSubmission,
PollResponse,
)
POLL_DSAR_CAPABILITY = dsar_capability_name("poll")
_MAX_RECORDS = 5_000
_MAX_ANSWERS = 1_000
_MAX_ANSWER_BYTES = 256 * 1024
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
respondent_ids: tuple[str, ...]
actor_ids: tuple[str, ...]
email: str | None
poll_id: str | None
invitation_id: str | None
response_id: str | None
class PollDsarProvider:
provider_id = "poll"
module_id = "poll"
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] = []
invitation_conditions = []
if selectors.respondent_ids:
invitation_conditions.append(
PollInvitation.respondent_id.in_(selectors.respondent_ids)
)
if selectors.email:
invitation_conditions.append(
func.lower(PollInvitation.email) == selectors.email
)
invitations = db.query(PollInvitation).filter(
PollInvitation.tenant_id == tenant_id,
or_(*invitation_conditions),
)
if selectors.poll_id:
invitations = invitations.filter(PollInvitation.poll_id == selectors.poll_id)
if selectors.invitation_id:
invitations = invitations.filter(PollInvitation.id == selectors.invitation_id)
invitation_rows = _limited(
invitations,
PollInvitation.created_at,
PollInvitation.id,
label="invitation",
)
records.extend(_invitation_record(row) for row in invitation_rows)
linked_response_ids: tuple[str, ...] = ()
if invitation_rows:
invitation_ids = [row.id for row in invitation_rows]
linked_rows = (
db.query(PollParticipationSubmission.response_id)
.filter(
PollParticipationSubmission.tenant_id == tenant_id,
PollParticipationSubmission.invitation_id.in_(invitation_ids),
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(linked_rows) > _MAX_RECORDS:
raise ValueError(
"Poll DSAR participation-link limit exceeded; narrow selectors."
)
linked_response_ids = tuple(
dict.fromkeys(str(response_id) for (response_id,) in linked_rows)
)
response_conditions = []
if selectors.invitation_id:
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
else:
if selectors.respondent_ids:
response_conditions.append(
PollResponse.respondent_id.in_(selectors.respondent_ids)
)
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
if response_conditions:
responses = db.query(PollResponse).filter(
PollResponse.tenant_id == tenant_id,
or_(*response_conditions),
)
if selectors.poll_id:
responses = responses.filter(PollResponse.poll_id == selectors.poll_id)
if selectors.response_id:
responses = responses.filter(PollResponse.id == selectors.response_id)
records.extend(
_response_record(row)
for row in _limited(
responses,
PollResponse.submitted_at,
PollResponse.id,
label="response",
)
)
if selectors.actor_ids:
polls = db.query(Poll).filter(
Poll.tenant_id == tenant_id,
Poll.created_by_user_id.in_(selectors.actor_ids),
)
transitions = db.query(PollLifecycleTransition).filter(
PollLifecycleTransition.tenant_id == tenant_id,
PollLifecycleTransition.actor_user_id.in_(selectors.actor_ids),
)
if selectors.poll_id:
polls = polls.filter(Poll.id == selectors.poll_id)
transitions = transitions.filter(
PollLifecycleTransition.poll_id == selectors.poll_id
)
records.extend(
_creator_attribution(row)
for row in _limited(
polls,
Poll.created_at,
Poll.id,
label="creator attribution",
)
)
records.extend(
_transition_attribution(row)
for row in _limited(
transitions,
PollLifecycleTransition.created_at,
PollLifecycleTransition.id,
label="lifecycle attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Poll 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("Poll DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
participation = record.resource_type in {
"poll_response",
"poll_invitation",
}
actions.append(
DsarErasureActionRef(
action_id=(
f"poll:{'manual_review' if participation else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if participation else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if participation else "Retain ") + record.title,
rationale=(
"Removing or anonymizing participation may change published "
"results, response-update behavior, or retained invitation "
"evidence and therefore requires the Poll owner and retention "
"authority to review the effect."
if participation
else record.retention_reason
or "Poll actor attribution remains governance 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("Poll DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Poll DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Poll participation remains unchanged pending result and "
"retention-impact review."
if action.kind == "manual_review"
else "Poll lifecycle attribution remains governance 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("poll.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("poll.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("poll.identity"),
references.get("identity.id"),
),
"respondent_id": _coalesce(
references.get("poll.respondent"),
references.get("poll.respondent_id"),
),
"email": _coalesce_email(subject.email, references.get("poll.email")),
"poll_id": _coalesce(
references.get("poll.poll"), references.get("poll.poll_id")
),
"invitation_id": _coalesce(
references.get("poll.invitation"),
references.get("poll.invitation_id"),
),
"response_id": _coalesce(
references.get("poll.response"),
references.get("poll.response_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
base_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_respondent = _optional_string(values["respondent_id"])
if direct_respondent and base_ids and direct_respondent not in base_ids:
return None
respondent_ids = base_ids or ((direct_respondent,) if direct_respondent else ())
email = _optional_string(values["email"])
if not respondent_ids and not email:
return None
return _SubjectSelectors(
respondent_ids=respondent_ids,
actor_ids=base_ids,
email=email,
poll_id=_optional_string(values["poll_id"]),
invitation_id=_optional_string(values["invitation_id"]),
response_id=_optional_string(values["response_id"]),
)
def _response_record(row: PollResponse) -> DsarRecordRef:
answers = _answers(row.answers)
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_response",
resource_id=row.id,
category="identified_poll_participation",
title=f"Poll response: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"poll_kind": row.poll.kind,
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"answers": answers,
"submitted_at": _iso(row.submitted_at),
"retired_at": _iso(row.deleted_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Response erasure or anonymization requires Poll result and retention review."
),
)
def _invitation_record(row: PollInvitation) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_invitation",
resource_id=row.id,
category="poll_invitation_and_contact",
title=f"Poll invitation: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"email": row.email,
"expires_at": _iso(row.expires_at),
"revoked_at": _iso(row.revoked_at),
"last_used_at": _iso(row.last_used_at),
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Invitation erasure requires participation and response-link review."
),
)
def _creator_attribution(row: Poll) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_creator_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll creator attribution",
data={
"poll_id": row.id,
"kind": row.kind,
"status": row.status,
"visibility": row.visibility,
"opens_at": _iso(row.opens_at),
"closes_at": _iso(row.closes_at),
"created_at": _iso(row.created_at),
"activity": "created_poll",
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll creator attribution is governance evidence.",
)
def _transition_attribution(row: PollLifecycleTransition) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_lifecycle_actor_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll lifecycle actor attribution",
data={
"poll_id": row.poll_id,
"action": row.action,
"from_status": row.from_status,
"to_status": row.to_status,
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll lifecycle attribution is governance evidence.",
)
def _answers(value: object) -> list[object]:
if not isinstance(value, list) or len(value) > _MAX_ANSWERS:
raise ValueError("Poll response answers exceed the DSAR bound.")
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Poll response answers are not JSON serializable.") from exc
if len(encoded) > _MAX_ANSWER_BYTES:
raise ValueError("Poll response answer payload exceeds the DSAR byte bound.")
return json.loads(encoded.decode("utf-8"))
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"Poll 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 _coalesce_email(*values: str | None) -> str | None | object:
normalized = {
str(value).strip().casefold()
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("Poll DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"poll_response",
"poll_invitation",
"poll_creator_attribution",
"poll_lifecycle_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "poll" or record.module_id != "poll":
raise ValueError("Poll DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Poll DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "poll" or action.module_id != "poll":
raise ValueError("Poll DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("poll:"):
raise ValueError("Poll DSAR action identity is invalid.")
__all__ = ["POLL_DSAR_CAPABILITY", "PollDsarProvider"]
+99 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic,
MigrationSpec,
ModuleContext,
@@ -18,10 +19,11 @@ from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.db.base import Base
from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
from govoplan_poll.backend.dsar_provider import POLL_DSAR_CAPABILITY, PollDsarProvider
MODULE_ID = "poll"
MODULE_NAME = "Poll"
MODULE_VERSION = "0.1.15"
MODULE_VERSION = "0.1.19"
READ_SCOPE = "poll:poll:read"
WRITE_SCOPE = "poll:poll:write"
ADMIN_SCOPE = "poll:poll:admin"
@@ -81,6 +83,17 @@ DOCUMENTATION = (
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
related_modules=("scheduling", "evaluation", "calendar", "campaigns", "portal"),
translations={
"de": {
"title": "Abgrenzung des Poll-Moduls",
"summary": "Leichtgewichtige Entscheidungs- und Verfügbarkeitsabfragen für wiederverwendbare Modulintegrationen.",
"body": (
"Poll verwaltet wiederverwendbare Abfragedefinitionen, Optionen, Einladungen, Antworten, Sichtbarkeitsregeln, Abschlusssemantik und Ergebnisübersichten. "
"Scheduling verwendet Poll für Verfügbarkeitsmatrizen, während Evaluation umfangreichere Befragungen, Bewertungen, Rubriken und Analysen verwaltet. "
"Access ist optional: Wenn es installiert ist, kann Poll die Auflösung von Akteuren, Berechtigungsprüfungen und Rollenvorlagen nutzen; ohne Access ist Poll auf anonyme, signierte Link- oder durch Adapter bereitgestellte Teilnahmeabläufe beschränkt."
),
}
},
metadata={"seed": True},
),
DocumentationTopic(
@@ -97,6 +110,18 @@ DOCUMENTATION = (
documentation_types=("user",),
audience=("user", "participant", "organizer"),
related_modules=("scheduling", "evaluation"),
translations={
"de": {
"title": "An einer Abfrage teilnehmen",
"summary": "Abfragen können Einzel- oder Mehrfachauswahl, Ja/Nein, Ja/Nein/Vielleicht, Rangfolgen und Verfügbarkeiten erfassen.",
"body": (
"Eine Einladung oder ein signierter Teilnahmelink bestimmt, zu welcher Abfrage und Teilnehmeridentität eine Antwort gehört. "
"Die Abfragerichtlinie steuert Anonymität, nachträgliche Änderungen, Ergebnissichtbarkeit, Öffnungs- und Schließzeiten sowie die Zulässigkeit der Antwort Vielleicht. "
"Das Absenden ist atomar: Kapazitäts- und Auswahlbedingungen werden geprüft, bevor die gespeicherte Antwort eine frühere Antwort ersetzt. "
"Ein gültiger signierter Link löst außerdem vor der Ausführung von Poll seinen Mandanten auf, sodass die Modulrichtlinie des Mandanten die öffentliche Oberfläche zurückziehen kann, ohne Zustand eines anderen Mandanten offenzulegen."
),
}
},
metadata={"kind": "reference"},
),
)
@@ -144,6 +169,11 @@ def _poll_participation_gateway_provider(context: ModuleContext) -> object:
return _poll_scheduling_provider(context)
def _dsar_provider(context: ModuleContext) -> PollDsarProvider:
del context
return PollDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -159,6 +189,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.signed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.governed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name=POLL_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -168,6 +199,17 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider,
CAPABILITY_POLL_PARTICIPATION_GATEWAY: _poll_participation_gateway_provider,
POLL_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
POLL_DSAR_CAPABILITY: CapabilityDocumentation(
label="Poll data-subject request provider",
summary=(
"Exports identified responses, invitation contact data, and minimized "
"operator attribution without token or gateway secrets."
),
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -196,7 +238,62 @@ manifest = ModuleManifest(
label="Poll",
),
),
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="poll.data-subject-requests",
title="Poll data-subject requests",
summary=(
"Export identified responses and invitations while preserving result "
"integrity and the boundary around anonymous participation."
),
body=(
"Poll correlates exact respondent identifiers and normalized email "
"addresses inside the active tenant. A matching invitation can resolve "
"its explicitly linked participation submission and response without "
"exposing the signed token. Subject-owned responses include bounded "
"answers, respondent labels, Poll context, and retirement state. "
"Invitations include contact and lifecycle state but never token hashes, "
"gateway configuration, participation policy, metadata, fingerprints, "
"or idempotency values. Creator and lifecycle activity is exported only "
"as minimized attribution. Truly anonymous responses have no stable "
"subject selector and cannot be correlated. Participation erasure "
"requires manual result and retention review; no automatic action "
"silently changes a Poll outcome."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "participant", "organizer", "auditor"),
related_modules=("core", "scheduling", "notifications", "mail"),
translations={
"de": {
"title": "Datenschutzanfragen für Poll",
"summary": "Identifizierte Antworten und Einladungen exportieren und dabei Ergebnisintegrität sowie die Grenze anonymer Teilnahme wahren.",
"body": (
"Poll korreliert ausschließlich im aktiven Mandanten genaue Teilnehmerkennungen und normalisierte E-Mail-Adressen. "
"Eine passende Einladung kann ihre ausdrücklich verknüpfte Teilnahmeübermittlung und Antwort auflösen, ohne den signierten Token offenzulegen. "
"Eigene Antworten enthalten begrenzte Antwortwerte, Teilnehmerbezeichnungen, Poll-Kontext und Ausmusterungszustand. Einladungen enthalten Kontakt- und Lebenszyklusdaten, jedoch niemals Token-Hashes, Gateway-Konfiguration, Teilnahmebedingungen, Metadaten, Fingerabdrücke oder Idempotenzwerte. "
"Erstellungs- und Lebenszyklusaktivität wird nur als minimierte Zuordnung exportiert. Vollständig anonyme Antworten besitzen keinen stabilen Betroffenenbezug und können nicht korreliert werden. "
"Die Löschung von Teilnahmedaten erfordert eine manuelle Prüfung von Ergebnisintegrität und Aufbewahrung; keine automatische Aktion verändert unbemerkt ein Abfrageergebnis."
),
}
},
metadata={
"help_contexts": ["privacy.data-subject-requests"],
"consequence_classes": {
"export_identified_response": (
"Returns bounded subject-owned answers and Poll context."
),
"anonymous_limitation": (
"Cannot correlate a response that deliberately has no subject identifier."
),
"review_participation_erasure": (
"Requires result-integrity and retention review."
),
},
},
),
*DOCUMENTATION,
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
+284
View File
@@ -0,0 +1,284 @@
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_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,
)
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollParticipationSubmission,
PollResponse,
)
from govoplan_poll.backend.dsar_provider import POLL_DSAR_CAPABILITY, PollDsarProvider
from govoplan_poll.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 16, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: PollDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (POLL_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != POLL_DSAR_CAPABILITY:
raise KeyError(name)
return "poll"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("poll",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != POLL_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "poll"})(),)
class PollDsarProviderTests(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 = PollDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
poll = Poll(
id="poll-1",
tenant_id="tenant-1",
slug="resident-availability",
title="Resident appointment availability",
description="Institutional description",
kind="availability",
status="open",
visibility="private",
result_visibility="after_close",
allow_anonymous=True,
allow_response_update=True,
min_choices=1,
created_by_user_id="account-1",
metadata_={"secret": "poll-metadata-do-not-export"},
)
self.session.add(poll)
self.session.flush()
invitation = PollInvitation(
id="invitation-1",
tenant_id="tenant-1",
poll_id="poll-1",
token_hash="token-hash-do-not-export",
respondent_id="account-1",
respondent_label="Ada Example",
email="Ada@Example.DE",
expires_at=NOW,
last_used_at=NOW,
response_gateway_={"secret": "gateway-do-not-export"},
participation_policy_={"secret": "policy-do-not-export"},
metadata_={"secret": "invitation-metadata-do-not-export"},
)
response = PollResponse(
id="response-1",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id="account-1",
respondent_label="Ada Example",
answers=[{"option_id": "option-a", "available": True}],
submitted_at=NOW,
metadata_={"secret": "response-metadata-do-not-export"},
)
anonymous = PollResponse(
id="response-anonymous",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id=None,
answers=[{"private": "anonymous-answer-do-not-correlate"}],
submitted_at=NOW,
)
other = PollResponse(
id="response-other",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id="account-other",
respondent_label="Other Person",
answers=[{"private": "other-answer-do-not-export"}],
submitted_at=NOW,
)
self.session.add_all((invitation, response, anonymous, other))
self.session.flush()
self.session.add(
PollParticipationSubmission(
id="submission-1",
tenant_id="tenant-1",
poll_id="poll-1",
invitation_id="invitation-1",
response_id="response-1",
idempotency_key="submission-idempotency-do-not-export",
request_fingerprint="submission-fingerprint-do-not-export",
)
)
self.session.add(
PollLifecycleTransition(
id="transition-1",
tenant_id="tenant-1",
poll_id="poll-1",
action="open",
from_status="draft",
to_status="open",
idempotency_key="transition-idempotency-do-not-export",
actor_user_id="account-1",
metadata_={"secret": "transition-metadata-do-not-export"},
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1", email="ada@example.de")
def test_search_exports_identified_participation_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"poll_response",
"poll_invitation",
"poll_creator_attribution",
"poll_lifecycle_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("option-a", exported)
self.assertIn("Ada@Example.DE", exported)
for excluded in (
"token-hash-do-not-export",
"gateway-do-not-export",
"policy-do-not-export",
"invitation-metadata-do-not-export",
"response-metadata-do-not-export",
"submission-idempotency-do-not-export",
"submission-fingerprint-do-not-export",
"transition-idempotency-do-not-export",
"transition-metadata-do-not-export",
"anonymous-answer-do-not-correlate",
"other-answer-do-not-export",
):
self.assertNotIn(excluded, exported)
def test_email_only_follows_explicit_invitation_response_link(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="ADA@EXAMPLE.DE"),
)
self.assertEqual(
{"poll_invitation", "poll_response"},
{record.resource_type for record in records},
)
def test_poll_narrowing_conflicts_and_anonymous_limit(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"poll.poll": "poll-1"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"poll.respondent": "account-other"},
),
)
poll_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={"poll.poll": "poll-1"}),
)
self.assertTrue(narrowed)
self.assertEqual((), conflict)
self.assertEqual((), poll_only)
self.assertNotIn(
"response-anonymous", {record.resource_id for record in narrowed}
)
def test_erasure_requires_review_and_preserves_results(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.assertEqual(
{"manual_review", "retain"}, {action.kind for action in actions}
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-poll-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertIsNone(self.session.get(PollResponse, "response-1").deleted_at)
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(POLL_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POLL-1",
request_kind="access",
subject=self._subject(),
purpose="Poll participation 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(4, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+8 -1
View File
@@ -25,9 +25,16 @@ class PollManifestTests(unittest.TestCase):
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.provides_interfaces})
self.assertIn(CAPABILITY_POLL_PARTICIPATION_GATEWAY, manifest.capability_factories)
self.assertEqual(manifest.version, "0.1.11")
self.assertEqual(manifest.version, "0.1.19")
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})
for topic in manifest.documentation:
german = topic.translations.get("de", {})
self.assertTrue(
all(german.get(field) for field in ("title", "summary", "body")),
topic.id,
)
if __name__ == "__main__":
unittest.main()