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.
Files changed (42) hide show
  1. easyvista_python_client/__init__.py +74 -0
  2. easyvista_python_client/_async/__init__.py +13 -0
  3. easyvista_python_client/_async/_concurrency.py +78 -0
  4. easyvista_python_client/_async/_transport.py +266 -0
  5. easyvista_python_client/_async/client.py +790 -0
  6. easyvista_python_client/_fields.py +26 -0
  7. easyvista_python_client/_html.py +41 -0
  8. easyvista_python_client/_sync/__init__.py +13 -0
  9. easyvista_python_client/_sync/_concurrency.py +50 -0
  10. easyvista_python_client/_sync/_transport.py +266 -0
  11. easyvista_python_client/_sync/client.py +790 -0
  12. easyvista_python_client/_transport.py +29 -0
  13. easyvista_python_client/config.py +71 -0
  14. easyvista_python_client/context.py +116 -0
  15. easyvista_python_client/directory.py +53 -0
  16. easyvista_python_client/exceptions.py +53 -0
  17. easyvista_python_client/field_model.py +74 -0
  18. easyvista_python_client/filters.py +82 -0
  19. easyvista_python_client/models/__init__.py +1 -0
  20. easyvista_python_client/models/action.py +65 -0
  21. easyvista_python_client/models/asset.py +36 -0
  22. easyvista_python_client/models/common.py +78 -0
  23. easyvista_python_client/models/department.py +68 -0
  24. easyvista_python_client/models/document.py +32 -0
  25. easyvista_python_client/models/employee.py +67 -0
  26. easyvista_python_client/models/request.py +172 -0
  27. easyvista_python_client/pagination.py +84 -0
  28. easyvista_python_client/py.typed +0 -0
  29. easyvista_python_client/references.py +146 -0
  30. easyvista_python_client/reporting.py +143 -0
  31. easyvista_python_client/resources/__init__.py +1 -0
  32. easyvista_python_client/resources/actions.py +72 -0
  33. easyvista_python_client/resources/assets.py +47 -0
  34. easyvista_python_client/resources/departments.py +57 -0
  35. easyvista_python_client/resources/descriptor.py +99 -0
  36. easyvista_python_client/resources/documents.py +78 -0
  37. easyvista_python_client/resources/employees.py +57 -0
  38. easyvista_python_client/resources/requests.py +91 -0
  39. easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
  40. easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
  41. easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
  42. 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