Skip to content

Commit 45e2b9d

Browse files
committed
rewire markdown converters
1 parent a95b73e commit 45e2b9d

12 files changed

Lines changed: 208 additions & 28 deletions

File tree

.coverage

0 Bytes
Binary file not shown.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async def main() -> None:
5757
ticket_id = await glpi.create_ticket(
5858
PostTicket(
5959
name="Printer issue",
60-
content="<p>The printer is not reachable from the office network.</p>",
60+
content="The printer is not reachable from the office network.",
6161
)
6262
)
6363
ticket = await glpi.get_ticket(ticket_id)

docs/user_guide.rst

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ coroutines to it from any synchronous thread:
151151
)
152152
try:
153153
ticket_id = glpi.create_ticket(
154-
"Printer issue", "<p>The printer is offline.</p>"
154+
"Printer issue", "The printer is offline."
155155
)
156156
print("created ticket", ticket_id)
157157
finally:
@@ -270,7 +270,7 @@ ambient extras when both are present.
270270
271271
ticket = PostTicket(
272272
name="Printer offline",
273-
content="<p>The third-floor printer cannot be reached.</p>",
273+
content="The third-floor printer cannot be reached.",
274274
extra_payload={"_room_code": "PAR-3F-12"},
275275
)
276276
ticket_id = await client.create_ticket(ticket)
@@ -290,12 +290,12 @@ helpers under ``/Assistance/Ticket``.
290290
from glpi_python_client import PatchTicket, PostTicket
291291
292292
ticket_id = await client.create_ticket(
293-
PostTicket(name="Wi-Fi unreachable", content="<p>802.1X failure</p>")
293+
PostTicket(name="Wi-Fi unreachable", content="802.1X failure")
294294
)
295295
try:
296296
await client.update_ticket(
297297
ticket_id,
298-
PatchTicket(content="<p>Updated diagnosis</p>"),
298+
PatchTicket(content="Updated diagnosis"),
299299
)
300300
ticket = await client.get_ticket(ticket_id)
301301
results = await client.search_tickets("status==1", limit=20)
@@ -326,15 +326,15 @@ delete_`` shape (``link_`` / ``unlink_`` for documents).
326326
327327
followup_id = await client.create_ticket_followup(
328328
ticket_id,
329-
PostFollowup(content="<p>Triaged: ongoing</p>"),
329+
PostFollowup(content="Triaged: ongoing"),
330330
)
331331
task_id = await client.create_ticket_task(
332332
ticket_id,
333-
PostTicketTask(content="<p>On-site visit</p>", duration=900),
333+
PostTicketTask(content="On-site visit", duration=900),
334334
)
335335
solution_id = await client.create_ticket_solution(
336336
ticket_id,
337-
PostSolution(content="<p>Replaced the access point</p>"),
337+
PostSolution(content="Replaced the access point"),
338338
)
339339
340340
followups = await client.list_ticket_followups(ticket_id)
@@ -507,24 +507,24 @@ The following example mirrors the integration test suite:
507507
)
508508
)
509509
ticket_id = await client.create_ticket(
510-
PostTicket(name="VPN drops", content="<p>Daily VPN drops at 11:00</p>")
510+
PostTicket(name="VPN drops", content="Daily VPN drops at 11:00")
511511
)
512512
try:
513513
await client.create_ticket_followup(
514514
ticket_id,
515-
PostFollowup(content="<p>Reproduced on lab laptop</p>"),
515+
PostFollowup(content="Reproduced on lab laptop"),
516516
)
517517
await client.create_ticket_task(
518518
ticket_id,
519-
PostTicketTask(content="<p>Capture VPN logs</p>", duration=1800),
519+
PostTicketTask(content="Capture VPN logs", duration=1800),
520520
)
521521
await client.add_ticket_team_member(
522522
ticket_id,
523523
PostTeamMember(type="User", id=user_id, role="assigned"),
524524
)
525525
await client.create_ticket_solution(
526526
ticket_id,
527-
PostSolution(content="<p>Upgraded VPN client</p>"),
527+
PostSolution(content="Upgraded VPN client"),
528528
)
529529
context = await client.get_ticket_context(ticket_id)
530530
print(context.ticket.name, len(context.followups))

