glpi-python-client 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,7 +64,7 @@ from glpi_python_client.models import (
64
64
  PostUser,
65
65
  )
66
66
 
67
- __version__ = "0.2.0"
67
+ __version__ = "0.2.1"
68
68
 
69
69
  __all__ = [
70
70
  "DeleteDocument",
@@ -8,9 +8,14 @@ single object to reason about a ticket and its history.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from datetime import datetime
12
+ from enum import Enum
13
+ from typing import Any
14
+
11
15
  from pydantic import Field
12
16
 
13
17
  from glpi_python_client.models._base import GlpiModel
18
+ from glpi_python_client.models.api_schema._common import IdNameRef
14
19
  from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
15
20
  from glpi_python_client.models.api_schema.assistance.timeline._document import (
16
21
  GetTimelineDocument,
@@ -25,6 +30,78 @@ from glpi_python_client.models.api_schema.assistance.timeline._task import (
25
30
  GetTicketTask,
26
31
  )
27
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 _render_value(value: object | None) -> str | None:
55
+ """Convert one supported metadata value into a display string.
56
+
57
+ The ticket-context Markdown view reuses one compact subtitle format
58
+ across the main ticket and every timeline item. This helper keeps
59
+ the formatting rules consistent for timestamps, GLPI references,
60
+ and enum values while leaving plain strings unchanged.
61
+ """
62
+
63
+ if value is None:
64
+ return None
65
+ if isinstance(value, datetime):
66
+ return value.isoformat()
67
+ if isinstance(value, IdNameRef):
68
+ return _ref_label(value)
69
+ if isinstance(value, Enum):
70
+ return value.name.replace("_", " ").title()
71
+ return str(value)
72
+
73
+
74
+ def _subtitle_line(*parts: tuple[str, object | None]) -> str | None:
75
+ """Build one Markdown subtitle line from labeled metadata values.
76
+
77
+ Missing values are skipped so callers can pass the full set of
78
+ potentially interesting fields without having to pre-filter them.
79
+ The subtitle is emitted as one Markdown blockquote line to make the
80
+ metadata visually distinct from the section body.
81
+ """
82
+
83
+ rendered_parts = []
84
+ for label, value in parts:
85
+ rendered_value = _render_value(value)
86
+ if rendered_value:
87
+ rendered_parts.append(f"{label}: {rendered_value}")
88
+ if not rendered_parts:
89
+ return None
90
+ return f"> {' | '.join(rendered_parts)}"
91
+
92
+
93
+ def _event_sort_key(event: Any) -> datetime:
94
+ """Compute the sort key used to order timeline events for rendering.
95
+
96
+ Ticket context rendering must follow the actual activity chronology,
97
+ not the left/right anchoring hint used by the GLPI chat UI. Entries
98
+ are therefore always ordered by ``date_creation`` and items missing a
99
+ creation timestamp are pushed to the end while preserving the sort's
100
+ stability.
101
+ """
102
+
103
+ return getattr(event, "date_creation", None) or _MAX_DATETIME
104
+
28
105
 
29
106
  class GlpiTicketContext(GlpiModel):
30
107
  """Grouped public ticket context returned by ticket-context workflows.
@@ -49,5 +126,114 @@ class GlpiTicketContext(GlpiModel):
49
126
  solutions: list[GetSolution] = Field(default_factory=list)
50
127
  documents: list[GetTimelineDocument] = Field(default_factory=list)
51
128
 
129
+ def to_markdown(self) -> str:
130
+ """Render the ticket and its timeline as one Markdown transcript.
131
+
132
+ The rendering starts with the ticket title, then a compact
133
+ subtitle line containing the requester, last editor, and the
134
+ key timestamps exposed by the public ticket model. The ticket
135
+ body is separated from the timeline itself, and each followup,
136
+ task, and solution receives its own heading plus a metadata
137
+ subtitle. Timeline entries are always sorted by ``date_creation``
138
+ so the transcript follows the actual chronology rather than the
139
+ GLPI UI anchoring hints. Linked documents are still appended in a
140
+ dedicated section because the document-link payload does not
141
+ expose the same authoring metadata.
142
+
143
+ Returns
144
+ -------
145
+ str
146
+ Markdown transcript suitable for direct display or for
147
+ forwarding into a downstream Markdown renderer. The string
148
+ never ends with trailing whitespace.
149
+ """
150
+
151
+ lines: list[str] = []
152
+ ticket = self.ticket
153
+ ticket_label = ticket.name or "(unnamed ticket)"
154
+ if ticket.id is not None:
155
+ lines.append(f"# Ticket #{ticket.id} \u2014 {ticket_label}")
156
+ else:
157
+ lines.append(f"# Ticket \u2014 {ticket_label}")
158
+
159
+ ticket_subtitle = _subtitle_line(
160
+ ("Status", ticket.status),
161
+ ("Requester", ticket.user_recipient),
162
+ ("Last edited by", ticket.user_editor),
163
+ ("Created at", ticket.date_creation),
164
+ ("Updated at", ticket.date_mod),
165
+ ("Resolved at", ticket.date_solve),
166
+ ("Closed at", ticket.date_close),
167
+ )
168
+ if ticket_subtitle is not None:
169
+ lines.append(ticket_subtitle)
170
+
171
+ if ticket.content:
172
+ lines.append("")
173
+ lines.append("## Description")
174
+ lines.append("")
175
+ lines.append(ticket.content)
176
+
177
+ events: list[tuple[str, Any]] = []
178
+ events.extend(("Followup", item) for item in self.followups)
179
+ events.extend(("Task", item) for item in self.tasks)
180
+ events.extend(("Solution", item) for item in self.solutions)
181
+ events.sort(key=lambda pair: _event_sort_key(pair[1]))
182
+
183
+ if events:
184
+ lines.append("")
185
+ lines.append("## Timeline")
186
+
187
+ for kind, event in events:
188
+ event_id = getattr(event, "id", None)
189
+ heading = (
190
+ f"### {kind} #{event_id}" if event_id is not None else f"### {kind}"
191
+ )
192
+ lines.append("")
193
+ lines.append(heading)
194
+
195
+ event_subtitle = _subtitle_line(
196
+ ("Created by", getattr(event, "user", None)),
197
+ ("Last edited by", getattr(event, "user_editor", None)),
198
+ ("Created at", getattr(event, "date_creation", None)),
199
+ ("Updated at", getattr(event, "date_mod", None)),
200
+ ("Scheduled for", getattr(event, "date", None)),
201
+ ("Planned start", getattr(event, "planned_begin", None)),
202
+ ("Planned end", getattr(event, "planned_end", None)),
203
+ ("Approved at", getattr(event, "date_approval", None)),
204
+ ("State", getattr(event, "state", None)),
205
+ ("Status", getattr(event, "status", None)),
206
+ (
207
+ "Duration",
208
+ (
209
+ f"{duration}s"
210
+ if (duration := getattr(event, "duration", None)) is not None
211
+ else None
212
+ ),
213
+ ),
214
+ ("Technician", getattr(event, "user_tech", None)),
215
+ ("Technician group", getattr(event, "group_tech", None)),
216
+ ("Approver", getattr(event, "approver", None)),
217
+ )
218
+ if event_subtitle is not None:
219
+ lines.append(event_subtitle)
220
+
221
+ content = getattr(event, "content", None)
222
+ if content:
223
+ lines.append("")
224
+ lines.append(content)
225
+
226
+ if self.documents:
227
+ lines.append("")
228
+ lines.append("## Documents")
229
+ for document in self.documents:
230
+ identifier = document.documents_id or document.id
231
+ label = document.filepath or (
232
+ f"document #{identifier}" if identifier is not None else "document"
233
+ )
234
+ lines.append(f"- {label}")
235
+
236
+ return "\n".join(lines).rstrip()
237
+
52
238
 
53
239
  __all__ = ["GlpiTicketContext"]
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from datetime import datetime, timezone
6
+
5
7
  import pytest
6
8
  from pydantic import ValidationError
7
9
 
@@ -38,3 +40,171 @@ def test_ticket_context_accepts_timeline_records() -> None:
38
40
  context = GlpiTicketContext.model_validate(payload)
39
41
  assert context.tasks[0].id == 11
40
42
  assert context.documents[0].documents_id == 99
43
+
44
+
45
+ def test_to_markdown_renders_header_and_status() -> None:
46
+ """``to_markdown`` includes the ticket id, name, status and content."""
47
+
48
+ context = GlpiTicketContext.model_validate(
49
+ {
50
+ "ticket": {
51
+ "id": 42,
52
+ "name": "Printer broken",
53
+ "content": "Cannot print",
54
+ "status": {"id": 2, "name": "Processing (assigned)"},
55
+ }
56
+ }
57
+ )
58
+ rendered = context.to_markdown()
59
+ assert rendered.startswith("# Ticket #42 \u2014 Printer broken")
60
+ assert "> Status: Processing (assigned)" in rendered
61
+ assert "## Description" in rendered
62
+ assert "Cannot print" in rendered
63
+
64
+
65
+ def test_to_markdown_renders_ticket_subtitle_metadata() -> None:
66
+ """The main ticket subtitle includes requester, editor, and timestamps."""
67
+
68
+ context = GlpiTicketContext.model_validate(
69
+ {
70
+ "ticket": {
71
+ "id": 42,
72
+ "name": "Printer broken",
73
+ "user_recipient": {"id": 7, "name": "Alice"},
74
+ "user_editor": {"id": 8, "name": "Bob"},
75
+ "date_creation": datetime(2024, 1, 1, 9, 30, tzinfo=timezone.utc),
76
+ "date_mod": datetime(2024, 1, 2, 11, 45, tzinfo=timezone.utc),
77
+ }
78
+ }
79
+ )
80
+
81
+ rendered = context.to_markdown()
82
+
83
+ assert "Requester: Alice" in rendered
84
+ assert "Last edited by: Bob" in rendered
85
+ assert "Created at: 2024-01-01T09:30:00+00:00" in rendered
86
+ assert "Updated at: 2024-01-02T11:45:00+00:00" in rendered
87
+
88
+
89
+ def test_to_markdown_orders_events_by_creation_when_no_position() -> None:
90
+ """Events without ``timeline_position`` are ordered by creation date."""
91
+
92
+ context = GlpiTicketContext.model_validate(
93
+ {
94
+ "ticket": {"id": 1, "name": "x"},
95
+ "followups": [
96
+ {
97
+ "id": 2,
98
+ "content": "second note",
99
+ "date_creation": datetime(2024, 1, 2, tzinfo=timezone.utc),
100
+ },
101
+ {
102
+ "id": 1,
103
+ "content": "first note",
104
+ "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc),
105
+ },
106
+ ],
107
+ }
108
+ )
109
+ rendered = context.to_markdown()
110
+ assert rendered.index("first note") < rendered.index("second note")
111
+
112
+
113
+ def test_to_markdown_ignores_timeline_position_for_ordering() -> None:
114
+ """Timeline anchoring does not override chronological ordering."""
115
+
116
+ context = GlpiTicketContext.model_validate(
117
+ {
118
+ "ticket": {"id": 1, "name": "x"},
119
+ "followups": [
120
+ {
121
+ "id": 1,
122
+ "content": "no position late",
123
+ "date_creation": datetime(2024, 1, 5, tzinfo=timezone.utc),
124
+ },
125
+ ],
126
+ "tasks": [
127
+ {
128
+ "id": 2,
129
+ "content": "left positioned",
130
+ "timeline_position": 1,
131
+ "date_creation": datetime(2024, 1, 10, tzinfo=timezone.utc),
132
+ },
133
+ ],
134
+ }
135
+ )
136
+ rendered = context.to_markdown()
137
+ assert rendered.index("no position late") < rendered.index("left positioned")
138
+ assert "## Timeline" in rendered
139
+ assert "### Task #2" in rendered
140
+ assert "### Followup #1" in rendered
141
+
142
+
143
+ def test_to_markdown_renders_solution_and_documents() -> None:
144
+ """Solutions and document links are rendered with their dedicated sections."""
145
+
146
+ context = GlpiTicketContext.model_validate(
147
+ {
148
+ "ticket": {"id": 7, "name": "Reset"},
149
+ "solutions": [{"id": 4, "content": "All fixed"}],
150
+ "documents": [
151
+ {"id": 11, "documents_id": 99, "filepath": "logs/run.txt"},
152
+ {"id": 12, "documents_id": 100},
153
+ ],
154
+ }
155
+ )
156
+ rendered = context.to_markdown()
157
+ assert "### Solution #4" in rendered
158
+ assert "All fixed" in rendered
159
+ assert "## Documents" in rendered
160
+ assert "- logs/run.txt" in rendered
161
+ assert "- document #100" in rendered
162
+
163
+
164
+ def test_to_markdown_handles_empty_timeline() -> None:
165
+ """A ticket with no events still produces a valid Markdown header."""
166
+
167
+ context = GlpiTicketContext.model_validate({"ticket": {"id": 3, "name": "Quiet"}})
168
+ rendered = context.to_markdown()
169
+ assert rendered == "# Ticket #3 \u2014 Quiet"
170
+
171
+
172
+ def test_to_markdown_renders_task_duration() -> None:
173
+ """Tasks expose their ``duration`` field in seconds."""
174
+
175
+ context = GlpiTicketContext.model_validate(
176
+ {
177
+ "ticket": {"id": 1, "name": "x"},
178
+ "tasks": [{"id": 9, "content": "work", "duration": 1800}],
179
+ }
180
+ )
181
+ rendered = context.to_markdown()
182
+ assert "> Duration: 1800s" in rendered
183
+
184
+
185
+ def test_to_markdown_renders_event_creator_editor_and_timestamps() -> None:
186
+ """Timeline subtitles include author, editor, and timestamp metadata."""
187
+
188
+ context = GlpiTicketContext.model_validate(
189
+ {
190
+ "ticket": {"id": 1, "name": "x"},
191
+ "followups": [
192
+ {
193
+ "id": 12,
194
+ "content": "note",
195
+ "user": {"id": 7, "name": "Alice"},
196
+ "user_editor": {"id": 8, "name": "Bob"},
197
+ "date_creation": datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc),
198
+ "date_mod": datetime(2024, 1, 2, 10, 5, tzinfo=timezone.utc),
199
+ }
200
+ ],
201
+ }
202
+ )
203
+
204
+ rendered = context.to_markdown()
205
+
206
+ assert "### Followup #12" in rendered
207
+ assert "Created by: Alice" in rendered
208
+ assert "Last edited by: Bob" in rendered
209
+ assert "Created at: 2024-01-02T10:00:00+00:00" in rendered
210
+ assert "Updated at: 2024-01-02T10:05:00+00:00" in rendered
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glpi-python-client
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A typed Python client for GLPI ITSM APIs.
5
5
  Project-URL: Homepage, https://github.com/baraline/glpi_python_client
6
6
  Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
@@ -1,4 +1,4 @@
1
- glpi_python_client/__init__.py,sha256=dvP_bOZBNLXIMt05cJ-9G5uAllPnrxC3KdNPLbDE2Z8,2614
1
+ glpi_python_client/__init__.py,sha256=COSBZDaTdbd29TBO8qFMDCSIIf3AZELcnwHY_5QrAkY,2614
2
2
  glpi_python_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  glpi_python_client/auth/__init__.py,sha256=rvvca0KTgvI2bkTSYX-h5KxpPpHQd2n9UFYl0nHAJ_Y,457
4
4
  glpi_python_client/auth/_v1_session.py,sha256=R_SlKBrFJXCmXF0K3rmGAqkxH8cjaltItOJstcd-GVY,10263
@@ -81,13 +81,13 @@ glpi_python_client/models/api_schema/management/_document.py,sha256=Q-Tvo2fj9jzQ
81
81
  glpi_python_client/models/api_schema/management/tests/__init__.py,sha256=c_aRMSPAlwOgz_YlkII5mvYgr780F4Du4eSnZn5ITpM,63
82
82
  glpi_python_client/models/api_schema/management/tests/test_management_schemas.py,sha256=wMM8287WBhDUezKezCgINMyxqwjtYxpYRTKkRpmRNTI,1473
83
83
  glpi_python_client/models/custom_schema/__init__.py,sha256=hB9OfuJzV6cF6jwvrLeT2CJhFHTqXYC5VlmwOoRknck,448
84
- glpi_python_client/models/custom_schema/_ticket_context.py,sha256=HTOVvL6bLh0i1A33DG8Qw79Fs2ckMvp9BzIYSzL3HMI,1805
84
+ glpi_python_client/models/custom_schema/_ticket_context.py,sha256=G-W1IDY6JSv1FF2X2ntRKzso8GJAvPUcAlesv-MzdFc,8952
85
85
  glpi_python_client/models/custom_schema/tests/__init__.py,sha256=62-bctnIbC39LbL18mCX10ItpxeqFvpORXslREcqjzs,55
86
- glpi_python_client/models/custom_schema/tests/test_ticket_context.py,sha256=jr_ipxyQBPYEjjiZ4CiaQX_ljbkda6uG1iOnPe6cvZA,1365
86
+ glpi_python_client/models/custom_schema/tests/test_ticket_context.py,sha256=yZHQqFJGlg7X4rh8FIX6OAlNb4lKvF1yvirOXUf0WxQ,7195
87
87
  glpi_python_client/testing/__init__.py,sha256=ilUPAUlbMa2NuXiRGZjC97argNKMnJ-heK_kf7fotkE,512
88
88
  glpi_python_client/testing/fixtures.py,sha256=VQ-blVMyG706SR7SWuTW2UYr_nGa1qjKVnLwpsUFgHc,758
89
89
  glpi_python_client/testing/utils.py,sha256=MtfHRKVDW_Lq8LrDaY9U6QaFvU-K0CnXMV4ZvJyVyzU,3054
90
- glpi_python_client-0.2.0.dist-info/METADATA,sha256=cegISZjSMa4flRNaHJUYCLyCEL18B80rtVbwujH78PE,6351
91
- glpi_python_client-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
92
- glpi_python_client-0.2.0.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
93
- glpi_python_client-0.2.0.dist-info/RECORD,,
90
+ glpi_python_client-0.2.1.dist-info/METADATA,sha256=M8mNSEvZZjLZIeKyO-n_2WgBERzX8cfW4GNi33qXd2M,6351
91
+ glpi_python_client-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
92
+ glpi_python_client-0.2.1.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
93
+ glpi_python_client-0.2.1.dist-info/RECORD,,