pyprocore 1.0.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.
- app.py +176 -0
- auth/__init__.py +45 -0
- auth/oauth.py +166 -0
- auth/token_manager.py +106 -0
- auth/token_store.py +158 -0
- core/__init__.py +57 -0
- core/client.py +425 -0
- core/config.py +97 -0
- core/endpoints.py +54 -0
- core/exceptions.py +58 -0
- core/logger.py +141 -0
- models/__init__.py +25 -0
- models/base.py +11 -0
- models/resources.py +86 -0
- parser/__init__.py +17 -0
- parser/email_parser.py +165 -0
- pyprocore-1.0.0.dist-info/METADATA +241 -0
- pyprocore-1.0.0.dist-info/RECORD +27 -0
- pyprocore-1.0.0.dist-info/WHEEL +5 -0
- pyprocore-1.0.0.dist-info/entry_points.txt +2 -0
- pyprocore-1.0.0.dist-info/top_level.txt +6 -0
- services/__init__.py +31 -0
- services/companies.py +31 -0
- services/files.py +281 -0
- services/projects.py +87 -0
- services/rfis.py +147 -0
- services/submittals.py +140 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
app.py,sha256=Ov7JpwDrg0hczDCGLg-xNAKOYfi0fKJTEVxva6mwGN4,5565
|
|
2
|
+
auth/__init__.py,sha256=5GxjVbiveFzp_ul6NKkopXI7MwpDD--PBNcqrTw3oss,1282
|
|
3
|
+
auth/oauth.py,sha256=faFM5gdgFmqj42BCufsiQzPjV_s-l9aidq69SHc9WhI,5740
|
|
4
|
+
auth/token_manager.py,sha256=lKpQyzBn111DhB1F_EybrErPNvvRfPYpQgp5ZlDDnfk,3713
|
|
5
|
+
auth/token_store.py,sha256=X0I-Zg4AKq9EL55o8Gd-pWvn8RTi3LSqk4-GQAL6Uh0,5349
|
|
6
|
+
core/__init__.py,sha256=RqbfJLARs_8hpR1P5qmiYcoUdkqwspWaEeN6dBcScEQ,1414
|
|
7
|
+
core/client.py,sha256=CnnZUK9sQU_ZuzCHjh6dep606crHHVLE_fuF8bzJGS0,14438
|
|
8
|
+
core/config.py,sha256=OTrA0kq3bpTgYqcomB9zFgJcuqhazSKXO4lTISx6MoE,2956
|
|
9
|
+
core/endpoints.py,sha256=_l8S2wu5w2uvYVd4QeeQ5duyU5oJWDUe3pPQ-WWO9eg,1612
|
|
10
|
+
core/exceptions.py,sha256=xndIP033kKas0VJN7oQ1L0bFZ-zsN-s_UwpThEokb3E,1585
|
|
11
|
+
core/logger.py,sha256=OtPdHNZgffsSwrEVBhSwAPwbs3_MuD3RtkQdo9-2zGk,4016
|
|
12
|
+
models/__init__.py,sha256=2IwIIJAo1itAnU_RCqnvWMwEj1JYC-SXopF17YCHhh4,383
|
|
13
|
+
models/base.py,sha256=_qCHmEpWBuKOajD1AFKVEMNwyw3B9WQpQqIkeROl804,308
|
|
14
|
+
models/resources.py,sha256=2NXNqx3UQh1gYyQyqLzt5PFVGsdTPM6TJw979A9xKgo,2009
|
|
15
|
+
parser/__init__.py,sha256=ka0WVtIT1NgzQIKPP1FESTpGjW9fFxMuPRjAikzEm3s,329
|
|
16
|
+
parser/email_parser.py,sha256=c6xG4oDRJMt870r5kAgoNfvepgbgOkskGgVV1ai20PI,5807
|
|
17
|
+
services/__init__.py,sha256=cEzm2U-gSb-0YmUR-hwzkNwPj2OznbFXo1Eqm-vHAxQ,879
|
|
18
|
+
services/companies.py,sha256=9y2zkqv-U5Jh9k4QZyLgSmKhWNwWURZz_hWCEioyRWM,1046
|
|
19
|
+
services/files.py,sha256=I_mfiEn6NII8938R1XQcM4Wfh8sFHcRcf8p9bstt5t8,9884
|
|
20
|
+
services/projects.py,sha256=7IK8d_TpT0qI5q1fMHl2QFeiCt8ynu7XTnxtIFI6EBQ,2769
|
|
21
|
+
services/rfis.py,sha256=jc-HrJHvtRHjd-CkZySTKfWeh9HTZqLtv7C1-byLIr0,4856
|
|
22
|
+
services/submittals.py,sha256=J-hMU7l3e0NqoH5I2UtDEvheENNigYxAvIlIHi3i62E,4733
|
|
23
|
+
pyprocore-1.0.0.dist-info/METADATA,sha256=cszEl4t-FObr-kTPlPYoksN4PGE7RvpxZBHrK1urSw4,6348
|
|
24
|
+
pyprocore-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
25
|
+
pyprocore-1.0.0.dist-info/entry_points.txt,sha256=dLRkcqKECUJsQKG0uh6lNQxe0YCYHbXVlTZV1A9UQOM,41
|
|
26
|
+
pyprocore-1.0.0.dist-info/top_level.txt,sha256=B6v51zzul23u5voBAWwqzqFQG33BPcCbr7EeGiFpn7k,37
|
|
27
|
+
pyprocore-1.0.0.dist-info/RECORD,,
|
services/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Service layer exports for Procore resources."""
|
|
2
|
+
|
|
3
|
+
from services.companies import CompaniesService, list_companies
|
|
4
|
+
from services.files import FileDownloadService, attachment_filename, download_url
|
|
5
|
+
from services.projects import ProjectsService, get_project, list_projects
|
|
6
|
+
from services.rfis import RFIsService, download_rfi_attachments, get_rfi, list_rfis
|
|
7
|
+
from services.submittals import (
|
|
8
|
+
SubmittalsService,
|
|
9
|
+
download_submittal_attachments,
|
|
10
|
+
get_submittal,
|
|
11
|
+
list_submittals,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CompaniesService",
|
|
16
|
+
"FileDownloadService",
|
|
17
|
+
"ProjectsService",
|
|
18
|
+
"RFIsService",
|
|
19
|
+
"SubmittalsService",
|
|
20
|
+
"attachment_filename",
|
|
21
|
+
"download_rfi_attachments",
|
|
22
|
+
"download_submittal_attachments",
|
|
23
|
+
"download_url",
|
|
24
|
+
"get_project",
|
|
25
|
+
"get_rfi",
|
|
26
|
+
"get_submittal",
|
|
27
|
+
"list_companies",
|
|
28
|
+
"list_projects",
|
|
29
|
+
"list_rfis",
|
|
30
|
+
"list_submittals",
|
|
31
|
+
]
|
services/companies.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Company service for the Procore SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core import endpoints
|
|
6
|
+
from core.client import ProcoreClient
|
|
7
|
+
from models import Company
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompaniesService:
|
|
11
|
+
"""Service for Procore company resources."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: ProcoreClient | None = None) -> None:
|
|
14
|
+
"""Initialize the service.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
client: Optional shared Procore HTTP client.
|
|
18
|
+
"""
|
|
19
|
+
self._client = client or ProcoreClient()
|
|
20
|
+
|
|
21
|
+
def list_companies(self) -> list[Company]:
|
|
22
|
+
"""Return companies available to the authenticated Procore user."""
|
|
23
|
+
response = self._client.get_all(endpoints.companies())
|
|
24
|
+
if isinstance(response, list):
|
|
25
|
+
return [Company.model_validate(company) for company in response]
|
|
26
|
+
return [Company.model_validate(response)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_companies(client: ProcoreClient | None = None) -> list[Company]:
|
|
30
|
+
"""Return companies available to the authenticated Procore user."""
|
|
31
|
+
return CompaniesService(client=client).list_companies()
|
services/files.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Shared file download helpers for Procore resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from tenacity import (
|
|
13
|
+
retry,
|
|
14
|
+
retry_if_exception_type,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from auth.token_manager import TokenManager
|
|
20
|
+
from core.client import DEFAULT_TIMEOUT_SECONDS
|
|
21
|
+
from core.exceptions import ProcoreAPIError, ValidationError
|
|
22
|
+
from core.logger import get_logger, log_exception, structured_message
|
|
23
|
+
|
|
24
|
+
DEFAULT_DOWNLOAD_DIR = Path(__file__).resolve().parents[1] / "downloads"
|
|
25
|
+
DEFAULT_CHUNK_SIZE = 1024 * 1024
|
|
26
|
+
UNSAFE_FILENAME_PATTERN = re.compile(r"[^A-Za-z0-9._ -]+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileDownloadService:
|
|
30
|
+
"""Service for downloading signed Procore attachment URLs."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
token_manager: TokenManager | None = None,
|
|
35
|
+
session: requests.Session | None = None,
|
|
36
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
37
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize the file download service.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
token_manager: Optional token manager for bearer tokens.
|
|
43
|
+
session: Optional HTTP session for file requests.
|
|
44
|
+
timeout_seconds: Timeout for file requests.
|
|
45
|
+
chunk_size: Number of bytes written per streamed chunk.
|
|
46
|
+
"""
|
|
47
|
+
self._token_manager = token_manager or TokenManager()
|
|
48
|
+
self._session = session or requests.Session()
|
|
49
|
+
self._timeout_seconds = timeout_seconds
|
|
50
|
+
self._chunk_size = chunk_size
|
|
51
|
+
self._logger = get_logger("files")
|
|
52
|
+
|
|
53
|
+
def download_url(
|
|
54
|
+
self,
|
|
55
|
+
url: str,
|
|
56
|
+
destination: Path | str,
|
|
57
|
+
*,
|
|
58
|
+
overwrite: bool = False,
|
|
59
|
+
) -> Path:
|
|
60
|
+
"""Download a signed Procore URL to a destination path.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
url: Signed Procore attachment URL.
|
|
64
|
+
destination: Full local destination path.
|
|
65
|
+
overwrite: Whether to overwrite an existing destination file.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The saved file path.
|
|
69
|
+
"""
|
|
70
|
+
normalized_url = url.strip()
|
|
71
|
+
if not normalized_url:
|
|
72
|
+
raise ValidationError("Attachment URL cannot be empty.")
|
|
73
|
+
|
|
74
|
+
destination_path = Path(destination)
|
|
75
|
+
if destination_path.name in {"", ".", ".."}:
|
|
76
|
+
raise ValidationError("Attachment destination must include a filename.")
|
|
77
|
+
|
|
78
|
+
destination_path = destination_path.with_name(
|
|
79
|
+
sanitize_filename(destination_path.name)
|
|
80
|
+
)
|
|
81
|
+
if destination_path.exists() and not overwrite:
|
|
82
|
+
self._logger.info(
|
|
83
|
+
structured_message(
|
|
84
|
+
"download_skipped",
|
|
85
|
+
path=str(destination_path),
|
|
86
|
+
reason="exists",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return destination_path
|
|
90
|
+
|
|
91
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = self._get_with_retry(normalized_url)
|
|
95
|
+
self._raise_for_download_status(response, normalized_url)
|
|
96
|
+
self._write_stream(response, destination_path)
|
|
97
|
+
except requests.RequestException as exc:
|
|
98
|
+
log_exception(
|
|
99
|
+
self._logger,
|
|
100
|
+
exc=exc,
|
|
101
|
+
request_url=normalized_url,
|
|
102
|
+
http_status=getattr(
|
|
103
|
+
locals().get("response", None), "status_code", None
|
|
104
|
+
),
|
|
105
|
+
response_body=getattr(locals().get("response", None), "text", None),
|
|
106
|
+
)
|
|
107
|
+
raise ProcoreAPIError(
|
|
108
|
+
f"Attachment download failed for {normalized_url}: {exc}"
|
|
109
|
+
) from exc
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
log_exception(
|
|
112
|
+
self._logger,
|
|
113
|
+
exc=exc,
|
|
114
|
+
request_url=normalized_url,
|
|
115
|
+
http_status=getattr(
|
|
116
|
+
locals().get("response", None), "status_code", None
|
|
117
|
+
),
|
|
118
|
+
response_body=getattr(locals().get("response", None), "text", None),
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
self._logger.info(
|
|
123
|
+
structured_message(
|
|
124
|
+
"download_complete",
|
|
125
|
+
path=str(destination_path),
|
|
126
|
+
bytes=destination_path.stat().st_size,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return destination_path
|
|
130
|
+
|
|
131
|
+
def download_attachment(
|
|
132
|
+
self,
|
|
133
|
+
attachment: Mapping[str, Any],
|
|
134
|
+
destination_dir: Path | str,
|
|
135
|
+
fallback_name: str,
|
|
136
|
+
*,
|
|
137
|
+
overwrite: bool = False,
|
|
138
|
+
) -> Path | None:
|
|
139
|
+
"""Download an attachment mapping that contains a ``url`` value.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
attachment: Procore attachment metadata.
|
|
143
|
+
destination_dir: Local directory where the file should be saved.
|
|
144
|
+
fallback_name: Filename used when metadata and URL do not provide
|
|
145
|
+
one.
|
|
146
|
+
overwrite: Whether to overwrite an existing destination file.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The saved file path, or ``None`` when the attachment has no URL.
|
|
150
|
+
"""
|
|
151
|
+
url = attachment.get("url")
|
|
152
|
+
if not isinstance(url, str) or not url.strip():
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
filename = attachment_filename(attachment, fallback_name)
|
|
156
|
+
return self.download_url(
|
|
157
|
+
url,
|
|
158
|
+
Path(destination_dir) / filename,
|
|
159
|
+
overwrite=overwrite,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def download_attachments(
|
|
163
|
+
self,
|
|
164
|
+
attachments: Iterable[Mapping[str, Any]],
|
|
165
|
+
destination_dir: Path | str,
|
|
166
|
+
*,
|
|
167
|
+
fallback_prefix: str = "attachment",
|
|
168
|
+
overwrite: bool = False,
|
|
169
|
+
) -> list[Path]:
|
|
170
|
+
"""Download multiple attachment mappings to a destination directory."""
|
|
171
|
+
downloaded_files: list[Path] = []
|
|
172
|
+
for index, attachment in enumerate(attachments, start=1):
|
|
173
|
+
downloaded_file = self.download_attachment(
|
|
174
|
+
attachment,
|
|
175
|
+
destination_dir,
|
|
176
|
+
fallback_name=f"{fallback_prefix}-{index}",
|
|
177
|
+
overwrite=overwrite,
|
|
178
|
+
)
|
|
179
|
+
if downloaded_file is not None:
|
|
180
|
+
downloaded_files.append(downloaded_file)
|
|
181
|
+
return downloaded_files
|
|
182
|
+
|
|
183
|
+
@retry(
|
|
184
|
+
retry=retry_if_exception_type((requests.RequestException, ProcoreAPIError)),
|
|
185
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
186
|
+
stop=stop_after_attempt(3),
|
|
187
|
+
reraise=True,
|
|
188
|
+
)
|
|
189
|
+
def _get_with_retry(self, url: str) -> requests.Response:
|
|
190
|
+
"""GET a signed URL with retry support."""
|
|
191
|
+
headers = {"Authorization": f"Bearer {self._token_manager.get_access_token()}"}
|
|
192
|
+
response = self._session.get(
|
|
193
|
+
url,
|
|
194
|
+
headers=headers,
|
|
195
|
+
stream=True,
|
|
196
|
+
timeout=self._timeout_seconds,
|
|
197
|
+
)
|
|
198
|
+
if response.status_code >= 500 or response.status_code == 429:
|
|
199
|
+
raise ProcoreAPIError(
|
|
200
|
+
f"Attachment download failed with status {response.status_code} for {url}",
|
|
201
|
+
status_code=response.status_code,
|
|
202
|
+
response_body=response.text,
|
|
203
|
+
)
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _raise_for_download_status(response: requests.Response, url: str) -> None:
|
|
208
|
+
"""Raise when a download response is unsuccessful."""
|
|
209
|
+
if response.ok:
|
|
210
|
+
return
|
|
211
|
+
raise ProcoreAPIError(
|
|
212
|
+
f"Attachment download failed with status {response.status_code} for {url}",
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
response_body=response.text,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _write_stream(
|
|
218
|
+
self, response: requests.Response, destination_path: Path
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Write a streamed response to disk in chunks."""
|
|
221
|
+
temporary_path = destination_path.with_suffix(f"{destination_path.suffix}.tmp")
|
|
222
|
+
bytes_written = 0
|
|
223
|
+
try:
|
|
224
|
+
with temporary_path.open("wb") as file_handle:
|
|
225
|
+
for chunk in response.iter_content(chunk_size=self._chunk_size):
|
|
226
|
+
if not chunk:
|
|
227
|
+
continue
|
|
228
|
+
file_handle.write(chunk)
|
|
229
|
+
bytes_written += len(chunk)
|
|
230
|
+
self._logger.info(
|
|
231
|
+
structured_message(
|
|
232
|
+
"download_progress",
|
|
233
|
+
path=str(destination_path),
|
|
234
|
+
bytes=bytes_written,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
temporary_path.replace(destination_path)
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise ProcoreAPIError(
|
|
240
|
+
f"Unable to save attachment {destination_path}: {exc}"
|
|
241
|
+
) from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def sanitize_filename(filename: str) -> str:
|
|
245
|
+
"""Return a filesystem-safe filename."""
|
|
246
|
+
candidate = Path(filename.strip()).name
|
|
247
|
+
candidate = UNSAFE_FILENAME_PATTERN.sub("_", candidate)
|
|
248
|
+
candidate = candidate.strip(" .")
|
|
249
|
+
return candidate or "attachment"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def attachment_filename(attachment: Mapping[str, Any], fallback_name: str) -> str:
|
|
253
|
+
"""Return a safe filename from attachment metadata or URL path."""
|
|
254
|
+
for key in ("filename", "file_name", "name"):
|
|
255
|
+
value = attachment.get(key)
|
|
256
|
+
if isinstance(value, str) and value.strip():
|
|
257
|
+
return sanitize_filename(value)
|
|
258
|
+
|
|
259
|
+
url = attachment.get("url")
|
|
260
|
+
if isinstance(url, str) and url.strip():
|
|
261
|
+
parsed_path = unquote(urlparse(url).path)
|
|
262
|
+
path_name = Path(parsed_path).name
|
|
263
|
+
if path_name:
|
|
264
|
+
return sanitize_filename(path_name)
|
|
265
|
+
|
|
266
|
+
return sanitize_filename(fallback_name)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def download_url(
|
|
270
|
+
url: str,
|
|
271
|
+
destination: Path | str,
|
|
272
|
+
token_manager: TokenManager | None = None,
|
|
273
|
+
*,
|
|
274
|
+
overwrite: bool = False,
|
|
275
|
+
) -> Path:
|
|
276
|
+
"""Download a signed Procore URL with the default file service."""
|
|
277
|
+
return FileDownloadService(token_manager=token_manager).download_url(
|
|
278
|
+
url,
|
|
279
|
+
destination,
|
|
280
|
+
overwrite=overwrite,
|
|
281
|
+
)
|
services/projects.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Project service for the Procore SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core import endpoints
|
|
6
|
+
from core.client import ProcoreClient
|
|
7
|
+
from core.config import ProcoreSettings, get_settings
|
|
8
|
+
from core.exceptions import ResourceNotFoundError, ValidationError
|
|
9
|
+
from models import Project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectsService:
|
|
13
|
+
"""Service for Procore project resources."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
client: ProcoreClient | None = None,
|
|
18
|
+
settings: ProcoreSettings | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Initialize the service.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
client: Optional shared Procore HTTP client.
|
|
24
|
+
settings: Optional SDK settings. Used for the default company ID.
|
|
25
|
+
"""
|
|
26
|
+
self._client = client or ProcoreClient()
|
|
27
|
+
self._settings = settings or get_settings()
|
|
28
|
+
|
|
29
|
+
def list_projects(self, company_id: int) -> list[Project]:
|
|
30
|
+
"""Return projects for a Procore company.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
company_id: Procore company ID.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A list of project records.
|
|
37
|
+
"""
|
|
38
|
+
if company_id <= 0:
|
|
39
|
+
raise ValidationError("company_id must be a positive integer.")
|
|
40
|
+
|
|
41
|
+
response = self._client.get_all(endpoints.projects(company_id))
|
|
42
|
+
if isinstance(response, list):
|
|
43
|
+
return [Project.model_validate(project) for project in response]
|
|
44
|
+
return [Project.model_validate(response)]
|
|
45
|
+
|
|
46
|
+
def get_project(self, project_id: int) -> Project:
|
|
47
|
+
"""Return a single project from the configured company.
|
|
48
|
+
|
|
49
|
+
This uses the verified company projects endpoint and finds the requested
|
|
50
|
+
project locally instead of relying on an unverified single-project path.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
project_id: Procore project ID.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The matching project record.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ResourceNotFoundError: If the configured company does not contain
|
|
60
|
+
the requested project.
|
|
61
|
+
"""
|
|
62
|
+
if project_id <= 0:
|
|
63
|
+
raise ValidationError("project_id must be a positive integer.")
|
|
64
|
+
|
|
65
|
+
for project in self.list_projects(self._settings.company_id):
|
|
66
|
+
if project.id == project_id:
|
|
67
|
+
return project
|
|
68
|
+
|
|
69
|
+
raise ResourceNotFoundError(
|
|
70
|
+
f"Project {project_id} was not found for company {self._settings.company_id}."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_projects(
|
|
75
|
+
company_id: int,
|
|
76
|
+
client: ProcoreClient | None = None,
|
|
77
|
+
) -> list[Project]:
|
|
78
|
+
"""Return projects for a Procore company."""
|
|
79
|
+
return ProjectsService(client=client).list_projects(company_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_project(
|
|
83
|
+
project_id: int,
|
|
84
|
+
client: ProcoreClient | None = None,
|
|
85
|
+
) -> Project:
|
|
86
|
+
"""Return a single project from the configured company."""
|
|
87
|
+
return ProjectsService(client=client).get_project(project_id)
|
services/rfis.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""RFI service for the Procore SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from core import endpoints
|
|
10
|
+
from core.client import ProcoreClient
|
|
11
|
+
from core.exceptions import ValidationError
|
|
12
|
+
from models import RFI
|
|
13
|
+
from services.files import FileDownloadService
|
|
14
|
+
|
|
15
|
+
DEFAULT_DOWNLOAD_DIR = Path(__file__).resolve().parents[1] / "downloads" / "rfis"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RFIsService:
|
|
19
|
+
"""Service for Procore RFI resources."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
client: ProcoreClient | None = None,
|
|
24
|
+
file_service: FileDownloadService | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initialize the service.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
client: Optional shared Procore HTTP client.
|
|
30
|
+
file_service: Optional shared file download service.
|
|
31
|
+
"""
|
|
32
|
+
self._client = client or ProcoreClient()
|
|
33
|
+
self._file_service = file_service or FileDownloadService()
|
|
34
|
+
|
|
35
|
+
def list_rfis(self, project_id: int) -> list[RFI]:
|
|
36
|
+
"""Return RFIs for a Procore project."""
|
|
37
|
+
self._validate_positive_id(project_id, "project_id")
|
|
38
|
+
|
|
39
|
+
response = self._client.get_all(endpoints.rfis(project_id))
|
|
40
|
+
if isinstance(response, list):
|
|
41
|
+
return [RFI.model_validate(rfi) for rfi in response]
|
|
42
|
+
return [RFI.model_validate(response)]
|
|
43
|
+
|
|
44
|
+
def get_rfi(self, project_id: int, rfi_id: int) -> RFI:
|
|
45
|
+
"""Return a single RFI for a Procore project."""
|
|
46
|
+
self._validate_positive_id(project_id, "project_id")
|
|
47
|
+
self._validate_positive_id(rfi_id, "rfi_id")
|
|
48
|
+
|
|
49
|
+
response = self._client.get(endpoints.rfi(project_id, rfi_id))
|
|
50
|
+
if not isinstance(response, dict):
|
|
51
|
+
raise ValidationError("Expected Procore RFI response to be an object.")
|
|
52
|
+
return RFI.model_validate(response)
|
|
53
|
+
|
|
54
|
+
def download_rfi_attachments(
|
|
55
|
+
self,
|
|
56
|
+
project_id: int,
|
|
57
|
+
rfi_id: int,
|
|
58
|
+
destination_dir: Path | str | None = None,
|
|
59
|
+
) -> list[Path]:
|
|
60
|
+
"""Download all attachments from an RFI's questions.
|
|
61
|
+
|
|
62
|
+
RFI attachment URLs are read from ``questions[].attachments[].url``.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
project_id: Procore project ID.
|
|
66
|
+
rfi_id: Procore RFI ID.
|
|
67
|
+
destination_dir: Optional directory to save files. Defaults to
|
|
68
|
+
``downloads/rfis/{rfi_id}``.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Paths to downloaded files.
|
|
72
|
+
"""
|
|
73
|
+
rfi = self.get_rfi(project_id, rfi_id)
|
|
74
|
+
attachments = self._extract_question_attachments(rfi.model_dump())
|
|
75
|
+
output_dir = (
|
|
76
|
+
Path(destination_dir)
|
|
77
|
+
if destination_dir
|
|
78
|
+
else DEFAULT_DOWNLOAD_DIR / str(rfi_id)
|
|
79
|
+
)
|
|
80
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
return self._file_service.download_attachments(
|
|
83
|
+
attachments,
|
|
84
|
+
output_dir,
|
|
85
|
+
fallback_prefix=f"rfi-{rfi_id}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _extract_question_attachments(rfi: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
90
|
+
"""Extract ``questions[].attachments[]`` dictionaries from an RFI."""
|
|
91
|
+
questions = rfi.get("questions", [])
|
|
92
|
+
if not isinstance(questions, Sequence) or isinstance(questions, (str, bytes)):
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
attachments: list[dict[str, Any]] = []
|
|
96
|
+
for question in questions:
|
|
97
|
+
if not isinstance(question, Mapping):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
question_attachments = question.get("attachments", [])
|
|
101
|
+
if not isinstance(question_attachments, Sequence) or isinstance(
|
|
102
|
+
question_attachments,
|
|
103
|
+
(str, bytes),
|
|
104
|
+
):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
attachments.extend(
|
|
108
|
+
dict(attachment)
|
|
109
|
+
for attachment in question_attachments
|
|
110
|
+
if isinstance(attachment, Mapping)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return attachments
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _validate_positive_id(value: int, name: str) -> None:
|
|
117
|
+
"""Validate Procore integer identifiers."""
|
|
118
|
+
if value <= 0:
|
|
119
|
+
raise ValidationError(f"{name} must be a positive integer.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_rfis(project_id: int, client: ProcoreClient | None = None) -> list[RFI]:
|
|
123
|
+
"""Return RFIs for a Procore project."""
|
|
124
|
+
return RFIsService(client=client).list_rfis(project_id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_rfi(
|
|
128
|
+
project_id: int,
|
|
129
|
+
rfi_id: int,
|
|
130
|
+
client: ProcoreClient | None = None,
|
|
131
|
+
) -> RFI:
|
|
132
|
+
"""Return a single RFI for a Procore project."""
|
|
133
|
+
return RFIsService(client=client).get_rfi(project_id, rfi_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def download_rfi_attachments(
|
|
137
|
+
project_id: int,
|
|
138
|
+
rfi_id: int,
|
|
139
|
+
destination_dir: Path | str | None = None,
|
|
140
|
+
client: ProcoreClient | None = None,
|
|
141
|
+
) -> list[Path]:
|
|
142
|
+
"""Download all attachments from an RFI's questions."""
|
|
143
|
+
return RFIsService(client=client).download_rfi_attachments(
|
|
144
|
+
project_id,
|
|
145
|
+
rfi_id,
|
|
146
|
+
destination_dir,
|
|
147
|
+
)
|