Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11162d5f1 | ||
|
|
2be4a0c598 | ||
|
|
0ad2ef96b4 | ||
|
|
38e038b3a0 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/tickets-webui",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"description": "Canonical GovOPlaN operational ticket lifecycle module.",
|
||||
"type": "module",
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-tickets"
|
||||
version = "0.1.20"
|
||||
version = "0.1.23"
|
||||
description = "Canonical GovOPlaN operational ticket lifecycle module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.30",
|
||||
"govoplan-core>=0.1.46",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'tickets.module-boundary': {'consequence_classes': {'canonical_store': 'Tickets ist der einzige '
|
||||
'betriebsbereite '
|
||||
'Ticketshop; Helpdesk '
|
||||
'liefert Richtlinien und '
|
||||
'Warteschlangensemantik.',
|
||||
'case_boundary': 'Fall Eskalation schafft '
|
||||
'eine stabile auditierbare '
|
||||
'Verbindung und konvertiert '
|
||||
'oder kopiert nie '
|
||||
'Ticket-Historie.',
|
||||
'soft_delete': 'Die Löschung verbirgt die '
|
||||
'aktuelle Arbeit, bewahrt aber '
|
||||
'unveränderliche Nachweise der '
|
||||
'Rechenschaftspflicht.'}}}
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_tickets.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
@@ -61,7 +64,7 @@ from govoplan_tickets.backend.service import (
|
||||
|
||||
MODULE_ID = "tickets"
|
||||
MODULE_NAME = "Tickets"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
MODULE_VERSION = "0.1.23"
|
||||
WRITE_SCOPE = LEGACY_WRITE_SCOPE
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"cases",
|
||||
@@ -169,11 +172,43 @@ ROLE_TEMPLATES = (
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id="tickets.authorized-pagination",
|
||||
title="Authorized ticket pages and totals",
|
||||
summary="Ticket pages and their exact totals use the same current authorization filter.",
|
||||
body=(
|
||||
"Ticket lists apply tenant, deletion, search, queue, status, and current read-access filters in the database "
|
||||
"before counting or selecting a page. Elevated ticket scopes retain their existing access; other readers see "
|
||||
"tenant-visible tickets and tickets linked to their current subjects or actor identities. Hidden tickets do "
|
||||
"not enter totals or consume page slots. Pages contain at most 200 records and use service target, priority, "
|
||||
"updated time, then stable ticket ID ordering. Each request evaluates current access again; paging is not an "
|
||||
"immutable snapshot across concurrent edits. Exact JSON subject matching supports SQLite and PostgreSQL and "
|
||||
"does not coerce numeric or boolean identifiers; unsupported database dialects fail closed."
|
||||
),
|
||||
layer="always", documentation_types=("user", "admin"),
|
||||
audience=("user", "operator", "tenant_admin"),
|
||||
translations={"de": {
|
||||
"title": "Berechtigte Ticketseiten und Gesamtzahlen",
|
||||
"summary": "Ticketseiten und ihre exakten Gesamtzahlen verwenden denselben aktuellen Berechtigungsfilter.",
|
||||
"body": (
|
||||
"Ticketlisten wenden Mandanten-, Lösch-, Such-, Warteschlangen-, Status- und aktuelle Lesefilter in der "
|
||||
"Datenbank vor Zählung und Seitenauswahl an. Erweiterte Ticketrechte behalten ihren bisherigen Zugriff; "
|
||||
"andere Lesende sehen mandantenweit sichtbare Tickets und Tickets mit Bezug zu ihren aktuellen Subjekten "
|
||||
"oder Akteurskennungen. Verborgene Tickets zählen nicht mit und belegen keine Seitenplätze. Seiten enthalten "
|
||||
"höchstens 200 Datensätze, sortiert nach Serviceziel, Priorität, Änderungszeit und stabiler Ticketkennung. "
|
||||
"Jede Anfrage prüft den aktuellen Zugriff erneut; Seitenabrufe bilden bei parallelen Änderungen keinen "
|
||||
"unveränderlichen Snapshot. Exakte JSON-Subjektvergleiche unterstützen SQLite und PostgreSQL ohne Umwandlung "
|
||||
"numerischer oder boolescher Kennungen; nicht unterstützte Datenbankdialekte werden sicher abgelehnt."
|
||||
),
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="tickets.module-boundary",
|
||||
title="Tickets module boundary",
|
||||
summary="Operational requests, incidents, problems, reports, queue work, service targets, and auditable resolution.",
|
||||
body=(
|
||||
"Documentation books sit immediately beside the visible heading or contextual label for "
|
||||
"Tickets, not among operational action buttons. Field help remains beside its label. "
|
||||
"Tickets owns the operational service record and its lifecycle. Cases remains authoritative for formal procedures; "
|
||||
"Helpdesk may contribute routing and service-target policy without creating a second ticket store. Attachments and "
|
||||
"related work are typed references. Soft deletion removes a ticket from ordinary work and Search while retaining its immutable history."
|
||||
@@ -185,7 +220,10 @@ DOCUMENTATION = (
|
||||
"de": {
|
||||
"title": "Modulgrenze von Tickets",
|
||||
"summary": "Operative Anfragen, Störungen, Probleme, Meldungen, Warteschlangenarbeit, Serviceziele und nachvollziehbare Lösungen.",
|
||||
"body": "Tickets verantwortet den operativen Servicevorgang und seinen Lebenszyklus. Cases bleibt für förmliche Verfahren maßgeblich; Helpdesk kann Weiterleitung und Serviceziele beisteuern, ohne einen zweiten Ticketspeicher anzulegen. Anhänge und verbundene Arbeit sind typisierte Verweise. Eine weiche Löschung entfernt ein Ticket aus der normalen Arbeit und Suche, erhält aber die unveränderliche Historie.",
|
||||
"body": "Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||
"Kontextbezeichnung für Tickets, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
|
||||
"bleibt neben der Feldbezeichnung. "
|
||||
"Tickets verantwortet den operativen Servicevorgang und seinen Lebenszyklus. Cases bleibt für förmliche Verfahren maßgeblich; Helpdesk kann Weiterleitung und Serviceziele beisteuern, ohne einen zweiten Ticketspeicher anzulegen. Anhänge und verbundene Arbeit sind typisierte Verweise. Eine weiche Löschung entfernt ein Ticket aus der normalen Arbeit und Suche, erhält aber die unveränderliche Historie.",
|
||||
}
|
||||
},
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
@@ -382,5 +420,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -6,8 +6,10 @@ import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import false, func, or_, true
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.principal_helpers import principal_actor_ids as _principal_actor_ids
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
@@ -22,6 +24,7 @@ from govoplan_core.core.tickets import (
|
||||
ticket_routing_provider,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.db.json_predicates import json_array_contains_object_strings, json_object_matches_strings
|
||||
from govoplan_tickets.backend.db.models import (
|
||||
Ticket,
|
||||
TicketComment,
|
||||
@@ -163,14 +166,15 @@ def list_tickets(
|
||||
clean_query = query.strip().casefold()
|
||||
if clean_query:
|
||||
statement = statement.filter(Ticket.search_text.contains(clean_query))
|
||||
candidates = statement.order_by(
|
||||
statement = statement.filter(_ticket_read_predicate(principal))
|
||||
total = int(statement.with_entities(func.count()).scalar() or 0)
|
||||
selected = statement.order_by(
|
||||
Ticket.service_target_at.asc().nullslast(),
|
||||
Ticket.priority.desc(),
|
||||
Ticket.updated_at.desc(),
|
||||
).all()
|
||||
accessible = tuple(row for row in candidates if _can_read_row(principal, row))
|
||||
selected = accessible[offset : offset + limit]
|
||||
return tuple(_record(row) for row in selected), len(accessible)
|
||||
Ticket.id.asc(),
|
||||
).offset(offset).limit(limit).all()
|
||||
return tuple(_record(row) for row in selected), total
|
||||
|
||||
|
||||
def triage_ticket(
|
||||
@@ -926,6 +930,26 @@ def _required_ticket(session: Session, principal: object, *, ticket_id: str, loc
|
||||
return row
|
||||
|
||||
|
||||
def _ticket_read_predicate(principal: object):
|
||||
"""Owner policy shared by SQL count/page; never page before authorization."""
|
||||
if not _has_scope(principal, READ_SCOPE):
|
||||
return false()
|
||||
if _has_any_scope(principal, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE, LEGACY_WRITE_SCOPE):
|
||||
return true()
|
||||
conditions = [Ticket.visibility == "tenant"]
|
||||
actors = _principal_actor_ids(principal)
|
||||
if actors:
|
||||
conditions.append(Ticket.created_by.in_(actors))
|
||||
for kind, subject_id in _principal_subjects(principal):
|
||||
fields = {"kind": kind, "id": subject_id}
|
||||
conditions.extend(
|
||||
json_object_matches_strings(column, fields)
|
||||
for column in (Ticket.assignee, Ticket.reporter, Ticket.requester)
|
||||
)
|
||||
conditions.append(json_array_contains_object_strings(Ticket.participants, fields))
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def _can_read_row(principal: object, row: Ticket) -> bool:
|
||||
if row.tenant_id != _principal_tenant(principal) or not _has_scope(principal, READ_SCOPE):
|
||||
return False
|
||||
@@ -1020,22 +1044,6 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def _principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||
user = getattr(principal, "user", None)
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
str(value)
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _principal_actor(principal: object) -> str | None:
|
||||
values = _principal_actor_ids(principal)
|
||||
return values[0] if values else None
|
||||
|
||||
@@ -25,7 +25,7 @@ class ManifestTests(unittest.TestCase):
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual("tickets", manifest.id)
|
||||
self.assertEqual("0.1.20", manifest.version)
|
||||
self.assertEqual("0.1.22", manifest.version)
|
||||
self.assertEqual(("access",), manifest.dependencies)
|
||||
self.assertEqual(
|
||||
{
|
||||
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
import test_ticket_service as fixture
|
||||
from govoplan_tickets.backend import service
|
||||
from govoplan_tickets.backend.db.models import Ticket
|
||||
|
||||
|
||||
class TicketListQueryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = fixture.TicketServiceTests()
|
||||
self.fixture.setUp()
|
||||
self.engine = self.fixture.Session.kw["bind"]
|
||||
with self.fixture.Session() as session:
|
||||
for index in range(50):
|
||||
service.create_ticket(
|
||||
session, self.fixture.manager,
|
||||
record=replace(fixture._record(reporter="reporter-1"), ticket_id=f"ticket-{index:03}", ticket_number=f"TKT-{index:03}"),
|
||||
idempotency_key=f"fixture-{index}",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def test_one_row_page_loads_one_row_with_exact_total_and_stable_ties(self):
|
||||
with self.fixture.Session() as session:
|
||||
session.query(Ticket).update({Ticket.updated_at: fixture.NOW})
|
||||
session.commit()
|
||||
loaded = []
|
||||
queries = []
|
||||
|
||||
def row_loaded(target, context):
|
||||
loaded.append(target.id)
|
||||
|
||||
def executed(conn, cursor, statement, parameters, context, executemany):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
queries.append(statement)
|
||||
|
||||
event.listen(Ticket, "load", row_loaded)
|
||||
event.listen(self.engine, "before_cursor_execute", executed)
|
||||
try:
|
||||
with self.fixture.Session() as session:
|
||||
page, total = service.list_tickets(session, self.fixture.manager, offset=1, limit=1)
|
||||
self.assertEqual(50, total)
|
||||
self.assertEqual(["ticket-001"], [item.ticket_id for item in page])
|
||||
self.assertEqual(["ticket-001"], loaded)
|
||||
self.assertEqual(2, len(queries))
|
||||
self.assertIn("LIMIT", queries[-1])
|
||||
finally:
|
||||
event.remove(Ticket, "load", row_loaded)
|
||||
event.remove(self.engine, "before_cursor_execute", executed)
|
||||
|
||||
def test_count_and_page_match_current_object_authorization(self):
|
||||
viewer = fixture._Principal("viewer-1", {service.READ_SCOPE})
|
||||
viewer.group_ids = ("group-1",)
|
||||
with self.fixture.Session() as session:
|
||||
rows = session.query(Ticket).order_by(Ticket.id).all()
|
||||
rows[0].visibility = "tenant"
|
||||
rows[1].assignee = {"kind": "account", "id": viewer.account_id}
|
||||
rows[2].reporter = {"kind": "account", "id": viewer.account_id}
|
||||
rows[3].requester = {"kind": "account", "id": viewer.account_id}
|
||||
rows[4].participants = [{"kind": "group", "id": "group-1"}]
|
||||
rows[5].created_by = viewer.account_id
|
||||
rows[6].participants = ['{"kind":"account","id":"viewer-1"}']
|
||||
rows[7].tenant_id = "tenant-2"
|
||||
rows[7].visibility = "tenant"
|
||||
session.commit()
|
||||
expected = {row.id for row in rows if service._can_read_row(viewer, row)}
|
||||
actual = set()
|
||||
for offset in range(len(expected)):
|
||||
page, total = service.list_tickets(session, viewer, offset=offset, limit=1)
|
||||
self.assertEqual(len(expected), total)
|
||||
self.assertEqual(1, len(page))
|
||||
actual.add(page[0].ticket_id)
|
||||
self.assertEqual(expected, actual)
|
||||
self.assertEqual(6, len(actual))
|
||||
rows[4].participants = []
|
||||
session.commit()
|
||||
self.assertEqual(5, service.list_tickets(session, viewer, limit=1)[1])
|
||||
viewer.scopes = frozenset()
|
||||
self.assertEqual(((), 0), service.list_tickets(session, viewer, limit=1))
|
||||
|
||||
def test_read_predicate_compiles_for_postgresql_with_bound_subjects(self):
|
||||
viewer = fixture._Principal("viewer' OR 1=1 --", {service.READ_SCOPE})
|
||||
compiled = select(Ticket.id).where(service._ticket_read_predicate(viewer)).compile(dialect=postgresql.dialect())
|
||||
self.assertIn("json_array_elements", str(compiled))
|
||||
self.assertNotIn(viewer.account_id, str(compiled))
|
||||
self.assertIn(viewer.account_id, compiled.params.values())
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/tickets-webui",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -338,7 +338,9 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) {
|
||||
</label>
|
||||
<span className="tickets-count">{total} tickets</span>
|
||||
</>}
|
||||
helpAction={<DocumentationHelpLink reference={{ topicId: "tickets.operational-workflow", documentationType: "user" }} label="Open Tickets documentation" />}
|
||||
title="Tickets"
|
||||
titleLevel={1}
|
||||
titleHelp={<DocumentationHelpLink reference={{ topicId: "tickets.operational-workflow", documentationType: "user" }} label="Open Tickets documentation" />}
|
||||
createAction={canReport ?
|
||||
<Button type="button" variant="primary" onClick={() => {
|
||||
setEditing(null);
|
||||
@@ -450,7 +452,7 @@ function TicketDetail({ record, availability, history, canTriage, canAssign, can
|
||||
<header className="ticket-detail-header">
|
||||
<div>
|
||||
<span className="ticket-eyebrow">{record.ticket_number} · {humanize(record.ticket_type)}</span>
|
||||
<h1>{record.title}</h1>
|
||||
<h2>{record.title}</h2>
|
||||
</div>
|
||||
<div className="ticket-detail-badges">
|
||||
<StatusBadge status={priorityTone(record.priority)} label={humanize(record.priority)} />
|
||||
@@ -514,7 +516,7 @@ function TicketDetail({ record, availability, history, canTriage, canAssign, can
|
||||
<Button type="submit" disabled={saving || !comment.trim()}>Add comment</Button>
|
||||
</form>
|
||||
</section>}
|
||||
{canAdmin && <section className="ticket-destructive-actions" aria-label="Destructive ticket actions"><h2>Administrative removal</h2><p>Deletion removes the ticket from active queues and Search while retaining immutable evidence.</p><Button type="button" variant="danger" onClick={onDelete}><Trash2 size={16} /> Delete ticket</Button></section>}
|
||||
{canAdmin && <section className="ticket-destructive-actions" aria-label="Destructive ticket actions"><h2>Administrative removal</h2><p>Deletion removes the ticket from active queues and Search while retaining immutable evidence.</p><Button type="button" variant="danger" helpContextId="tickets.action.delete" helpModuleId="tickets" onClick={onDelete}><Trash2 size={16} /> Delete ticket</Button></section>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
/** Module-owned translations for contextual headings. */
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"Tickets": "Tickets",
|
||||
},
|
||||
de: {
|
||||
"Tickets": "Tickets",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/tickets.css";
|
||||
|
||||
|
||||
const TicketsPage = lazy(() => import("./features/tickets/TicketsPage"));
|
||||
|
||||
export const ticketsModule: PlatformWebModule = {
|
||||
translations: generatedTranslations,
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
version: "0.1.20",
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ticket-detail-header h1 {
|
||||
.ticket-detail-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0;
|
||||
|
||||
Reference in New Issue
Block a user