Source code for glpi_python_client.models.custom_schema._ticket_context

"""Aggregated ticket context view bundling timeline records.

The ticket context model gathers the primary ticket record together with
the most common timeline records (followups, tasks, solutions) and any
linked documents. It is consumed by higher-level workflows that need a
single object to reason about a ticket and its history.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any

from pydantic import Field

from glpi_python_client.models._base import GlpiModel
from glpi_python_client.models.api_schema._common import IdNameRef
from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
from glpi_python_client.models.api_schema.assistance.timeline._followup import (
    GetFollowup,
)
from glpi_python_client.models.api_schema.assistance.timeline._solution import (
    GetSolution,
)
from glpi_python_client.models.api_schema.assistance.timeline._task import (
    GetTicketTask,
)
from glpi_python_client.models.api_schema.management._document import GetDocument

_MAX_DATETIME = datetime.max


def _ref_label(ref: IdNameRef | None) -> str | None:
    """Return the human-readable label of one ``IdNameRef`` reference.

    The helper prefers ``name`` (the GLPI display label) and falls back to
    the numeric identifier when the server only returned the foreign key.
    Returns ``None`` when the reference itself is missing so callers can
    omit the field from the rendered Markdown.
    """

    if ref is None:
        return None
    if ref.name:
        return ref.name
    if ref.id is not None:
        return f"#{ref.id}"
    return None


def _render_value(value: object | None) -> str | None:
    """Convert one supported metadata value into a display string.

    The ticket-context Markdown view reuses one compact subtitle format
    across the main ticket and every timeline item. This helper keeps
    the formatting rules consistent for timestamps, GLPI references,
    and enum values while leaving plain strings unchanged.
    """

    if value is None:
        return None
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, IdNameRef):
        return _ref_label(value)
    if isinstance(value, Enum):
        return value.name.replace("_", " ").title()
    return str(value)


def _subtitle_line(*parts: tuple[str, object | None]) -> str | None:
    """Build one Markdown subtitle line from labeled metadata values.

    Missing values are skipped so callers can pass the full set of
    potentially interesting fields without having to pre-filter them.
    The subtitle is emitted as one Markdown blockquote line to make the
    metadata visually distinct from the section body.
    """

    rendered_parts = []
    for label, value in parts:
        rendered_value = _render_value(value)
        if rendered_value:
            rendered_parts.append(f"{label}: {rendered_value}")
    if not rendered_parts:
        return None
    return f"> {' | '.join(rendered_parts)}"


[docs] @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. Ticket context rendering must follow the actual activity chronology, not the left/right anchoring hint used by the GLPI chat UI. Entries are therefore always ordered by ``date_creation`` and items missing a creation timestamp are pushed to the end while preserving the sort's stability. """ return getattr(event, "date_creation", None) or _MAX_DATETIME
[docs] class GlpiTicketContext(GlpiModel): """Grouped public ticket context returned by ticket-context workflows. Parameters ---------- ticket : GetTicket Primary ticket record returned by the GLPI API. tasks : list[GetTicketTask], optional Linked task records. followups : list[GetFollowup], optional Linked followup records. solutions : list[GetSolution], optional Linked solution records. documents : list[GetDocument], optional Linked document records. """ ticket: GetTicket tasks: list[GetTicketTask] = Field(default_factory=list) followups: list[GetFollowup] = Field(default_factory=list) solutions: list[GetSolution] = Field(default_factory=list) documents: list[GetDocument] = Field(default_factory=list)
[docs] 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 subtitle line containing the requester, last editor, and the key timestamps exposed by the public ticket model. The ticket body is separated from the timeline itself, and each followup, task, and solution receives its own heading plus a metadata subtitle. Timeline entries are always sorted by ``date_creation`` so the transcript follows the actual chronology rather than the GLPI UI anchoring hints. Linked documents are still appended in a 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 Markdown transcript suitable for direct display or for forwarding into a downstream Markdown renderer. The string 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)" if ticket.id is not None: lines.append(f"# Ticket #{ticket.id} \u2014 {ticket_label}") else: lines.append(f"# Ticket \u2014 {ticket_label}") 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 opts.include_description and ticket.content: lines.append("") lines.append("## Description") lines.append("") lines.append(ticket.content) events: list[tuple[str, Any]] = [] 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: lines.append("") lines.append("## Timeline") for kind, event in events: event_id = getattr(event, "id", None) heading = ( f"### {kind} #{event_id}" if event_id is not None else f"### {kind}" ) lines.append("") lines.append(heading) 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) content = getattr(event, "content", None) if content: lines.append("") lines.append(content) if opts.include_documents and self.documents: lines.append("") lines.append("## Documents") for document in self.documents: label = ( document.filename or document.name or ( f"document #{document.id}" if document.id is not None else "document" ) ) lines.append(f"- {label}") return "\n".join(lines).rstrip()
__all__ = ["GlpiTicketContext", "TicketMarkdownOptions"]