feat(helpdesk): configure ticket routing profiles

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent 064ec6158d
commit 32bd55d5bd
24 changed files with 1584 additions and 102 deletions
@@ -0,0 +1,3 @@
from govoplan_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
__all__ = ["HelpdeskProfileHistory", "HelpdeskServiceProfile"]
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class HelpdeskServiceProfile(Base, TimestampMixin):
__tablename__ = "helpdesk_service_profiles"
__table_args__ = (
UniqueConstraint("tenant_id", "profile_key", name="uq_helpdesk_service_profile_key"),
Index("ix_helpdesk_service_profile_routing", "tenant_id", "active", "sort_order"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
profile_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
queue_ref: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
ticket_types: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
priorities: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
target_minutes: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
default_target_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class HelpdeskProfileHistory(Base, TimestampMixin):
__tablename__ = "helpdesk_profile_history"
__table_args__ = (
UniqueConstraint("tenant_id", "profile_id", "revision", name="uq_helpdesk_profile_history_revision"),
UniqueConstraint("tenant_id", "idempotency_key", name="uq_helpdesk_profile_history_idempotency"),
Index("ix_helpdesk_profile_history_timeline", "tenant_id", "profile_id", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(ForeignKey("helpdesk_service_profiles.id", ondelete="RESTRICT"), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
__all__ = ["HelpdeskProfileHistory", "HelpdeskServiceProfile"]
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
TICKET_TYPES = frozenset({"request", "incident", "problem", "report"})
PRIORITIES = frozenset({"low", "normal", "high", "urgent"})
class HelpdeskDomainError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ServiceProfile:
tenant_id: str
profile_id: str
profile_key: str
revision: int
label: str
queue_ref: str
ticket_types: tuple[str, ...]
priorities: tuple[str, ...]
target_minutes: Mapping[str, int]
active: bool
sort_order: int
recorded_at: datetime
change_reason: str
description: str | None = None
default_target_minutes: int | None = None
def __post_init__(self) -> None:
for value, label, maximum in (
(self.tenant_id, "Helpdesk profile tenant", 255),
(self.profile_id, "Helpdesk profile identifier", 255),
(self.profile_key, "Helpdesk profile key", 120),
(self.label, "Helpdesk profile label", 255),
(self.queue_ref, "Helpdesk queue reference", 255),
(self.change_reason, "Helpdesk profile change reason", 1_000),
):
_required(value, label, maximum)
if self.revision < 1:
raise HelpdeskDomainError("Helpdesk profile revisions start at one.")
if self.description is not None and (not self.description.strip() or len(self.description) > 10_000):
raise HelpdeskDomainError("Helpdesk profile description is limited to 10,000 characters.")
types = tuple(dict.fromkeys(self.ticket_types))
priorities = tuple(dict.fromkeys(self.priorities))
if set(types) - TICKET_TYPES:
raise HelpdeskDomainError("Helpdesk profile contains unsupported ticket types.")
if set(priorities) - PRIORITIES:
raise HelpdeskDomainError("Helpdesk profile contains unsupported priorities.")
if not types:
raise HelpdeskDomainError("Helpdesk profiles require at least one ticket type.")
if not priorities:
raise HelpdeskDomainError("Helpdesk profiles require at least one priority.")
object.__setattr__(self, "ticket_types", types)
object.__setattr__(self, "priorities", priorities)
normalized_targets: dict[str, int] = {}
for priority, minutes in self.target_minutes.items():
if priority not in PRIORITIES or not 1 <= int(minutes) <= 525_600:
raise HelpdeskDomainError("Helpdesk target minutes must be between 1 and 525,600 for a supported priority.")
normalized_targets[priority] = int(minutes)
object.__setattr__(self, "target_minutes", normalized_targets)
if self.default_target_minutes is not None and not 1 <= self.default_target_minutes <= 525_600:
raise HelpdeskDomainError("Default Helpdesk target minutes must be between 1 and 525,600.")
if self.sort_order < 0 or self.sort_order > 100_000:
raise HelpdeskDomainError("Helpdesk profile sort order must be between 0 and 100,000.")
if self.recorded_at.tzinfo is None or self.recorded_at.utcoffset() is None:
raise HelpdeskDomainError("Helpdesk profile recorded_at must include a timezone.")
def matches(self, ticket_type: str, priority: str) -> bool:
return ticket_type in self.ticket_types and priority in self.priorities
def target_for(self, priority: str) -> int | None:
return self.target_minutes.get(priority, self.default_target_minutes)
def to_dict(self) -> dict[str, Any]:
return {
"tenant_id": self.tenant_id,
"profile_id": self.profile_id,
"profile_key": self.profile_key,
"revision": self.revision,
"label": self.label,
"description": self.description,
"queue_ref": self.queue_ref,
"ticket_types": list(self.ticket_types),
"priorities": list(self.priorities),
"target_minutes": dict(self.target_minutes),
"default_target_minutes": self.default_target_minutes,
"active": self.active,
"sort_order": self.sort_order,
"recorded_at": self.recorded_at.isoformat(),
"change_reason": self.change_reason,
}
@classmethod
def from_mapping(cls, value: Mapping[str, object]) -> "ServiceProfile":
target_minutes = value.get("target_minutes") or {}
if not isinstance(target_minutes, Mapping):
raise HelpdeskDomainError("Helpdesk target minutes must be an object.")
return cls(
tenant_id=_text(value.get("tenant_id")),
profile_id=_text(value.get("profile_id")),
profile_key=_text(value.get("profile_key")),
revision=int(value.get("revision") or 0),
label=_text(value.get("label")),
description=_optional(value.get("description")),
queue_ref=_text(value.get("queue_ref")),
ticket_types=_strings(value.get("ticket_types")),
priorities=_strings(value.get("priorities")),
target_minutes={str(key): int(item) for key, item in target_minutes.items()},
default_target_minutes=(int(value["default_target_minutes"]) if value.get("default_target_minutes") is not None else None),
active=bool(value.get("active", True)),
sort_order=int(value.get("sort_order") or 0),
recorded_at=_datetime(value.get("recorded_at")),
change_reason=_text(value.get("change_reason")),
)
def _required(value: str, label: str, maximum: int) -> None:
if not value.strip() or len(value) > maximum:
raise HelpdeskDomainError(f"{label} must contain 1 to {maximum} characters.")
def _text(value: object) -> str:
return str(value or "").strip()
def _optional(value: object) -> str | None:
return str(value).strip() if value is not None else None
def _strings(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
raise HelpdeskDomainError("Helpdesk profile selections must be lists.")
return tuple(str(item) for item in value)
def _datetime(value: object) -> datetime:
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError as exc:
raise HelpdeskDomainError("Helpdesk profile timestamp is invalid.") from exc
__all__ = ["HelpdeskDomainError", "ServiceProfile"]
@@ -0,0 +1,148 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
HELPDESK_DSAR_CAPABILITY = dsar_capability_name("helpdesk")
_CONFLICT = object()
class HelpdeskDsarProvider:
provider_id = "helpdesk"
module_id = "helpdesk"
def search_subject(self, session: object, *, tenant_id: str, subject: DsarSubjectRef) -> Sequence[DsarRecordRef]:
db = _session(session)
actor_ids = _actor_ids(subject)
if actor_ids is None:
return ()
rows = (
db.query(HelpdeskProfileHistory, HelpdeskServiceProfile)
.join(HelpdeskServiceProfile, HelpdeskProfileHistory.profile_id == HelpdeskServiceProfile.id)
.filter(
HelpdeskProfileHistory.tenant_id == tenant_id,
HelpdeskProfileHistory.actor_id.in_(actor_ids),
)
.order_by(HelpdeskProfileHistory.occurred_at.asc())
.limit(5_001)
.all()
)
if len(rows) > 5_000:
raise ValueError("Helpdesk DSAR attribution limit exceeded; narrow the selectors.")
return tuple(
DsarRecordRef(
provider_id="helpdesk",
module_id="helpdesk",
resource_type="service_profile_actor_attribution",
resource_id=history.id,
category="configuration_accountability_evidence",
title=f"Helpdesk profile lifecycle attribution: {profile.profile_key}",
data={
"activity": "configured_helpdesk_service_profile",
"profile_id": profile.id,
"profile_key": profile.profile_key,
"revision": history.revision,
"occurred_at": _iso(history.occurred_at),
},
observed_at=_aware(history.occurred_at),
immutable_evidence=True,
retention_reason="Helpdesk service-profile change attribution is immutable configuration accountability evidence.",
)
for history, profile in rows
)
def plan_erasure(self, session: object, *, tenant_id: str, subject: DsarSubjectRef, records: Sequence[DsarRecordRef]) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _actor_ids(subject) is None:
raise ValueError("Helpdesk DSAR subject selectors conflict.")
return tuple(
DsarErasureActionRef(
action_id=f"helpdesk:retain:{record.resource_id}",
provider_id="helpdesk",
module_id="helpdesk",
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason or "Configuration attribution is immutable evidence.",
executable=False,
)
for record in records
if _valid_record(record)
)
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 _actor_ids(subject) is None:
raise ValueError("Helpdesk DSAR subject selectors conflict.")
results = []
for action in actions:
if action.provider_id != "helpdesk" or action.module_id != "helpdesk" or action.kind != "retain" or action.executable:
raise ValueError("Helpdesk DSAR publishes retain-only actions.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Helpdesk configuration attribution remains immutable evidence.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _actor_ids(subject: DsarSubjectRef) -> tuple[str, ...] | None:
refs = subject.external_references
values = (
_coalesce(subject.account_id, refs.get("helpdesk.account"), refs.get("access.account")),
_coalesce(subject.identity_id, refs.get("helpdesk.identity"), refs.get("identity.id")),
_coalesce(subject.membership_id, refs.get("helpdesk.membership"), refs.get("tenancy.membership")),
)
if any(value is _CONFLICT for value in values):
return None
result = tuple(dict.fromkeys(value for value in values if isinstance(value, str) and value))
return result or None
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
return _CONFLICT if len(normalized) > 1 else next(iter(normalized), None)
def _valid_record(record: DsarRecordRef) -> bool:
if record.provider_id != "helpdesk" or record.module_id != "helpdesk" or record.resource_type != "service_profile_actor_attribution":
raise ValueError("Helpdesk DSAR record identity is invalid.")
return True
def _aware(value: datetime | None) -> datetime | None:
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _iso(value: datetime | None) -> str | None:
result = _aware(value)
return result.isoformat() if result else None
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Helpdesk DSAR requires a SQLAlchemy Session.")
return value
__all__ = ["HELPDESK_DSAR_CAPABILITY", "HelpdeskDsarProvider"]
+151 -54
View File
@@ -1,12 +1,37 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tickets import CAPABILITY_TICKET_ROUTING
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_helpdesk.backend.db import models as helpdesk_models
from govoplan_helpdesk.backend.dsar_provider import HELPDESK_DSAR_CAPABILITY, HelpdeskDsarProvider
from govoplan_helpdesk.backend.service import SqlHelpdeskRoutingProvider
MODULE_ID = "helpdesk"
MODULE_NAME = "Helpdesk"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.20"
READ_SCOPE = "helpdesk:workspace:read"
WRITE_SCOPE = "helpdesk:workspace:write"
ADMIN_SCOPE = "helpdesk:workspace:admin"
@@ -24,48 +49,44 @@ OPTIONAL_DEPENDENCIES = (
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Helpdesk",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
return PermissionDefinition(scope=scope, label=label, description=description, category=MODULE_NAME, level="tenant", module_id=module_id, resource=resource, action=action)
def _router(context: ModuleContext):
from govoplan_helpdesk.backend.router import create_router
return create_router(context.registry)
def _routing(_context: ModuleContext) -> SqlHelpdeskRoutingProvider:
return SqlHelpdeskRoutingProvider()
def _dsar(_context: ModuleContext) -> HelpdeskDsarProvider:
return HelpdeskDsarProvider()
PERMISSIONS = (
_permission(READ_SCOPE, "View helpdesk workspace", "Read helpdesk records, configuration, and workflow context."),
_permission(WRITE_SCOPE, "Manage helpdesk workspace", "Create and update helpdesk records and workflow state."),
_permission(ADMIN_SCOPE, "Administer helpdesk workspace", "Configure helpdesk policies, templates, and tenant-level administration."),
_permission(READ_SCOPE, "View Helpdesk workspace", "Read configured Helpdesk service, queue, and target profiles."),
_permission(WRITE_SCOPE, "Manage Helpdesk workspace", "Participate in Helpdesk-governed ticket work without configuring policy."),
_permission(ADMIN_SCOPE, "Administer Helpdesk workspace", "Configure routing, queue, priority, and service-target profiles for canonical Tickets."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="helpdesk_manager",
name="Helpdesk manager",
description="Manage helpdesk records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="helpdesk_viewer",
name="Helpdesk viewer",
description="Read helpdesk records and workflow context.",
permissions=(READ_SCOPE,),
),
RoleTemplate(slug="helpdesk_manager", name="Helpdesk manager", description="Manage canonical Tickets under configured Helpdesk queue and service policies.", permissions=(READ_SCOPE, WRITE_SCOPE)),
RoleTemplate(slug="helpdesk_viewer", name="Helpdesk viewer", description="Read Helpdesk service-profile and routing context.", permissions=(READ_SCOPE,)),
RoleTemplate(slug="helpdesk_administrator", name="Helpdesk administrator", description="Configure Helpdesk routing and service-target profiles.", permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)),
)
DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Internal service desk workflows for IT, facilities, HR, finance, procurement, access requests, assignment, escalation, and resolution tracking.",
id="helpdesk.module-boundary",
title="Helpdesk module boundary",
summary="Service profiles, queue routing, and service targets over the canonical Tickets lifecycle.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
"Helpdesk owns service-desk policy: profile matching, queue semantics, routing explanation, service targets, and future escalation clocks. "
"Tickets owns every operational request, incident, problem, report, assignment, comment, resolution, and immutable ticket history. "
"Helpdesk never creates a parallel ticket store. Disabling Helpdesk leaves Tickets usable with manual queue and target selection."
),
layer="available",
documentation_types=("admin", "user"),
@@ -73,34 +94,68 @@ DOCUMENTATION = (
translations={
"de": {
"title": "Modulgrenze von Helpdesk",
"summary": "Interne Servicedesk-Abläufe für IT, Gebäudemanagement, Personal, Finanzen, Beschaffung, Zugriffsanfragen, Zuordnung, Eskalation und Lösungsnachverfolgung.",
"body": "Dieses Repository ist derzeit ein Grundgerüst für ein Plattformmodul. Es registriert die Fachgrenze, Berechtigungsoberfläche, Rollenvorlagen und Dokumentationsmetadaten, bevor Laufzeit-APIs, Datenbankmodelle, Migrationen und WebUI-Routen eingeführt werden.",
"summary": "Serviceprofile, Warteschlangensteuerung und Serviceziele über dem maßgeblichen Tickets-Lebenszyklus.",
"body": "Helpdesk verantwortet Servicedesk-Regeln: Profilabgleich, Warteschlangenbedeutung, Weiterleitungsbegründung, Serviceziele und künftig Eskalationsuhren. Tickets verantwortet jede operative Anfrage, Störung, jedes Problem und jede Meldung sowie Zuweisung, Kommentar, Lösung und unveränderliche Historie. Helpdesk legt keinen parallelen Ticketspeicher an. Wird Helpdesk deaktiviert, bleiben Tickets mit manueller Wahl von Warteschlange und Ziel nutzbar.",
}
},
order=100,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Repository domain boundary",
href="govoplan-helpdesk/docs/HELPDESK_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
links=(DocumentationLink(label="Repository domain boundary", href="govoplan-helpdesk/docs/HELPDESK_DOMAIN_BOUNDARY.md", kind="repository"),),
metadata={
"kind": "reference",
"seed": True,
"consequence_classes": {
"seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.",
"policy_overlay": "Applies queue and service-target policy without duplicating Ticket state.",
"reduced_installation": "Manual Ticket routing remains available when Helpdesk is disabled.",
},
"domain_objects": ['internal support tickets', 'service categories', 'assignment and escalation state', 'resolution records', 'SLA-facing timestamps'],
"first_slice": (
"Define Helpdesk queue, SLA, and escalation profiles over the "
"canonical Tickets model without introducing duplicate ticket storage."
),
},
),
DocumentationTopic(
id="helpdesk.configure-routing-profiles",
title="Configure Helpdesk routing and service-target profiles",
summary="Match bounded Ticket types and priorities to one queue and a priority-specific target.",
body=(
"Each active tenant profile has a stable key, ordered precedence, supported Ticket types and priorities, a queue reference, and optional target minutes by priority or default. "
"At Ticket intake Helpdesk evaluates active profiles in order. An explicit profile key or queue hint narrows the match. The selected profile revision, queue, target duration, and explanation are recorded with the Ticket. "
"If no profile matches, intake succeeds without an automatic target and authorized Ticket staff choose queue and target manually. Profile edits are revision-guarded, replay-safe, and append immutable configuration history."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("helpdesk_administrator", "operator", "auditor"),
conditions=(DocumentationCondition(required_scopes=(ADMIN_SCOPE,)),),
translations={
"de": {
"title": "Helpdesk-Profile für Weiterleitung und Serviceziele konfigurieren",
"summary": "Begrenzte Ticketarten und Prioritäten einer Warteschlange und einem prioritätsabhängigen Ziel zuordnen.",
"body": "Jedes aktive Mandantenprofil besitzt einen stabilen Schlüssel, eine Reihenfolge, unterstützte Ticketarten und Prioritäten, einen Warteschlangenverweis und optionale Zielminuten je Priorität oder als Standard. Bei der Ticketaufnahme wertet Helpdesk aktive Profile der Reihe nach aus. Ein ausdrücklicher Profilschlüssel oder Warteschlangenhinweis grenzt die Auswahl ein. Profilrevision, Warteschlange, Zieldauer und Begründung werden am Ticket festgehalten. Trifft kein Profil zu, gelingt die Aufnahme ohne automatisches Ziel; berechtigte Ticket-Bearbeitende wählen Warteschlange und Ziel manuell. Profiländerungen sind revisionsgeschützt, wiederholungssicher und erzeugen unveränderliche Konfigurationshistorie.",
}
},
related_modules=("tickets",),
metadata={"kind": "workflow", "help_contexts": ["helpdesk.route.profiles", "helpdesk.action.save-profile"]},
),
DocumentationTopic(
id="helpdesk.data-subject-requests",
title="Helpdesk data-subject requests",
summary="Export minimized service-profile change attribution while keeping policy values outside subject data.",
body=(
"Helpdesk stores tenant configuration and immutable profile-change attribution, not Ticket reporters, requesters, participants, comments, or resolutions. "
"The provider matches exact account, identity, and membership actor identifiers and exports only profile identity, revision, action, and time. Rule values, request hashes, and idempotency keys are excluded. "
"Change attribution is retained as configuration accountability evidence; Ticket subject data remains covered by Tickets."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("privacy_officer", "auditor", "tenant_admin", "user"),
conditions=(DocumentationCondition(any_scopes=(READ_SCOPE, ADMIN_SCOPE)),),
translations={
"de": {
"title": "Datenschutzanfragen für Helpdesk",
"summary": "Minimierte Zuordnung von Serviceprofil-Änderungen exportieren, ohne Regelwerte als Betroffenendaten zu behandeln.",
"body": "Helpdesk speichert Mandantenkonfiguration und unveränderliche Zuordnung von Profiländerungen, aber keine meldenden, anfragenden oder beteiligten Personen, Kommentare oder Lösungen aus Tickets. Der Anbieter gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen ab und exportiert nur Profilidentität, Revision, Aktion und Zeitpunkt. Regelwerte, Anfrage-Hashes und Idempotenzschlüssel werden ausgeschlossen. Die Änderungszuordnung bleibt als Rechenschaftsnachweis erhalten; Ticket-Betroffenendaten deckt Tickets ab.",
}
},
metadata={"kind": "workflow", "help_contexts": ["helpdesk.admin.dsar"]},
),
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -108,17 +163,59 @@ manifest = ModuleManifest(
dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_TICKET_ROUTING, version="1.0.0"),
ModuleInterfaceProvider(name=HELPDESK_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(NavItem(path="/helpdesk", label="Helpdesk profiles", icon="headset", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.navigation"),),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/helpdesk-webui",
routes=(FrontendRoute(path="/helpdesk", component="HelpdeskProfilesPage", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.route.profiles"),),
nav_items=(NavItem(path="/helpdesk", label="Helpdesk profiles", icon="headset", required_any=(ADMIN_SCOPE,), order=82, surface_id="helpdesk.navigation"),),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("helpdesk.route.profiles",),
order=10,
),
),
view_surfaces=(
ViewSurface(id="helpdesk.action.save-profile", module_id=MODULE_ID, kind="action", label="Save Helpdesk service profile", parent_id="helpdesk.route.profiles", order=30),
),
),
capability_factories={CAPABILITY_TICKET_ROUTING: _routing, HELPDESK_DSAR_CAPABILITY: _dsar},
capability_documentation={
CAPABILITY_TICKET_ROUTING: CapabilityDocumentation(label="Helpdesk Ticket routing", summary="Matches tenant service profiles and returns only applied queue, service target, and explanation facts.", contract_version="1.0.0"),
HELPDESK_DSAR_CAPABILITY: CapabilityDocumentation(label="Helpdesk data-subject request provider", summary="Exports minimized immutable service-profile change attribution.", contract_version="0.1.0"),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(helpdesk_models.HelpdeskProfileHistory, helpdesk_models.HelpdeskServiceProfile, label="Helpdesk"),
retirement_notes="Destructive retirement removes Helpdesk policy profiles and their configuration history after a database snapshot.",
),
uninstall_guard_providers=(persistent_table_uninstall_guard(helpdesk_models.HelpdeskServiceProfile, helpdesk_models.HelpdeskProfileHistory, label="Helpdesk"),),
documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="scaffold",
maturity="vertical_slice",
documentation_ref="docs/HELPDESK_DOMAIN_BOUNDARY.md",
known_limits=("Helpdesk intake, queue, SLA, and resolution lifecycles are not implemented yet.",),
owned_concepts=("helpdesk request", "service queue", "resolution"),
non_owned_concepts=("case", "project work item", "mailbox message"),
test_ref="tests/test_helpdesk_routing.py",
known_limits=("Business calendars and pause/resume escalation clocks remain a later Helpdesk policy slice.",),
supported_authority_modes=("native_authoritative", "governance_overlay"),
owned_concepts=("helpdesk service profile", "service queue policy", "service target policy"),
non_owned_concepts=("ticket", "case", "project work item", "mailbox message"),
),
)
@@ -0,0 +1 @@
"""Helpdesk-owned database migrations."""
@@ -0,0 +1,72 @@
"""v0.1.20 Helpdesk routing and service-target profiles.
Revision ID: 9e2a5c8f1b4d
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "9e2a5c8f1b4d"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"helpdesk_service_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("profile_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("queue_ref", sa.String(length=255), nullable=False),
sa.Column("ticket_types", sa.JSON(), nullable=False),
sa.Column("priorities", sa.JSON(), nullable=False),
sa.Column("target_minutes", sa.JSON(), nullable=False),
sa.Column("default_target_minutes", sa.Integer(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_helpdesk_service_profiles")),
sa.UniqueConstraint("tenant_id", "profile_key", name="uq_helpdesk_service_profile_key"),
)
for column in ("tenant_id", "profile_key", "queue_ref", "active", "sort_order", "created_by", "updated_by"):
op.create_index(op.f(f"ix_helpdesk_service_profiles_{column}"), "helpdesk_service_profiles", [column], unique=False)
op.create_index("ix_helpdesk_service_profile_routing", "helpdesk_service_profiles", ["tenant_id", "active", "sort_order"], unique=False)
op.create_table(
"helpdesk_profile_history",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["helpdesk_service_profiles.id"], name=op.f("fk_helpdesk_profile_history_profile_id_helpdesk_service_profiles"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_helpdesk_profile_history")),
sa.UniqueConstraint("tenant_id", "profile_id", "revision", name="uq_helpdesk_profile_history_revision"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_helpdesk_profile_history_idempotency"),
)
for column in ("tenant_id", "profile_id", "occurred_at", "actor_id"):
op.create_index(op.f(f"ix_helpdesk_profile_history_{column}"), "helpdesk_profile_history", [column], unique=False)
op.create_index("ix_helpdesk_profile_history_timeline", "helpdesk_profile_history", ["tenant_id", "profile_id", "occurred_at"], unique=False)
def downgrade() -> None:
op.drop_table("helpdesk_profile_history")
op.drop_table("helpdesk_service_profiles")
@@ -0,0 +1 @@
"""Helpdesk migration revisions."""
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_helpdesk.backend.domain import HelpdeskDomainError, ServiceProfile
from govoplan_helpdesk.backend.schemas import ServiceProfileListResponse, ServiceProfileWriteRequest
from govoplan_helpdesk.backend.service import (
ADMIN_SCOPE,
HelpdeskConflictError,
HelpdeskStoreError,
list_service_profiles,
upsert_service_profile,
)
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/helpdesk", tags=["helpdesk"])
@router.get("/service-profiles", response_model=ServiceProfileListResponse)
def api_list_profiles(
include_inactive: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ServiceProfileListResponse:
_require(principal)
items = list_service_profiles(session, principal, include_inactive=include_inactive)
return ServiceProfileListResponse(profiles=[item.to_dict() for item in items])
@router.put("/service-profiles/{profile_id}", response_model=dict)
def api_write_profile(
profile_id: str,
payload: ServiceProfileWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict:
_require(principal)
if str(payload.profile.get("profile_id") or "") != profile_id:
raise HTTPException(status_code=400, detail="Helpdesk profile path and payload identifiers differ")
try:
item = upsert_service_profile(
session,
principal,
profile=ServiceProfile.from_mapping(payload.profile),
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
registry=registry,
)
session.commit()
except (HelpdeskStoreError, HelpdeskDomainError, IntegrityError) as exc:
session.rollback()
code = 409 if isinstance(exc, (HelpdeskConflictError, IntegrityError)) or "conflict" in str(exc).casefold() else 400
raise HTTPException(status_code=code, detail=str(exc)) from exc
return item.to_dict()
return router
def _require(principal: ApiPrincipal) -> None:
if not has_scope(principal, ADMIN_SCOPE):
raise HTTPException(status_code=403, detail=f"Missing scope: {ADMIN_SCOPE}")
__all__ = ["create_router"]
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ServiceProfileWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
profile: dict[str, Any]
expected_revision: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=255)
class ServiceProfileListResponse(BaseModel):
profiles: list[dict[str, Any]]
__all__ = ["ServiceProfileListResponse", "ServiceProfileWriteRequest"]
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
import hashlib
import json
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.core.tickets import TicketRoutingPlan, TicketRoutingRequest
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_helpdesk.backend.db.models import HelpdeskProfileHistory, HelpdeskServiceProfile
from govoplan_helpdesk.backend.domain import ServiceProfile
class HelpdeskStoreError(ValueError):
pass
class HelpdeskConflictError(HelpdeskStoreError):
pass
ADMIN_SCOPE = "helpdesk:workspace:admin"
def upsert_service_profile(
session: Session,
principal: object,
*,
profile: ServiceProfile,
expected_revision: int | None,
idempotency_key: str,
registry: object | None = None,
) -> ServiceProfile:
if not _has_scope(principal, ADMIN_SCOPE):
raise PermissionError("Helpdesk profile changes require administration access.")
tenant_id = _tenant(principal)
if profile.tenant_id != tenant_id:
raise HelpdeskStoreError("Helpdesk profiles cannot cross tenants.")
clean_key = _bounded(idempotency_key, "Helpdesk idempotency key", 255)
digest = _digest({"profile": profile.to_dict(), "expected_revision": expected_revision})
replay = _replay(session, tenant_id, clean_key, digest)
if replay is not None:
return ServiceProfile.from_mapping(replay.snapshot)
row = (
session.query(HelpdeskServiceProfile)
.filter(
HelpdeskServiceProfile.tenant_id == tenant_id,
HelpdeskServiceProfile.id == profile.profile_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
if expected_revision is not None or profile.revision != 1:
raise HelpdeskConflictError("A new Helpdesk profile must start at revision 1 without an expected revision.")
duplicate = session.query(HelpdeskServiceProfile.id).filter(
HelpdeskServiceProfile.tenant_id == tenant_id,
HelpdeskServiceProfile.profile_key == profile.profile_key,
).first()
if duplicate is not None:
raise HelpdeskConflictError("A Helpdesk profile with this key already exists.")
persisted = profile
row = HelpdeskServiceProfile(
id=profile.profile_id,
tenant_id=tenant_id,
profile_key=profile.profile_key,
created_by=_actor(principal),
)
operation = "created"
else:
if expected_revision != row.revision or profile.revision != row.revision + 1:
raise HelpdeskConflictError("Helpdesk profile revision conflict: the expected revision is stale.")
if profile.profile_key != row.profile_key:
raise HelpdeskStoreError("Helpdesk profile keys are immutable.")
persisted = profile
operation = "updated"
_write(row, persisted)
row.updated_by = _actor(principal)
session.add(row)
session.flush()
session.add(
HelpdeskProfileHistory(
tenant_id=tenant_id,
profile_id=row.id,
revision=persisted.revision,
occurred_at=persisted.recorded_at,
actor_id=row.updated_by,
reason=persisted.change_reason,
idempotency_key=clean_key,
request_sha256=digest,
snapshot=persisted.to_dict(),
)
)
emit_platform_event(
session,
PlatformEvent(
type=f"helpdesk.service_profile.{operation}",
module_id="helpdesk",
payload={"profile_id": row.id, "profile_key": row.profile_key, "revision": row.revision, "active": row.active},
occurred_at=persisted.recorded_at,
actor=EventActorRef(type="account", id=row.updated_by),
tenant=EventTenantRef(id=tenant_id),
resource=EventObjectRef(type="helpdesk_service_profile", id=row.id, label=row.label),
classification="internal",
),
registry=registry,
)
session.flush()
return persisted
def list_service_profiles(
session: Session,
principal: object,
*,
include_inactive: bool = True,
) -> tuple[ServiceProfile, ...]:
query = session.query(HelpdeskServiceProfile).filter(HelpdeskServiceProfile.tenant_id == _tenant(principal))
if not include_inactive:
query = query.filter(HelpdeskServiceProfile.active.is_(True))
rows = query.order_by(HelpdeskServiceProfile.sort_order.asc(), HelpdeskServiceProfile.label.asc()).all()
return tuple(_profile(row) for row in rows)
class SqlHelpdeskRoutingProvider:
"""Apply Helpdesk-owned routing and service-target policy to a Ticket intake."""
def route_ticket(
self,
session: object,
principal: object,
*,
request: TicketRoutingRequest,
) -> TicketRoutingPlan:
db = _session(session)
if request.tenant_id != _tenant(principal):
raise HelpdeskStoreError("Helpdesk routing cannot cross tenants.")
profiles = list_service_profiles(db, principal, include_inactive=False)
requested_key = str(request.attributes.get("helpdesk_profile_key") or "").strip()
candidates = tuple(
profile
for profile in profiles
if profile.matches(request.ticket_type, request.priority)
and (not requested_key or profile.profile_key == requested_key)
and (not request.queue_hint or profile.queue_ref == request.queue_hint)
)
selected = candidates[0] if candidates else None
if selected is None:
explanation = (
"No active Helpdesk profile matched; the submitted queue hint is retained."
if request.queue_hint
else "No active Helpdesk profile matched; manual queue and target selection remains available."
)
return TicketRoutingPlan(
provider_id="helpdesk",
queue_ref=request.queue_hint,
explanation=explanation,
metadata={"matched": False},
)
minutes = selected.target_for(request.priority)
return TicketRoutingPlan(
provider_id="helpdesk",
queue_ref=selected.queue_ref,
service_target_at=(request.received_at + timedelta(minutes=minutes) if minutes is not None else None),
explanation=f"Matched Helpdesk service profile {selected.label}.",
metadata={
"matched": True,
"profile_id": selected.profile_id,
"profile_key": selected.profile_key,
"profile_revision": selected.revision,
"target_minutes": minutes,
},
)
def _write(row: HelpdeskServiceProfile, profile: ServiceProfile) -> None:
row.revision = profile.revision
row.label = profile.label
row.description = profile.description
row.queue_ref = profile.queue_ref
row.ticket_types = list(profile.ticket_types)
row.priorities = list(profile.priorities)
row.target_minutes = dict(profile.target_minutes)
row.default_target_minutes = profile.default_target_minutes
row.active = profile.active
row.sort_order = profile.sort_order
def _profile(row: HelpdeskServiceProfile) -> ServiceProfile:
occurred = row.updated_at or row.created_at
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
return ServiceProfile(
tenant_id=row.tenant_id,
profile_id=row.id,
profile_key=row.profile_key,
revision=row.revision,
label=row.label,
description=row.description,
queue_ref=row.queue_ref,
ticket_types=tuple(row.ticket_types or ()),
priorities=tuple(row.priorities or ()),
target_minutes={str(key): int(value) for key, value in (row.target_minutes or {}).items()},
default_target_minutes=row.default_target_minutes,
active=row.active,
sort_order=row.sort_order,
recorded_at=occurred,
change_reason="Loaded current Helpdesk service profile.",
)
def _replay(session: Session, tenant_id: str, key: str, digest: str) -> HelpdeskProfileHistory | None:
row = session.query(HelpdeskProfileHistory).filter(
HelpdeskProfileHistory.tenant_id == tenant_id,
HelpdeskProfileHistory.idempotency_key == key,
).one_or_none()
if row is not None and row.request_sha256 != digest:
raise HelpdeskConflictError("Helpdesk idempotency key was reused for another request.")
return row
def _tenant(principal: object) -> str:
value = str(getattr(principal, "tenant_id", "") or "").strip()
if not value:
raise HelpdeskStoreError("Helpdesk operations require a tenant-bound principal.")
return value
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scopes_grant_compatible(
frozenset(getattr(principal, "scopes", ()) or ()),
scope,
)
def _actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
for value in (getattr(principal, "account_id", None), getattr(principal, "identity_id", None), getattr(principal, "membership_id", None), getattr(user, "id", None)):
if str(value or "").strip():
return str(value)
return None
def _bounded(value: object, label: str, maximum: int) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > maximum:
raise HelpdeskStoreError(f"{label} must contain 1 to {maximum} characters.")
return clean
def _digest(value: object) -> str:
encoded = json.dumps(_json(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _json(value: object) -> Any:
if isinstance(value, datetime):
return value.astimezone(UTC).isoformat()
if isinstance(value, Mapping):
return {str(key): _json(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json(item) for item in value]
return value
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Helpdesk requires a SQLAlchemy session.")
return value
__all__ = [
"HelpdeskConflictError",
"HelpdeskStoreError",
"SqlHelpdeskRoutingProvider",
"list_service_profiles",
"upsert_service_profile",
]