feat(helpdesk): configure ticket routing profiles
This commit is contained in:
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
+72
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user