From 59eccffc3055e310ff4c748375d9887908ab43ce Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 18 May 2026 08:46:17 +0200 Subject: [PATCH] add ticket option object --- docs/api_reference.rst | 5 + docs/user_guide.rst | 68 +++++++ glpi_python_client/__init__.py | 2 + glpi_python_client/models/__init__.py | 6 +- .../models/custom_schema/__init__.py | 3 +- .../models/custom_schema/_ticket_context.py | 182 +++++++++++++---- .../tests/test_ticket_context.py | 190 +++++++++++++++++- 7 files changed, 414 insertions(+), 42 deletions(-) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 4571a47..3a2503d 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -23,6 +23,11 @@ Aggregated Models :undoc-members: :show-inheritance: +.. autoclass:: TicketMarkdownOptions + :members: + :undoc-members: + :show-inheritance: + Common Reference Models ----------------------- diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 81ab09f..8252b40 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -697,6 +697,74 @@ Example output:: ## Documents - diagnostic.txt +Customising the Markdown output +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pass a :class:`TicketMarkdownOptions` instance to select which sections +and metadata fields appear in the output. All flags default to ``True`` +so the default call reproduces the full transcript shown above. + ++-------------------------------+----------------------------------------------+ +| Flag | Controls | ++===============================+==============================================+ +| ``include_description`` | ``## Description`` section | ++-------------------------------+----------------------------------------------+ +| ``include_followups`` | Followup entries in ``## Timeline`` | ++-------------------------------+----------------------------------------------+ +| ``include_tasks`` | Task entries in ``## Timeline`` | ++-------------------------------+----------------------------------------------+ +| ``include_solutions`` | Solution entries in ``## Timeline`` | ++-------------------------------+----------------------------------------------+ +| ``include_documents`` | ``## Documents`` section | ++-------------------------------+----------------------------------------------+ +| ``show_status`` | ``Status`` in the ticket subtitle | ++-------------------------------+----------------------------------------------+ +| ``show_requester`` | ``Requester`` in the ticket subtitle | ++-------------------------------+----------------------------------------------+ +| ``show_editor`` | ``Last edited by`` in the ticket subtitle | ++-------------------------------+----------------------------------------------+ +| ``show_dates`` | All ticket-level date fields | ++-------------------------------+----------------------------------------------+ +| ``show_event_author`` | ``Created by`` in event subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_event_editor`` | ``Last edited by`` in event subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_event_dates`` | All date fields in event subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_event_state`` | ``State`` in event subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_event_status`` | ``Status`` in event subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_duration`` | ``Duration`` in task subtitles | ++-------------------------------+----------------------------------------------+ +| ``show_technician`` | ``Technician`` / ``Technician group`` | ++-------------------------------+----------------------------------------------+ +| ``show_approver`` | ``Approver`` in solution subtitles | ++-------------------------------+----------------------------------------------+ + +Example — description and timeline only, no metadata fields: + +.. code-block:: python + + from glpi_python_client import TicketMarkdownOptions + + opts = TicketMarkdownOptions( + include_documents=False, + show_status=False, + show_requester=False, + show_editor=False, + show_dates=False, + show_event_author=False, + show_event_editor=False, + show_event_dates=False, + show_event_state=False, + show_event_status=False, + show_duration=False, + show_technician=False, + show_approver=False, + ) + print(bundle.to_markdown(opts)) + Reporting helpers ~~~~~~~~~~~~~~~~~ diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index dd41e43..2081a34 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -62,6 +62,7 @@ PostTicketTask, PostTimelineDocument, PostUser, + TicketMarkdownOptions, ) __version__ = "0.2.1" @@ -121,5 +122,6 @@ "PostTicketTask", "PostTimelineDocument", "PostUser", + "TicketMarkdownOptions", "__version__", ] diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index 003e017..19cc77e 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -80,7 +80,10 @@ PatchDocument, PostDocument, ) -from glpi_python_client.models.custom_schema import GlpiTicketContext +from glpi_python_client.models.custom_schema import ( + GlpiTicketContext, + TicketMarkdownOptions, +) __all__ = [ "DeleteDocument", @@ -136,4 +139,5 @@ "PostTicketTask", "PostTimelineDocument", "PostUser", + "TicketMarkdownOptions", ] diff --git a/glpi_python_client/models/custom_schema/__init__.py b/glpi_python_client/models/custom_schema/__init__.py index 24ea501..22fece7 100644 --- a/glpi_python_client/models/custom_schema/__init__.py +++ b/glpi_python_client/models/custom_schema/__init__.py @@ -8,6 +8,7 @@ from glpi_python_client.models.custom_schema._ticket_context import ( GlpiTicketContext, + TicketMarkdownOptions, ) -__all__ = ["GlpiTicketContext"] +__all__ = ["GlpiTicketContext", "TicketMarkdownOptions"] diff --git a/glpi_python_client/models/custom_schema/_ticket_context.py b/glpi_python_client/models/custom_schema/_ticket_context.py index 98bdd38..3fb49ea 100644 --- a/glpi_python_client/models/custom_schema/_ticket_context.py +++ b/glpi_python_client/models/custom_schema/_ticket_context.py @@ -8,6 +8,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Any @@ -90,6 +91,73 @@ def _subtitle_line(*parts: tuple[str, object | None]) -> str | None: return f"> {' | '.join(rendered_parts)}" +@dataclass +class TicketMarkdownOptions: + """Options controlling which sections and fields appear in the Markdown export. + + All flags default to ``True`` so that a bare ``to_markdown()`` call + reproduces the original full output. + + Parameters + ---------- + include_description : bool + Emit the ``## Description`` section with the ticket body. + include_followups : bool + Include followup entries in the ``## Timeline`` section. + include_tasks : bool + Include task entries in the ``## Timeline`` section. + include_solutions : bool + Include solution entries in the ``## Timeline`` section. + include_documents : bool + Append the ``## Documents`` section with linked file references. + show_status : bool + Emit the ``Status`` field in the ticket subtitle line. + show_requester : bool + Emit the ``Requester`` field in the ticket subtitle line. + show_editor : bool + Emit the ``Last edited by`` field in the ticket subtitle line. + show_dates : bool + Emit all ticket-level date fields (created, updated, resolved, + closed) in the ticket subtitle line. + show_event_author : bool + Emit the ``Created by`` field in timeline-entry subtitle lines. + show_event_editor : bool + Emit the ``Last edited by`` field in timeline-entry subtitle lines. + show_event_dates : bool + Emit date fields (created, updated, scheduled, planned start/end, + approved) in timeline-entry subtitle lines. + show_event_state : bool + Emit the ``State`` field in timeline-entry subtitle lines. + show_event_status : bool + Emit the ``Status`` field in timeline-entry subtitle lines. + show_duration : bool + Emit the ``Duration`` field in task subtitle lines. + show_technician : bool + Emit the ``Technician`` and ``Technician group`` fields in task + subtitle lines. + show_approver : bool + Emit the ``Approver`` field in solution subtitle lines. + """ + + include_description: bool = field(default=True) + include_followups: bool = field(default=True) + include_tasks: bool = field(default=True) + include_solutions: bool = field(default=True) + include_documents: bool = field(default=True) + show_status: bool = field(default=True) + show_requester: bool = field(default=True) + show_editor: bool = field(default=True) + show_dates: bool = field(default=True) + show_event_author: bool = field(default=True) + show_event_editor: bool = field(default=True) + show_event_dates: bool = field(default=True) + show_event_state: bool = field(default=True) + show_event_status: bool = field(default=True) + show_duration: bool = field(default=True) + show_technician: bool = field(default=True) + show_approver: bool = field(default=True) + + def _event_sort_key(event: Any) -> datetime: """Compute the sort key used to order timeline events for rendering. @@ -126,7 +194,10 @@ class GlpiTicketContext(GlpiModel): solutions: list[GetSolution] = Field(default_factory=list) documents: list[GetTimelineDocument] = Field(default_factory=list) - def to_markdown(self) -> str: + def to_markdown( + self, + options: TicketMarkdownOptions | None = None, + ) -> str: """Render the ticket and its timeline as one Markdown transcript. The rendering starts with the ticket title, then a compact @@ -140,6 +211,14 @@ def to_markdown(self) -> str: dedicated section because the document-link payload does not expose the same authoring metadata. + Parameters + ---------- + options : TicketMarkdownOptions, optional + Controls which sections and metadata fields are included in + the output. When *None* (the default) a fresh + :class:`TicketMarkdownOptions` is used, which enables all + sections and fields. + Returns ------- str @@ -148,6 +227,8 @@ def to_markdown(self) -> str: never ends with trailing whitespace. """ + opts = options if options is not None else TicketMarkdownOptions() + lines: list[str] = [] ticket = self.ticket ticket_label = ticket.name or "(unnamed ticket)" @@ -156,28 +237,37 @@ def to_markdown(self) -> str: else: lines.append(f"# Ticket \u2014 {ticket_label}") - ticket_subtitle = _subtitle_line( - ("Status", ticket.status), - ("Requester", ticket.user_recipient), - ("Last edited by", ticket.user_editor), - ("Created at", ticket.date_creation), - ("Updated at", ticket.date_mod), - ("Resolved at", ticket.date_solve), - ("Closed at", ticket.date_close), - ) + ticket_subtitle_parts: list[tuple[str, object | None]] = [] + if opts.show_status: + ticket_subtitle_parts.append(("Status", ticket.status)) + if opts.show_requester: + ticket_subtitle_parts.append(("Requester", ticket.user_recipient)) + if opts.show_editor: + ticket_subtitle_parts.append(("Last edited by", ticket.user_editor)) + if opts.show_dates: + ticket_subtitle_parts += [ + ("Created at", ticket.date_creation), + ("Updated at", ticket.date_mod), + ("Resolved at", ticket.date_solve), + ("Closed at", ticket.date_close), + ] + ticket_subtitle = _subtitle_line(*ticket_subtitle_parts) if ticket_subtitle is not None: lines.append(ticket_subtitle) - if ticket.content: + if opts.include_description and ticket.content: lines.append("") lines.append("## Description") lines.append("") lines.append(ticket.content) events: list[tuple[str, Any]] = [] - events.extend(("Followup", item) for item in self.followups) - events.extend(("Task", item) for item in self.tasks) - events.extend(("Solution", item) for item in self.solutions) + if opts.include_followups: + events.extend(("Followup", item) for item in self.followups) + if opts.include_tasks: + events.extend(("Task", item) for item in self.tasks) + if opts.include_solutions: + events.extend(("Solution", item) for item in self.solutions) events.sort(key=lambda pair: _event_sort_key(pair[1])) if events: @@ -192,29 +282,43 @@ def to_markdown(self) -> str: lines.append("") lines.append(heading) - event_subtitle = _subtitle_line( - ("Created by", getattr(event, "user", None)), - ("Last edited by", getattr(event, "user_editor", None)), - ("Created at", getattr(event, "date_creation", None)), - ("Updated at", getattr(event, "date_mod", None)), - ("Scheduled for", getattr(event, "date", None)), - ("Planned start", getattr(event, "planned_begin", None)), - ("Planned end", getattr(event, "planned_end", None)), - ("Approved at", getattr(event, "date_approval", None)), - ("State", getattr(event, "state", None)), - ("Status", getattr(event, "status", None)), - ( - "Duration", - ( - f"{duration}s" - if (duration := getattr(event, "duration", None)) is not None - else None - ), - ), - ("Technician", getattr(event, "user_tech", None)), - ("Technician group", getattr(event, "group_tech", None)), - ("Approver", getattr(event, "approver", None)), - ) + event_subtitle_parts: list[tuple[str, object | None]] = [] + if opts.show_event_author: + event_subtitle_parts.append( + ("Created by", getattr(event, "user", None)) + ) + if opts.show_event_editor: + event_subtitle_parts.append( + ("Last edited by", getattr(event, "user_editor", None)) + ) + if opts.show_event_dates: + event_subtitle_parts += [ + ("Created at", getattr(event, "date_creation", None)), + ("Updated at", getattr(event, "date_mod", None)), + ("Scheduled for", getattr(event, "date", None)), + ("Planned start", getattr(event, "planned_begin", None)), + ("Planned end", getattr(event, "planned_end", None)), + ("Approved at", getattr(event, "date_approval", None)), + ] + if opts.show_event_state: + event_subtitle_parts.append(("State", getattr(event, "state", None))) + if opts.show_event_status: + event_subtitle_parts.append(("Status", getattr(event, "status", None))) + if opts.show_duration: + duration = getattr(event, "duration", None) + event_subtitle_parts.append( + ("Duration", f"{duration}s" if duration is not None else None) + ) + if opts.show_technician: + event_subtitle_parts += [ + ("Technician", getattr(event, "user_tech", None)), + ("Technician group", getattr(event, "group_tech", None)), + ] + if opts.show_approver: + event_subtitle_parts.append( + ("Approver", getattr(event, "approver", None)) + ) + event_subtitle = _subtitle_line(*event_subtitle_parts) if event_subtitle is not None: lines.append(event_subtitle) @@ -223,7 +327,7 @@ def to_markdown(self) -> str: lines.append("") lines.append(content) - if self.documents: + if opts.include_documents and self.documents: lines.append("") lines.append("## Documents") for document in self.documents: @@ -236,4 +340,4 @@ def to_markdown(self) -> str: return "\n".join(lines).rstrip() -__all__ = ["GlpiTicketContext"] +__all__ = ["GlpiTicketContext", "TicketMarkdownOptions"] diff --git a/glpi_python_client/models/custom_schema/tests/test_ticket_context.py b/glpi_python_client/models/custom_schema/tests/test_ticket_context.py index fbe181a..e755256 100644 --- a/glpi_python_client/models/custom_schema/tests/test_ticket_context.py +++ b/glpi_python_client/models/custom_schema/tests/test_ticket_context.py @@ -7,7 +7,10 @@ import pytest from pydantic import ValidationError -from glpi_python_client.models.custom_schema import GlpiTicketContext +from glpi_python_client.models.custom_schema import ( + GlpiTicketContext, + TicketMarkdownOptions, +) def test_ticket_context_requires_ticket() -> None: @@ -208,3 +211,188 @@ def test_to_markdown_renders_event_creator_editor_and_timestamps() -> None: assert "Last edited by: Bob" in rendered assert "Created at: 2024-01-02T10:00:00+00:00" in rendered assert "Updated at: 2024-01-02T10:05:00+00:00" in rendered + + +# --------------------------------------------------------------------------- +# TicketMarkdownOptions - section inclusion +# --------------------------------------------------------------------------- + + +_FULL_PAYLOAD = { + "ticket": { + "id": 1, + "name": "Test ticket", + "content": "body text", + "status": {"id": 2, "name": "Open"}, + "user_recipient": {"id": 3, "name": "Alice"}, + "user_editor": {"id": 4, "name": "Bob"}, + "date_creation": datetime(2024, 1, 1, tzinfo=timezone.utc), + "date_mod": datetime(2024, 1, 2, tzinfo=timezone.utc), + }, + "followups": [{"id": 10, "content": "followup body"}], + "tasks": [{"id": 20, "content": "task body", "duration": 600}], + "solutions": [{"id": 30, "content": "solution body"}], + "documents": [{"id": 40, "documents_id": 99, "filepath": "file.txt"}], +} + + +def test_options_exclude_description() -> None: + """``include_description=False`` omits the description section.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(include_description=False)) + assert "## Description" not in rendered + assert "body text" not in rendered + + +def test_options_exclude_followups() -> None: + """``include_followups=False`` omits followup entries from the timeline.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(include_followups=False)) + assert "### Followup" not in rendered + assert "followup body" not in rendered + assert "### Task #20" in rendered + + +def test_options_exclude_tasks() -> None: + """``include_tasks=False`` omits task entries from the timeline.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(include_tasks=False)) + assert "### Task" not in rendered + assert "task body" not in rendered + assert "### Followup #10" in rendered + + +def test_options_exclude_solutions() -> None: + """``include_solutions=False`` omits solution entries from the timeline.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(include_solutions=False)) + assert "### Solution" not in rendered + assert "solution body" not in rendered + + +def test_options_exclude_documents() -> None: + """``include_documents=False`` omits the documents section.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(include_documents=False)) + assert "## Documents" not in rendered + assert "file.txt" not in rendered + + +def test_options_exclude_all_timeline_sections() -> None: + """Excluding all three timeline types also removes the Timeline heading.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown( + TicketMarkdownOptions( + include_followups=False, + include_tasks=False, + include_solutions=False, + ) + ) + assert "## Timeline" not in rendered + + +# --------------------------------------------------------------------------- +# TicketMarkdownOptions - ticket header field visibility +# --------------------------------------------------------------------------- + + +def test_options_hide_status() -> None: + """``show_status=False`` removes the status from the ticket subtitle.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(show_status=False)) + assert "Status: Open" not in rendered + assert "Requester: Alice" in rendered + + +def test_options_hide_requester() -> None: + """``show_requester=False`` removes the requester from the ticket subtitle.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(show_requester=False)) + assert "Requester:" not in rendered + assert "Status: Open" in rendered + + +def test_options_hide_editor() -> None: + """``show_editor=False`` removes the editor from the ticket subtitle.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(show_editor=False)) + assert "Last edited by: Bob" not in rendered + + +def test_options_hide_dates() -> None: + """``show_dates=False`` removes all date fields from the ticket subtitle.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + rendered = context.to_markdown(TicketMarkdownOptions(show_dates=False)) + assert "Created at:" not in rendered + assert "Updated at:" not in rendered + assert "Status: Open" in rendered + + +# --------------------------------------------------------------------------- +# TicketMarkdownOptions - timeline event field visibility +# --------------------------------------------------------------------------- + + +def test_options_hide_event_author() -> None: + """``show_event_author=False`` removes the creator from event subtitles.""" + + context = GlpiTicketContext.model_validate( + { + "ticket": {"id": 1, "name": "x"}, + "followups": [ + {"id": 5, "content": "note", "user": {"id": 7, "name": "Alice"}} + ], + } + ) + rendered = context.to_markdown(TicketMarkdownOptions(show_event_author=False)) + assert "Created by:" not in rendered + + +def test_options_hide_event_dates() -> None: + """``show_event_dates=False`` removes all date fields from event subtitles.""" + + context = GlpiTicketContext.model_validate( + { + "ticket": {"id": 1, "name": "x"}, + "followups": [ + { + "id": 5, + "content": "note", + "date_creation": datetime(2024, 3, 1, tzinfo=timezone.utc), + } + ], + } + ) + rendered = context.to_markdown(TicketMarkdownOptions(show_event_dates=False)) + assert "Created at:" not in rendered + + +def test_options_hide_duration() -> None: + """``show_duration=False`` removes the duration from task subtitles.""" + + context = GlpiTicketContext.model_validate( + { + "ticket": {"id": 1, "name": "x"}, + "tasks": [{"id": 9, "content": "work", "duration": 1800}], + } + ) + rendered = context.to_markdown(TicketMarkdownOptions(show_duration=False)) + assert "Duration:" not in rendered + assert "work" in rendered + + +def test_options_default_reproduces_original_output() -> None: + """A bare ``to_markdown()`` call and an explicit default options call are equal.""" + + context = GlpiTicketContext.model_validate(_FULL_PAYLOAD) + assert context.to_markdown() == context.to_markdown(TicketMarkdownOptions())