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,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