commit 992b99e5bade91ac5cac3487e55a8e56509bb5de Author: Albrecht Degering Date: Tue Jul 7 15:49:06 2026 +0200 Release v0.1.5 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..de6be63 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/policy +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..af46c1b --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/policy + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8f29421 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/policy +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..95b5e56 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/policy +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..cb0408a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/policy +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..032dd5f --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# GovOPlaN Policy + +`govoplan-policy` owns policy and retention API route contributions during the +GovOPlaN module split. + +The current package delegates to the legacy access administration +implementation while route ownership is separated before model migration. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a48aef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-policy" +version = "0.1.5" +description = "GovOPlaN policy platform module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.5", + "govoplan-access>=0.1.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_policy = ["py.typed"] + +[project.entry-points."govoplan.modules"] +policy = "govoplan_policy.backend.manifest:get_manifest" + diff --git a/src/govoplan_policy.egg-info/PKG-INFO b/src/govoplan_policy.egg-info/PKG-INFO new file mode 100644 index 0000000..1015a51 --- /dev/null +++ b/src/govoplan_policy.egg-info/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 2.4 +Name: govoplan-policy +Version: 0.1.4 +Summary: GovOPlaN policy platform module. +Author: GovOPlaN +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: govoplan-core>=0.1.4 +Requires-Dist: govoplan-access>=0.1.4 + +# GovOPlaN Policy + +`govoplan-policy` owns policy and retention API route contributions during the +GovOPlaN module split. + +The current package delegates to the legacy access administration +implementation while route ownership is separated before model migration. + diff --git a/src/govoplan_policy.egg-info/SOURCES.txt b/src/govoplan_policy.egg-info/SOURCES.txt new file mode 100644 index 0000000..9060033 --- /dev/null +++ b/src/govoplan_policy.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +README.md +pyproject.toml +src/govoplan_policy/__init__.py +src/govoplan_policy/py.typed +src/govoplan_policy.egg-info/PKG-INFO +src/govoplan_policy.egg-info/SOURCES.txt +src/govoplan_policy.egg-info/dependency_links.txt +src/govoplan_policy.egg-info/entry_points.txt +src/govoplan_policy.egg-info/requires.txt +src/govoplan_policy.egg-info/top_level.txt +src/govoplan_policy/backend/__init__.py +src/govoplan_policy/backend/manifest.py +src/govoplan_policy/backend/api/__init__.py +src/govoplan_policy/backend/api/v1/__init__.py +src/govoplan_policy/backend/api/v1/routes.py +src/govoplan_policy/backend/api/v1/schemas.py \ No newline at end of file diff --git a/src/govoplan_policy.egg-info/dependency_links.txt b/src/govoplan_policy.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_policy.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_policy.egg-info/entry_points.txt b/src/govoplan_policy.egg-info/entry_points.txt new file mode 100644 index 0000000..eee1d6e --- /dev/null +++ b/src/govoplan_policy.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +policy = govoplan_policy.backend.manifest:get_manifest diff --git a/src/govoplan_policy.egg-info/requires.txt b/src/govoplan_policy.egg-info/requires.txt new file mode 100644 index 0000000..58ec5d6 --- /dev/null +++ b/src/govoplan_policy.egg-info/requires.txt @@ -0,0 +1,2 @@ +govoplan-core>=0.1.4 +govoplan-access>=0.1.4 diff --git a/src/govoplan_policy.egg-info/top_level.txt b/src/govoplan_policy.egg-info/top_level.txt new file mode 100644 index 0000000..8e22451 --- /dev/null +++ b/src/govoplan_policy.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_policy diff --git a/src/govoplan_policy/__init__.py b/src/govoplan_policy/__init__.py new file mode 100644 index 0000000..509a9a2 --- /dev/null +++ b/src/govoplan_policy/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN policy module.""" + diff --git a/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b52f7e8 Binary files /dev/null and b/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9e43ecc Binary files /dev/null and b/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/__init__.py b/src/govoplan_policy/backend/__init__.py new file mode 100644 index 0000000..5a8360f --- /dev/null +++ b/src/govoplan_policy/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN policy module.""" + diff --git a/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a8b607d Binary files /dev/null and b/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8f346db Binary files /dev/null and b/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000..dda7db8 Binary files /dev/null and b/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..adb126a Binary files /dev/null and b/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/api/__init__.py b/src/govoplan_policy/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6cbbae6 Binary files /dev/null and b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..727ddff Binary files /dev/null and b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__init__.py b/src/govoplan_policy/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8e6b8e1 Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..aa48d0e Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000..64bd49a Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..68ae94d Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..619bf00 Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..64b18c8 Binary files /dev/null and b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc differ diff --git a/src/govoplan_policy/backend/api/v1/routes.py b/src/govoplan_policy/backend/api/v1/routes.py new file mode 100644 index 0000000..df4d67f --- /dev/null +++ b/src/govoplan_policy/backend/api/v1/routes.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.db.session import get_session +from govoplan_core.privacy.retention import ( + PrivacyPolicyError, + apply_retention_policy, + effective_privacy_policy, + effective_privacy_policy_sources, + get_privacy_policy_for_scope, + parent_privacy_policy, + parent_privacy_policy_sources, + set_privacy_policy_for_scope, +) + +from .schemas import ( + PrivacyRetentionPolicyItem, + PrivacyRetentionPolicyScopeRequest, + PrivacyRetentionPolicyScopeResponse, + RetentionRunRequest, + RetentionRunResponse, +) + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _require_permission(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") + + +def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None: + if scope_type == "system": + _require_permission(principal, "system:settings:read") + else: + _require_permission(principal, "admin:policies:read") + + +def _require_privacy_policy_write(principal: ApiPrincipal, scope_type: str) -> None: + if scope_type == "system": + _require_permission(principal, "system:settings:write") + else: + _require_permission(principal, "admin:policies:write") + + +@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) +def read_privacy_retention_policy( + scope_type: str, + scope_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + clean_scope = scope_type.strip().casefold() + _require_privacy_policy_read(principal, clean_scope) + try: + policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + return PrivacyRetentionPolicyScopeResponse( + scope_type=clean_scope, + scope_id=scope_id, + policy=policy, + effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), + parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + ) + except PrivacyPolicyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) +def write_privacy_retention_policy( + scope_type: str, + payload: PrivacyRetentionPolicyScopeRequest, + scope_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + clean_scope = scope_type.strip().casefold() + _require_privacy_policy_write(principal, clean_scope) + try: + policy = set_privacy_policy_for_scope( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope, + scope_id=scope_id, + policy=payload.policy.model_dump(mode="json", exclude_none=True), + ) + session.commit() + effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) + return PrivacyRetentionPolicyScopeResponse( + scope_type=clean_scope, + scope_id=scope_id, + policy=policy, + effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), + parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, + effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), + ) + except PrivacyPolicyError as exc: + session.rollback() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return [] + return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) + + +def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return effective_privacy_policy_sources(session) + if scope_type == "tenant": + return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id) + if scope_type == "campaign" and scope_id: + return effective_privacy_policy_sources(session, campaign_id=scope_id) + if scope_type == "user" and scope_id: + return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id) + if scope_type == "group" and scope_id: + return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id) + return effective_privacy_policy_sources(session, tenant_id=tenant_id) + + +def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return None + return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) + + +def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): + if scope_type == "system": + return effective_privacy_policy(session) + if scope_type == "tenant": + return effective_privacy_policy(session, tenant_id=scope_id or tenant_id) + if scope_type == "campaign" and scope_id: + return effective_privacy_policy(session, campaign_id=scope_id) + if scope_type == "user" and scope_id: + return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) + if scope_type == "group" and scope_id: + return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) + return effective_privacy_policy(session, tenant_id=tenant_id) + + +@router.post("/system/retention/run", response_model=RetentionRunResponse) +def run_retention_policy( + payload: RetentionRunRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + result = apply_retention_policy(session, dry_run=payload.dry_run) + audit_from_principal( + session, + principal, + action="retention_policy.run", + scope="system", + object_type="retention_policy", + object_id="global", + details={"dry_run": payload.dry_run, "counts": result.get("counts")}, + ) + session.commit() + return RetentionRunResponse(result=result) diff --git a/src/govoplan_policy/backend/api/v1/schemas.py b/src/govoplan_policy/backend/api/v1/schemas.py new file mode 100644 index 0000000..30ff092 --- /dev/null +++ b/src/govoplan_policy/backend/api/v1/schemas.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +RETENTION_DAY_KEYS = ( + "raw_campaign_json_retention_days", + "generated_eml_retention_days", + "stored_report_detail_retention_days", + "mock_mailbox_retention_days", + "audit_detail_retention_days", +) + + +RETENTION_POLICY_FIELD_KEYS = ( + "store_raw_campaign_json", + *RETENTION_DAY_KEYS, + "audit_detail_level", +) + + +def default_allow_lower_level_limits() -> dict[str, bool]: + return {key: True for key in RETENTION_POLICY_FIELD_KEYS} + + +def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None: + if value in (None, ""): + return default_allow_lower_level_limits() if fill_defaults else None + if not isinstance(value, dict): + raise ValueError("allow_lower_level_limits must be an object") + normalized = default_allow_lower_level_limits() if fill_defaults else {} + for key, allowed in value.items(): + clean_key = str(key) + if clean_key not in RETENTION_POLICY_FIELD_KEYS: + raise ValueError(f"Unknown retention policy field: {clean_key}") + normalized[clean_key] = bool(allowed) + return normalized + + +class PolicySourceStepItem(BaseModel): + scope_type: str + scope_id: str | None = None + label: str + applied_fields: list[str] = Field(default_factory=list) + policy: dict[str, Any] = Field(default_factory=dict) + + +class PrivacyRetentionPolicyItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + store_raw_campaign_json: bool = True + raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) + generated_eml_retention_days: int | None = Field(default=None, ge=0) + stored_report_detail_retention_days: int | None = Field(default=None, ge=0) + mock_mailbox_retention_days: int | None = Field(default=None, ge=0) + audit_detail_retention_days: int | None = Field(default=None, ge=0) + audit_detail_level: Literal["full", "redacted", "minimal"] = "full" + allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits) + + @field_validator("allow_lower_level_limits", mode="before") + @classmethod + def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: + return normalize_allow_lower_level_limits(value, fill_defaults=True) + + +class PrivacyRetentionPolicyPatchItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + store_raw_campaign_json: bool | None = None + raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) + generated_eml_retention_days: int | None = Field(default=None, ge=0) + stored_report_detail_retention_days: int | None = Field(default=None, ge=0) + mock_mailbox_retention_days: int | None = Field(default=None, ge=0) + audit_detail_retention_days: int | None = Field(default=None, ge=0) + audit_detail_level: Literal["full", "redacted", "minimal"] | None = None + allow_lower_level_limits: dict[str, bool] | None = None + + @field_validator("allow_lower_level_limits", mode="before") + @classmethod + def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: + return normalize_allow_lower_level_limits(value, fill_defaults=False) + + +class PrivacyRetentionPolicyScopeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem) + + +class PrivacyRetentionPolicyScopeResponse(BaseModel): + scope_type: Literal["system", "tenant", "user", "group", "campaign"] + scope_id: str | None = None + policy: dict[str, Any] + effective_policy: PrivacyRetentionPolicyItem + parent_policy: PrivacyRetentionPolicyItem | None = None + effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list) + parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list) + + +class RetentionRunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + dry_run: bool = True + + +class RetentionRunResponse(BaseModel): + result: dict[str, Any] diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py new file mode 100644 index 0000000..5357155 --- /dev/null +++ b/src/govoplan_policy/backend/manifest.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from govoplan_core.core.modules import ModuleContext, ModuleManifest + + +def _route_factory(context: ModuleContext): + del context + from govoplan_policy.backend.api.v1.routes import router + + return router + + +manifest = ModuleManifest( + id="policy", + name="Policy", + version="0.1.5", + dependencies=("access",), + route_factory=_route_factory, +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_policy/py.typed b/src/govoplan_policy/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_policy/py.typed @@ -0,0 +1 @@ +