workwire 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {workwire-0.2.0 → workwire-0.3.0}/CHANGELOG.md +7 -0
- {workwire-0.2.0 → workwire-0.3.0}/PKG-INFO +37 -25
- {workwire-0.2.0 → workwire-0.3.0}/README.md +35 -2
- {workwire-0.2.0 → workwire-0.3.0}/pyproject.toml +3 -3
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/__init__.py +26 -1
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/_client.py +8 -4
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/_coverage.py +62 -4
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/_models.py +231 -13
- workwire-0.3.0/src/workwire/resources/__init__.py +7 -0
- workwire-0.3.0/src/workwire/resources/jobs.py +171 -0
- workwire-0.3.0/src/workwire/resources/locations.py +138 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/resources/projects.py +19 -2
- workwire-0.2.0/src/workwire/resources/__init__.py +0 -5
- {workwire-0.2.0 → workwire-0.3.0}/.gitignore +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/LICENSE +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/SECURITY.md +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/_version.py +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/errors.py +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/py.typed +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/resources/partnerships.py +0 -0
- {workwire-0.2.0 → workwire-0.3.0}/src/workwire/resources/wires.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/workwire/workwire-python/compare/v0.2.0...v0.3.0) (2026-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **WOR-446:** wrap the v1.53.0 surface — project locations, party scope, Jobs lens and org-local fields ([#17](https://github.com/workwire/workwire-python/issues/17)) ([1072284](https://github.com/workwire/workwire-python/commit/107228458633e5f79266684055d3405d6c69dc91))
|
|
9
|
+
|
|
3
10
|
## [0.2.0](https://github.com/workwire/workwire-python/compare/v0.1.0...v0.2.0) (2026-09-04)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,36 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: workwire
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official Python SDK for the Workwire API
|
|
5
5
|
Project-URL: Homepage, https://github.com/workwire/workwire-python
|
|
6
6
|
Project-URL: Changelog, https://github.com/workwire/workwire-python/blob/main/CHANGELOG.md
|
|
7
7
|
Project-URL: Issues, https://github.com/workwire/workwire-python/issues
|
|
8
8
|
Author-email: Workwire <support@workwire.com>
|
|
9
|
-
License: MIT
|
|
10
|
-
|
|
11
|
-
Copyright (c) 2026 Workwire Inc.
|
|
12
|
-
|
|
13
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
-
in the Software without restriction, including without limitation the rights
|
|
16
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
-
furnished to do so, subject to the following conditions:
|
|
19
|
-
|
|
20
|
-
The above copyright notice and this permission notice shall be included in all
|
|
21
|
-
copies or substantial portions of the Software.
|
|
22
|
-
|
|
23
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
-
SOFTWARE.
|
|
9
|
+
License-Expression: MIT
|
|
30
10
|
License-File: LICENSE
|
|
31
11
|
Classifier: Development Status :: 3 - Alpha
|
|
32
12
|
Classifier: Intended Audience :: Developers
|
|
33
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
34
13
|
Classifier: Operating System :: OS Independent
|
|
35
14
|
Classifier: Programming Language :: Python :: 3
|
|
36
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -117,6 +96,35 @@ The quickstart is runnable as a script:
|
|
|
117
96
|
drafts the wire above against a partnership you name, and only sends it
|
|
118
97
|
with `--go-live`.
|
|
119
98
|
|
|
99
|
+
## Locations and Jobs
|
|
100
|
+
|
|
101
|
+
Every project has a location tree under one `site` root
|
|
102
|
+
(`project.root_location_id`). A party's scope is the locations its work
|
|
103
|
+
covers, and it gets one Job (`JOB-…`) per scope location. A Job's
|
|
104
|
+
`your_reference`, `quantity` and `checked_at` are your org's own, never
|
|
105
|
+
shown to the other side.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from workwire import LocationNode, Party
|
|
109
|
+
|
|
110
|
+
project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
|
|
111
|
+
LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
|
|
112
|
+
])
|
|
113
|
+
tree = client.projects.locations.list(project.id).data # the site root first
|
|
114
|
+
level_1 = next(loc for loc in tree if loc.name == "Level 1")
|
|
115
|
+
client.wires.create(project_id=project.id, title="Drywall — Level 1", parties=[Party.partnered(
|
|
116
|
+
partnership_id=partnership.id, trade="Drywall", scope_text="Level 1.",
|
|
117
|
+
contract_value="40000.00", scope_location_ids=[level_1.id], # one Job per scope location
|
|
118
|
+
)])
|
|
119
|
+
page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
|
|
120
|
+
client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Leave `scope_location_ids` out to scope a party to the whole project.
|
|
124
|
+
`client.jobs.update` sends only the fields you pass: leave one out to keep
|
|
125
|
+
it, pass `None` to clear it. Add to a tree later with
|
|
126
|
+
`client.projects.locations.create(...)` or `.create_batch(...)`.
|
|
127
|
+
|
|
120
128
|
## Authentication
|
|
121
129
|
|
|
122
130
|
An org admin issues an API key in the Workwire dashboard under **Settings →
|
|
@@ -176,8 +184,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
|
|
|
176
184
|
|
|
177
185
|
## Retries and idempotency
|
|
178
186
|
|
|
179
|
-
Every `POST` automatically carries an `Idempotency-Key` (a fresh
|
|
180
|
-
pass `idempotency_key=` to control replay across your own process
|
|
187
|
+
Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
|
|
188
|
+
UUID, or pass `idempotency_key=` to control replay across your own process
|
|
189
|
+
restarts).
|
|
181
190
|
That makes retries safe, so the SDK retries for you: connection errors,
|
|
182
191
|
timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
|
|
183
192
|
retryable 409 — `idempotency_in_flight`, where the API asks you to retry
|
|
@@ -202,6 +211,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
|
|
|
202
211
|
...
|
|
203
212
|
```
|
|
204
213
|
|
|
214
|
+
A list's other envelope fields ride on the page: `client.jobs.list()`
|
|
215
|
+
returns a `JobPage` with `.quantity_unit`.
|
|
216
|
+
|
|
205
217
|
## Responses
|
|
206
218
|
|
|
207
219
|
Responses are typed objects with attribute access, `.to_dict()`, and
|
|
@@ -73,6 +73,35 @@ The quickstart is runnable as a script:
|
|
|
73
73
|
drafts the wire above against a partnership you name, and only sends it
|
|
74
74
|
with `--go-live`.
|
|
75
75
|
|
|
76
|
+
## Locations and Jobs
|
|
77
|
+
|
|
78
|
+
Every project has a location tree under one `site` root
|
|
79
|
+
(`project.root_location_id`). A party's scope is the locations its work
|
|
80
|
+
covers, and it gets one Job (`JOB-…`) per scope location. A Job's
|
|
81
|
+
`your_reference`, `quantity` and `checked_at` are your org's own, never
|
|
82
|
+
shown to the other side.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from workwire import LocationNode, Party
|
|
86
|
+
|
|
87
|
+
project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
|
|
88
|
+
LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
|
|
89
|
+
])
|
|
90
|
+
tree = client.projects.locations.list(project.id).data # the site root first
|
|
91
|
+
level_1 = next(loc for loc in tree if loc.name == "Level 1")
|
|
92
|
+
client.wires.create(project_id=project.id, title="Drywall — Level 1", parties=[Party.partnered(
|
|
93
|
+
partnership_id=partnership.id, trade="Drywall", scope_text="Level 1.",
|
|
94
|
+
contract_value="40000.00", scope_location_ids=[level_1.id], # one Job per scope location
|
|
95
|
+
)])
|
|
96
|
+
page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
|
|
97
|
+
client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Leave `scope_location_ids` out to scope a party to the whole project.
|
|
101
|
+
`client.jobs.update` sends only the fields you pass: leave one out to keep
|
|
102
|
+
it, pass `None` to clear it. Add to a tree later with
|
|
103
|
+
`client.projects.locations.create(...)` or `.create_batch(...)`.
|
|
104
|
+
|
|
76
105
|
## Authentication
|
|
77
106
|
|
|
78
107
|
An org admin issues an API key in the Workwire dashboard under **Settings →
|
|
@@ -132,8 +161,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
|
|
|
132
161
|
|
|
133
162
|
## Retries and idempotency
|
|
134
163
|
|
|
135
|
-
Every `POST` automatically carries an `Idempotency-Key` (a fresh
|
|
136
|
-
pass `idempotency_key=` to control replay across your own process
|
|
164
|
+
Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
|
|
165
|
+
UUID, or pass `idempotency_key=` to control replay across your own process
|
|
166
|
+
restarts).
|
|
137
167
|
That makes retries safe, so the SDK retries for you: connection errors,
|
|
138
168
|
timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
|
|
139
169
|
retryable 409 — `idempotency_in_flight`, where the API asks you to retry
|
|
@@ -158,6 +188,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
|
|
|
158
188
|
...
|
|
159
189
|
```
|
|
160
190
|
|
|
191
|
+
A list's other envelope fields ride on the page: `client.jobs.list()`
|
|
192
|
+
returns a `JobPage` with `.quantity_unit`.
|
|
193
|
+
|
|
161
194
|
## Responses
|
|
162
195
|
|
|
163
196
|
Responses are typed objects with attribute access, `.to_dict()`, and
|
|
@@ -4,17 +4,17 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "workwire"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official Python SDK for the Workwire API"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license =
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
11
12
|
authors = [{ name = "Workwire", email = "support@workwire.com" }]
|
|
12
13
|
requires-python = ">=3.10"
|
|
13
14
|
dependencies = ["httpx>=0.24,<1"]
|
|
14
15
|
classifiers = [
|
|
15
16
|
"Development Status :: 3 - Alpha",
|
|
16
17
|
"Intended Audience :: Developers",
|
|
17
|
-
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Operating System :: OS Independent",
|
|
19
19
|
"Programming Language :: Python :: 3",
|
|
20
20
|
"Programming Language :: Python :: 3.10",
|
|
@@ -40,23 +40,35 @@ from ._client import (
|
|
|
40
40
|
BaseClient,
|
|
41
41
|
)
|
|
42
42
|
from ._models import (
|
|
43
|
+
NOT_GIVEN,
|
|
43
44
|
PO,
|
|
44
45
|
ColdInvite,
|
|
46
|
+
JobMilestones,
|
|
47
|
+
JobPage,
|
|
48
|
+
JobPathLocation,
|
|
49
|
+
JobRow,
|
|
50
|
+
Location,
|
|
51
|
+
LocationBatch,
|
|
52
|
+
LocationKind,
|
|
53
|
+
LocationNode,
|
|
45
54
|
Milestone,
|
|
46
55
|
MilestoneSpec,
|
|
56
|
+
NotGiven,
|
|
47
57
|
Page,
|
|
48
58
|
Partnership,
|
|
49
59
|
PartnershipDetail,
|
|
50
60
|
PartnershipListItem,
|
|
51
61
|
Party,
|
|
62
|
+
PartyJob,
|
|
52
63
|
Project,
|
|
64
|
+
ProjectTemplate,
|
|
53
65
|
Wire,
|
|
54
66
|
WireListItem,
|
|
55
67
|
WireParty,
|
|
56
68
|
WorkwireObject,
|
|
57
69
|
)
|
|
58
70
|
from ._version import __version__
|
|
59
|
-
from .resources import Partnerships, Projects, Wires
|
|
71
|
+
from .resources import Jobs, Partnerships, Projects, Wires
|
|
60
72
|
|
|
61
73
|
__all__ = [
|
|
62
74
|
"Workwire",
|
|
@@ -66,18 +78,30 @@ __all__ = [
|
|
|
66
78
|
"Party",
|
|
67
79
|
"MilestoneSpec",
|
|
68
80
|
"ColdInvite",
|
|
81
|
+
"LocationNode",
|
|
82
|
+
"LocationKind",
|
|
83
|
+
"ProjectTemplate",
|
|
84
|
+
"NOT_GIVEN",
|
|
85
|
+
"NotGiven",
|
|
69
86
|
# response types
|
|
70
87
|
"WorkwireObject",
|
|
71
88
|
"Page",
|
|
89
|
+
"JobPage",
|
|
72
90
|
"Project",
|
|
91
|
+
"Location",
|
|
92
|
+
"LocationBatch",
|
|
73
93
|
"Wire",
|
|
74
94
|
"WireListItem",
|
|
75
95
|
"WireParty",
|
|
96
|
+
"PartyJob",
|
|
76
97
|
"Milestone",
|
|
77
98
|
"PO",
|
|
78
99
|
"Partnership",
|
|
79
100
|
"PartnershipDetail",
|
|
80
101
|
"PartnershipListItem",
|
|
102
|
+
"JobRow",
|
|
103
|
+
"JobPathLocation",
|
|
104
|
+
"JobMilestones",
|
|
81
105
|
]
|
|
82
106
|
|
|
83
107
|
|
|
@@ -112,6 +136,7 @@ class Workwire(BaseClient):
|
|
|
112
136
|
self.projects = Projects(self)
|
|
113
137
|
self.wires = Wires(self)
|
|
114
138
|
self.partnerships = Partnerships(self)
|
|
139
|
+
self.jobs = Jobs(self)
|
|
115
140
|
|
|
116
141
|
def request(
|
|
117
142
|
self,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The transport invariants live here, and the test suite holds them:
|
|
4
4
|
|
|
5
|
-
- Every POST carries an ``Idempotency-Key`` — caller-supplied or a
|
|
6
|
-
UUIDv4 — and every retry of that
|
|
7
|
-
serialized bytes**. The server's replay fingerprint is a raw-byte hash of
|
|
5
|
+
- Every POST and PATCH carries an ``Idempotency-Key`` — caller-supplied or a
|
|
6
|
+
generated UUIDv4 — and every retry of that request resends the **same key
|
|
7
|
+
and the same serialized bytes**. The server's replay fingerprint is a raw-byte hash of
|
|
8
8
|
the body, so the body is serialized exactly once per logical request and
|
|
9
9
|
the bytes are reused verbatim across attempts.
|
|
10
10
|
- Retries: connection errors, timeouts, 429 (honoring ``Retry-After``),
|
|
@@ -46,6 +46,10 @@ _RETRY_AFTER_CAP = 30.0
|
|
|
46
46
|
|
|
47
47
|
_RETRYABLE_STATUSES = frozenset({429}) # plus every 5xx, handled by range
|
|
48
48
|
|
|
49
|
+
# The methods the served idempotency layer guards ("Every POST and PATCH
|
|
50
|
+
# accepts an optional Idempotency-Key header").
|
|
51
|
+
_KEYED_METHODS = frozenset({"POST", "PATCH"})
|
|
52
|
+
|
|
49
53
|
|
|
50
54
|
class BaseClient:
|
|
51
55
|
"""HTTP plumbing shared by every resource. Not public API — customers
|
|
@@ -150,7 +154,7 @@ class BaseClient:
|
|
|
150
154
|
content = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
|
|
151
155
|
headers["Content-Type"] = "application/json"
|
|
152
156
|
|
|
153
|
-
if method
|
|
157
|
+
if method in _KEYED_METHODS:
|
|
154
158
|
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
155
159
|
|
|
156
160
|
attempt = 0
|
|
@@ -19,14 +19,24 @@ from . import _models
|
|
|
19
19
|
# operationId → coverage entry.
|
|
20
20
|
# request_fields: top-level request-body properties the SDK can send.
|
|
21
21
|
# query_params: query parameters the SDK can send.
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# response_model: the model the operation's response schema maps to.
|
|
23
|
+
# For paginated lists: the envelope is items_key + next_cursor, plus any
|
|
24
|
+
# extra field page_model annotates (default Page, which annotates none), and
|
|
25
|
+
# the item schema maps to list_item_model. For a one-shot list envelope
|
|
26
|
+
# (no cursor), envelope_key names its one field and envelope_item_model the
|
|
27
|
+
# item schema's model.
|
|
25
28
|
OPERATIONS: dict[str, dict[str, object]] = {
|
|
26
29
|
"createProject": {
|
|
27
30
|
"method": "POST",
|
|
28
31
|
"path": "/v1/projects",
|
|
29
|
-
"request_fields": {
|
|
32
|
+
"request_fields": {
|
|
33
|
+
"name",
|
|
34
|
+
"address",
|
|
35
|
+
"project_code",
|
|
36
|
+
"external_metadata",
|
|
37
|
+
"template",
|
|
38
|
+
"locations",
|
|
39
|
+
},
|
|
30
40
|
"query_params": set(),
|
|
31
41
|
"response_model": _models.Project,
|
|
32
42
|
},
|
|
@@ -45,6 +55,29 @@ OPERATIONS: dict[str, dict[str, object]] = {
|
|
|
45
55
|
"query_params": set(),
|
|
46
56
|
"response_model": _models.Project,
|
|
47
57
|
},
|
|
58
|
+
"listProjectLocations": {
|
|
59
|
+
"method": "GET",
|
|
60
|
+
"path": "/v1/projects/{id}/locations",
|
|
61
|
+
"request_fields": set(),
|
|
62
|
+
"query_params": {"cursor", "limit"},
|
|
63
|
+
"list_item_model": _models.Location,
|
|
64
|
+
"items_key": "locations",
|
|
65
|
+
},
|
|
66
|
+
"createProjectLocation": {
|
|
67
|
+
"method": "POST",
|
|
68
|
+
"path": "/v1/projects/{id}/locations",
|
|
69
|
+
"request_fields": {"parent_id", "kind", "name"},
|
|
70
|
+
"query_params": set(),
|
|
71
|
+
"response_model": _models.Location,
|
|
72
|
+
},
|
|
73
|
+
"createProjectLocationsBatch": {
|
|
74
|
+
"method": "POST",
|
|
75
|
+
"path": "/v1/projects/{id}/locations/batch",
|
|
76
|
+
"request_fields": {"parent_id", "locations"},
|
|
77
|
+
"query_params": set(),
|
|
78
|
+
"envelope_item_model": _models.Location,
|
|
79
|
+
"envelope_key": "locations",
|
|
80
|
+
},
|
|
48
81
|
"createWire": {
|
|
49
82
|
"method": "POST",
|
|
50
83
|
"path": "/v1/wires",
|
|
@@ -102,6 +135,22 @@ OPERATIONS: dict[str, dict[str, object]] = {
|
|
|
102
135
|
"query_params": set(),
|
|
103
136
|
"response_model": _models.PartnershipDetail,
|
|
104
137
|
},
|
|
138
|
+
"listJobs": {
|
|
139
|
+
"method": "GET",
|
|
140
|
+
"path": "/v1/jobs",
|
|
141
|
+
"request_fields": set(),
|
|
142
|
+
"query_params": {"status", "project", "counterparty", "checked", "cursor", "limit"},
|
|
143
|
+
"list_item_model": _models.JobRow,
|
|
144
|
+
"items_key": "jobs",
|
|
145
|
+
"page_model": _models.JobPage,
|
|
146
|
+
},
|
|
147
|
+
"updateJob": {
|
|
148
|
+
"method": "PATCH",
|
|
149
|
+
"path": "/v1/jobs/{id}",
|
|
150
|
+
"request_fields": {"your_reference", "quantity", "checked"},
|
|
151
|
+
"query_params": set(),
|
|
152
|
+
"response_model": _models.PartyJob,
|
|
153
|
+
},
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
# Component schema → the SDK type whose fields mirror it. Request-side types
|
|
@@ -109,8 +158,11 @@ OPERATIONS: dict[str, dict[str, object]] = {
|
|
|
109
158
|
REQUEST_SCHEMA_TYPES: dict[str, type] = {
|
|
110
159
|
"ComposeMilestone": _models.MilestoneSpec,
|
|
111
160
|
"ColdInvitePartnership": _models.ColdInvite,
|
|
161
|
+
"LocationNode": _models.LocationNode,
|
|
112
162
|
}
|
|
113
163
|
|
|
164
|
+
# A dotted key names an inline object schema: "JobRow.milestones" is the
|
|
165
|
+
# object JobRow's milestones property declares in place.
|
|
114
166
|
RESPONSE_SCHEMA_MODELS: dict[str, type[_models.WorkwireObject]] = {
|
|
115
167
|
"Project": _models.Project,
|
|
116
168
|
"Wire": _models.Wire,
|
|
@@ -121,6 +173,11 @@ RESPONSE_SCHEMA_MODELS: dict[str, type[_models.WorkwireObject]] = {
|
|
|
121
173
|
"Partnership": _models.Partnership,
|
|
122
174
|
"PartnershipDetail": _models.PartnershipDetail,
|
|
123
175
|
"PartnershipListItem": _models.PartnershipListItem,
|
|
176
|
+
"Location": _models.Location,
|
|
177
|
+
"PartyJob": _models.PartyJob,
|
|
178
|
+
"JobPathLocation": _models.JobPathLocation,
|
|
179
|
+
"JobRow": _models.JobRow,
|
|
180
|
+
"JobRow.milestones": _models.JobMilestones,
|
|
124
181
|
}
|
|
125
182
|
|
|
126
183
|
# ComposeParty is special-cased in the drift check: its fields are split
|
|
@@ -133,4 +190,5 @@ COMPOSE_PARTY_FIELDS = {
|
|
|
133
190
|
"scope_text",
|
|
134
191
|
"contract_value",
|
|
135
192
|
"milestones",
|
|
193
|
+
"scope_location_ids",
|
|
136
194
|
}
|
|
@@ -8,16 +8,19 @@ annotations on each subclass mirror the served response schema field-for-field
|
|
|
8
8
|
they are documentation and typing aids, not a filter.
|
|
9
9
|
|
|
10
10
|
Request-side constructors (:class:`Party`, :class:`MilestoneSpec`,
|
|
11
|
-
:class:`ColdInvite`) make the composer contract
|
|
12
|
-
are required arguments,
|
|
13
|
-
constructor you called
|
|
11
|
+
:class:`ColdInvite`, :class:`LocationNode`) make the composer contract
|
|
12
|
+
structural: required fields are required arguments, the partnered-XOR-cold-
|
|
13
|
+
invite choice is which constructor you called, and a location tree's nesting
|
|
14
|
+
is the nesting of the objects you built. :data:`NOT_GIVEN` marks an argument
|
|
15
|
+
as *not passed* where the API tells absent apart from ``null``.
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
18
20
|
from collections.abc import Iterator, Mapping, Sequence
|
|
21
|
+
from collections.abc import Set as AbstractSet
|
|
19
22
|
from dataclasses import dataclass
|
|
20
|
-
from typing import Any
|
|
23
|
+
from typing import Any, Literal
|
|
21
24
|
|
|
22
25
|
__all__ = [
|
|
23
26
|
"WorkwireObject",
|
|
@@ -30,9 +33,22 @@ __all__ = [
|
|
|
30
33
|
"Partnership",
|
|
31
34
|
"PartnershipDetail",
|
|
32
35
|
"PartnershipListItem",
|
|
36
|
+
"Location",
|
|
37
|
+
"LocationBatch",
|
|
38
|
+
"PartyJob",
|
|
39
|
+
"JobPathLocation",
|
|
40
|
+
"JobMilestones",
|
|
41
|
+
"JobRow",
|
|
42
|
+
"Page",
|
|
43
|
+
"JobPage",
|
|
33
44
|
"Party",
|
|
34
45
|
"MilestoneSpec",
|
|
35
46
|
"ColdInvite",
|
|
47
|
+
"LocationNode",
|
|
48
|
+
"LocationKind",
|
|
49
|
+
"ProjectTemplate",
|
|
50
|
+
"NotGiven",
|
|
51
|
+
"NOT_GIVEN",
|
|
36
52
|
]
|
|
37
53
|
|
|
38
54
|
|
|
@@ -51,7 +67,8 @@ class WorkwireObject:
|
|
|
51
67
|
served ``null``.
|
|
52
68
|
"""
|
|
53
69
|
|
|
54
|
-
# Nested field name → WorkwireObject subclass to wrap
|
|
70
|
+
# Nested field name → WorkwireObject subclass to wrap the field's object
|
|
71
|
+
# (or, for a list, each of its items) with.
|
|
55
72
|
_nested: Mapping[str, type[WorkwireObject]] = {}
|
|
56
73
|
|
|
57
74
|
def __init__(self, data: Mapping[str, Any], *, request_id: str | None = None) -> None:
|
|
@@ -93,8 +110,12 @@ class WorkwireObject:
|
|
|
93
110
|
|
|
94
111
|
def _wrap(self, name: str, value: Any) -> Any:
|
|
95
112
|
cls = self._nested.get(name)
|
|
96
|
-
if cls is
|
|
113
|
+
if cls is None:
|
|
114
|
+
return value
|
|
115
|
+
if isinstance(value, list):
|
|
97
116
|
return [cls(item) if isinstance(item, Mapping) else item for item in value]
|
|
117
|
+
if isinstance(value, Mapping):
|
|
118
|
+
return cls(value)
|
|
98
119
|
return value
|
|
99
120
|
|
|
100
121
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
@@ -150,8 +171,23 @@ class PO(WorkwireObject):
|
|
|
150
171
|
created_at: str
|
|
151
172
|
|
|
152
173
|
|
|
174
|
+
class PartyJob(WorkwireObject):
|
|
175
|
+
"""One Job on a wire party (``JOB-…``) — the party's work at one location
|
|
176
|
+
of its scope. ``your_reference``, ``quantity`` and ``checked_at`` are
|
|
177
|
+
**your org's own** fields, never the other side's."""
|
|
178
|
+
|
|
179
|
+
id: str
|
|
180
|
+
display_id: str
|
|
181
|
+
location_id: str
|
|
182
|
+
your_reference: str | None
|
|
183
|
+
quantity: str | None
|
|
184
|
+
checked_at: str | None
|
|
185
|
+
archived_at: str | None
|
|
186
|
+
created_at: str
|
|
187
|
+
|
|
188
|
+
|
|
153
189
|
class WireParty(WorkwireObject):
|
|
154
|
-
_nested = {"milestones": Milestone, "pos": PO}
|
|
190
|
+
_nested = {"milestones": Milestone, "pos": PO, "jobs": PartyJob}
|
|
155
191
|
|
|
156
192
|
id: str
|
|
157
193
|
wire_id: str
|
|
@@ -177,6 +213,8 @@ class WireParty(WorkwireObject):
|
|
|
177
213
|
milestones: list[Milestone] | None
|
|
178
214
|
pos: list[PO] | None
|
|
179
215
|
amendment_count: int | None
|
|
216
|
+
scope_locations: list[str] | None
|
|
217
|
+
jobs: list[PartyJob] | None
|
|
180
218
|
|
|
181
219
|
|
|
182
220
|
class Project(WorkwireObject):
|
|
@@ -187,6 +225,7 @@ class Project(WorkwireObject):
|
|
|
187
225
|
project_code: str | None
|
|
188
226
|
status: str
|
|
189
227
|
external_metadata: dict[str, Any] | None
|
|
228
|
+
root_location_id: str | None
|
|
190
229
|
created_at: str
|
|
191
230
|
updated_at: str
|
|
192
231
|
|
|
@@ -255,10 +294,116 @@ class PartnershipListItem(WorkwireObject):
|
|
|
255
294
|
attested_at: str | None
|
|
256
295
|
|
|
257
296
|
|
|
297
|
+
class Location(WorkwireObject):
|
|
298
|
+
"""One location of a project's tree. ``path`` is the ancestor ids, root
|
|
299
|
+
first (empty on the root, whose ``parent_id`` is ``None``)."""
|
|
300
|
+
|
|
301
|
+
id: str
|
|
302
|
+
project_id: str
|
|
303
|
+
parent_id: str | None
|
|
304
|
+
kind: str
|
|
305
|
+
name: str
|
|
306
|
+
path: list[str]
|
|
307
|
+
archived_at: str | None
|
|
308
|
+
created_at: str
|
|
309
|
+
updated_at: str
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class LocationBatch(list[Location]):
|
|
313
|
+
"""What ``projects.locations.create_batch`` returns: a plain ``list`` of
|
|
314
|
+
the created :class:`Location` objects, flat, every parent before its
|
|
315
|
+
children. The served envelope stays reachable — ``request_id`` and
|
|
316
|
+
``to_dict()`` are the response's — so a field the API adds beside
|
|
317
|
+
``locations`` is kept, not dropped."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, data: Mapping[str, Any], *, request_id: str | None = None) -> None:
|
|
320
|
+
super().__init__(
|
|
321
|
+
Location(item, request_id=request_id) if isinstance(item, Mapping) else item
|
|
322
|
+
for item in data.get("locations") or []
|
|
323
|
+
)
|
|
324
|
+
self._data = dict(data)
|
|
325
|
+
self._request_id = request_id
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def request_id(self) -> str | None:
|
|
329
|
+
return self._request_id
|
|
330
|
+
|
|
331
|
+
def to_dict(self) -> dict[str, Any]:
|
|
332
|
+
import copy
|
|
333
|
+
|
|
334
|
+
return copy.deepcopy(self._data)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class JobPathLocation(WorkwireObject):
|
|
338
|
+
"""One ancestor on a Job row's ``location_path``."""
|
|
339
|
+
|
|
340
|
+
id: str
|
|
341
|
+
name: str
|
|
342
|
+
kind: str
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class JobMilestones(WorkwireObject):
|
|
346
|
+
"""A Job row's party milestone counts (repeated across the party's Jobs)."""
|
|
347
|
+
|
|
348
|
+
confirmed: int
|
|
349
|
+
total: int
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class JobRow(WorkwireObject):
|
|
353
|
+
"""One row of :meth:`Jobs.list`: the Job with its party, wire, project,
|
|
354
|
+
location and counterparty context. ``your_reference``, ``quantity`` and
|
|
355
|
+
``checked_at`` are your org's own fields, never the other side's."""
|
|
356
|
+
|
|
357
|
+
_nested = {"location_path": JobPathLocation, "milestones": JobMilestones}
|
|
358
|
+
|
|
359
|
+
id: str
|
|
360
|
+
display_id: str
|
|
361
|
+
your_reference: str | None
|
|
362
|
+
quantity: str | None
|
|
363
|
+
checked_at: str | None
|
|
364
|
+
wire_party_id: str
|
|
365
|
+
party_state: str
|
|
366
|
+
wire_id: str
|
|
367
|
+
wire_display_id: str
|
|
368
|
+
wire_state: str
|
|
369
|
+
project_id: str
|
|
370
|
+
project_name: str
|
|
371
|
+
location_id: str
|
|
372
|
+
location_name: str
|
|
373
|
+
location_kind: str
|
|
374
|
+
location_path: list[JobPathLocation]
|
|
375
|
+
counterparty_org_id: str | None
|
|
376
|
+
counterparty_name: str | None
|
|
377
|
+
milestones: JobMilestones
|
|
378
|
+
archived_at: str | None
|
|
379
|
+
created_at: str
|
|
380
|
+
|
|
381
|
+
|
|
258
382
|
# --------------------------------------------------------------------------
|
|
259
383
|
# Request-side typed constructors
|
|
260
384
|
# --------------------------------------------------------------------------
|
|
261
385
|
|
|
386
|
+
# The served enums, as typing aids — the server stays the referee (an unknown
|
|
387
|
+
# kind is ``422 invalid_location_kind``). ``site`` is the root's alone,
|
|
388
|
+
# created with the project, so it is not a kind you can send.
|
|
389
|
+
LocationKind = Literal["house", "building", "floor", "unit", "room", "common_area"]
|
|
390
|
+
ProjectTemplate = Literal["development", "single_home", "renovation"]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class NotGiven:
|
|
394
|
+
"""The type of :data:`NOT_GIVEN`: *this argument was not passed* — kept
|
|
395
|
+
apart from an explicit ``None``, which the API reads as *clear the
|
|
396
|
+
field*. Falsy, so ``if value:`` reads it as unset."""
|
|
397
|
+
|
|
398
|
+
def __bool__(self) -> bool:
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
def __repr__(self) -> str:
|
|
402
|
+
return "NOT_GIVEN"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
NOT_GIVEN = NotGiven()
|
|
406
|
+
|
|
262
407
|
|
|
263
408
|
@dataclass(frozen=True)
|
|
264
409
|
class MilestoneSpec:
|
|
@@ -297,6 +442,57 @@ class ColdInvite:
|
|
|
297
442
|
return payload
|
|
298
443
|
|
|
299
444
|
|
|
445
|
+
@dataclass(frozen=True)
|
|
446
|
+
class LocationNode:
|
|
447
|
+
"""One location of a tree being created — its ``kind``, its ``name``, and
|
|
448
|
+
the ``children`` nested beneath it. Any nesting is allowed (a building
|
|
449
|
+
may hold floors or units directly). At most 500 locations per request,
|
|
450
|
+
counted over every level."""
|
|
451
|
+
|
|
452
|
+
kind: LocationKind
|
|
453
|
+
name: str
|
|
454
|
+
children: Sequence[LocationNode] | None = None
|
|
455
|
+
|
|
456
|
+
def __post_init__(self) -> None:
|
|
457
|
+
if self.children is None:
|
|
458
|
+
return
|
|
459
|
+
if isinstance(self.children, LocationNode):
|
|
460
|
+
raise TypeError("children must be a list of workwire.LocationNode")
|
|
461
|
+
children = tuple(_ordered(self.children, "children")) # frozen all the way down
|
|
462
|
+
for child in children:
|
|
463
|
+
if not isinstance(child, LocationNode):
|
|
464
|
+
raise TypeError("children must be workwire.LocationNode objects")
|
|
465
|
+
object.__setattr__(self, "children", children)
|
|
466
|
+
|
|
467
|
+
def to_payload(self) -> dict[str, Any]:
|
|
468
|
+
payload: dict[str, Any] = {"kind": self.kind, "name": self.name}
|
|
469
|
+
if self.children is not None:
|
|
470
|
+
payload["children"] = [child.to_payload() for child in self.children]
|
|
471
|
+
return payload
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _ordered(items: Any, what: str) -> list[Any]:
|
|
475
|
+
# Read exactly once (an iterator must not be spent by a check). A str is
|
|
476
|
+
# a sequence of characters; a set or mapping has no order that holds
|
|
477
|
+
# across processes, so the same call would serialize to different bytes —
|
|
478
|
+
# a different idempotency fingerprint on a replay.
|
|
479
|
+
if isinstance(items, (str, bytes, AbstractSet, Mapping)):
|
|
480
|
+
raise TypeError(f"{what} must be a list or tuple, not {type(items).__name__}")
|
|
481
|
+
return list(items)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _location_payloads(locations: Sequence[LocationNode]) -> list[dict[str, Any]]:
|
|
485
|
+
nodes = _ordered(locations, "locations")
|
|
486
|
+
for node in nodes:
|
|
487
|
+
if not isinstance(node, LocationNode):
|
|
488
|
+
raise TypeError("locations must be built with workwire.LocationNode(...)")
|
|
489
|
+
return [node.to_payload() for node in nodes]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _scope_location_ids(ids: Sequence[str]) -> list[str]:
|
|
493
|
+
return _ordered(ids, "scope_location_ids")
|
|
494
|
+
|
|
495
|
+
|
|
300
496
|
class Party:
|
|
301
497
|
"""One party on a wire being composed.
|
|
302
498
|
|
|
@@ -309,6 +505,12 @@ class Party:
|
|
|
309
505
|
|
|
310
506
|
``contract_value`` is a decimal string, e.g. ``"125000.00"``, visible
|
|
311
507
|
only to the two sides of this party.
|
|
508
|
+
|
|
509
|
+
``scope_location_ids`` names the locations of the project's tree this
|
|
510
|
+
party's work covers — the party gets one Job per location. Leave it
|
|
511
|
+
``None`` (not sent) for the whole project. An empty list is sent as
|
|
512
|
+
given, never dropped — dropping it would widen the scope to the whole
|
|
513
|
+
project — and the server refuses it (``scope_empty``).
|
|
312
514
|
"""
|
|
313
515
|
|
|
314
516
|
def __init__(self, payload: dict[str, Any], *, _guard: object = None) -> None:
|
|
@@ -327,6 +529,7 @@ class Party:
|
|
|
327
529
|
scope_text: str,
|
|
328
530
|
contract_value: str,
|
|
329
531
|
milestones: Sequence[MilestoneSpec] | None = None,
|
|
532
|
+
scope_location_ids: Sequence[str] | None = None,
|
|
330
533
|
) -> Party:
|
|
331
534
|
payload: dict[str, Any] = {
|
|
332
535
|
"partnership_id": partnership_id,
|
|
@@ -336,6 +539,8 @@ class Party:
|
|
|
336
539
|
}
|
|
337
540
|
if milestones:
|
|
338
541
|
payload["milestones"] = [m.to_payload() for m in milestones]
|
|
542
|
+
if scope_location_ids is not None:
|
|
543
|
+
payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
|
|
339
544
|
return cls(payload, _guard=_PARTY_GUARD)
|
|
340
545
|
|
|
341
546
|
@classmethod
|
|
@@ -348,6 +553,7 @@ class Party:
|
|
|
348
553
|
scope_text: str,
|
|
349
554
|
contract_value: str,
|
|
350
555
|
milestones: Sequence[MilestoneSpec] | None = None,
|
|
556
|
+
scope_location_ids: Sequence[str] | None = None,
|
|
351
557
|
) -> Party:
|
|
352
558
|
payload: dict[str, Any] = {
|
|
353
559
|
"cold_invite": {
|
|
@@ -360,6 +566,8 @@ class Party:
|
|
|
360
566
|
}
|
|
361
567
|
if milestones:
|
|
362
568
|
payload["milestones"] = [m.to_payload() for m in milestones]
|
|
569
|
+
if scope_location_ids is not None:
|
|
570
|
+
payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
|
|
363
571
|
return cls(payload, _guard=_PARTY_GUARD)
|
|
364
572
|
|
|
365
573
|
def to_payload(self) -> dict[str, Any]:
|
|
@@ -375,12 +583,14 @@ class Page(WorkwireObject):
|
|
|
375
583
|
"""One page of a cursor-paginated list.
|
|
376
584
|
|
|
377
585
|
``data`` is the page's items as typed objects (whatever the served key —
|
|
378
|
-
``projects``, ``wires``, ``partnerships``
|
|
379
|
-
cursor (``None`` at the end); ``has_more``
|
|
380
|
-
ends **only** on a null cursor — a short page
|
|
381
|
-
legal middle page.
|
|
382
|
-
|
|
383
|
-
|
|
586
|
+
``projects``, ``wires``, ``partnerships``, ``locations``, ``jobs``);
|
|
587
|
+
``next_cursor`` is the served cursor (``None`` at the end); ``has_more``
|
|
588
|
+
derives from it. Iteration ends **only** on a null cursor — a short page
|
|
589
|
+
with a live cursor is a legal middle page.
|
|
590
|
+
|
|
591
|
+
Any other envelope field rides on the page as an attribute, like every
|
|
592
|
+
served field; a subclass such as :class:`JobPage` declares the ones its
|
|
593
|
+
list serves. ``auto_paging_iter()`` walks the whole list transparently.
|
|
384
594
|
"""
|
|
385
595
|
|
|
386
596
|
def __init__(
|
|
@@ -419,3 +629,11 @@ class Page(WorkwireObject):
|
|
|
419
629
|
if page._fetch_next is None:
|
|
420
630
|
raise RuntimeError("this page cannot fetch further pages")
|
|
421
631
|
page = page._fetch_next(page.next_cursor)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class JobPage(Page):
|
|
635
|
+
"""A page of :meth:`Jobs.list` — a :class:`Page` whose envelope also
|
|
636
|
+
carries ``quantity_unit``: your org's one unit label for every
|
|
637
|
+
``quantity`` (``"sheets"``), set in the app; ``None`` until it is."""
|
|
638
|
+
|
|
639
|
+
quantity_unit: str | None
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Jobs — one Wire party's work at one location of its scope (``JOB-…``,
|
|
2
|
+
numbered per project).
|
|
3
|
+
|
|
4
|
+
Jobs are derived: a party gets one per scope location
|
|
5
|
+
(``scope_location_ids`` on ``Party.partnered(...)`` /
|
|
6
|
+
``Party.cold_invite(...)``; absent, the whole project), and nothing creates,
|
|
7
|
+
names or closes one. Each org keeps its own private fields on a Job —
|
|
8
|
+
``your_reference``, ``quantity`` (in the org's ``quantity_unit``) and
|
|
9
|
+
``checked_at`` — never shown to the other side.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from .._models import NOT_GIVEN, JobPage, JobRow, NotGiven, PartyJob
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .._client import BaseClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The served quantity grammar is at most 32 digits either side of the point.
|
|
24
|
+
_QUANTITY_DIGITS = 32
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quantity(value: str | Decimal | None) -> str | None:
|
|
28
|
+
if value is None or isinstance(value, str):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, Decimal):
|
|
31
|
+
if not value.is_finite():
|
|
32
|
+
raise ValueError(f"quantity must be a finite decimal, got {value}")
|
|
33
|
+
exponent = value.as_tuple().exponent
|
|
34
|
+
assert isinstance(exponent, int) # finite
|
|
35
|
+
if value.adjusted() >= _QUANTITY_DIGITS or exponent < -_QUANTITY_DIGITS:
|
|
36
|
+
# Far outside anything the API takes: fixed-point would expand it
|
|
37
|
+
# to as many digits as its exponent says. Send it compact and let
|
|
38
|
+
# the server refuse it.
|
|
39
|
+
return str(value)
|
|
40
|
+
# Fixed-point: identical to str() for an ordinary Decimal, but never
|
|
41
|
+
# exponent notation (str(Decimal("1E+2")) is "1E+2", which the API
|
|
42
|
+
# refuses).
|
|
43
|
+
return format(value, "f")
|
|
44
|
+
if isinstance(value, float):
|
|
45
|
+
raise TypeError(
|
|
46
|
+
"quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
|
|
47
|
+
"never a float: a binary float is not an exact quantity"
|
|
48
|
+
)
|
|
49
|
+
raise TypeError(
|
|
50
|
+
"quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
|
|
51
|
+
f"not {type(value).__name__} — pass str(n) or Decimal(n)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Jobs:
|
|
56
|
+
def __init__(self, client: BaseClient) -> None:
|
|
57
|
+
self._client = client
|
|
58
|
+
|
|
59
|
+
def list(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
status: str | None = None,
|
|
63
|
+
project: str | None = None,
|
|
64
|
+
counterparty: str | None = None,
|
|
65
|
+
checked: bool | None = None,
|
|
66
|
+
cursor: str | None = None,
|
|
67
|
+
limit: int | None = None,
|
|
68
|
+
timeout: float | None = None,
|
|
69
|
+
) -> JobPage:
|
|
70
|
+
"""List every Job your org is a side of — on a Wire your org created,
|
|
71
|
+
or on your org's party once that Wire is sent (a draft's Jobs stay
|
|
72
|
+
hidden from the sub) — newest first. Requires ``wire_parties:read``.
|
|
73
|
+
|
|
74
|
+
``status`` is ``open`` (default: not archived, and the party has not
|
|
75
|
+
ended), ``ended``, or ``all``; ``project`` and ``counterparty`` are
|
|
76
|
+
ids; ``checked`` keeps only the Jobs your org has (``True``) or has
|
|
77
|
+
not (``False``) checked. The filters AND together. ``limit``
|
|
78
|
+
defaults to 50, max 250 (clamped by the server). Each row carries
|
|
79
|
+
your org's own fields, and the page's ``quantity_unit`` is your
|
|
80
|
+
org's unit for every ``quantity`` on it.
|
|
81
|
+
|
|
82
|
+
The app's per-user ``pinned`` filter is not offered: pins belong to
|
|
83
|
+
a user, an API key has none, and the server refuses ``?pinned``
|
|
84
|
+
(``InvalidRequestError``).
|
|
85
|
+
"""
|
|
86
|
+
params: dict[str, Any] = {}
|
|
87
|
+
if status is not None:
|
|
88
|
+
params["status"] = status
|
|
89
|
+
if project is not None:
|
|
90
|
+
params["project"] = project
|
|
91
|
+
if counterparty is not None:
|
|
92
|
+
params["counterparty"] = counterparty
|
|
93
|
+
if checked is not None:
|
|
94
|
+
if not isinstance(checked, bool):
|
|
95
|
+
raise TypeError("checked must be True, False, or None")
|
|
96
|
+
params["checked"] = "true" if checked else "false"
|
|
97
|
+
if cursor is not None:
|
|
98
|
+
params["cursor"] = cursor
|
|
99
|
+
if limit is not None:
|
|
100
|
+
params["limit"] = limit
|
|
101
|
+
data, request_id = self._client._request(
|
|
102
|
+
"GET", "/v1/jobs", params=params, timeout=timeout
|
|
103
|
+
)
|
|
104
|
+
return JobPage(
|
|
105
|
+
data,
|
|
106
|
+
request_id=request_id,
|
|
107
|
+
items_key="jobs",
|
|
108
|
+
item_cls=JobRow,
|
|
109
|
+
fetch_next=lambda next_cursor: self.list(
|
|
110
|
+
status=status,
|
|
111
|
+
project=project,
|
|
112
|
+
counterparty=counterparty,
|
|
113
|
+
checked=checked,
|
|
114
|
+
cursor=next_cursor,
|
|
115
|
+
limit=limit,
|
|
116
|
+
timeout=timeout,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def update(
|
|
121
|
+
self,
|
|
122
|
+
job_id: str,
|
|
123
|
+
*,
|
|
124
|
+
your_reference: str | None | NotGiven = NOT_GIVEN,
|
|
125
|
+
quantity: str | Decimal | None | NotGiven = NOT_GIVEN,
|
|
126
|
+
checked: bool | None | NotGiven = NOT_GIVEN,
|
|
127
|
+
idempotency_key: str | None = None,
|
|
128
|
+
timeout: float | None = None,
|
|
129
|
+
) -> PartyJob:
|
|
130
|
+
"""Set your org's own fields on a Job. Requires
|
|
131
|
+
``wire_parties:write``.
|
|
132
|
+
|
|
133
|
+
**Presence is the contract**: an argument you leave out is not sent
|
|
134
|
+
and the server keeps that field; an explicit ``None`` is sent as
|
|
135
|
+
``null`` and clears it. At least one is required.
|
|
136
|
+
|
|
137
|
+
- ``your_reference`` — your own reference (≤200 characters).
|
|
138
|
+
- ``quantity`` — a decimal string (``"12.5"``) or a
|
|
139
|
+
``decimal.Decimal``, in your org's ``quantity_unit``; at most 2
|
|
140
|
+
decimal places. Never a float.
|
|
141
|
+
- ``checked`` — ``True`` records your org's inspection now (again on
|
|
142
|
+
every ``True``); ``False`` or ``None`` clears it.
|
|
143
|
+
|
|
144
|
+
Never visible to the other side, and never the other side's. A Job
|
|
145
|
+
your org is not a side of — a draft's Job, for the sub — is a
|
|
146
|
+
``NotFoundError``. Returns the Job as your org now sees it.
|
|
147
|
+
"""
|
|
148
|
+
body: dict[str, Any] = {}
|
|
149
|
+
if not isinstance(your_reference, NotGiven):
|
|
150
|
+
if your_reference is not None and not isinstance(your_reference, str):
|
|
151
|
+
raise TypeError("your_reference must be a str or None")
|
|
152
|
+
body["your_reference"] = your_reference
|
|
153
|
+
if not isinstance(quantity, NotGiven):
|
|
154
|
+
body["quantity"] = _quantity(quantity)
|
|
155
|
+
if not isinstance(checked, NotGiven):
|
|
156
|
+
if checked is not None and not isinstance(checked, bool):
|
|
157
|
+
raise TypeError("checked must be True, False, or None")
|
|
158
|
+
body["checked"] = checked
|
|
159
|
+
if not body:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"pass at least one of your_reference, quantity, checked "
|
|
162
|
+
"(None clears a field; leaving one out keeps it)"
|
|
163
|
+
)
|
|
164
|
+
data, request_id = self._client._request(
|
|
165
|
+
"PATCH",
|
|
166
|
+
f"/v1/jobs/{job_id}",
|
|
167
|
+
json_body=body,
|
|
168
|
+
idempotency_key=idempotency_key,
|
|
169
|
+
timeout=timeout,
|
|
170
|
+
)
|
|
171
|
+
return PartyJob(data, request_id=request_id)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Project locations — a project's location tree, reached as
|
|
2
|
+
``client.projects.locations``.
|
|
3
|
+
|
|
4
|
+
Every project has one ``site`` root, created with the project
|
|
5
|
+
(``Project.root_location_id``), and beneath it houses, buildings, floors,
|
|
6
|
+
units, rooms and common areas, nested any way. Locations have stable ids —
|
|
7
|
+
nothing references one by name or path. Seed a tree when you create the
|
|
8
|
+
project (``projects.create(locations=...)``) or add to it here; renames,
|
|
9
|
+
moves and archives happen in the Workwire app.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from .._models import (
|
|
18
|
+
Location,
|
|
19
|
+
LocationBatch,
|
|
20
|
+
LocationKind,
|
|
21
|
+
LocationNode,
|
|
22
|
+
Page,
|
|
23
|
+
_location_payloads,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .._client import BaseClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectLocations:
|
|
31
|
+
def __init__(self, client: BaseClient) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
def list(
|
|
35
|
+
self,
|
|
36
|
+
project_id: str,
|
|
37
|
+
*,
|
|
38
|
+
cursor: str | None = None,
|
|
39
|
+
limit: int | None = None,
|
|
40
|
+
timeout: float | None = None,
|
|
41
|
+
) -> Page:
|
|
42
|
+
"""Read a project's location tree. Requires ``projects:read``.
|
|
43
|
+
|
|
44
|
+
Tree order is ``path``, then ``id``: the root first, every location
|
|
45
|
+
after its parent, siblings in creation order — each parent's
|
|
46
|
+
children together, not depth-first. Archived locations are not
|
|
47
|
+
listed. ``limit`` defaults to and maxes at 1,000 (clamped by the
|
|
48
|
+
server), and a full page always carries ``next_cursor``, so a
|
|
49
|
+
smaller tree is one page; a larger tree's later pages are each in
|
|
50
|
+
tree order on their own — sort every page's rows together by
|
|
51
|
+
``path``, then ``id``, for the whole tree's order
|
|
52
|
+
(``auto_paging_iter()`` reads them all). Another org's project is a
|
|
53
|
+
``NotFoundError``.
|
|
54
|
+
"""
|
|
55
|
+
params: dict[str, Any] = {}
|
|
56
|
+
if cursor is not None:
|
|
57
|
+
params["cursor"] = cursor
|
|
58
|
+
if limit is not None:
|
|
59
|
+
params["limit"] = limit
|
|
60
|
+
data, request_id = self._client._request(
|
|
61
|
+
"GET", f"/v1/projects/{project_id}/locations", params=params, timeout=timeout
|
|
62
|
+
)
|
|
63
|
+
return Page(
|
|
64
|
+
data,
|
|
65
|
+
request_id=request_id,
|
|
66
|
+
items_key="locations",
|
|
67
|
+
item_cls=Location,
|
|
68
|
+
fetch_next=lambda next_cursor: self.list(
|
|
69
|
+
project_id, cursor=next_cursor, limit=limit, timeout=timeout
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def create(
|
|
74
|
+
self,
|
|
75
|
+
project_id: str,
|
|
76
|
+
*,
|
|
77
|
+
kind: LocationKind,
|
|
78
|
+
name: str,
|
|
79
|
+
parent_id: str | None = None,
|
|
80
|
+
idempotency_key: str | None = None,
|
|
81
|
+
timeout: float | None = None,
|
|
82
|
+
) -> Location:
|
|
83
|
+
"""Add one location beneath ``parent_id`` — absent, the project's
|
|
84
|
+
root. Requires ``projects:write``.
|
|
85
|
+
|
|
86
|
+
A parent that is not an unarchived location of this project's tree
|
|
87
|
+
raises ``InvalidRequestError`` (code ``invalid_parent``); an unknown
|
|
88
|
+
``kind`` is checked with the body here — ``InvalidRequestError``
|
|
89
|
+
code ``validation_error``, ``fields["kind"]``. An archived project's
|
|
90
|
+
tree is read-only (``ConflictError``, code ``project_archived``).
|
|
91
|
+
"""
|
|
92
|
+
body: dict[str, Any] = {"kind": kind, "name": name}
|
|
93
|
+
if parent_id is not None:
|
|
94
|
+
body["parent_id"] = parent_id
|
|
95
|
+
data, request_id = self._client._request(
|
|
96
|
+
"POST",
|
|
97
|
+
f"/v1/projects/{project_id}/locations",
|
|
98
|
+
json_body=body,
|
|
99
|
+
idempotency_key=idempotency_key,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
return Location(data, request_id=request_id)
|
|
103
|
+
|
|
104
|
+
def create_batch(
|
|
105
|
+
self,
|
|
106
|
+
project_id: str,
|
|
107
|
+
*,
|
|
108
|
+
locations: Sequence[LocationNode],
|
|
109
|
+
parent_id: str | None = None,
|
|
110
|
+
idempotency_key: str | None = None,
|
|
111
|
+
timeout: float | None = None,
|
|
112
|
+
) -> LocationBatch:
|
|
113
|
+
"""Create a nested list of locations beneath ``parent_id`` — absent,
|
|
114
|
+
the project's root — in one transaction. Requires
|
|
115
|
+
``projects:write``.
|
|
116
|
+
|
|
117
|
+
At most 500 locations, counted over every level: more raises
|
|
118
|
+
``InvalidRequestError`` (code ``too_many_locations``, with
|
|
119
|
+
``details["limit"]``), as does an unknown ``kind``
|
|
120
|
+
(``invalid_location_kind``); a refused node creates nothing. Returns
|
|
121
|
+
a :class:`LocationBatch` — a plain list of the created locations,
|
|
122
|
+
flat, every parent before its children, so you have every id; each
|
|
123
|
+
carries the response's ``request_id``.
|
|
124
|
+
"""
|
|
125
|
+
payloads = _location_payloads(locations)
|
|
126
|
+
if not payloads:
|
|
127
|
+
raise ValueError("a batch needs at least one location")
|
|
128
|
+
body: dict[str, Any] = {"locations": payloads}
|
|
129
|
+
if parent_id is not None:
|
|
130
|
+
body["parent_id"] = parent_id
|
|
131
|
+
data, request_id = self._client._request(
|
|
132
|
+
"POST",
|
|
133
|
+
f"/v1/projects/{project_id}/locations/batch",
|
|
134
|
+
json_body=body,
|
|
135
|
+
idempotency_key=idempotency_key,
|
|
136
|
+
timeout=timeout,
|
|
137
|
+
)
|
|
138
|
+
return LocationBatch(data or {}, request_id=request_id)
|
|
@@ -7,10 +7,11 @@ signatures keyword-only and mirroring the spec's schemas. The drift gate
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from collections.abc import Mapping
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
|
-
from .._models import Page, Project
|
|
13
|
+
from .._models import LocationNode, Page, Project, ProjectTemplate, _location_payloads
|
|
14
|
+
from .locations import ProjectLocations
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from .._client import BaseClient
|
|
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
|
|
|
19
20
|
class Projects:
|
|
20
21
|
def __init__(self, client: BaseClient) -> None:
|
|
21
22
|
self._client = client
|
|
23
|
+
self.locations = ProjectLocations(client)
|
|
22
24
|
|
|
23
25
|
def create(
|
|
24
26
|
self,
|
|
@@ -27,6 +29,8 @@ class Projects:
|
|
|
27
29
|
address: str | None = None,
|
|
28
30
|
project_code: str | None = None,
|
|
29
31
|
external_metadata: Mapping[str, Any] | None = None,
|
|
32
|
+
template: ProjectTemplate | None = None,
|
|
33
|
+
locations: Sequence[LocationNode] | None = None,
|
|
30
34
|
idempotency_key: str | None = None,
|
|
31
35
|
timeout: float | None = None,
|
|
32
36
|
) -> Project:
|
|
@@ -36,6 +40,15 @@ class Projects:
|
|
|
36
40
|
``external_metadata`` (≤16 KB) round-trips verbatim — carry your own
|
|
37
41
|
system's identifiers in it (keys beginning ``_workwire:`` are
|
|
38
42
|
reserved).
|
|
43
|
+
|
|
44
|
+
The project is created with its location tree's ``site`` root
|
|
45
|
+
(``root_location_id``). ``locations`` seeds a tree beneath the root
|
|
46
|
+
in the same transaction — at most 500 locations counted over every
|
|
47
|
+
level (``too_many_locations``), each of a known kind
|
|
48
|
+
(``invalid_location_kind``); a refused node creates nothing, the
|
|
49
|
+
project included. ``template`` (``development``, ``single_home``,
|
|
50
|
+
``renovation``) records the Project Type your tree follows in the
|
|
51
|
+
project's history only — the server seeds nothing from it.
|
|
39
52
|
"""
|
|
40
53
|
body: dict[str, Any] = {"name": name}
|
|
41
54
|
if address is not None:
|
|
@@ -44,6 +57,10 @@ class Projects:
|
|
|
44
57
|
body["project_code"] = project_code
|
|
45
58
|
if external_metadata is not None:
|
|
46
59
|
body["external_metadata"] = dict(external_metadata)
|
|
60
|
+
if template is not None:
|
|
61
|
+
body["template"] = template
|
|
62
|
+
if locations is not None:
|
|
63
|
+
body["locations"] = _location_payloads(locations)
|
|
47
64
|
data, request_id = self._client._request(
|
|
48
65
|
"POST",
|
|
49
66
|
"/v1/projects",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|