glpi_python_client/clients/tests/test_smoke.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ async def test_create_ticket_task_uses_task_endpoint(
165165
await client.create_ticket_task(8, PostTicketTask(content="task", duration=120))
166166
call = recorder.calls[0]
167167
assert call["endpoint"] == "Assistance/Ticket/8/Timeline/Task"
168-
assert call["json"] == {"content": "task", "duration": 120}
168+
assert call["json"] == {"content": "<p>task</p>", "duration": 120}
169169

170170

171171
async def test_create_ticket_solution_uses_solution_endpoint(
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Annotated content types for transparent Markdown/HTML transport handling.
2+
3+
GLPI exchanges rich-text fields (ticket ``content``, followup ``content``,
4+
task ``content``, solution ``content``, ...) over the wire as HTML
5+
(``format: html`` in the OpenAPI contract), but the public package surface
6+
is intentionally Markdown-only: callers should never need to author or read
7+
HTML. The annotated type defined here wires
8+
:class:`glpi_python_client.content.conversion.GlpiContentConverter` into
9+
Pydantic so the conversion happens transparently on every model boundary:
10+
11+
* On validation (incoming HTML payloads from GLPI) the value is normalised
12+
to canonical Markdown before being assigned to the field, so attribute
13+
access always returns Markdown.
14+
* On serialisation (outgoing request bodies built via
15+
:func:`glpi_python_client.clients.commons._payloads.model_to_payload`)
16+
the Markdown value is rendered back to HTML so GLPI receives the format
17+
it expects.
18+
19+
Plain-text content (no ``<...>`` markup) is preserved verbatim on the
20+
inbound path and rendered as HTML paragraphs on the outbound path, matching
21+
the converter's default behaviour. ``None`` values are passed through
22+
unchanged so optional fields and ``exclude_none`` semantics keep working.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from typing import Annotated
28+
29+
from pydantic import BeforeValidator, PlainSerializer
30+
31+
from glpi_python_client.content.conversion import GlpiContentConverter
32+
33+
34+
def _from_transport(value: object) -> str | None:
35+
"""Normalise an inbound GLPI content value into canonical Markdown.
36+
37+
Pydantic invokes this before validation, so the field type stays ``str``
38+
while the caller-visible value is always Markdown. ``None`` is preserved
39+
unchanged so optional content fields keep their tri-state semantics.
40+
"""
41+
42+
if value is None:
43+
return None
44+
return GlpiContentConverter.from_transport(value)
45+
46+
47+
def _to_transport(value: str | None) -> str | None:
48+
"""Render an outbound Markdown content value as the HTML GLPI expects.
49+
50+
``None`` is preserved so ``model_dump(exclude_none=True)`` continues to
51+
drop unset fields from request bodies. Empty Markdown is rendered as an
52+
empty string to stay consistent with the inbound converter behaviour.
53+
"""
54+
55+
if value is None:
56+
return None
57+
return GlpiContentConverter.to_transport(value)
58+
59+
60+
GlpiMarkdownContent = Annotated[
61+
str | None,
62+
BeforeValidator(_from_transport),
63+
PlainSerializer(_to_transport, return_type=str | None, when_used="always"),
64+
]
65+
"""Annotated ``str | None`` that round-trips Markdown through GLPI's HTML wire format.
66+
67+
Use this annotation on every model field that maps to a GLPI ``format: html``
68+
content slot (ticket descriptions, followup bodies, task bodies, solution
69+
bodies). The conversion is invisible to package users: the field accepts
70+
Markdown on construction, exposes Markdown on attribute access, and emits
71+
HTML on serialisation.
72+
"""
73+
74+
__all__ = ["GlpiMarkdownContent"]

glpi_python_client/models/api_schema/assistance/_ticket.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
IdNameCompletenameRef,
2424
IdNameRef,
2525
)
26+
from glpi_python_client.models.api_schema._content import GlpiMarkdownContent
2627
from glpi_python_client.models.api_schema.enums import (
2728
GlpiGlobalValidation,
2829
GlpiPriority,
@@ -59,7 +60,7 @@ class GetTicket(GlpiModel):
5960

6061
id: int | None = None
6162
name: str | None = None
62-
content: str | None = None
63+
content: GlpiMarkdownContent = None
6364
user_recipient: IdNameRef | None = None
6465
user_editor: IdNameRef | None = None
6566
is_deleted: bool | None = None
@@ -111,7 +112,7 @@ class PostTicket(GlpiModel):
111112
"""
112113

113114
name: str | None = None
114-
content: str | None = None
115+
content: GlpiMarkdownContent = None
115116
is_deleted: bool | None = None
116117
category: IdNameRef | None = None
117118
location: IdNameRef | None = None
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for transparent Markdown <-> HTML transport on content fields.
2+
3+
The package contract is that every ``content`` field accepts and returns
4+
canonical Markdown while GLPI continues to receive HTML over the wire.
5+
These tests cover both directions on every model that exposes a Markdown
6+
content field, plus the inert behaviours (``None``, plain text round trip)
7+
that keep ``exclude_none`` semantics and non-HTML payloads stable.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pytest
13+
14+
from glpi_python_client.clients.commons._payloads import model_to_payload
15+
from glpi_python_client.models.api_schema.assistance import (
16+
GetTicket,
17+
PatchTicket,
18+
PostTicket,
19+
)
20+
from glpi_python_client.models.api_schema.assistance.timeline import (
21+
GetFollowup,
22+
GetSolution,
23+
GetTicketTask,
24+
PostFollowup,
25+
PostSolution,
26+
PostTicketTask,
27+
)
28+
29+
30+
@pytest.mark.parametrize(
31+
"model_cls",
32+
[PostTicket, PatchTicket, PostFollowup, PostSolution, PostTicketTask],
33+
)
34+
def test_outgoing_markdown_is_rendered_to_html(model_cls: type) -> None:
35+
"""Markdown supplied by callers becomes HTML in the request payload."""
36+
37+
instance = model_cls(content="The printer is **offline**.")
38+
39+
payload = model_to_payload(instance)
40+
41+
assert payload["content"] == "<p>The printer is <strong>offline</strong>.</p>"
42+
43+
44+
@pytest.mark.parametrize(
45+
"model_cls",
46+
[PostTicket, PatchTicket, PostFollowup, PostSolution, PostTicketTask],
47+
)
48+
def test_outgoing_none_content_is_dropped_from_payload(
49+
model_cls: type,
50+
) -> None:
51+
"""``None`` content stays ``None`` so ``exclude_none`` removes it."""
52+
53+
instance = model_cls()
54+
55+
payload = model_to_payload(instance)
56+
57+
assert "content" not in payload
58+
59+
60+
@pytest.mark.parametrize(
61+
"model_cls",
62+
[GetTicket, GetFollowup, GetSolution, GetTicketTask],
63+
)
64+
def test_incoming_html_is_normalised_to_markdown(model_cls: type) -> None:
65+
"""HTML returned by GLPI is normalised to Markdown on attribute access."""
66+
67+
instance = model_cls.model_validate(
68+
{"content": "<p>The printer is <strong>offline</strong>.</p>"}
69+
)
70+
71+
assert instance.content == "The printer is **offline**."
72+
73+
74+
@pytest.mark.parametrize(
75+
"model_cls",
76+
[GetTicket, GetFollowup, GetSolution, GetTicketTask],
77+
)
78+
def test_incoming_plain_text_passes_through(model_cls: type) -> None:
79+
"""Plain-text content (no HTML tags) is preserved verbatim."""
80+
81+
instance = model_cls.model_validate({"content": "Plain text body"})
82+
83+
assert instance.content == "Plain text body"
84+
85+
86+
def test_round_trip_preserves_markdown_intent() -> None:
87+
"""Markdown in -> HTML on the wire -> Markdown back on read."""
88+
89+
outgoing = PostTicket(name="Round trip", content="A **bold** statement.")
90+
payload = model_to_payload(outgoing)
91+
92+
incoming = GetTicket.model_validate({"name": "Round trip", **payload})
93+
94+
assert incoming.content == "A **bold** statement."
95+
96+
97+
def test_outgoing_empty_string_renders_empty() -> None:
98+
"""Empty Markdown serialises to an empty string, not to ``<p></p>``."""
99+
100+
payload = model_to_payload(PostTicket(content=""))
101+
102+
assert payload["content"] == ""

glpi_python_client/models/api_schema/assistance/timeline/_followup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from glpi_python_client.models._base import GlpiModel
1717
from glpi_python_client.models.api_schema._common import IdNameRef
18+
from glpi_python_client.models.api_schema._content import GlpiMarkdownContent
1819
from glpi_python_client.models.api_schema.enums import GlpiTimelinePosition
1920

2021

@@ -27,7 +28,7 @@ class GetFollowup(GlpiModel):
2728
id: int | None = None
2829
itemtype: str | None = None
2930
items_id: int | None = None
30-
content: str | None = None
31+
content: GlpiMarkdownContent = None
3132
is_private: bool | None = None
3233
user: IdNameRef | None = None
3334
user_editor: IdNameRef | None = None
@@ -45,7 +46,7 @@ class PostFollowup(GlpiModel):
4546

4647
itemtype: str | None = None
4748
items_id: int | None = None
48-
content: str | None = None
49+
content: GlpiMarkdownContent = None
4950
is_private: bool | None = None
5051
user: IdNameRef | None = None
5152
user_editor: IdNameRef | None = None

glpi_python_client/models/api_schema/assistance/timeline/_solution.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from glpi_python_client.models._base import GlpiModel
1717
from glpi_python_client.models.api_schema._common import IdNameRef
18+
from glpi_python_client.models.api_schema._content import GlpiMarkdownContent
1819
from glpi_python_client.models.api_schema.enums import GlpiSolutionStatus
1920

2021

@@ -28,7 +29,7 @@ class GetSolution(GlpiModel):
2829
itemtype: str | None = None
2930
items_id: int | None = None
3031
type: IdNameRef | None = None
31-
content: str | None = None
32+
content: GlpiMarkdownContent = None
3233
user: IdNameRef | None = None
3334
user_editor: IdNameRef | None = None
3435
approver: IdNameRef | None = None
@@ -45,7 +46,7 @@ class PostSolution(GlpiModel):
4546
itemtype: str | None = None
4647
items_id: int | None = None
4748
type: IdNameRef | None = None
48-
content: str | None = None
49+
content: GlpiMarkdownContent = None
4950
user: IdNameRef | None = None
5051
user_editor: IdNameRef | None = None
5152
approver: IdNameRef | None = None

glpi_python_client/models/api_schema/assistance/timeline/_task.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from glpi_python_client.models._base import GlpiModel
1717
from glpi_python_client.models.api_schema._common import IdNameRef
18+
from glpi_python_client.models.api_schema._content import GlpiMarkdownContent
1819
from glpi_python_client.models.api_schema.enums import (
1920
GlpiTaskState,
2021
GlpiTimelinePosition,
@@ -29,7 +30,7 @@ class GetTicketTask(GlpiModel):
2930

3031
id: int | None = None
3132
uuid: str | None = None
32-
content: str | None = None
33+
content: GlpiMarkdownContent = None
3334
is_private: bool | None = None
3435
user: IdNameRef | None = None
3536
user_editor: IdNameRef | None = None
@@ -52,7 +53,7 @@ class GetTicketTask(GlpiModel):
5253
class PostTicketTask(GlpiModel):
5354
"""Request body for ``POST`` on ticket timeline task endpoints."""
5455

55-
content: str | None = None
56+
content: GlpiMarkdownContent = None
5657
is_private: bool | None = None
5758
user: IdNameRef | None = None
5859
user_editor: IdNameRef | None = None

0 commit comments

Comments
 (0)