Skip to content

Commit 2f113da

Browse files
committed
add ticket context markdown and more exemples
1 parent 45e2b9d commit 2f113da

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

docs/user_guide.rst

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,147 @@ timeline list calls concurrently and returns a single
436436
print(len(bundle.followups), len(bundle.tasks))
437437
print(len(bundle.solutions), len(bundle.documents))
438438
439+
The context exposes :meth:`GlpiTicketContext.to_markdown` which renders
440+
the ticket header and every timeline event (followups, tasks,
441+
solutions, document links) as a single Markdown transcript. Events are
442+
ordered by ``timeline_position`` when set and otherwise by
443+
``date_creation``:
444+
445+
.. code-block:: python
446+
447+
bundle = await client.get_ticket_context(ticket_id)
448+
print(bundle.to_markdown())
449+
450+
Focused Workflow Examples
451+
-------------------------
452+
453+
The snippets below each exercise one focused workflow and end by
454+
rendering the resulting :class:`GlpiTicketContext` as Markdown. Every
455+
example is mirrored by an integration test in
456+
``integration_tests/test_integration.py`` (named
457+
``test_example_*``).
458+
459+
Example 1 — Create a ticket and read it back
460+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
461+
462+
.. code-block:: python
463+
464+
from glpi_python_client import GlpiClient, PostTicket
465+
466+
async with GlpiClient.from_env() as client:
467+
ticket_id = await client.create_ticket(
468+
PostTicket(
469+
name="Wi-Fi unreachable",
470+
content="802.1X handshake fails on the 5 GHz radio.",
471+
)
472+
)
473+
context = await client.get_ticket_context(ticket_id)
474+
print(context.to_markdown())
475+
476+
Expected Markdown (abridged)::
477+
478+
# Ticket #123 — Wi-Fi unreachable
479+
- **Status**: New
480+
481+
802.1X handshake fails on the 5 GHz radio.
482+
483+
Example 2 — Add a followup response
484+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
485+
486+
.. code-block:: python
487+
488+
from glpi_python_client import PostFollowup
489+
490+
await client.create_ticket_followup(
491+
ticket_id,
492+
PostFollowup(content="Reproduced on the lab laptop, capturing logs."),
493+
)
494+
context = await client.get_ticket_context(ticket_id)
495+
print(context.to_markdown())
496+
497+
Expected Markdown (abridged)::
498+
499+
# Ticket #123 — Wi-Fi unreachable
500+
501+
## Followup #45
502+
- **Created**: 2025-01-02T10:15:00+00:00
503+
504+
Reproduced on the lab laptop, capturing logs.
505+
506+
Example 3 — Add a task with a duration
507+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
508+
509+
.. code-block:: python
510+
511+
from glpi_python_client import PostTicketTask
512+
513+
await client.create_ticket_task(
514+
ticket_id,
515+
PostTicketTask(
516+
content="On-site visit to swap the access point.",
517+
duration=1800,
518+
),
519+
)
520+
context = await client.get_ticket_context(ticket_id)
521+
print(context.to_markdown())
522+
523+
Expected Markdown (abridged)::
524+
525+
## Task #12
526+
- **Duration**: 1800s
527+
528+
On-site visit to swap the access point.
529+
530+
Example 4 — Close a ticket with a solution
531+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
532+
533+
GLPI moves a ticket to the *Solved* status as soon as a solution is
534+
posted, so adding a solution is the supported way to change the ticket
535+
status from the v2 API.
536+
537+
.. code-block:: python
538+
539+
from glpi_python_client import PostSolution
540+
541+
await client.create_ticket_solution(
542+
ticket_id,
543+
PostSolution(content="Replaced the access point firmware."),
544+
)
545+
context = await client.get_ticket_context(ticket_id)
546+
print(context.to_markdown())
547+
548+
Expected Markdown (abridged)::
549+
550+
# Ticket #123 — Wi-Fi unreachable
551+
- **Status**: Solved
552+
553+
## Solution #7
554+
555+
Replaced the access point firmware.
556+
557+
Example 5 — Upload a document to an existing ticket
558+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
559+
560+
``upload_document`` accepts a ``ticket_id`` and links the new document
561+
to the timeline in a single call. The call requires the legacy v1
562+
session (``v1_base_url`` and ``v1_user_token``).
563+
564+
.. code-block:: python
565+
566+
await client.upload_document(
567+
filename="diagnostic.txt",
568+
content=b"link layer ok\nradius timeout 3s\n",
569+
mime_type="text/plain",
570+
ticket_id=ticket_id,
571+
)
572+
context = await client.get_ticket_context(ticket_id)
573+
print(context.to_markdown())
574+
575+
Expected Markdown (abridged)::
576+
577+
## Documents
578+
- diagnostic.txt
579+
439580
Reporting Helpers
440581
-----------------
441582

glpi_python_client/models/custom_schema/_ticket_context.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88

99
from __future__ import annotations
1010

11+
from datetime import datetime
12+
from typing import Any
13+
1114
from pydantic import Field
1215

