easyvista-python-client 0.1.0__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.
- easyvista_python_client/__init__.py +74 -0
- easyvista_python_client/_async/__init__.py +13 -0
- easyvista_python_client/_async/_concurrency.py +78 -0
- easyvista_python_client/_async/_transport.py +266 -0
- easyvista_python_client/_async/client.py +790 -0
- easyvista_python_client/_fields.py +26 -0
- easyvista_python_client/_html.py +41 -0
- easyvista_python_client/_sync/__init__.py +13 -0
- easyvista_python_client/_sync/_concurrency.py +50 -0
- easyvista_python_client/_sync/_transport.py +266 -0
- easyvista_python_client/_sync/client.py +790 -0
- easyvista_python_client/_transport.py +29 -0
- easyvista_python_client/config.py +71 -0
- easyvista_python_client/context.py +116 -0
- easyvista_python_client/directory.py +53 -0
- easyvista_python_client/exceptions.py +53 -0
- easyvista_python_client/field_model.py +74 -0
- easyvista_python_client/filters.py +82 -0
- easyvista_python_client/models/__init__.py +1 -0
- easyvista_python_client/models/action.py +65 -0
- easyvista_python_client/models/asset.py +36 -0
- easyvista_python_client/models/common.py +78 -0
- easyvista_python_client/models/department.py +68 -0
- easyvista_python_client/models/document.py +32 -0
- easyvista_python_client/models/employee.py +67 -0
- easyvista_python_client/models/request.py +172 -0
- easyvista_python_client/pagination.py +84 -0
- easyvista_python_client/py.typed +0 -0
- easyvista_python_client/references.py +146 -0
- easyvista_python_client/reporting.py +143 -0
- easyvista_python_client/resources/__init__.py +1 -0
- easyvista_python_client/resources/actions.py +72 -0
- easyvista_python_client/resources/assets.py +47 -0
- easyvista_python_client/resources/departments.py +57 -0
- easyvista_python_client/resources/descriptor.py +99 -0
- easyvista_python_client/resources/documents.py +78 -0
- easyvista_python_client/resources/employees.py +57 -0
- easyvista_python_client/resources/requests.py +91 -0
- easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
- easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
- easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
- easyvista_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared request description for the EasyVista client.
|
|
2
|
+
|
|
3
|
+
``RequestSpec`` is all this module holds, and it stays here permanently. It is
|
|
4
|
+
a frozen dataclass with no I/O, imported by every resource builder in
|
|
5
|
+
``resources/`` (actions, assets, departments, descriptor, documents,
|
|
6
|
+
employees, requests -- seven modules), so it sits at the package root while
|
|
7
|
+
the executors that consume it live in the generated trees. A shared resource
|
|
8
|
+
builder must not import from a tree, and a pure value type with no I/O has no
|
|
9
|
+
business living inside one either.
|
|
10
|
+
|
|
11
|
+
Everything else EasyVista-specific about talking to the API -- URL building,
|
|
12
|
+
auth headers, error mapping, the executor -- lives in
|
|
13
|
+
``_async/_transport.py`` and its generated ``_sync/`` twin.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RequestSpec:
|
|
24
|
+
"""A resource-relative HTTP request, independent of sync/async execution."""
|
|
25
|
+
|
|
26
|
+
method: str
|
|
27
|
+
path: str
|
|
28
|
+
params: dict[str, Any] | None = None
|
|
29
|
+
json: dict[str, Any] | None = None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Connection configuration for the EasyVista client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class EasyvistaConfig:
|
|
11
|
+
"""Immutable connection settings.
|
|
12
|
+
|
|
13
|
+
Provide either ``token`` (Bearer) or ``login`` + ``password`` (Basic).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
server: str
|
|
17
|
+
account: str
|
|
18
|
+
token: str | None = field(default=None, repr=False)
|
|
19
|
+
login: str | None = None
|
|
20
|
+
password: str | None = field(default=None, repr=False)
|
|
21
|
+
timeout: float = 30.0
|
|
22
|
+
max_retries: int = 0
|
|
23
|
+
verify_ssl: bool = True
|
|
24
|
+
default_max_rows: int = 100
|
|
25
|
+
api_version: str = "v1"
|
|
26
|
+
_server_normalized: str = field(init=False, repr=False)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
object.__setattr__(self, "_server_normalized", self.server.rstrip("/"))
|
|
30
|
+
if not self.token and not (self.login and self.password):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"An EasyVista credential is required: pass token=... or "
|
|
33
|
+
"login=... and password=..."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def api_root(self) -> str:
|
|
38
|
+
return f"{self._server_normalized}/api/{self.api_version}/{self.account}"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def uses_basic_auth(self) -> bool:
|
|
42
|
+
return self.token is None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_env(cls) -> EasyvistaConfig:
|
|
46
|
+
"""Build config from environment variables.
|
|
47
|
+
|
|
48
|
+
Reads ``EASYVISTA_URL`` (or ``EASYVISTA_SERVER``), ``EASYVISTA_ACCOUNT``,
|
|
49
|
+
then ``EASYVISTA_TOKEN`` or ``EASYVISTA_TOKEN_FILE``, else
|
|
50
|
+
``EASYVISTA_LOGIN`` / ``EASYVISTA_PASSWORD``.
|
|
51
|
+
"""
|
|
52
|
+
server = os.environ.get("EASYVISTA_URL") or os.environ.get("EASYVISTA_SERVER")
|
|
53
|
+
account = os.environ.get("EASYVISTA_ACCOUNT")
|
|
54
|
+
if not server or not account:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"EASYVISTA_URL (or EASYVISTA_SERVER) and EASYVISTA_ACCOUNT must be set."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
token = os.environ.get("EASYVISTA_TOKEN")
|
|
60
|
+
token_file = os.environ.get("EASYVISTA_TOKEN_FILE")
|
|
61
|
+
if not token and token_file:
|
|
62
|
+
with open(token_file, encoding="utf-8") as handle:
|
|
63
|
+
token = handle.read().strip()
|
|
64
|
+
|
|
65
|
+
return cls(
|
|
66
|
+
server=server,
|
|
67
|
+
account=account,
|
|
68
|
+
token=token,
|
|
69
|
+
login=os.environ.get("EASYVISTA_LOGIN"),
|
|
70
|
+
password=os.environ.get("EASYVISTA_PASSWORD"),
|
|
71
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Aggregated ticket context and an href-free Markdown renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ._fields import _label, _text
|
|
8
|
+
from ._html import html_to_text
|
|
9
|
+
from .models.action import Action
|
|
10
|
+
from .models.document import Document
|
|
11
|
+
from .models.request import Request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cell(value: str) -> str:
|
|
15
|
+
"""Make a value safe inside a Markdown table cell."""
|
|
16
|
+
return value.replace("|", "\\|").replace("\n", " ").strip()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TicketContext:
|
|
21
|
+
"""A ticket plus its resolved narrative content.
|
|
22
|
+
|
|
23
|
+
Holds the *raw* resolved text (``description``/``comment`` may still be HTML);
|
|
24
|
+
:meth:`to_markdown` does the plain-text reduction and formatting.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
ticket: Request
|
|
28
|
+
description: str | None
|
|
29
|
+
comment: str | None
|
|
30
|
+
actions: list[Action]
|
|
31
|
+
documents: list[Document]
|
|
32
|
+
|
|
33
|
+
def to_markdown(self) -> str:
|
|
34
|
+
"""Render an href-free Markdown document for this ticket."""
|
|
35
|
+
data = self.ticket.model_dump(by_alias=True)
|
|
36
|
+
rfc = self.ticket.rfc_number or "(unknown)"
|
|
37
|
+
title = _text(data.get("TITLE"))
|
|
38
|
+
lines: list[str] = [f"# Ticket {rfc}" + (f" — {title}" if title else ""), ""]
|
|
39
|
+
|
|
40
|
+
rows: list[tuple[str, str]] = []
|
|
41
|
+
for label, value in (
|
|
42
|
+
("Status", self.ticket.reference("STATUS").display),
|
|
43
|
+
("Department", self.ticket.reference("DEPARTMENT").display),
|
|
44
|
+
("Location", self.ticket.reference("LOCATION").display),
|
|
45
|
+
("Catalog", self.ticket.reference("CATALOG_REQUEST").display),
|
|
46
|
+
("Created", _text(data.get("CREATION_DATE_UT"))),
|
|
47
|
+
("Updated", _text(data.get("LAST_UPDATE"))),
|
|
48
|
+
):
|
|
49
|
+
if value:
|
|
50
|
+
rows.append((label, value))
|
|
51
|
+
if rows:
|
|
52
|
+
lines.append("| Field | Value |")
|
|
53
|
+
lines.append("|-------|-------|")
|
|
54
|
+
lines.extend(f"| {k} | {_cell(v)} |" for k, v in rows)
|
|
55
|
+
lines.append("")
|
|
56
|
+
|
|
57
|
+
# Headings name the ROLE a block plays in this ticket, decided from the
|
|
58
|
+
# data in hand -- not the EasyVista field it came from.
|
|
59
|
+
#
|
|
60
|
+
# Which memo carries a ticket's body is a per-deployment fact. On the
|
|
61
|
+
# verified instance DESCRIPTION is unused and the body arrives in
|
|
62
|
+
# COMMENT (0/15 sampled tickets had a non-empty DESCRIPTION, 15/15 had a
|
|
63
|
+
# COMMENT), and `RequestUpdate.description` writes COMMENT too -- so
|
|
64
|
+
# this library's own tickets export that way on any instance. Titling
|
|
65
|
+
# that block "Comment" mislabels the single most important part of the
|
|
66
|
+
# document for an LLM, or for a RAG chunker splitting on "## ".
|
|
67
|
+
#
|
|
68
|
+
# But the opposite hard-coding is just as wrong: an instance that
|
|
69
|
+
# populates DESCRIPTION properly uses COMMENT for a genuine follow-up
|
|
70
|
+
# note, and fusing or relabelling the two would destroy a real
|
|
71
|
+
# distinction. So neither universal is asserted. When only one memo has
|
|
72
|
+
# text it IS the body, whichever it came from, and is titled
|
|
73
|
+
# "Description"; when both do, the distinction is real and each keeps
|
|
74
|
+
# its own heading. An instance where DESCRIPTION works renders exactly
|
|
75
|
+
# as it did before.
|
|
76
|
+
description = html_to_text(self.description)
|
|
77
|
+
comment = html_to_text(self.comment)
|
|
78
|
+
if description and comment:
|
|
79
|
+
lines.extend(["## Description", "", description, ""])
|
|
80
|
+
lines.extend(["## Comment", "", comment, ""])
|
|
81
|
+
elif description or comment:
|
|
82
|
+
lines.extend(["## Description", "", description or comment, ""])
|
|
83
|
+
|
|
84
|
+
if self.actions:
|
|
85
|
+
lines.extend(["## Actions", ""])
|
|
86
|
+
for action in self.actions:
|
|
87
|
+
adata = action.model_dump(by_alias=True)
|
|
88
|
+
type_label = _label(adata.get("ACTION_TYPE"), ("NAME_EN", "NAME_FR"))
|
|
89
|
+
type_label = (
|
|
90
|
+
type_label or _text(adata.get("ACTION_LABEL_FR")) or "Action"
|
|
91
|
+
)
|
|
92
|
+
author = _text(adata.get("DONE_BY"))
|
|
93
|
+
heading = type_label + (f" — {author}" if author else "")
|
|
94
|
+
lines.append(f"### {heading}")
|
|
95
|
+
# DESCRIPTION carries the note text once get_ticket_context has
|
|
96
|
+
# resolved it; COMMENT is a separate field that never does
|
|
97
|
+
# (verified live). Fall back to it only for records that
|
|
98
|
+
# predate resolution.
|
|
99
|
+
body = html_to_text(
|
|
100
|
+
action.description if isinstance(action.description, str) else None
|
|
101
|
+
) or html_to_text(
|
|
102
|
+
action.comment if isinstance(action.comment, str) else None
|
|
103
|
+
)
|
|
104
|
+
if body:
|
|
105
|
+
lines.extend(["", body])
|
|
106
|
+
lines.append("")
|
|
107
|
+
|
|
108
|
+
if self.documents:
|
|
109
|
+
lines.extend(["## Attachments", ""])
|
|
110
|
+
for doc in self.documents:
|
|
111
|
+
name = doc.filename or doc.name
|
|
112
|
+
if name:
|
|
113
|
+
lines.append(f"- {name}")
|
|
114
|
+
lines.append("")
|
|
115
|
+
|
|
116
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Directory aggregation and fuzzy department resolution (client-agnostic helpers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .models.asset import Asset
|
|
8
|
+
from .models.department import Department
|
|
9
|
+
from .models.employee import Employee
|
|
10
|
+
from .models.request import Request
|
|
11
|
+
from .reporting import TicketStatistics
|
|
12
|
+
|
|
13
|
+
# O-DIR-1: the descending-sort token for "most recent" is not yet live-confirmed.
|
|
14
|
+
# EasyVista ignores an unknown ``sort`` param (falls back to default order) rather
|
|
15
|
+
# than erroring, so this is safe; adjust once confirmed against the live instance.
|
|
16
|
+
RECENT_TICKETS_SORT = "RFC_NUMBER:DESC"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DepartmentContext:
|
|
21
|
+
"""A department plus its related directory/ticket/asset context.
|
|
22
|
+
|
|
23
|
+
Only ``department`` is guaranteed; every related part degrades to ``[]`` / ``None``
|
|
24
|
+
/ ``0`` when a profile restriction (403) or a missing record (404) blocks it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
department: Department
|
|
28
|
+
employees: list[Employee]
|
|
29
|
+
manager: Employee | None
|
|
30
|
+
note: str | None
|
|
31
|
+
ticket_count: int
|
|
32
|
+
recent_tickets: list[Request]
|
|
33
|
+
ticket_statistics: TicketStatistics | None
|
|
34
|
+
assets: list[Asset]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _normalize_name(value: str) -> str:
|
|
38
|
+
"""Case-, space- and hyphen-insensitive key for fuzzy name matching."""
|
|
39
|
+
return value.replace("-", "").replace(" ", "").lower()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _department_matches(dept: Department, needle: str) -> bool:
|
|
43
|
+
"""True if ``needle`` (already normalized) is a substring of any string field.
|
|
44
|
+
|
|
45
|
+
Scans every localized label + code + path (all string fields), skipping the
|
|
46
|
+
record's own ``HREF`` so a URL never yields a false positive.
|
|
47
|
+
"""
|
|
48
|
+
for key, value in dept.model_dump(by_alias=True).items():
|
|
49
|
+
if isinstance(key, str) and key.upper() == "HREF":
|
|
50
|
+
continue
|
|
51
|
+
if isinstance(value, str) and needle in _normalize_name(value):
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Exception hierarchy for the EasyVista client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EasyvistaError(Exception):
|
|
7
|
+
"""Base class for all EasyVista client errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
ev_code: str | None = None,
|
|
15
|
+
ev_message: str | None = None,
|
|
16
|
+
body: bytes | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.ev_code = ev_code
|
|
21
|
+
self.ev_message = ev_message
|
|
22
|
+
# The raw response body, for a caller that hit one the transport does
|
|
23
|
+
# not recognize (an nginx/WAF HTML page, a plain-text 5xx). The
|
|
24
|
+
# transport deliberately stopped interpolating it into `message` (P2:
|
|
25
|
+
# nothing redacts exception TEXT, so it would print wherever the
|
|
26
|
+
# exception surfaces), which would otherwise make it unrecoverable.
|
|
27
|
+
# NOT passed to `super().__init__`, so it never becomes part of
|
|
28
|
+
# `.args` -- that is what keeps it out of `str()`/`repr()`.
|
|
29
|
+
self.body = body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EasyvistaAuthError(EasyvistaError):
|
|
33
|
+
"""401 / 403 — authentication or authorization failed."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EasyvistaNotFound(EasyvistaError):
|
|
37
|
+
"""404 — resource not found."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EasyvistaValidationError(EasyvistaError):
|
|
41
|
+
"""400 — request rejected as invalid by EasyVista."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EasyvistaRateLimitError(EasyvistaError):
|
|
45
|
+
"""429 — rate limited."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EasyvistaServerError(EasyvistaError):
|
|
49
|
+
"""5xx — EasyVista server error."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EasyvistaConnectionError(EasyvistaError):
|
|
53
|
+
"""Transport-level failure (timeout, connection refused, etc.)."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Generic, config-free classification of EasyVista record fields.
|
|
2
|
+
|
|
3
|
+
Partitions any record's fields into official / custom (``e_``) / available
|
|
4
|
+
(``available_field_x``) / link (href-only sub-resource) buckets, using the API's
|
|
5
|
+
documented conventions. The model's own declared field aliases are the only
|
|
6
|
+
"registry": a declared field is always official, so official ``E_``-columns like
|
|
7
|
+
``E_MAIL`` are never mistaken for custom.
|
|
8
|
+
|
|
9
|
+
Leaf module: stdlib only, no model/client imports.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_AVAILABLE = re.compile(r"AVAILABLE_FIELD_\d+$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class FieldClassification:
|
|
23
|
+
"""A record's fields split into four buckets (each field in exactly one)."""
|
|
24
|
+
|
|
25
|
+
official: dict[str, Any]
|
|
26
|
+
custom: dict[str, Any]
|
|
27
|
+
available: dict[str, Any]
|
|
28
|
+
links: dict[str, str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify(
|
|
32
|
+
record: dict[str, Any], declared: set[str] | None = None
|
|
33
|
+
) -> FieldClassification:
|
|
34
|
+
"""Partition ``record`` (a by-alias model dump). ``declared`` is the set of
|
|
35
|
+
the model's official field aliases, upper-cased; declared fields never count
|
|
36
|
+
as custom. Never raises; a non-dict yields empty buckets."""
|
|
37
|
+
declared = declared or set()
|
|
38
|
+
official: dict[str, Any] = {}
|
|
39
|
+
custom: dict[str, Any] = {}
|
|
40
|
+
available: dict[str, Any] = {}
|
|
41
|
+
links: dict[str, str] = {}
|
|
42
|
+
if isinstance(record, dict):
|
|
43
|
+
for key, value in record.items():
|
|
44
|
+
if not isinstance(key, str):
|
|
45
|
+
continue
|
|
46
|
+
upper = key.upper()
|
|
47
|
+
if upper == "HREF":
|
|
48
|
+
continue
|
|
49
|
+
if isinstance(value, dict) and set(value.keys()) == {"HREF"}:
|
|
50
|
+
links[key] = value["HREF"]
|
|
51
|
+
elif _AVAILABLE.match(upper):
|
|
52
|
+
available[key] = value
|
|
53
|
+
elif upper.startswith("E_") and upper not in declared:
|
|
54
|
+
custom[key] = value
|
|
55
|
+
else:
|
|
56
|
+
official[key] = value
|
|
57
|
+
return FieldClassification(
|
|
58
|
+
official=official, custom=custom, available=available, links=links
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_memo(data: Any, field: str) -> str | None:
|
|
63
|
+
"""The text of a Memo sub-resource response ``{"<FIELD>": "<text>", "HREF": …}``.
|
|
64
|
+
|
|
65
|
+
Matches ``field`` case-insensitively and returns its string value (``""`` for
|
|
66
|
+
an empty Memo). Returns ``None`` for a missing/non-string field or non-dict.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(data, dict):
|
|
69
|
+
return None
|
|
70
|
+
target = field.upper()
|
|
71
|
+
for key, value in data.items():
|
|
72
|
+
if isinstance(key, str) and key.upper() == target and isinstance(value, str):
|
|
73
|
+
return value
|
|
74
|
+
return None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Safe builders for EasyVista ``search`` expressions.
|
|
2
|
+
|
|
3
|
+
EasyVista's search grammar has two traps a caller cannot see, both verified
|
|
4
|
+
against a live instance:
|
|
5
|
+
|
|
6
|
+
1. An expression it cannot parse is **silently ignored** and every record is
|
|
7
|
+
returned — a filter that fails yields the whole table, not an error.
|
|
8
|
+
2. ``,`` is a live combinator (OR within one field, AND across fields), so an
|
|
9
|
+
unescaped value that closes its quote can append conditions and silently
|
|
10
|
+
widen the result set.
|
|
11
|
+
|
|
12
|
+
These builders exist so neither can happen. Filters return ``None`` for blank
|
|
13
|
+
input so callers compose without conditionals::
|
|
14
|
+
|
|
15
|
+
search = ev_equals_filter("DEPARTMENT_CODE", code)
|
|
16
|
+
if search is not None:
|
|
17
|
+
client.search_departments(search=search)
|
|
18
|
+
|
|
19
|
+
``field`` is expected to be a trusted, developer-supplied constant (e.g.
|
|
20
|
+
``"DEPARTMENT_CODE"``) and is not validated; ``value`` is the untrusted input
|
|
21
|
+
these builders check.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
|
|
28
|
+
# A double quote terminates the quoted value, letting a caller reach the ','
|
|
29
|
+
# combinator; no escape for it is known (verified live). ',' itself is NOT
|
|
30
|
+
# rejected: inside quotes it is inert, so without a '"' it cannot combine.
|
|
31
|
+
_UNSAFE_CHARS = ('"',)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_safe_ev_value(value: str) -> bool:
|
|
35
|
+
"""Whether ``value`` can be rendered inside an EasyVista search expression."""
|
|
36
|
+
return not any(char in value for char in _UNSAFE_CHARS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def escape_ev_value(value: str) -> str:
|
|
40
|
+
"""Render ``value`` for use inside a quoted EasyVista search value.
|
|
41
|
+
|
|
42
|
+
Raises ``ValueError`` if it cannot be rendered safely. ``ValueError`` — not
|
|
43
|
+
``EasyvistaValidationError`` — because nothing reached the API: this is a
|
|
44
|
+
local input fault, not a server rejection.
|
|
45
|
+
"""
|
|
46
|
+
if not is_safe_ev_value(value):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"{value!r} cannot be used in an EasyVista search: the double-quote "
|
|
49
|
+
"character terminates a quoted value and EasyVista provides no escape "
|
|
50
|
+
"for it."
|
|
51
|
+
)
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ev_equals_filter(field: str, value: str | int | None) -> str | None:
|
|
56
|
+
"""Build an exact-match filter: ``FIELD:"value"``."""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
text = str(value).strip()
|
|
60
|
+
if not text:
|
|
61
|
+
return None
|
|
62
|
+
return f'{field}:"{escape_ev_value(text)}"'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ev_in_filter(field: str, values: Iterable[str | int | None]) -> str | None:
|
|
66
|
+
"""Build a "field is one of these" filter: ``FIELD:"a",FIELD:"b"``.
|
|
67
|
+
|
|
68
|
+
``,`` is OR when every condition names the same field (verified live).
|
|
69
|
+
Blank values are skipped; no usable value returns ``None``.
|
|
70
|
+
"""
|
|
71
|
+
parts = [f for f in (ev_equals_filter(field, v) for v in values) if f]
|
|
72
|
+
if not parts:
|
|
73
|
+
return None
|
|
74
|
+
return ",".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"escape_ev_value",
|
|
79
|
+
"ev_equals_filter",
|
|
80
|
+
"ev_in_filter",
|
|
81
|
+
"is_safe_ev_value",
|
|
82
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pydantic models for EasyVista resources."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Models for EasyVista actions (≈ ticket followups)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, model_validator
|
|
8
|
+
|
|
9
|
+
from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Action(EasyvistaModel):
|
|
13
|
+
"""An action recorded against a ticket.
|
|
14
|
+
|
|
15
|
+
Two shapes reach this model. ``list_actions`` returns a slim collection
|
|
16
|
+
record; ``get_action`` returns a much fuller item-level one whose
|
|
17
|
+
``DESCRIPTION`` and ``COMMENT`` are Memo href objects. The note text a
|
|
18
|
+
caller supplied as ``PostAction.description`` comes back through
|
|
19
|
+
``DESCRIPTION`` — **not** ``COMMENT``, and not on the list endpoint at all
|
|
20
|
+
(verified live). ``extra="allow"`` preserves everything else.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
action_id: OptionalInt = Field(default=None, alias="ACTION_ID")
|
|
24
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
25
|
+
comment: str | dict[str, Any] | None = Field(default=None, alias="COMMENT")
|
|
26
|
+
# Memo href object on the item-level GET; a plain string once resolved.
|
|
27
|
+
description: str | dict[str, Any] | None = Field(default=None, alias="DESCRIPTION")
|
|
28
|
+
# The live API returns ACTION_TYPE as a nested object (id/name/...), not a
|
|
29
|
+
# bare string, so accept either (same polymorphism as Request.description).
|
|
30
|
+
action_type: str | dict[str, Any] | None = Field(default=None, alias="ACTION_TYPE")
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _derive_action_id_from_href(self) -> Action:
|
|
34
|
+
"""Populate ``action_id`` from ``href`` when the API omits it.
|
|
35
|
+
|
|
36
|
+
Defensive, and deliberately narrow: it fires only when ``href``'s
|
|
37
|
+
trailing segment is numeric. It does **not** fire for a create
|
|
38
|
+
response — ``POST requests/{rfc}/actions`` returns an HREF naming the
|
|
39
|
+
**parent request**, not the new action, so its tail is an RFC number
|
|
40
|
+
and ``.isdigit()`` correctly declines rather than inventing an id
|
|
41
|
+
(verified live). A created action's id is not recoverable from its
|
|
42
|
+
create response at all; see :meth:`EasyvistaClient.create_action`.
|
|
43
|
+
Reads that carry a real ``ACTION_ID`` are left untouched.
|
|
44
|
+
"""
|
|
45
|
+
if self.action_id is None and isinstance(self.href, str) and self.href:
|
|
46
|
+
tail = self.href.rstrip("/").rsplit("/", 1)[-1].split("?", 1)[0]
|
|
47
|
+
if tail.isdigit():
|
|
48
|
+
self.action_id = int(tail)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PostAction(EasyvistaWriteModel):
|
|
53
|
+
"""Payload for creating an action on a ticket.
|
|
54
|
+
|
|
55
|
+
Field set follows the documented (and live-verified) create-action body:
|
|
56
|
+
identify the action type via ``action_type_id`` (or ``action_type_name``) and
|
|
57
|
+
the assigned group via ``group_id`` (or ``group_name``); ``description`` holds
|
|
58
|
+
the note text. Inherits ``custom_fields``/``to_api()`` from EasyvistaWriteModel.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
action_type_id: int | None = None
|
|
62
|
+
action_type_name: str | None = None
|
|
63
|
+
group_id: int | None = None
|
|
64
|
+
group_name: str | None = None
|
|
65
|
+
description: str | None = None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Models for the EasyVista ``assets`` resource.
|
|
2
|
+
|
|
3
|
+
Field sets are the documented/common AM_ASSET fields; ``extra="allow"`` on the
|
|
4
|
+
read model preserves any others. Exact field names pending live validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from .common import EasyvistaModel, EasyvistaWriteModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Asset(EasyvistaModel):
|
|
15
|
+
"""An asset as returned by the API."""
|
|
16
|
+
|
|
17
|
+
asset_id: int | None = Field(default=None, alias="ASSET_ID")
|
|
18
|
+
asset_tag: str | None = Field(default=None, alias="ASSET_TAG")
|
|
19
|
+
serial_number: str | None = Field(default=None, alias="SERIAL_NUMBER")
|
|
20
|
+
status_id: int | None = Field(default=None, alias="STATUS_ID")
|
|
21
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PostAsset(EasyvistaWriteModel):
|
|
25
|
+
"""Payload for creating an asset.
|
|
26
|
+
|
|
27
|
+
``catalog_id`` is required by EasyVista (identifies the equipment model).
|
|
28
|
+
``custom_fields`` are serialized with an ``e_`` prefix (see EasyvistaWriteModel).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
catalog_id: int
|
|
32
|
+
asset_tag: str | None = None
|
|
33
|
+
serial_number: str | None = None
|
|
34
|
+
status_id: int | None = None
|
|
35
|
+
comment_asset: str | None = None
|
|
36
|
+
installation_date: str | None = None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared Pydantic base models for EasyVista resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from ..field_model import FieldClassification, classify
|
|
10
|
+
from ..references import Reference, resolve_reference
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _empty_str_to_none(value: Any) -> Any:
|
|
14
|
+
"""Coerce the API's empty-string sentinel for an absent scalar to ``None``.
|
|
15
|
+
|
|
16
|
+
EasyVista returns ``""`` for numeric columns that carry no value (e.g.
|
|
17
|
+
``MANAGER_ID`` / ``FUNCTION_ID`` on the single-record directory GETs); without
|
|
18
|
+
this an ``int`` field would fail validation. Any non-empty or non-string value
|
|
19
|
+
passes through untouched.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(value, str) and not value.strip():
|
|
22
|
+
return None
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
OptionalInt = Annotated[int | None, BeforeValidator(_empty_str_to_none)]
|
|
27
|
+
"""An ``int | None`` field that treats the API's ``""`` sentinel as ``None``."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EasyvistaModel(BaseModel):
|
|
31
|
+
"""Base for read models.
|
|
32
|
+
|
|
33
|
+
Tolerates the API's ``ALL_CAPS`` field names (via aliases on subclasses) and
|
|
34
|
+
preserves unknown / custom ``e_*`` fields so they round-trip unchanged.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
38
|
+
|
|
39
|
+
def reference(self, name: str) -> Reference:
|
|
40
|
+
"""Resolve a reference attribute (``STATUS``, ``URGENCY``, custom ``e_*``…)
|
|
41
|
+
to a normalized :class:`~easyvista_python_client.references.Reference`."""
|
|
42
|
+
return resolve_reference(self.model_dump(by_alias=True), name)
|
|
43
|
+
|
|
44
|
+
def classify_fields(self) -> FieldClassification:
|
|
45
|
+
"""Partition this record's fields into official / custom (``e_*``) /
|
|
46
|
+
available / link buckets.
|
|
47
|
+
|
|
48
|
+
See :class:`~easyvista_python_client.field_model.FieldClassification`.
|
|
49
|
+
"""
|
|
50
|
+
declared = {
|
|
51
|
+
(f.alias or name).upper() for name, f in type(self).model_fields.items()
|
|
52
|
+
}
|
|
53
|
+
return classify(self.model_dump(by_alias=True), declared)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EasyvistaWriteModel(BaseModel):
|
|
57
|
+
"""Base for write payloads (create/update).
|
|
58
|
+
|
|
59
|
+
Rejects unknown fields (``extra="forbid"``) to catch caller typos, and
|
|
60
|
+
serializes ``custom_fields`` with an ``e_`` prefix (unless already prefixed).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
custom_fields: dict[str, Any] = Field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
def to_api(self) -> dict[str, Any]:
|
|
68
|
+
"""Return the API body: known fields (``None`` dropped) plus ``e_``-prefixed
|
|
69
|
+
custom fields.
|
|
70
|
+
|
|
71
|
+
Booleans may be sent as native JSON ``true``/``false`` (EasyVista also
|
|
72
|
+
accepts ``0``/``1`` and the strings ``"true"``/``"false"``).
|
|
73
|
+
"""
|
|
74
|
+
data = self.model_dump(exclude_none=True, exclude={"custom_fields"})
|
|
75
|
+
for key, value in self.custom_fields.items():
|
|
76
|
+
api_key = key if key.startswith("e_") else f"e_{key}"
|
|
77
|
+
data[api_key] = value
|
|
78
|
+
return data
|