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,68 @@
|
|
|
1
|
+
"""Models for the EasyVista ``departments`` resource.
|
|
2
|
+
|
|
3
|
+
``Department`` declares the stable scalar fields; ``extra="allow"`` (from
|
|
4
|
+
``EasyvistaModel``) preserves the localized ``DEPARTMENT_<lang>`` label columns (used
|
|
5
|
+
by ``name``), the ``COMMENT_DEPARTMENT`` Memo link (surfaced by
|
|
6
|
+
``classify_fields().links`` and read via ``client.get_department_comment``), and any
|
|
7
|
+
instance-specific columns. Field aliases are grounded in the live inventory
|
|
8
|
+
(``docs/easyvista-field-inventory.md``). Writes are **provisional** pending an
|
|
9
|
+
authorised profile (spec open item O-DIR-2).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from ..references import localized_label
|
|
17
|
+
from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Department(EasyvistaModel):
|
|
21
|
+
"""A department (organisation node) as returned by the API."""
|
|
22
|
+
|
|
23
|
+
department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
|
|
24
|
+
department_code: str | None = Field(default=None, alias="DEPARTMENT_CODE")
|
|
25
|
+
department_path: str | None = Field(default=None, alias="DEPARTMENT_PATH")
|
|
26
|
+
manager_id: OptionalInt = Field(default=None, alias="MANAGER_ID")
|
|
27
|
+
level: OptionalInt = Field(default=None, alias="LEVEL")
|
|
28
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str | None:
|
|
32
|
+
"""Best localized department label, falling back to code then path.
|
|
33
|
+
|
|
34
|
+
A plain property (not a serialized field), so it never recurses through
|
|
35
|
+
``model_dump`` or appears in ``classify_fields``.
|
|
36
|
+
"""
|
|
37
|
+
return localized_label(
|
|
38
|
+
self.model_dump(by_alias=True),
|
|
39
|
+
"DEPARTMENT",
|
|
40
|
+
fallbacks=(self.department_code, self.department_path),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PostDepartment(EasyvistaWriteModel):
|
|
45
|
+
"""Provisional payload for creating a department.
|
|
46
|
+
|
|
47
|
+
Sent inside the envelope ``{"departments": [...]}``. Field set is a best guess
|
|
48
|
+
pending an authorised profile (spec open item O-DIR-2);
|
|
49
|
+
``custom_fields`` serialize with an ``e_`` prefix (see ``EasyvistaWriteModel``).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
department_code: str | None = None
|
|
53
|
+
department_en: str | None = None
|
|
54
|
+
department_fr: str | None = None
|
|
55
|
+
manager_id: int | None = None
|
|
56
|
+
parent_department_id: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DepartmentUpdate(EasyvistaWriteModel):
|
|
60
|
+
"""Provisional payload for updating a department via PUT.
|
|
61
|
+
|
|
62
|
+
Field set pending an authorised profile (spec open item O-DIR-2).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
department_code: str | None = None
|
|
66
|
+
department_en: str | None = None
|
|
67
|
+
department_fr: str | None = None
|
|
68
|
+
manager_id: int | None = None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Model for documents attached to an EasyVista ticket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
|
|
7
|
+
from .common import EasyvistaModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Document(EasyvistaModel):
|
|
11
|
+
"""A document attached to a ticket (or the HREF returned after upload).
|
|
12
|
+
|
|
13
|
+
The live ``GET requests/{rfc}/documents`` list exposes each attachment as
|
|
14
|
+
``DOCUMENT`` (filename), ``DOCUMENT_ID`` and ``DDL_HREF`` (the direct-download
|
|
15
|
+
URL) — verified against a live instance. ``filename`` falls back to
|
|
16
|
+
``DOCUMENT`` (then ``NAME``) so it is populated on both the list shape and any
|
|
17
|
+
``FILE_NAME``-style shape; ``download_href`` is the URL to fetch the bytes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
21
|
+
filename: str | None = Field(default=None, alias="FILE_NAME")
|
|
22
|
+
name: str | None = Field(default=None, alias="NAME")
|
|
23
|
+
document: str | None = Field(default=None, alias="DOCUMENT")
|
|
24
|
+
document_id: str | None = Field(default=None, alias="DOCUMENT_ID")
|
|
25
|
+
download_href: str | None = Field(default=None, alias="DDL_HREF")
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="after")
|
|
28
|
+
def _fill_filename(self) -> Document:
|
|
29
|
+
"""Populate ``filename`` from ``DOCUMENT`` or ``NAME`` when unset."""
|
|
30
|
+
if not self.filename:
|
|
31
|
+
self.filename = self.document or self.name
|
|
32
|
+
return self
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Models for the EasyVista ``employees`` resource.
|
|
2
|
+
|
|
3
|
+
``Employee`` declares the fields the richer single-record GET returns
|
|
4
|
+
(``GET employees/{id}``). ``E_MAIL`` is a **declared official** field, so the generic
|
|
5
|
+
field model never misclassifies it as a custom ``e_*`` column. ``extra="allow"``
|
|
6
|
+
preserves the ``COMMENT_EMPLOYEE`` Memo link and any other columns. Aliases are
|
|
7
|
+
grounded in the live inventory (``docs/easyvista-field-inventory.md``). Writes are
|
|
8
|
+
**provisional** pending an authorised profile (spec open item O-DIR-2).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Employee(EasyvistaModel):
|
|
19
|
+
"""An employee (person) as returned by the single-record GET."""
|
|
20
|
+
|
|
21
|
+
employee_id: OptionalInt = Field(default=None, alias="EMPLOYEE_ID")
|
|
22
|
+
last_name: str | None = Field(default=None, alias="LAST_NAME")
|
|
23
|
+
e_mail: str | None = Field(default=None, alias="E_MAIL")
|
|
24
|
+
department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
|
|
25
|
+
department_path: str | None = Field(default=None, alias="DEPARTMENT_PATH")
|
|
26
|
+
location_id: OptionalInt = Field(default=None, alias="LOCATION_ID")
|
|
27
|
+
phone_number: str | None = Field(default=None, alias="PHONE_NUMBER")
|
|
28
|
+
cellular_number: str | None = Field(default=None, alias="CELLULAR_NUMBER")
|
|
29
|
+
profil_id: OptionalInt = Field(default=None, alias="PROFIL_ID")
|
|
30
|
+
manager_id: OptionalInt = Field(default=None, alias="MANAGER_ID")
|
|
31
|
+
employee_guid: str | None = Field(default=None, alias="EMPLOYEE_GUID")
|
|
32
|
+
identification: str | None = Field(default=None, alias="IDENTIFICATION")
|
|
33
|
+
login: str | None = Field(default=None, alias="LOGIN")
|
|
34
|
+
function_id: OptionalInt = Field(default=None, alias="FUNCTION_ID")
|
|
35
|
+
language_id: OptionalInt = Field(default=None, alias="LANGUAGE_ID")
|
|
36
|
+
last_update: str | None = Field(default=None, alias="LAST_UPDATE")
|
|
37
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PostEmployee(EasyvistaWriteModel):
|
|
41
|
+
"""Provisional payload for creating an employee (envelope ``{"employees": [...]}``).
|
|
42
|
+
|
|
43
|
+
Field set is a best guess pending an authorised profile (spec open item O-DIR-2);
|
|
44
|
+
``custom_fields`` serialize with an ``e_`` prefix (see ``EasyvistaWriteModel``).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
last_name: str | None = None
|
|
48
|
+
e_mail: str | None = None
|
|
49
|
+
department_id: int | None = None
|
|
50
|
+
location_id: int | None = None
|
|
51
|
+
phone_number: str | None = None
|
|
52
|
+
cellular_number: str | None = None
|
|
53
|
+
manager_id: int | None = None
|
|
54
|
+
login: str | None = None
|
|
55
|
+
function_id: int | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EmployeeUpdate(EasyvistaWriteModel):
|
|
59
|
+
"""Provisional payload for updating an employee via PUT (spec open item O-DIR-2)."""
|
|
60
|
+
|
|
61
|
+
last_name: str | None = None
|
|
62
|
+
e_mail: str | None = None
|
|
63
|
+
department_id: int | None = None
|
|
64
|
+
location_id: int | None = None
|
|
65
|
+
phone_number: str | None = None
|
|
66
|
+
cellular_number: str | None = None
|
|
67
|
+
manager_id: int | None = None
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Models for the EasyVista ``requests`` resource (tickets).
|
|
2
|
+
|
|
3
|
+
``Request``'s declared fields are those verified present on live single-ticket
|
|
4
|
+
GETs (see its class docstring for exactly which, and which are deliberately
|
|
5
|
+
left undeclared); ``extra="allow"`` preserves everything else. ``PostRequest``
|
|
6
|
+
and ``RequestUpdate`` field sets follow the documented create/update bodies
|
|
7
|
+
(see their own docstrings).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, model_validator
|
|
15
|
+
|
|
16
|
+
from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Request(EasyvistaModel):
|
|
20
|
+
"""A ticket (incident/request) as returned by the API.
|
|
21
|
+
|
|
22
|
+
The declared fields are those verified present on live single-ticket GETs.
|
|
23
|
+
``extra="allow"`` preserves everything else, including the deliberately
|
|
24
|
+
undeclared ones:
|
|
25
|
+
|
|
26
|
+
* ``*_PATH`` (``SD_CATALOG_PATH``, ``DEPARTMENT_PATH``) — verified live:
|
|
27
|
+
returned and populated, but **silently ignored as search conditions** (a
|
|
28
|
+
real value returns the whole table while its ``*_ID`` sibling filters).
|
|
29
|
+
``LOCATION_PATH`` is *presumed* to behave the same by family resemblance
|
|
30
|
+
only — no sampled ticket carried both ``LOCATION_PATH`` and
|
|
31
|
+
``LOCATION_ID``, so it was never actually tested. Left undeclared so this
|
|
32
|
+
model never invites filtering on any of them.
|
|
33
|
+
* ``E_*`` — instance-specific custom fields; they belong in the custom
|
|
34
|
+
bucket of :meth:`~EasyvistaModel.classify_fields`, not here.
|
|
35
|
+
* ``AVAILABLE_FIELD_*`` — the API's spare slots.
|
|
36
|
+
|
|
37
|
+
``TITLE`` is empty on tickets created through the portal/catalog on some
|
|
38
|
+
instances (the human summary lives in ``DESCRIPTION`` / the catalog path),
|
|
39
|
+
so ``title`` is legitimately ``None`` for those; it is populated for tickets
|
|
40
|
+
created through this client with ``PostRequest(title=...)``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# identity
|
|
44
|
+
rfc_number: str | None = Field(default=None, alias="RFC_NUMBER")
|
|
45
|
+
request_id: OptionalInt = Field(default=None, alias="REQUEST_ID")
|
|
46
|
+
href: str | None = Field(default=None, alias="HREF")
|
|
47
|
+
|
|
48
|
+
# content
|
|
49
|
+
title: str | None = Field(default=None, alias="TITLE")
|
|
50
|
+
# The list view returns DESCRIPTION inline (a string); the single-ticket GET
|
|
51
|
+
# expands it into an HREF reference object (``{"HREF": ".../description"}``).
|
|
52
|
+
# Accept either so both read paths validate. Whether the resolved text is
|
|
53
|
+
# HTML or plain text is still unverified (spec open item O4).
|
|
54
|
+
description: str | dict[str, Any] | None = Field(default=None, alias="DESCRIPTION")
|
|
55
|
+
external_reference: str | None = Field(default=None, alias="EXTERNAL_REFERENCE")
|
|
56
|
+
|
|
57
|
+
# classification
|
|
58
|
+
sd_catalog_id: OptionalInt = Field(default=None, alias="SD_CATALOG_ID")
|
|
59
|
+
status_id: OptionalInt = Field(default=None, alias="STATUS_ID")
|
|
60
|
+
urgency_id: OptionalInt = Field(default=None, alias="URGENCY_ID")
|
|
61
|
+
impact_id: OptionalInt = Field(default=None, alias="IMPACT_ID")
|
|
62
|
+
severity_id: OptionalInt = Field(default=None, alias="SEVERITY_ID")
|
|
63
|
+
# Write-side ``PostRequest.origin`` reads back as REQUEST_ORIGIN_ID; ``ORIGIN``
|
|
64
|
+
# itself is not returned (spec open item O-ORIGIN).
|
|
65
|
+
request_origin_id: OptionalInt = Field(default=None, alias="REQUEST_ORIGIN_ID")
|
|
66
|
+
|
|
67
|
+
# parties and place
|
|
68
|
+
department_id: OptionalInt = Field(default=None, alias="DEPARTMENT_ID")
|
|
69
|
+
location_id: OptionalInt = Field(default=None, alias="LOCATION_ID")
|
|
70
|
+
requestor_id: OptionalInt = Field(default=None, alias="REQUESTOR_ID")
|
|
71
|
+
recipient_id: OptionalInt = Field(default=None, alias="RECIPIENT_ID")
|
|
72
|
+
owner_id: OptionalInt = Field(default=None, alias="OWNER_ID")
|
|
73
|
+
|
|
74
|
+
# timestamps and time limits — verified *returned*; their accepted write
|
|
75
|
+
# format is NOT verified (both a string and an int probe return HTTP 590),
|
|
76
|
+
# so no datetime parsing is claimed here. See spec open item O-590-DATE.
|
|
77
|
+
#
|
|
78
|
+
# These are the OFFICIAL time fields, portable across EasyVista
|
|
79
|
+
# deployments. The instance-specific GTR/GTI family (``E_GTR_STATUS``,
|
|
80
|
+
# ``E_GTI_UT``, ``E_DELAI_PEC``…) is deliberately NOT declared: it does not
|
|
81
|
+
# exist on another deployment, so it belongs in the custom bucket of
|
|
82
|
+
# :meth:`classify_fields`, reached by name at the call site.
|
|
83
|
+
submit_date_ut: str | None = Field(default=None, alias="SUBMIT_DATE_UT")
|
|
84
|
+
creation_date_ut: str | None = Field(default=None, alias="CREATION_DATE_UT")
|
|
85
|
+
max_resolution_date_ut: str | None = Field(
|
|
86
|
+
default=None, alias="MAX_RESOLUTION_DATE_UT"
|
|
87
|
+
)
|
|
88
|
+
expected_date_ut: str | None = Field(default=None, alias="EXPECTED_DATE_UT")
|
|
89
|
+
end_date_ut: str | None = Field(default=None, alias="END_DATE_UT")
|
|
90
|
+
last_update: str | None = Field(default=None, alias="LAST_UPDATE")
|
|
91
|
+
sla_id: OptionalInt = Field(default=None, alias="SLA_ID")
|
|
92
|
+
# Verified live (2026-07-28 Phase 0 probe, U6) as a string on every ticket
|
|
93
|
+
# checked -- never an int -- so no int branch is declared here.
|
|
94
|
+
time_used_to_solve_request: str | None = Field(
|
|
95
|
+
default=None, alias="TIME_USED_TO_SOLVE_REQUEST"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="after")
|
|
99
|
+
def _derive_rfc_from_href(self) -> Request:
|
|
100
|
+
"""Populate ``rfc_number`` from ``href`` when the API omits it.
|
|
101
|
+
|
|
102
|
+
``POST /requests`` (create) returns an HREF-only body
|
|
103
|
+
``{"HREF": ".../requests/<id>"}`` with no ``RFC_NUMBER``; the trailing
|
|
104
|
+
path segment of that HREF *is* the ticket's RFC (verified live), so
|
|
105
|
+
callers can use ``ticket.rfc_number`` immediately after a create. Reads,
|
|
106
|
+
which already carry ``RFC_NUMBER``, are left untouched.
|
|
107
|
+
"""
|
|
108
|
+
if not self.rfc_number and isinstance(self.href, str) and self.href:
|
|
109
|
+
tail = self.href.rstrip("/").rsplit("/", 1)[-1].split("?", 1)[0]
|
|
110
|
+
if tail:
|
|
111
|
+
self.rfc_number = tail
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PostRequest(EasyvistaWriteModel):
|
|
116
|
+
"""Payload for creating a ticket.
|
|
117
|
+
|
|
118
|
+
Field set matches the documented create body (``docs/API_Info.md``),
|
|
119
|
+
verified against a live instance: a ticket needs at minimum ``catalog_code``
|
|
120
|
+
plus ``title`` (and typically ``origin`` / ``department_id``). The exact
|
|
121
|
+
mandatory fields are configured **per catalog on the EasyVista side**, so the
|
|
122
|
+
client cannot know them statically; a missing one is rejected server-side and
|
|
123
|
+
surfaces as :class:`EasyvistaValidationError` (HTTP 590, code 2013), not a
|
|
124
|
+
retried server error. ``custom_fields`` values are serialized with an ``e_``
|
|
125
|
+
prefix unless they already start with ``e_`` (see :class:`EasyvistaWriteModel`).
|
|
126
|
+
|
|
127
|
+
``catalog_code`` is the only verified way to name a catalog here. An earlier
|
|
128
|
+
``catalog_guid`` field was removed: it is absent from the documented create
|
|
129
|
+
body, and it cannot be verified on a profile where ``GET /catalog-requests``
|
|
130
|
+
returns 403 (no way to obtain a real catalog GUID).
|
|
131
|
+
|
|
132
|
+
``description`` supplied at create time was **not** readable back through
|
|
133
|
+
either Memo on the verified instance -- neither ``DESCRIPTION`` nor
|
|
134
|
+
``COMMENT``. To set body text you can read again, follow the create with
|
|
135
|
+
``update_ticket(rfc, RequestUpdate(description=...))``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
catalog_code: str | None = None
|
|
139
|
+
title: str | None = None
|
|
140
|
+
description: str | None = None
|
|
141
|
+
origin: int | None = None
|
|
142
|
+
department_id: int | None = None
|
|
143
|
+
urgency_id: int | None = None
|
|
144
|
+
impact_id: int | None = None
|
|
145
|
+
severity_id: int | None = None
|
|
146
|
+
recipient_id: int | None = None
|
|
147
|
+
recipient_mail: str | None = None
|
|
148
|
+
external_reference: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RequestUpdate(EasyvistaWriteModel):
|
|
152
|
+
"""Payload for updating a ticket via PUT.
|
|
153
|
+
|
|
154
|
+
``docs/API_Info.md`` documents only the create, comment and close bodies, so
|
|
155
|
+
the update body is not vendor-documented. Every field here is one verified
|
|
156
|
+
accepted against a live instance -- ``title`` by the Phase 0 probe and by
|
|
157
|
+
``integration_tests/test_live_ticket_identity.py``. Nothing is added
|
|
158
|
+
speculatively: an unaccepted field would silently no-op or raise HTTP 590.
|
|
159
|
+
|
|
160
|
+
``description`` writes the ticket's **COMMENT** Memo, not ``DESCRIPTION`` --
|
|
161
|
+
verified live. EasyVista models ``COMMENT`` as the request's justification
|
|
162
|
+
and ``DESCRIPTION`` as a separate Memo; which one a deployment actually
|
|
163
|
+
populates is a per-instance configuration choice. On the instance this
|
|
164
|
+
client was verified against, ``DESCRIPTION`` is empty on every ticket and
|
|
165
|
+
``COMMENT`` carries the body text. Read it back with
|
|
166
|
+
``resolve_memo("requests/{rfc}/comment")``, or take
|
|
167
|
+
``TicketContext.comment``, which resolves it for you.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
status_id: int | None = None
|
|
171
|
+
title: str | None = None
|
|
172
|
+
description: str | None = None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Search-result container and record extraction for list endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SearchResult(Generic[T]):
|
|
13
|
+
"""A page of search results plus EasyVista's record counts.
|
|
14
|
+
|
|
15
|
+
``next_url`` is the API's ``@next`` link (an ``offset``-based next-page URL)
|
|
16
|
+
when more records exist than were returned, else ``None``. It is the
|
|
17
|
+
authoritative "are there more pages?" signal used by the iterators.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
records: list[T]
|
|
21
|
+
record_count: int
|
|
22
|
+
total_record_count: int
|
|
23
|
+
href: str | None = None
|
|
24
|
+
next_url: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_int(value: Any, default: int) -> int:
|
|
28
|
+
"""Coerce an EasyVista count to ``int``, falling back to ``default``."""
|
|
29
|
+
if value is None:
|
|
30
|
+
return default
|
|
31
|
+
try:
|
|
32
|
+
return int(value)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_search_result(data: Any, records: list[T]) -> SearchResult[T]:
|
|
38
|
+
"""Assemble a :class:`SearchResult` from a payload and its typed records.
|
|
39
|
+
|
|
40
|
+
The live API returns ``record_count`` / ``total_record_count`` as **strings**
|
|
41
|
+
(spec open item O1), so they are coerced to ``int`` here — the single place
|
|
42
|
+
both the ``requests`` and ``assets`` search parsers funnel through.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(data, dict):
|
|
45
|
+
return SearchResult(
|
|
46
|
+
records=records,
|
|
47
|
+
record_count=len(records),
|
|
48
|
+
total_record_count=len(records),
|
|
49
|
+
)
|
|
50
|
+
count = _to_int(data.get("record_count"), len(records))
|
|
51
|
+
total = _to_int(data.get("total_record_count"), count)
|
|
52
|
+
href = data.get("HREF")
|
|
53
|
+
next_url = data.get("@next")
|
|
54
|
+
return SearchResult(
|
|
55
|
+
records=records,
|
|
56
|
+
record_count=count,
|
|
57
|
+
total_record_count=total,
|
|
58
|
+
href=href if isinstance(href, str) else None,
|
|
59
|
+
next_url=next_url if isinstance(next_url, str) else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_records(data: Any, envelope_key: str | None = None) -> list[dict[str, Any]]:
|
|
64
|
+
"""Pull a list of record dicts out of an EasyVista JSON payload.
|
|
65
|
+
|
|
66
|
+
Handles the ``records`` list (GET list), the resource-named create/list
|
|
67
|
+
envelopes, and a bare single object. ``envelope_key`` names a resource's own
|
|
68
|
+
envelope (e.g. ``"departments"``) so a response echoed in that wrapper is
|
|
69
|
+
unwrapped too; it is checked right after ``records`` and before the legacy
|
|
70
|
+
defaults. With ``envelope_key=None`` the behavior is unchanged.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(data, dict):
|
|
73
|
+
keys = ["records"]
|
|
74
|
+
if envelope_key and envelope_key not in keys:
|
|
75
|
+
keys.append(envelope_key)
|
|
76
|
+
keys.extend(k for k in ("requests", "assets", "documents") if k not in keys)
|
|
77
|
+
for key in keys:
|
|
78
|
+
value = data.get(key)
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [r for r in value if isinstance(r, dict)]
|
|
81
|
+
return [data]
|
|
82
|
+
if isinstance(data, list):
|
|
83
|
+
return [r for r in data if isinstance(r, dict)]
|
|
84
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Generic, config-free resolution of EasyVista reference attributes.
|
|
2
|
+
|
|
3
|
+
EasyVista returns reference attributes in two shapes: nested objects that carry a
|
|
4
|
+
human label in ``*_EN`` / ``*_FR`` / ``*_PATH`` sub-keys (e.g. ``STATUS``,
|
|
5
|
+
``DEPARTMENT``, ``CATALOG_REQUEST``), and bare ids (e.g. ``URGENCY_ID``). This
|
|
6
|
+
module normalizes both to a :class:`Reference` using only the API's naming
|
|
7
|
+
conventions, so any field — including custom ``e_*`` fields on any instance —
|
|
8
|
+
resolves the same way with no registry or configuration.
|
|
9
|
+
|
|
10
|
+
Leaf module: stdlib only, no model/client imports.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Reference:
|
|
21
|
+
"""A normalized EasyVista reference: an id and/or a human label."""
|
|
22
|
+
|
|
23
|
+
id: str | None
|
|
24
|
+
label: str | None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def display(self) -> str | None:
|
|
28
|
+
"""The human label when available, else the id, else ``None``."""
|
|
29
|
+
return self.label or self.id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scalar(value: Any) -> str | None:
|
|
33
|
+
"""A non-empty id-like scalar as a string, else ``None`` (bools rejected)."""
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(value, (str, int)) and str(value).strip():
|
|
37
|
+
return str(value).strip()
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _nested_label(nested: dict[str, Any] | None) -> str | None:
|
|
42
|
+
"""First non-empty ``*_EN`` then ``*_FR`` then ``*_PATH`` string; never an href."""
|
|
43
|
+
if not nested:
|
|
44
|
+
return None
|
|
45
|
+
# Suffix-scan (not <name>_* prefix) so labels under any sub-key resolve —
|
|
46
|
+
# e.g. CATALOG_REQUEST's human label lives in TITLE_FR / TITLE_EN.
|
|
47
|
+
for suffix in ("_EN", "_FR", "_PATH"):
|
|
48
|
+
for key, value in nested.items():
|
|
49
|
+
if (
|
|
50
|
+
key.upper().endswith(suffix)
|
|
51
|
+
and isinstance(value, str)
|
|
52
|
+
and value.strip()
|
|
53
|
+
):
|
|
54
|
+
return value.strip()
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_reference(record: dict[str, Any], name: str) -> Reference:
|
|
59
|
+
"""Resolve reference ``name`` in a model's by-alias dump to ``(id, label)``.
|
|
60
|
+
|
|
61
|
+
``name`` is a raw API field name, matched case-insensitively. See the module
|
|
62
|
+
docstring for the conventions. Never raises; a non-dict record or a missing
|
|
63
|
+
field yields an empty :class:`Reference`.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(record, dict):
|
|
66
|
+
return Reference(id=None, label=None)
|
|
67
|
+
# Index top-level keys case-insensitively (EasyVista keys are ALL_CAPS, but
|
|
68
|
+
# custom fields on some instances are not — stay generic).
|
|
69
|
+
upper = {k.upper(): v for k, v in record.items() if isinstance(k, str)}
|
|
70
|
+
key = name.upper()
|
|
71
|
+
|
|
72
|
+
value = upper.get(key)
|
|
73
|
+
nested = value if isinstance(value, dict) else None
|
|
74
|
+
|
|
75
|
+
label = _nested_label(nested)
|
|
76
|
+
id_ = _resolve_id(upper, key, nested)
|
|
77
|
+
return Reference(id=id_, label=label)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_id(
|
|
81
|
+
upper: dict[str, Any], key: str, nested: dict[str, Any] | None
|
|
82
|
+
) -> str | None:
|
|
83
|
+
# 1. top-level <name>_ID / <name>_GUID / the scalar at <name> itself
|
|
84
|
+
for candidate in (f"{key}_ID", f"{key}_GUID", key):
|
|
85
|
+
got = _scalar(upper.get(candidate))
|
|
86
|
+
if got is not None:
|
|
87
|
+
return got
|
|
88
|
+
if nested:
|
|
89
|
+
# 2. exact <name>_ID / <name>_GUID inside the nested object
|
|
90
|
+
for sub in (f"{key}_ID", f"{key}_GUID"):
|
|
91
|
+
for nkey, nval in nested.items():
|
|
92
|
+
if isinstance(nkey, str) and nkey.upper() == sub:
|
|
93
|
+
got = _scalar(nval)
|
|
94
|
+
if got is not None:
|
|
95
|
+
return got
|
|
96
|
+
# Best-effort: first *_ID/_GUID sub-key by dict order (fine for the
|
|
97
|
+
# single-id nested objects EasyVista returns).
|
|
98
|
+
for nkey, nval in nested.items():
|
|
99
|
+
if isinstance(nkey, str) and nkey.upper().endswith(("_ID", "_GUID")):
|
|
100
|
+
got = _scalar(nval)
|
|
101
|
+
if got is not None:
|
|
102
|
+
return got
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_LANG_SUFFIXES = ("_EN", "_FR", "_GE", "_IT", "_PO", "_SP")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _usable_label(value: Any) -> str | None:
|
|
110
|
+
"""A stripped non-empty string that is not a ``[bracketed]`` placeholder.
|
|
111
|
+
|
|
112
|
+
Returns ``None`` otherwise.
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(value, str):
|
|
115
|
+
return None
|
|
116
|
+
text = value.strip()
|
|
117
|
+
if not text or (text.startswith("[") and text.endswith("]")):
|
|
118
|
+
return None
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def localized_label(
|
|
123
|
+
record: dict[str, Any], prefix: str, *, fallbacks: tuple[str | None, ...] = ()
|
|
124
|
+
) -> str | None:
|
|
125
|
+
"""Best populated ``"<prefix>_<lang>"`` label, else first usable ``fallbacks``.
|
|
126
|
+
|
|
127
|
+
Scans the EasyVista language columns ``<prefix>_EN`` → ``_FR`` → ``_GE`` → ``_IT``
|
|
128
|
+
→ ``_PO`` → ``_SP`` and returns the first value that is a non-empty string and not
|
|
129
|
+
a ``[bracketed]`` placeholder (unpopulated localized columns on a single-language
|
|
130
|
+
instance echo ``"[CODE]"``). Only the language suffixes are considered, so
|
|
131
|
+
``_CODE`` / ``_PATH`` are never mistaken for a label. Falls back to the first
|
|
132
|
+
usable value in ``fallbacks`` (e.g. a code then a path). Case-insensitive on keys;
|
|
133
|
+
never raises; returns ``None`` when nothing usable is found.
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(record, dict):
|
|
136
|
+
upper = {k.upper(): v for k, v in record.items() if isinstance(k, str)}
|
|
137
|
+
base = prefix.upper()
|
|
138
|
+
for suffix in _LANG_SUFFIXES:
|
|
139
|
+
got = _usable_label(upper.get(base + suffix))
|
|
140
|
+
if got is not None:
|
|
141
|
+
return got
|
|
142
|
+
for value in fallbacks:
|
|
143
|
+
got = _usable_label(value)
|
|
144
|
+
if got is not None:
|
|
145
|
+
return got
|
|
146
|
+
return None
|