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,143 @@
|
|
|
1
|
+
"""Pure, offline ticket statistics — counts and per-dimension breakdowns.
|
|
2
|
+
|
|
3
|
+
Network-free by design: it consumes already-fetched :class:`Request` objects so it
|
|
4
|
+
can be unit-tested without a client. The sync/async clients fetch records and
|
|
5
|
+
delegate to :func:`aggregate_tickets`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .models.request import Request
|
|
17
|
+
from .references import resolve_reference
|
|
18
|
+
|
|
19
|
+
_FRACTION_RE = re.compile(r"\.(\d+)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_iso_datetime(value: Any) -> datetime | None:
|
|
23
|
+
"""Parse an EasyVista timestamp to a timezone-aware ``datetime``, or ``None``.
|
|
24
|
+
|
|
25
|
+
Accepts a ``datetime`` (returned as-is, naive made UTC) or an ISO-8601 string.
|
|
26
|
+
Normalizes for Python 3.10's stricter ``fromisoformat``: maps a trailing ``Z``
|
|
27
|
+
to ``+00:00`` and pads/truncates fractional seconds to 6 digits. A naive result
|
|
28
|
+
is treated as UTC. Unparseable input returns ``None``.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(value, datetime):
|
|
31
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
32
|
+
if not isinstance(value, str) or not value.strip():
|
|
33
|
+
return None
|
|
34
|
+
text = value.strip()
|
|
35
|
+
if text.endswith(("Z", "z")):
|
|
36
|
+
text = text[:-1] + "+00:00"
|
|
37
|
+
match = _FRACTION_RE.search(text)
|
|
38
|
+
if match:
|
|
39
|
+
frac6 = (match.group(1) + "000000")[:6]
|
|
40
|
+
text = text[: match.start()] + "." + frac6 + text[match.end() :]
|
|
41
|
+
try:
|
|
42
|
+
parsed = datetime.fromisoformat(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DEFAULT_DIMENSIONS: tuple[str, ...] = (
|
|
49
|
+
"STATUS",
|
|
50
|
+
"DEPARTMENT",
|
|
51
|
+
"CATALOG_REQUEST",
|
|
52
|
+
"URGENCY",
|
|
53
|
+
"IMPACT",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_UNKNOWN = "(unknown)"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TicketStatistics:
|
|
61
|
+
"""A ticket count plus per-dimension breakdowns.
|
|
62
|
+
|
|
63
|
+
``total`` is the number of tickets aggregated (after any date window).
|
|
64
|
+
``breakdowns`` maps each requested dimension name to ``{label: count}``; for
|
|
65
|
+
every dimension ``sum(breakdowns[dim].values()) == total``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
total: int
|
|
69
|
+
breakdowns: dict[str, dict[str, int]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dimension_value(data: dict[str, Any], name: str) -> str:
|
|
73
|
+
"""Group key for one ticket on one dimension: label, else id, else unknown."""
|
|
74
|
+
return resolve_reference(data, name).display or _UNKNOWN
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fields_for_references(
|
|
78
|
+
names: Sequence[str], *, include_creation_date: bool
|
|
79
|
+
) -> list[str]:
|
|
80
|
+
"""Search-projection field list covering every reference in ``names``.
|
|
81
|
+
|
|
82
|
+
Requests each reference's nested object and its id fields so both label and id
|
|
83
|
+
are available when aggregating over search results; over-requesting a
|
|
84
|
+
non-existent field is ignored by the API.
|
|
85
|
+
"""
|
|
86
|
+
fields: list[str] = ["RFC_NUMBER"]
|
|
87
|
+
for name in names:
|
|
88
|
+
for field in (name, f"{name}_ID", f"{name}_GUID"):
|
|
89
|
+
if field not in fields:
|
|
90
|
+
fields.append(field)
|
|
91
|
+
if include_creation_date and "CREATION_DATE_UT" not in fields:
|
|
92
|
+
fields.append("CREATION_DATE_UT")
|
|
93
|
+
return fields
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bound(value: datetime | str | None, name: str) -> datetime | None:
|
|
97
|
+
"""Parse a window bound; raise ValueError on a malformed string."""
|
|
98
|
+
if value is None:
|
|
99
|
+
return None
|
|
100
|
+
parsed = _parse_iso_datetime(value)
|
|
101
|
+
if parsed is None:
|
|
102
|
+
raise ValueError(f"{name} is not a valid datetime: {value!r}")
|
|
103
|
+
return parsed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def aggregate_tickets(
|
|
107
|
+
tickets: Iterable[Request],
|
|
108
|
+
*,
|
|
109
|
+
dimensions: Sequence[str] = DEFAULT_DIMENSIONS,
|
|
110
|
+
created_since: datetime | str | None = None,
|
|
111
|
+
created_until: datetime | str | None = None,
|
|
112
|
+
) -> TicketStatistics:
|
|
113
|
+
"""Aggregate tickets into a total plus per-dimension breakdowns.
|
|
114
|
+
|
|
115
|
+
``dimensions`` selects which breakdowns to compute (default: all of
|
|
116
|
+
``DEFAULT_DIMENSIONS``); any field name is valid, including custom ``e_*``.
|
|
117
|
+
``created_since`` / ``created_until`` are inclusive bounds on the ticket's
|
|
118
|
+
``CREATION_DATE_UT`` (a ``datetime`` or ISO string); a ticket with a
|
|
119
|
+
missing/unparseable date is excluded when a bound is set. Raises ``ValueError``
|
|
120
|
+
for a malformed bound string.
|
|
121
|
+
"""
|
|
122
|
+
since = _bound(created_since, "created_since")
|
|
123
|
+
until = _bound(created_until, "created_until")
|
|
124
|
+
filtering = since is not None or until is not None
|
|
125
|
+
|
|
126
|
+
total = 0
|
|
127
|
+
breakdowns: dict[str, dict[str, int]] = {dim: {} for dim in dimensions}
|
|
128
|
+
for ticket in tickets:
|
|
129
|
+
data = ticket.model_dump(by_alias=True)
|
|
130
|
+
if filtering:
|
|
131
|
+
created = _parse_iso_datetime(data.get("CREATION_DATE_UT"))
|
|
132
|
+
if created is None:
|
|
133
|
+
continue
|
|
134
|
+
if since is not None and created < since:
|
|
135
|
+
continue
|
|
136
|
+
if until is not None and created > until:
|
|
137
|
+
continue
|
|
138
|
+
total += 1
|
|
139
|
+
for dim in dimensions:
|
|
140
|
+
key = _dimension_value(data, dim)
|
|
141
|
+
counts = breakdowns[dim]
|
|
142
|
+
counts[key] = counts.get(key, 0) + 1
|
|
143
|
+
return TicketStatistics(total=total, breakdowns=breakdowns)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resource builders: pure functions returning (RequestSpec, parser)."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Builders for the ticket ``actions`` sub-resource.
|
|
2
|
+
|
|
3
|
+
``list_actions`` rides the generic search builder over the top-level ``/actions``
|
|
4
|
+
resource (unwrapping the :class:`SearchResult` to a bare list). ``create_action``
|
|
5
|
+
posts a bare body nested under the parent request, which does not fit the flat-CRUD
|
|
6
|
+
engine, so it stays a small bespoke override alongside the ``ACTIONS`` descriptor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._transport import RequestSpec
|
|
15
|
+
from ..filters import ev_equals_filter
|
|
16
|
+
from ..models.action import Action, PostAction
|
|
17
|
+
from ..pagination import extract_records
|
|
18
|
+
from .descriptor import ResourceDescriptor, build_get, build_search
|
|
19
|
+
|
|
20
|
+
ACTIONS: ResourceDescriptor[Action] = ResourceDescriptor(
|
|
21
|
+
path="actions", envelope_key="actions", model=Action
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_create_action(
|
|
26
|
+
rfc_number: str, payload: PostAction
|
|
27
|
+
) -> tuple[RequestSpec, Callable[[Any], Action]]:
|
|
28
|
+
# Create is nested under the request, one action per call, with a bare body
|
|
29
|
+
# (NOT wrapped in an ``actions`` array) — verified against a live instance.
|
|
30
|
+
spec = RequestSpec("POST", f"requests/{rfc_number}/actions", json=payload.to_api())
|
|
31
|
+
|
|
32
|
+
def parse(data: Any) -> Action:
|
|
33
|
+
records = extract_records(data)
|
|
34
|
+
return Action.model_validate(records[0] if records else data)
|
|
35
|
+
|
|
36
|
+
return spec, parse
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_list_actions(
|
|
40
|
+
rfc_number: str,
|
|
41
|
+
) -> tuple[RequestSpec, Callable[[Any], list[Action]]]:
|
|
42
|
+
# Actions are listed via the TOP-LEVEL /actions resource filtered by the
|
|
43
|
+
# request number, not a nested requests/{rfc}/actions path (which the API
|
|
44
|
+
# rejects as "Unauthorized Method"). Verified against a live instance.
|
|
45
|
+
# An unsafe rfc_number raises rather than degrading: ',' is a live combinator,
|
|
46
|
+
# so a raw value could append conditions and list another ticket's actions. A
|
|
47
|
+
# blank one must raise too — ev_equals_filter returns None for blank input, and
|
|
48
|
+
# search=None would list every action just as surely.
|
|
49
|
+
search = ev_equals_filter("REQUEST.RFC_NUMBER", rfc_number)
|
|
50
|
+
if search is None:
|
|
51
|
+
raise ValueError("rfc_number is required to list a ticket's actions")
|
|
52
|
+
spec, parse_search = build_search(ACTIONS, search=search)
|
|
53
|
+
|
|
54
|
+
def parse(data: Any) -> list[Action]:
|
|
55
|
+
return parse_search(data).records
|
|
56
|
+
|
|
57
|
+
return spec, parse
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_get_action(
|
|
61
|
+
action_id: str | int,
|
|
62
|
+
) -> tuple[RequestSpec, Callable[[Any], Action]]:
|
|
63
|
+
"""Fetch ONE action by id.
|
|
64
|
+
|
|
65
|
+
The item-level record is far richer than the list endpoint's: the note text
|
|
66
|
+
a caller passed as ``PostAction.description`` comes back through a
|
|
67
|
+
``DESCRIPTION`` Memo sub-resource that ``list_actions`` does not return at
|
|
68
|
+
all (verified live). Uses the **top-level** ``actions/{id}`` path — the
|
|
69
|
+
nested ``requests/{rfc}/actions/{id}`` is rejected with HTTP 403, the same
|
|
70
|
+
way the nested list path is.
|
|
71
|
+
"""
|
|
72
|
+
return build_get(ACTIONS, action_id)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Builders for the ``assets`` resource.
|
|
2
|
+
|
|
3
|
+
Create/get/search ride the generic resource engine (:mod:`.descriptor`); the
|
|
4
|
+
returned ``(RequestSpec, parser)`` pairs are shared by the sync and async clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .._transport import RequestSpec
|
|
13
|
+
from ..models.asset import Asset, PostAsset
|
|
14
|
+
from ..pagination import SearchResult
|
|
15
|
+
from .descriptor import ResourceDescriptor, build_create, build_get, build_search
|
|
16
|
+
|
|
17
|
+
ASSETS: ResourceDescriptor[Asset] = ResourceDescriptor(
|
|
18
|
+
path="assets", envelope_key="assets", model=Asset
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_create_asset(
|
|
23
|
+
payload: PostAsset,
|
|
24
|
+
) -> tuple[RequestSpec, Callable[[Any], Asset]]:
|
|
25
|
+
return build_create(ASSETS, payload)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_get_asset(asset_id: str) -> tuple[RequestSpec, Callable[[Any], Asset]]:
|
|
29
|
+
return build_get(ASSETS, asset_id)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_search_assets(
|
|
33
|
+
*,
|
|
34
|
+
search: str | None = None,
|
|
35
|
+
fields: Iterable[str] | str | None = None,
|
|
36
|
+
sort: str | None = None,
|
|
37
|
+
max_rows: int | None = None,
|
|
38
|
+
offset: int | None = None,
|
|
39
|
+
) -> tuple[RequestSpec, Callable[[Any], SearchResult[Asset]]]:
|
|
40
|
+
return build_search(
|
|
41
|
+
ASSETS,
|
|
42
|
+
search=search,
|
|
43
|
+
fields=fields,
|
|
44
|
+
sort=sort,
|
|
45
|
+
max_rows=max_rows,
|
|
46
|
+
offset=offset,
|
|
47
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Builders for the ``departments`` resource — thin declarations over the engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._transport import RequestSpec
|
|
9
|
+
from ..models.department import Department, DepartmentUpdate, PostDepartment
|
|
10
|
+
from ..pagination import SearchResult
|
|
11
|
+
from .descriptor import (
|
|
12
|
+
ResourceDescriptor,
|
|
13
|
+
build_create,
|
|
14
|
+
build_get,
|
|
15
|
+
build_search,
|
|
16
|
+
build_update,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEPARTMENTS: ResourceDescriptor[Department] = ResourceDescriptor(
|
|
20
|
+
path="departments", envelope_key="departments", model=Department
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_get_department(
|
|
25
|
+
department_id: str | int,
|
|
26
|
+
) -> tuple[RequestSpec, Callable[[Any], Department]]:
|
|
27
|
+
return build_get(DEPARTMENTS, department_id)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_search_departments(
|
|
31
|
+
*,
|
|
32
|
+
search: str | None = None,
|
|
33
|
+
fields: Iterable[str] | str | None = None,
|
|
34
|
+
sort: str | None = None,
|
|
35
|
+
max_rows: int | None = None,
|
|
36
|
+
offset: int | None = None,
|
|
37
|
+
) -> tuple[RequestSpec, Callable[[Any], SearchResult[Department]]]:
|
|
38
|
+
return build_search(
|
|
39
|
+
DEPARTMENTS,
|
|
40
|
+
search=search,
|
|
41
|
+
fields=fields,
|
|
42
|
+
sort=sort,
|
|
43
|
+
max_rows=max_rows,
|
|
44
|
+
offset=offset,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_create_department(
|
|
49
|
+
payload: PostDepartment,
|
|
50
|
+
) -> tuple[RequestSpec, Callable[[Any], Department]]:
|
|
51
|
+
return build_create(DEPARTMENTS, payload)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_update_department(
|
|
55
|
+
department_id: str | int, update: DepartmentUpdate
|
|
56
|
+
) -> tuple[RequestSpec, Callable[[Any], Department]]:
|
|
57
|
+
return build_update(DEPARTMENTS, department_id, update)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Generic resource engine.
|
|
2
|
+
|
|
3
|
+
A documented flat-CRUD EasyVista resource is data (a :class:`ResourceDescriptor`),
|
|
4
|
+
not a bespoke module: a descriptor + these four builders produce the same
|
|
5
|
+
``(RequestSpec, parser)`` pairs the hand-written builders did. Resource-specific
|
|
6
|
+
quirks that do not fit the flat shape — the ticket ``close`` body, ``create_action``'s
|
|
7
|
+
bare nested POST, and the whole ``documents`` sub-resource (a per-ticket nested path) —
|
|
8
|
+
stay as small overrides alongside their descriptor.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from .._transport import RequestSpec
|
|
18
|
+
from ..models.common import EasyvistaModel, EasyvistaWriteModel
|
|
19
|
+
from ..pagination import SearchResult, build_search_result, extract_records
|
|
20
|
+
|
|
21
|
+
M = TypeVar("M", bound=EasyvistaModel)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ResourceDescriptor(Generic[M]):
|
|
26
|
+
"""A documented EasyVista resource.
|
|
27
|
+
|
|
28
|
+
Holds its path, create/list envelope key, and read model.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
path: str
|
|
32
|
+
envelope_key: str
|
|
33
|
+
model: type[M]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_record_parser(desc: ResourceDescriptor[M]) -> Callable[[Any], M]:
|
|
37
|
+
"""Build a parser that validates the first extracted record (or bare ``data``).
|
|
38
|
+
|
|
39
|
+
Shared by :func:`build_get`, :func:`build_create` and :func:`build_update` —
|
|
40
|
+
all three parse a single record, either wrapped in the resource's envelope
|
|
41
|
+
(or ``records``) or returned bare (e.g. a create's ``HREF``-only body).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def parse(data: Any) -> M:
|
|
45
|
+
records = extract_records(data, desc.envelope_key)
|
|
46
|
+
return desc.model.model_validate(records[0] if records else data)
|
|
47
|
+
|
|
48
|
+
return parse
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_get(
|
|
52
|
+
desc: ResourceDescriptor[M], record_id: Any
|
|
53
|
+
) -> tuple[RequestSpec, Callable[[Any], M]]:
|
|
54
|
+
return RequestSpec("GET", f"{desc.path}/{record_id}"), _first_record_parser(desc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_search(
|
|
58
|
+
desc: ResourceDescriptor[M],
|
|
59
|
+
*,
|
|
60
|
+
search: str | None = None,
|
|
61
|
+
fields: Iterable[str] | str | None = None,
|
|
62
|
+
sort: str | None = None,
|
|
63
|
+
max_rows: int | None = None,
|
|
64
|
+
offset: int | None = None,
|
|
65
|
+
) -> tuple[RequestSpec, Callable[[Any], SearchResult[M]]]:
|
|
66
|
+
params: dict[str, Any] = {}
|
|
67
|
+
if search is not None:
|
|
68
|
+
params["search"] = search
|
|
69
|
+
if fields is not None:
|
|
70
|
+
params["fields"] = fields if isinstance(fields, str) else ",".join(fields)
|
|
71
|
+
if sort is not None:
|
|
72
|
+
params["sort"] = sort
|
|
73
|
+
if max_rows is not None:
|
|
74
|
+
params["max_rows"] = max_rows
|
|
75
|
+
if offset is not None:
|
|
76
|
+
params["offset"] = offset
|
|
77
|
+
|
|
78
|
+
def parse(data: Any) -> SearchResult[M]:
|
|
79
|
+
records = [
|
|
80
|
+
desc.model.model_validate(r)
|
|
81
|
+
for r in extract_records(data, desc.envelope_key)
|
|
82
|
+
]
|
|
83
|
+
return build_search_result(data, records)
|
|
84
|
+
|
|
85
|
+
return RequestSpec("GET", desc.path, params=params), parse
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_create(
|
|
89
|
+
desc: ResourceDescriptor[M], payload: EasyvistaWriteModel
|
|
90
|
+
) -> tuple[RequestSpec, Callable[[Any], M]]:
|
|
91
|
+
spec = RequestSpec("POST", desc.path, json={desc.envelope_key: [payload.to_api()]})
|
|
92
|
+
return spec, _first_record_parser(desc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_update(
|
|
96
|
+
desc: ResourceDescriptor[M], record_id: Any, payload: EasyvistaWriteModel
|
|
97
|
+
) -> tuple[RequestSpec, Callable[[Any], M]]:
|
|
98
|
+
spec = RequestSpec("PUT", f"{desc.path}/{record_id}", json=payload.to_api())
|
|
99
|
+
return spec, _first_record_parser(desc)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Builders for the ticket ``documents`` sub-resource.
|
|
2
|
+
|
|
3
|
+
Documents are uploaded as base64 inside a JSON body, so no special multipart
|
|
4
|
+
transport handling is needed. The list endpoint shape is a best guess pending
|
|
5
|
+
live validation (spec open item O5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._transport import RequestSpec
|
|
15
|
+
from ..models.document import Document
|
|
16
|
+
from ..pagination import extract_records
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first_document(data: Any) -> Document:
|
|
20
|
+
records = extract_records(data)
|
|
21
|
+
return Document.model_validate(records[0] if records else data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _document_records(data: Any) -> list[dict[str, Any]]:
|
|
25
|
+
"""Find the list of document dicts in a list response.
|
|
26
|
+
|
|
27
|
+
The live list wraps items under a capital-D ``Documents`` key (verified live),
|
|
28
|
+
which the generic ``extract_records`` (lowercase ``documents``/``records``) does
|
|
29
|
+
not match; check any case-insensitive ``documents`` key first, then fall back.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
for key, value in data.items():
|
|
33
|
+
if key.lower() == "documents" and isinstance(value, list):
|
|
34
|
+
return [r for r in value if isinstance(r, dict)]
|
|
35
|
+
return extract_records(data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _all_documents(data: Any) -> list[Document]:
|
|
39
|
+
return [Document.model_validate(r) for r in _document_records(data)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_add_document(
|
|
43
|
+
rfc_number: str, *, filename: str, content: bytes
|
|
44
|
+
) -> tuple[RequestSpec, Callable[[Any], Document]]:
|
|
45
|
+
filedata = base64.b64encode(content).decode("ascii")
|
|
46
|
+
spec = RequestSpec(
|
|
47
|
+
"POST",
|
|
48
|
+
f"requests/{rfc_number}/documents",
|
|
49
|
+
json={"documents": [{"filename": filename, "filedata": filedata}]},
|
|
50
|
+
)
|
|
51
|
+
return spec, _first_document
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_list_documents(
|
|
55
|
+
rfc_number: str,
|
|
56
|
+
) -> tuple[RequestSpec, Callable[[Any], list[Document]]]:
|
|
57
|
+
return RequestSpec("GET", f"requests/{rfc_number}/documents"), _all_documents
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def download_href(document: Document | str) -> str:
|
|
61
|
+
"""The URL to fetch a document's bytes.
|
|
62
|
+
|
|
63
|
+
Accepts a :class:`Document` or a raw href/path. Prefers ``DDL_HREF`` (the
|
|
64
|
+
direct-download URL) and falls back to ``HREF``. Lives here, not on either
|
|
65
|
+
client, so the sync and async ``download_document`` share one definition of
|
|
66
|
+
which field carries the URL -- the same reason every other request/response
|
|
67
|
+
decision lives in this package.
|
|
68
|
+
"""
|
|
69
|
+
href = (
|
|
70
|
+
document
|
|
71
|
+
if isinstance(document, str)
|
|
72
|
+
else (document.download_href or document.href)
|
|
73
|
+
)
|
|
74
|
+
if not href:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"document has no download URL (neither DDL_HREF nor HREF is set)"
|
|
77
|
+
)
|
|
78
|
+
return href
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Builders for the ``employees`` resource — thin declarations over the engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._transport import RequestSpec
|
|
9
|
+
from ..models.employee import Employee, EmployeeUpdate, PostEmployee
|
|
10
|
+
from ..pagination import SearchResult
|
|
11
|
+
from .descriptor import (
|
|
12
|
+
ResourceDescriptor,
|
|
13
|
+
build_create,
|
|
14
|
+
build_get,
|
|
15
|
+
build_search,
|
|
16
|
+
build_update,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
EMPLOYEES: ResourceDescriptor[Employee] = ResourceDescriptor(
|
|
20
|
+
path="employees", envelope_key="employees", model=Employee
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_get_employee(
|
|
25
|
+
employee_id: str | int,
|
|
26
|
+
) -> tuple[RequestSpec, Callable[[Any], Employee]]:
|
|
27
|
+
return build_get(EMPLOYEES, employee_id)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_search_employees(
|
|
31
|
+
*,
|
|
32
|
+
search: str | None = None,
|
|
33
|
+
fields: Iterable[str] | str | None = None,
|
|
34
|
+
sort: str | None = None,
|
|
35
|
+
max_rows: int | None = None,
|
|
36
|
+
offset: int | None = None,
|
|
37
|
+
) -> tuple[RequestSpec, Callable[[Any], SearchResult[Employee]]]:
|
|
38
|
+
return build_search(
|
|
39
|
+
EMPLOYEES,
|
|
40
|
+
search=search,
|
|
41
|
+
fields=fields,
|
|
42
|
+
sort=sort,
|
|
43
|
+
max_rows=max_rows,
|
|
44
|
+
offset=offset,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_create_employee(
|
|
49
|
+
payload: PostEmployee,
|
|
50
|
+
) -> tuple[RequestSpec, Callable[[Any], Employee]]:
|
|
51
|
+
return build_create(EMPLOYEES, payload)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_update_employee(
|
|
55
|
+
employee_id: str | int, update: EmployeeUpdate
|
|
56
|
+
) -> tuple[RequestSpec, Callable[[Any], Employee]]:
|
|
57
|
+
return build_update(EMPLOYEES, employee_id, update)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Builders for the ``requests`` (ticket) resource.
|
|
2
|
+
|
|
3
|
+
Get/search/create/update ride the generic resource engine (:mod:`.descriptor`);
|
|
4
|
+
only the resource-specific ``close`` override stays bespoke. The clients execute the
|
|
5
|
+
returned spec and feed the JSON to the parser, so all request/response logic lives
|
|
6
|
+
here and is shared by the sync and async clients.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._transport import RequestSpec
|
|
15
|
+
from ..models.request import PostRequest, Request, RequestUpdate
|
|
16
|
+
from ..pagination import SearchResult, extract_records
|
|
17
|
+
from .descriptor import (
|
|
18
|
+
ResourceDescriptor,
|
|
19
|
+
build_create,
|
|
20
|
+
build_get,
|
|
21
|
+
build_search,
|
|
22
|
+
build_update,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
REQUESTS: ResourceDescriptor[Request] = ResourceDescriptor(
|
|
26
|
+
path="requests", envelope_key="requests", model=Request
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_create_ticket(
|
|
31
|
+
payload: PostRequest,
|
|
32
|
+
) -> tuple[RequestSpec, Callable[[Any], Request]]:
|
|
33
|
+
return build_create(REQUESTS, payload)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_get_ticket(rfc_number: str) -> tuple[RequestSpec, Callable[[Any], Request]]:
|
|
37
|
+
return build_get(REQUESTS, rfc_number)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_search_tickets(
|
|
41
|
+
*,
|
|
42
|
+
search: str | None = None,
|
|
43
|
+
fields: Iterable[str] | str | None = None,
|
|
44
|
+
sort: str | None = None,
|
|
45
|
+
max_rows: int | None = None,
|
|
46
|
+
offset: int | None = None,
|
|
47
|
+
) -> tuple[RequestSpec, Callable[[Any], SearchResult[Request]]]:
|
|
48
|
+
return build_search(
|
|
49
|
+
REQUESTS,
|
|
50
|
+
search=search,
|
|
51
|
+
fields=fields,
|
|
52
|
+
sort=sort,
|
|
53
|
+
max_rows=max_rows,
|
|
54
|
+
offset=offset,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_update_ticket(
|
|
59
|
+
rfc_number: str, update: RequestUpdate
|
|
60
|
+
) -> tuple[RequestSpec, Callable[[Any], Request]]:
|
|
61
|
+
return build_update(REQUESTS, rfc_number, update)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_close_ticket(
|
|
65
|
+
rfc_number: str,
|
|
66
|
+
*,
|
|
67
|
+
status_guid: str | None = None,
|
|
68
|
+
delete_actions: int | None = None,
|
|
69
|
+
comment: str | None = None,
|
|
70
|
+
) -> tuple[RequestSpec, Callable[[Any], Request]]:
|
|
71
|
+
"""Build a close (PUT) spec.
|
|
72
|
+
|
|
73
|
+
``status_guid`` is the instance's "closed" status GUID (EasyVista
|
|
74
|
+
``status_GUID``); without it the API may not actually transition the ticket.
|
|
75
|
+
``delete_actions=1`` drops the ticket's actions on close. Shapes follow the
|
|
76
|
+
documented close body (``docs/API_Info.md``), verified live.
|
|
77
|
+
"""
|
|
78
|
+
closed: dict[str, Any] = {}
|
|
79
|
+
if status_guid is not None:
|
|
80
|
+
closed["status_GUID"] = status_guid
|
|
81
|
+
if delete_actions is not None:
|
|
82
|
+
closed["delete_actions"] = delete_actions
|
|
83
|
+
if comment is not None:
|
|
84
|
+
closed["comment"] = comment
|
|
85
|
+
spec = RequestSpec("PUT", f"requests/{rfc_number}", json={"closed": closed})
|
|
86
|
+
|
|
87
|
+
def parse(data: Any) -> Request:
|
|
88
|
+
records = extract_records(data)
|
|
89
|
+
return Request.model_validate(records[0] if records else data)
|
|
90
|
+
|
|
91
|
+
return spec, parse
|