1316
from glpi_python_client.models._base import GlpiModel
17+
from glpi_python_client.models.api_schema._common import IdNameRef
1418
from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
1519
from glpi_python_client.models.api_schema.assistance.timeline._document import (
1620
GetTimelineDocument,
@@ -24,6 +28,44 @@
2428
from glpi_python_client.models.api_schema.assistance.timeline._task import (
2529
GetTicketTask,
2630
)
31+
from glpi_python_client.models.api_schema.enums import GlpiTimelinePosition
32+
33+
_MAX_DATETIME = datetime.max
34+
35+
36+
def _ref_label(ref: IdNameRef | None) -> str | None:
37+
"""Return the human-readable label of one ``IdNameRef`` reference.
38+
39+
The helper prefers ``name`` (the GLPI display label) and falls back to
40+
the numeric identifier when the server only returned the foreign key.
41+
Returns ``None`` when the reference itself is missing so callers can
42+
omit the field from the rendered Markdown.
43+
"""
44+
45+
if ref is None:
46+
return None
47+
if ref.name:
48+
return ref.name
49+
if ref.id is not None:
50+
return f"#{ref.id}"
51+
return None
52+
53+
54+
def _event_sort_key(event: Any) -> tuple[int, int, datetime]:
55+
"""Compute the sort key used to order timeline events for rendering.
56+
57+
Events with a meaningful ``timeline_position`` (a positive
58+
:class:`GlpiTimelinePosition` member) come first in position order so
59+
the rendered transcript matches the GLPI UI layout. The remaining
60+
events fall back to ``date_creation``; events missing both attributes
61+
are pushed to the end with a stable ordering.
62+
"""
63+
64+
position = getattr(event, "timeline_position", None)
65+
fallback_date = getattr(event, "date_creation", None) or _MAX_DATETIME
66+
if isinstance(position, GlpiTimelinePosition) and position.value > 0:
67+
return (0, position.value, fallback_date)
68+
return (1, 0, fallback_date)
2769

2870

2971
class GlpiTicketContext(GlpiModel):
@@ -49,5 +91,76 @@ class GlpiTicketContext(GlpiModel):
4991
solutions: list[GetSolution] = Field(default_factory=list)
5092
documents: list[GetTimelineDocument] = Field(default_factory=list)
5193

94+
def to_markdown(self) -> str:
95+
"""Render the ticket and its timeline as one Markdown transcript.
96+
97+
The header reports the ticket identifier, name, status, and body,
98+
followed by every timeline event (followups, tasks, solutions)
99+
sorted by ``timeline_position`` when set and otherwise by
100+
``date_creation``. Linked documents are appended at the end as a
101+
bullet list because the timeline document link payload does not
102+
carry a creation timestamp. Empty fields are omitted so the
103+
output stays compact regardless of how complete the GLPI payload
104+
is.
105+
106+
Returns
107+
-------
108+
str
109+
Markdown transcript suitable for direct display or for
110+
forwarding into a downstream Markdown renderer. The string
111+
never ends with trailing whitespace.
112+
"""
113+
114+
lines: list[str] = []
115+
ticket = self.ticket
116+
ticket_label = ticket.name or "(unnamed ticket)"
117+
if ticket.id is not None:
118+
lines.append(f"# Ticket #{ticket.id} \u2014 {ticket_label}")
119+
else:
120+
lines.append(f"# Ticket \u2014 {ticket_label}")
121+
status_label = _ref_label(ticket.status)
122+
if status_label is not None:
123+
lines.append(f"- **Status**: {status_label}")
124+
if ticket.content:
125+
lines.append("")
126+
lines.append(ticket.content)
127+
128+
events: list[tuple[str, Any]] = []
129+
events.extend(("Followup", item) for item in self.followups)
130+
events.extend(("Task", item) for item in self.tasks)
131+
events.extend(("Solution", item) for item in self.solutions)
132+
events.sort(key=lambda pair: _event_sort_key(pair[1]))
133+
134+
for kind, event in events:
135+
event_id = getattr(event, "id", None)
136+
heading = f"## {kind} #{event_id}" if event_id is not None else f"## {kind}"
137+
lines.append("")
138+
lines.append(heading)
139+
author_label = _ref_label(getattr(event, "user", None))
140+
if author_label is not None:
141+
lines.append(f"- **Author**: {author_label}")
142+
created = getattr(event, "date_creation", None)
143+
if created is not None:
144+
lines.append(f"- **Created**: {created.isoformat()}")
145+
duration = getattr(event, "duration", None)
146+
if duration is not None:
147+
lines.append(f"- **Duration**: {duration}s")
148+
content = getattr(event, "content", None)
149+
if content:
150+
lines.append("")
151+
lines.append(content)
152+
153+
if self.documents:
154+
lines.append("")
155+
lines.append("## Documents")
156+
for document in self.documents:
157+
identifier = document.documents_id or document.id
158+
label = document.filepath or (
159+
f"document #{identifier}" if identifier is not None else "document"
160+
)
161+
lines.append(f"- {label}")
162+
163+
return "\n".join(lines).rstrip()
164+
52165

53166
__all__ = ["GlpiTicketContext"]

0 commit comments

Comments
 (0)