lr-gladiator 0.4.0__py3-none-any.whl → 0.14.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.
- gladiator/__init__.py +1 -1
- gladiator/arena.py +734 -84
- gladiator/checksums.py +31 -0
- gladiator/cli.py +336 -48
- gladiator/config.py +16 -8
- lr_gladiator-0.14.0.dist-info/METADATA +198 -0
- lr_gladiator-0.14.0.dist-info/RECORD +11 -0
- lr_gladiator-0.4.0.dist-info/METADATA +0 -90
- lr_gladiator-0.4.0.dist-info/RECORD +0 -10
- {lr_gladiator-0.4.0.dist-info → lr_gladiator-0.14.0.dist-info}/WHEEL +0 -0
- {lr_gladiator-0.4.0.dist-info → lr_gladiator-0.14.0.dist-info}/entry_points.txt +0 -0
- {lr_gladiator-0.4.0.dist-info → lr_gladiator-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {lr_gladiator-0.4.0.dist-info → lr_gladiator-0.14.0.dist-info}/top_level.txt +0 -0
gladiator/arena.py
CHANGED
|
@@ -6,25 +6,31 @@ import subprocess
|
|
|
6
6
|
import shlex
|
|
7
7
|
import os
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
from typing import Dict, List, Optional, Tuple, FrozenSet
|
|
10
10
|
import requests
|
|
11
11
|
from .config import LoginConfig
|
|
12
|
+
from .checksums import sha256_file
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
class ArenaError(RuntimeError):
|
|
14
17
|
pass
|
|
15
18
|
|
|
19
|
+
|
|
16
20
|
class ArenaClient:
|
|
17
21
|
def __init__(self, cfg: LoginConfig):
|
|
18
22
|
self.cfg = cfg
|
|
19
23
|
self.session = requests.Session()
|
|
20
24
|
self.session.verify = cfg.verify_tls
|
|
21
25
|
# Default headers: explicitly request/submit JSON
|
|
22
|
-
self.session.headers.update(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
self.session.headers.update(
|
|
27
|
+
{
|
|
28
|
+
"Accept": "application/json",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"User-Agent": "gladiator-arena/0.1",
|
|
31
|
+
"Arena-Usage-Reason": cfg.reason or "gladiator/cli",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
28
34
|
if cfg.arena_session_id:
|
|
29
35
|
self.session.headers.update({"arena_session_id": cfg.arena_session_id})
|
|
30
36
|
|
|
@@ -87,7 +93,15 @@ class ArenaClient:
|
|
|
87
93
|
def _timestamp_of(f: Dict):
|
|
88
94
|
from datetime import datetime
|
|
89
95
|
from email.utils import parsedate_to_datetime
|
|
90
|
-
|
|
96
|
+
|
|
97
|
+
for k in (
|
|
98
|
+
"modifiedAt",
|
|
99
|
+
"updatedAt",
|
|
100
|
+
"lastModified",
|
|
101
|
+
"lastModifiedDate",
|
|
102
|
+
"effectiveDate",
|
|
103
|
+
"createdAt",
|
|
104
|
+
):
|
|
91
105
|
s = f.get(k)
|
|
92
106
|
if not s:
|
|
93
107
|
continue
|
|
@@ -125,29 +139,139 @@ class ArenaClient:
|
|
|
125
139
|
def get_latest_approved_revision(self, item_number: str) -> str:
|
|
126
140
|
return self._api_get_latest_approved(item_number)
|
|
127
141
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
def list_files(
|
|
143
|
+
self, item_number: str, revision: Optional[str] = None
|
|
144
|
+
) -> List[Dict]:
|
|
145
|
+
target_guid = self._api_resolve_revision_guid(
|
|
146
|
+
item_number, revision or "EFFECTIVE"
|
|
147
|
+
)
|
|
131
148
|
raw = self._api_list_files_by_item_guid(target_guid)
|
|
132
149
|
return self._latest_files(raw)
|
|
133
|
-
|
|
134
150
|
|
|
135
|
-
def download_files(
|
|
151
|
+
def download_files(
|
|
152
|
+
self,
|
|
153
|
+
item_number: str,
|
|
154
|
+
revision: Optional[str] = None,
|
|
155
|
+
out_dir: Path = Path("."),
|
|
156
|
+
) -> List[Path]:
|
|
136
157
|
files = self.list_files(item_number, revision)
|
|
137
158
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
138
159
|
downloaded: List[Path] = []
|
|
139
160
|
for f in files:
|
|
161
|
+
# Skip associations with no blob
|
|
162
|
+
if not f.get("haveContent", True):
|
|
163
|
+
self._log(
|
|
164
|
+
f"Skip {item_number}: file {f.get('filename')} has no content"
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
|
|
140
168
|
url = f.get("downloadUrl") or f.get("url")
|
|
141
169
|
filename = f.get("filename") or f.get("name")
|
|
142
170
|
if not url or not filename:
|
|
143
171
|
continue
|
|
172
|
+
|
|
144
173
|
p = out_dir / filename
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
try:
|
|
175
|
+
with self.session.get(
|
|
176
|
+
url,
|
|
177
|
+
stream=True,
|
|
178
|
+
headers={"arena_session_id": self.cfg.arena_session_id or ""},
|
|
179
|
+
) as r:
|
|
180
|
+
# If the blob is missing/forbidden, don’t abort the whole command
|
|
181
|
+
if r.status_code in (400, 403, 404):
|
|
182
|
+
self._log(
|
|
183
|
+
f"Skip {item_number}: {filename} content unavailable "
|
|
184
|
+
f"(HTTP {r.status_code})"
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
r.raise_for_status()
|
|
188
|
+
with open(p, "wb") as fh:
|
|
189
|
+
for chunk in r.iter_content(128 * 1024):
|
|
190
|
+
fh.write(chunk)
|
|
191
|
+
downloaded.append(p)
|
|
192
|
+
except requests.HTTPError as e:
|
|
193
|
+
# Be resilient: log and continue
|
|
194
|
+
self._log(f"Download failed for {filename}: {e}")
|
|
195
|
+
continue
|
|
196
|
+
return downloaded
|
|
197
|
+
|
|
198
|
+
def download_files_recursive(
|
|
199
|
+
self,
|
|
200
|
+
item_number: str,
|
|
201
|
+
revision: Optional[str] = None,
|
|
202
|
+
out_dir: Path = Path("."),
|
|
203
|
+
*,
|
|
204
|
+
max_depth: Optional[int] = None,
|
|
205
|
+
) -> List[Path]:
|
|
206
|
+
"""
|
|
207
|
+
Download files for `item_number` AND, recursively, for all subassemblies
|
|
208
|
+
discovered via the BOM. Each child item is placed under a subdirectory:
|
|
209
|
+
<out_dir>/<child_item_number>/
|
|
210
|
+
Root files go directly in <out_dir>/.
|
|
211
|
+
|
|
212
|
+
Depth semantics match `get_bom(..., recursive=True, max_depth=...)`.
|
|
213
|
+
"""
|
|
214
|
+
# Ensure the root directory exists
|
|
215
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
downloaded: List[Path] = []
|
|
218
|
+
bom_cache: Dict[str, List[Dict]] = {}
|
|
219
|
+
|
|
220
|
+
def fetch_children(item: str) -> List[Dict]:
|
|
221
|
+
if item not in bom_cache:
|
|
222
|
+
bom_cache[item] = self.get_bom(
|
|
223
|
+
item,
|
|
224
|
+
revision,
|
|
225
|
+
recursive=False,
|
|
226
|
+
max_depth=None,
|
|
227
|
+
)
|
|
228
|
+
return bom_cache[item]
|
|
229
|
+
|
|
230
|
+
def walk(
|
|
231
|
+
current_item: str,
|
|
232
|
+
dest: Path,
|
|
233
|
+
depth: int,
|
|
234
|
+
ancestors: FrozenSet[str],
|
|
235
|
+
) -> None:
|
|
236
|
+
if current_item in ancestors:
|
|
237
|
+
self._log(
|
|
238
|
+
"Detected BOM cycle involving "
|
|
239
|
+
f"{current_item} (ancestors: {', '.join(sorted(ancestors))})"
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
next_ancestors = ancestors | {current_item}
|
|
244
|
+
|
|
245
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
downloaded.extend(self.download_files(current_item, revision, out_dir=dest))
|
|
247
|
+
|
|
248
|
+
if max_depth is not None and depth >= max_depth:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
children = fetch_children(current_item)
|
|
252
|
+
seen_children: set[str] = set()
|
|
253
|
+
for child in children:
|
|
254
|
+
if not child:
|
|
255
|
+
continue
|
|
256
|
+
child_num = child.get("itemNumber")
|
|
257
|
+
if not child_num:
|
|
258
|
+
continue
|
|
259
|
+
if child_num == current_item:
|
|
260
|
+
continue
|
|
261
|
+
if child_num in seen_children:
|
|
262
|
+
continue
|
|
263
|
+
if child_num in next_ancestors:
|
|
264
|
+
self._log(
|
|
265
|
+
"Detected BOM cycle involving "
|
|
266
|
+
f"{child_num} (ancestors: {', '.join(sorted(next_ancestors))})"
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
seen_children.add(child_num)
|
|
270
|
+
|
|
271
|
+
child_dir = dest / child_num
|
|
272
|
+
walk(child_num, child_dir, depth + 1, next_ancestors)
|
|
273
|
+
|
|
274
|
+
walk(item_number, out_dir, depth=0, ancestors=frozenset())
|
|
151
275
|
return downloaded
|
|
152
276
|
|
|
153
277
|
def upload_file_to_working(
|
|
@@ -162,13 +286,13 @@ class ArenaClient:
|
|
|
162
286
|
description: Optional[str] = None,
|
|
163
287
|
primary: bool = True,
|
|
164
288
|
latest_edition_association: bool = True,
|
|
165
|
-
edition: str =
|
|
289
|
+
edition: str = None,
|
|
166
290
|
) -> Dict:
|
|
167
291
|
"""
|
|
168
|
-
Update-if-exists-else-create semantics
|
|
292
|
+
Update-if-exists-else-create semantics:
|
|
169
293
|
1) Resolve EFFECTIVE GUID from item number
|
|
170
294
|
2) Resolve WORKING revision GUID (fail if none)
|
|
171
|
-
3) Find existing file by
|
|
295
|
+
3) Find existing file by title orexact filename (WORKING first, then EFFECTIVE)
|
|
172
296
|
- If found: POST /files/{fileGuid}/content (multipart)
|
|
173
297
|
- Else: POST /items/{workingGuid}/files (multipart) with file.edition
|
|
174
298
|
"""
|
|
@@ -185,7 +309,139 @@ class ArenaClient:
|
|
|
185
309
|
edition=edition,
|
|
186
310
|
)
|
|
187
311
|
|
|
188
|
-
|
|
312
|
+
def get_bom(
|
|
313
|
+
self,
|
|
314
|
+
item_number: str,
|
|
315
|
+
revision: Optional[str] = None,
|
|
316
|
+
*,
|
|
317
|
+
recursive: bool = False,
|
|
318
|
+
max_depth: Optional[int] = None,
|
|
319
|
+
) -> List[Dict]:
|
|
320
|
+
"""
|
|
321
|
+
Return a normalized list of BOM lines for the given item.
|
|
322
|
+
|
|
323
|
+
By default this fetches the EFFECTIVE (approved) revision's BOM.
|
|
324
|
+
Use revision="WORKING" or a specific label (e.g., "B2") to override.
|
|
325
|
+
|
|
326
|
+
If recursive=True, expand subassemblies depth-first. max_depth limits the recursion
|
|
327
|
+
depth (1 = only direct children). If omitted, recursion is unlimited.
|
|
328
|
+
"""
|
|
329
|
+
selector = (revision or "EFFECTIVE").strip()
|
|
330
|
+
out: List[Dict] = []
|
|
331
|
+
self._bom_expand(
|
|
332
|
+
root_item=item_number,
|
|
333
|
+
selector=selector,
|
|
334
|
+
out=out,
|
|
335
|
+
recursive=recursive,
|
|
336
|
+
max_depth=max_depth,
|
|
337
|
+
_level=0,
|
|
338
|
+
_seen=set(),
|
|
339
|
+
)
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
# === Internal: single fetch + normalization (your original logic) ===
|
|
343
|
+
|
|
344
|
+
def _fetch_bom_normalized(self, item_number: str, selector: str) -> List[Dict]:
|
|
345
|
+
"""
|
|
346
|
+
Fetch and normalize the BOM for item_number with the given revision selector.
|
|
347
|
+
Falls back WORKING -> EFFECTIVE if selector is WORKING and no WORKING exists.
|
|
348
|
+
"""
|
|
349
|
+
# 1) Resolve the exact revision GUID we want the BOM for
|
|
350
|
+
try:
|
|
351
|
+
target_guid = self._api_resolve_revision_guid(item_number, selector)
|
|
352
|
+
except ArenaError:
|
|
353
|
+
if selector.strip().upper() == "WORKING":
|
|
354
|
+
# fallback: try EFFECTIVE for children that don't have a WORKING revision
|
|
355
|
+
target_guid = self._api_resolve_revision_guid(item_number, "EFFECTIVE")
|
|
356
|
+
else:
|
|
357
|
+
raise
|
|
358
|
+
|
|
359
|
+
# 2) GET /items/{guid}/bom
|
|
360
|
+
url = f"{self._api_base()}/items/{target_guid}/bom"
|
|
361
|
+
self._log(f"GET {url}")
|
|
362
|
+
r = self.session.get(url)
|
|
363
|
+
r.raise_for_status()
|
|
364
|
+
data = self._ensure_json(r)
|
|
365
|
+
|
|
366
|
+
rows = data.get("results", data if isinstance(data, list) else [])
|
|
367
|
+
norm: List[Dict] = []
|
|
368
|
+
for row in rows:
|
|
369
|
+
itm = row.get("item", {}) if isinstance(row, dict) else {}
|
|
370
|
+
norm.append(
|
|
371
|
+
{
|
|
372
|
+
# association/line
|
|
373
|
+
"guid": row.get("guid"),
|
|
374
|
+
"lineNumber": row.get("lineNumber"),
|
|
375
|
+
"notes": row.get("notes"),
|
|
376
|
+
"quantity": row.get("quantity"),
|
|
377
|
+
"refDes": row.get("refDes")
|
|
378
|
+
or row.get("referenceDesignators")
|
|
379
|
+
or "",
|
|
380
|
+
# child item
|
|
381
|
+
"itemGuid": itm.get("guid") or itm.get("id"),
|
|
382
|
+
"itemNumber": itm.get("number"),
|
|
383
|
+
"itemName": itm.get("name"),
|
|
384
|
+
"itemRevision": itm.get("revisionNumber"),
|
|
385
|
+
"itemRevisionStatus": itm.get("revisionStatus"),
|
|
386
|
+
"itemUrl": (itm.get("url") or {}).get("api"),
|
|
387
|
+
"itemAppUrl": (itm.get("url") or {}).get("app"),
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
return norm
|
|
391
|
+
|
|
392
|
+
# === Internal: recursive expansion ===
|
|
393
|
+
|
|
394
|
+
def _bom_expand(
|
|
395
|
+
self,
|
|
396
|
+
*,
|
|
397
|
+
root_item: str,
|
|
398
|
+
selector: str,
|
|
399
|
+
out: List[Dict],
|
|
400
|
+
recursive: bool,
|
|
401
|
+
max_depth: Optional[int],
|
|
402
|
+
_level: int,
|
|
403
|
+
_seen: set,
|
|
404
|
+
) -> None:
|
|
405
|
+
# avoid cycles
|
|
406
|
+
if root_item in _seen:
|
|
407
|
+
return
|
|
408
|
+
_seen.add(root_item)
|
|
409
|
+
|
|
410
|
+
rows = self._fetch_bom_normalized(root_item, selector)
|
|
411
|
+
|
|
412
|
+
# attach level and parentNumber (useful in JSON + for debugging)
|
|
413
|
+
for r in rows:
|
|
414
|
+
r["level"] = _level
|
|
415
|
+
r["parentNumber"] = root_item
|
|
416
|
+
out.append(r)
|
|
417
|
+
|
|
418
|
+
if not recursive:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
# depth check: if max_depth=1, only expand children once (level 0 -> level 1)
|
|
422
|
+
if max_depth is not None and _level >= max_depth:
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
# expand each child that looks like an assembly (if it has a BOM; empty BOM is okay)
|
|
426
|
+
for r in rows:
|
|
427
|
+
child_num = r.get("itemNumber")
|
|
428
|
+
if not child_num:
|
|
429
|
+
continue
|
|
430
|
+
try:
|
|
431
|
+
# Recurse; keep same selector, with WORKING->EFFECTIVE fallback handled in _fetch_bom_normalized
|
|
432
|
+
self._bom_expand(
|
|
433
|
+
root_item=child_num,
|
|
434
|
+
selector=selector,
|
|
435
|
+
out=out,
|
|
436
|
+
recursive=True,
|
|
437
|
+
max_depth=max_depth,
|
|
438
|
+
_level=_level + 1,
|
|
439
|
+
_seen=_seen,
|
|
440
|
+
)
|
|
441
|
+
except ArenaError:
|
|
442
|
+
# Child might not have a BOM; skip silently
|
|
443
|
+
continue
|
|
444
|
+
|
|
189
445
|
def _api_base(self) -> str:
|
|
190
446
|
return self.cfg.base_url.rstrip("/")
|
|
191
447
|
|
|
@@ -206,26 +462,35 @@ class ArenaClient:
|
|
|
206
462
|
# - revisionStatus == "EFFECTIVE" (string)
|
|
207
463
|
# - OR status == 1 (numeric)
|
|
208
464
|
effective = [
|
|
209
|
-
rv
|
|
210
|
-
|
|
465
|
+
rv
|
|
466
|
+
for rv in revs
|
|
467
|
+
if (str(rv.get("revisionStatus") or "").upper() == "EFFECTIVE")
|
|
468
|
+
or (rv.get("status") == 1)
|
|
211
469
|
]
|
|
212
470
|
if not effective:
|
|
213
471
|
raise ArenaError(f"No approved/released revisions for item {item_number}")
|
|
214
472
|
|
|
215
473
|
# Prefer the one that is not superseded; otherwise fall back to the most recently superseded.
|
|
216
|
-
current = next(
|
|
474
|
+
current = next(
|
|
475
|
+
(rv for rv in effective if not rv.get("supersededDateTime")), None
|
|
476
|
+
)
|
|
217
477
|
if not current:
|
|
218
478
|
# sort by supersededDateTime (None last) then by number/name as a stable tie-breaker
|
|
219
479
|
def _sd(rv):
|
|
220
480
|
dt = rv.get("supersededDateTime")
|
|
221
481
|
return dt or "0000-00-00T00:00:00Z"
|
|
482
|
+
|
|
222
483
|
effective.sort(key=_sd)
|
|
223
484
|
current = effective[-1]
|
|
224
485
|
|
|
225
486
|
# The human-visible revision is under "number" (e.g., "B3"); fall back defensively.
|
|
226
|
-
rev_label =
|
|
487
|
+
rev_label = (
|
|
488
|
+
current.get("number") or current.get("name") or current.get("revision")
|
|
489
|
+
)
|
|
227
490
|
if not rev_label:
|
|
228
|
-
raise ArenaError(
|
|
491
|
+
raise ArenaError(
|
|
492
|
+
f"Could not determine revision label for item {item_number}"
|
|
493
|
+
)
|
|
229
494
|
return rev_label
|
|
230
495
|
|
|
231
496
|
def _api_list_files(self, item_number: str) -> List[Dict]:
|
|
@@ -240,19 +505,27 @@ class ArenaClient:
|
|
|
240
505
|
for row in rows:
|
|
241
506
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
242
507
|
file_guid = f.get("guid") or f.get("id")
|
|
243
|
-
norm.append(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
508
|
+
norm.append(
|
|
509
|
+
{
|
|
510
|
+
"id": row.get("guid") or row.get("id"),
|
|
511
|
+
"fileGuid": file_guid,
|
|
512
|
+
"name": f.get("name") or f.get("title"),
|
|
513
|
+
"title": f.get("title"),
|
|
514
|
+
"filename": f.get("name") or f.get("title"),
|
|
515
|
+
"size": f.get("size"),
|
|
516
|
+
"haveContent": f.get("haveContent", True),
|
|
517
|
+
"downloadUrl": (
|
|
518
|
+
f"{self._api_base()}/files/{file_guid}/content"
|
|
519
|
+
if file_guid
|
|
520
|
+
else None
|
|
521
|
+
),
|
|
522
|
+
"edition": f.get("edition"),
|
|
523
|
+
"updatedAt": f.get("lastModifiedDateTime")
|
|
524
|
+
or f.get("lastModifiedDate")
|
|
525
|
+
or f.get("creationDateTime"),
|
|
526
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
527
|
+
}
|
|
528
|
+
)
|
|
256
529
|
return norm
|
|
257
530
|
|
|
258
531
|
def _api_resolve_revision_guid(self, item_number: str, selector: str | None) -> str:
|
|
@@ -266,7 +539,8 @@ class ArenaClient:
|
|
|
266
539
|
# Fetch revisions
|
|
267
540
|
url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
268
541
|
self._log(f"GET {url}")
|
|
269
|
-
r = self.session.get(url)
|
|
542
|
+
r = self.session.get(url)
|
|
543
|
+
r.raise_for_status()
|
|
270
544
|
data = self._ensure_json(r)
|
|
271
545
|
revs = data.get("results", data if isinstance(data, list) else [])
|
|
272
546
|
|
|
@@ -278,21 +552,36 @@ class ArenaClient:
|
|
|
278
552
|
|
|
279
553
|
# Named selectors
|
|
280
554
|
if sel in {"WORKING"}:
|
|
281
|
-
guid = pick(
|
|
555
|
+
guid = pick(
|
|
556
|
+
lambda rv: (rv.get("revisionStatus") or "").upper() == "WORKING"
|
|
557
|
+
or rv.get("status") == 0
|
|
558
|
+
)
|
|
282
559
|
if not guid:
|
|
283
560
|
raise ArenaError("No WORKING revision exists for this item.")
|
|
284
561
|
return guid
|
|
285
562
|
|
|
286
563
|
if sel in {"EFFECTIVE", "APPROVED", "RELEASED"}:
|
|
287
564
|
# Prefer the one not superseded
|
|
288
|
-
eff = [
|
|
565
|
+
eff = [
|
|
566
|
+
rv
|
|
567
|
+
for rv in revs
|
|
568
|
+
if (rv.get("revisionStatus") or "").upper() == "EFFECTIVE"
|
|
569
|
+
or rv.get("status") == 1
|
|
570
|
+
]
|
|
289
571
|
if not eff:
|
|
290
|
-
raise ArenaError(
|
|
291
|
-
|
|
572
|
+
raise ArenaError(
|
|
573
|
+
"No approved/effective revision exists for this item. Try using revision 'WORKING'."
|
|
574
|
+
)
|
|
575
|
+
current = next(
|
|
576
|
+
(rv for rv in eff if not rv.get("supersededDateTime")), eff[-1]
|
|
577
|
+
)
|
|
292
578
|
return current.get("guid")
|
|
293
579
|
|
|
294
580
|
# Specific label (e.g., "A", "B2")
|
|
295
|
-
guid = pick(
|
|
581
|
+
guid = pick(
|
|
582
|
+
lambda rv: (rv.get("number") or rv.get("name"))
|
|
583
|
+
and str(rv.get("number") or rv.get("name")).upper() == sel
|
|
584
|
+
)
|
|
296
585
|
if not guid:
|
|
297
586
|
raise ArenaError(f'Revision "{selector}" not found for item {item_number}.')
|
|
298
587
|
return guid
|
|
@@ -300,7 +589,8 @@ class ArenaClient:
|
|
|
300
589
|
def _api_list_files_by_item_guid(self, item_guid: str) -> list[dict]:
|
|
301
590
|
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
302
591
|
self._log(f"GET {url}")
|
|
303
|
-
r = self.session.get(url)
|
|
592
|
+
r = self.session.get(url)
|
|
593
|
+
r.raise_for_status()
|
|
304
594
|
data = self._ensure_json(r)
|
|
305
595
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
306
596
|
# … keep existing normalization from _api_list_files() …
|
|
@@ -308,19 +598,32 @@ class ArenaClient:
|
|
|
308
598
|
for row in rows:
|
|
309
599
|
f = row.get("file", {}) if isinstance(row, dict) else {}
|
|
310
600
|
file_guid = f.get("guid") or f.get("id")
|
|
311
|
-
norm.append(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
601
|
+
norm.append(
|
|
602
|
+
{
|
|
603
|
+
"id": row.get("guid") or row.get("id"),
|
|
604
|
+
"fileGuid": file_guid,
|
|
605
|
+
"title": f.get("title"),
|
|
606
|
+
"name": f.get("name"),
|
|
607
|
+
"filename": f.get("name"),
|
|
608
|
+
"size": f.get("size"),
|
|
609
|
+
"haveContent": f.get("haveContent", True),
|
|
610
|
+
"downloadUrl": (
|
|
611
|
+
f"{self._api_base()}/files/{file_guid}/content"
|
|
612
|
+
if file_guid
|
|
613
|
+
else None
|
|
614
|
+
),
|
|
615
|
+
"edition": f.get("edition"),
|
|
616
|
+
"updatedAt": f.get("lastModifiedDateTime")
|
|
617
|
+
or f.get("lastModifiedDate")
|
|
618
|
+
or f.get("creationDateTime"),
|
|
619
|
+
"attachmentGroupGuid": row.get("guid"),
|
|
620
|
+
"storageMethodName": (
|
|
621
|
+
f.get("storageMethodName") or f.get("storageMethod")
|
|
622
|
+
),
|
|
623
|
+
"location": f.get("location"),
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
return norm
|
|
324
627
|
|
|
325
628
|
def _api_upload_or_update_file(
|
|
326
629
|
self,
|
|
@@ -339,6 +642,14 @@ class ArenaClient:
|
|
|
339
642
|
if not file_path.exists() or not file_path.is_file():
|
|
340
643
|
raise ArenaError(f"File not found: {file_path}")
|
|
341
644
|
|
|
645
|
+
filename = file_path.name # Use truncated SHA256 hash if no edition is provided
|
|
646
|
+
if not edition:
|
|
647
|
+
# Arena seems to only accept 16 characters of edition information.
|
|
648
|
+
# The hex digest gives 16 hex × 4 bits = 64 bits of entropy.
|
|
649
|
+
# Less than a million files, collision risk is practically zero (~1 / 10^8).
|
|
650
|
+
edition = sha256_file(file_path)
|
|
651
|
+
edition = str(edition)[:16]
|
|
652
|
+
|
|
342
653
|
# 0) Resolve EFFECTIVE revision guid from item number
|
|
343
654
|
effective_guid = self._api_resolve_item_guid(item_number)
|
|
344
655
|
|
|
@@ -351,7 +662,9 @@ class ArenaClient:
|
|
|
351
662
|
rows = data.get("results", data if isinstance(data, list) else [])
|
|
352
663
|
working_guid = None
|
|
353
664
|
for rv in rows:
|
|
354
|
-
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
|
|
665
|
+
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
|
|
666
|
+
rv.get("status") == 0
|
|
667
|
+
):
|
|
355
668
|
working_guid = rv.get("guid")
|
|
356
669
|
break
|
|
357
670
|
if not working_guid:
|
|
@@ -371,15 +684,45 @@ class ArenaClient:
|
|
|
371
684
|
# Try to find existing association by exact filename (WORKING first, then EFFECTIVE)
|
|
372
685
|
filename = file_path.name
|
|
373
686
|
assoc = None
|
|
687
|
+
if title:
|
|
688
|
+
candidates = _list_assocs(working_guid)
|
|
689
|
+
|
|
690
|
+
def _a_title(a):
|
|
691
|
+
f = a.get("file") or {}
|
|
692
|
+
return (f.get("title") or a.get("title") or "").strip().casefold()
|
|
693
|
+
|
|
694
|
+
tnorm = title.strip().casefold()
|
|
695
|
+
# Prefer primary + latestEditionAssociation if duplicates exist
|
|
696
|
+
preferred = [
|
|
697
|
+
a
|
|
698
|
+
for a in candidates
|
|
699
|
+
if _a_title(a) == tnorm
|
|
700
|
+
and a.get("primary")
|
|
701
|
+
and a.get("latestEditionAssociation")
|
|
702
|
+
]
|
|
703
|
+
if preferred:
|
|
704
|
+
assoc = preferred[0]
|
|
705
|
+
else:
|
|
706
|
+
any_match = [a for a in candidates if _a_title(a) == tnorm]
|
|
707
|
+
if any_match:
|
|
708
|
+
assoc = any_match[0]
|
|
709
|
+
|
|
374
710
|
for guid in (working_guid, effective_guid):
|
|
375
711
|
assocs = _list_assocs(guid)
|
|
376
712
|
# prefer primary && latestEditionAssociation, then any by name
|
|
377
|
-
prim_latest = [
|
|
378
|
-
|
|
713
|
+
prim_latest = [
|
|
714
|
+
a
|
|
715
|
+
for a in assocs
|
|
716
|
+
if a.get("primary")
|
|
717
|
+
and a.get("latestEditionAssociation")
|
|
718
|
+
and ((a.get("file") or {}).get("name") == filename)
|
|
719
|
+
]
|
|
379
720
|
if prim_latest:
|
|
380
721
|
assoc = prim_latest[0]
|
|
381
722
|
break
|
|
382
|
-
any_by_name = [
|
|
723
|
+
any_by_name = [
|
|
724
|
+
a for a in assocs if (a.get("file") or {}).get("name") == filename
|
|
725
|
+
]
|
|
383
726
|
if any_by_name:
|
|
384
727
|
assoc = any_by_name[0]
|
|
385
728
|
break
|
|
@@ -400,6 +743,17 @@ class ArenaClient:
|
|
|
400
743
|
if existing_ct is not None:
|
|
401
744
|
self.session.headers["Content-Type"] = existing_ct
|
|
402
745
|
ur.raise_for_status()
|
|
746
|
+
|
|
747
|
+
# Update the edition label on the File itself
|
|
748
|
+
try:
|
|
749
|
+
put_url = f"{self._api_base()}/files/{file_guid}"
|
|
750
|
+
self._log(f"PUT {put_url} (set edition={edition})")
|
|
751
|
+
pr = self.session.put(put_url, json={"edition": str(edition)})
|
|
752
|
+
pr.raise_for_status()
|
|
753
|
+
except requests.HTTPError as e:
|
|
754
|
+
# Don't fail the whole operation if the label update is rejected
|
|
755
|
+
self._log(f"Edition update failed for {file_guid}: {e}")
|
|
756
|
+
|
|
403
757
|
# Many tenants return 201 with no JSON for content updates. Be flexible.
|
|
404
758
|
data = self._try_json(ur)
|
|
405
759
|
if data is None:
|
|
@@ -409,6 +763,7 @@ class ArenaClient:
|
|
|
409
763
|
"status": ur.status_code,
|
|
410
764
|
"fileGuid": file_guid,
|
|
411
765
|
"location": ur.headers.get("Location"),
|
|
766
|
+
"edition": str(edition),
|
|
412
767
|
}
|
|
413
768
|
return data
|
|
414
769
|
|
|
@@ -421,30 +776,44 @@ class ArenaClient:
|
|
|
421
776
|
cats = self._ensure_json(r).get("results", [])
|
|
422
777
|
cat_guid = None
|
|
423
778
|
for c in cats:
|
|
424
|
-
if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
|
|
779
|
+
if c.get("name") == category_name and (c.get("parentCategory") or {}).get(
|
|
780
|
+
"name"
|
|
781
|
+
) in {"Internal File", None}:
|
|
425
782
|
cat_guid = c.get("guid")
|
|
426
783
|
break
|
|
427
784
|
if not cat_guid:
|
|
428
|
-
raise ArenaError(
|
|
785
|
+
raise ArenaError(
|
|
786
|
+
f'File category "{category_name}" not found or not allowed.'
|
|
787
|
+
)
|
|
429
788
|
|
|
430
789
|
# 3) Prepare multipart (create association)
|
|
431
|
-
title = title or file_path.
|
|
432
|
-
file_format = file_format or (
|
|
790
|
+
title = title or file_path.name
|
|
791
|
+
file_format = file_format or (
|
|
792
|
+
file_path.suffix[1:].lower() if file_path.suffix else "bin"
|
|
793
|
+
)
|
|
433
794
|
description = description or "Uploaded via gladiator"
|
|
434
|
-
files = {
|
|
795
|
+
files = {
|
|
796
|
+
"content": (
|
|
797
|
+
file_path.name,
|
|
798
|
+
open(file_path, "rb"),
|
|
799
|
+
"application/octet-stream",
|
|
800
|
+
)
|
|
801
|
+
}
|
|
435
802
|
|
|
436
803
|
# NOTE: nested field names are sent in `data`, not `files`
|
|
437
804
|
data_form = {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
805
|
+
"file.title": title,
|
|
806
|
+
"file.description": description,
|
|
807
|
+
"file.category.guid": cat_guid,
|
|
808
|
+
"file.format": file_format,
|
|
809
|
+
"file.edition": str(edition),
|
|
810
|
+
"file.storageMethodName": "FILE",
|
|
811
|
+
"file.private": "false",
|
|
812
|
+
"primary": "true" if primary else "false",
|
|
813
|
+
"latestEditionAssociation": (
|
|
814
|
+
"true" if latest_edition_association else "false"
|
|
815
|
+
),
|
|
816
|
+
}
|
|
448
817
|
if reference:
|
|
449
818
|
data_form["reference"] = reference
|
|
450
819
|
|
|
@@ -466,6 +835,22 @@ class ArenaClient:
|
|
|
466
835
|
# Normalize common fields we use elsewhere
|
|
467
836
|
row = resp if isinstance(resp, dict) else {}
|
|
468
837
|
f = row.get("file", {})
|
|
838
|
+
|
|
839
|
+
# Ensure the edition label is exactly what we asked for (some tenants ignore form edition)
|
|
840
|
+
try:
|
|
841
|
+
file_guid_created = (f or {}).get("guid")
|
|
842
|
+
if file_guid_created and str(edition):
|
|
843
|
+
put_url = f"{self._api_base()}/files/{file_guid_created}"
|
|
844
|
+
self._log(f"PUT {put_url} (set edition={edition})")
|
|
845
|
+
pr = self.session.put(put_url, json={"edition": str(edition)})
|
|
846
|
+
pr.raise_for_status()
|
|
847
|
+
# Update local 'f' edition if the PUT succeeded
|
|
848
|
+
f["edition"] = str(edition)
|
|
849
|
+
except requests.HTTPError as e:
|
|
850
|
+
self._log(
|
|
851
|
+
f"Edition update after create failed for {file_guid_created}: {e}"
|
|
852
|
+
)
|
|
853
|
+
|
|
469
854
|
return {
|
|
470
855
|
"associationGuid": row.get("guid"),
|
|
471
856
|
"primary": row.get("primary"),
|
|
@@ -477,10 +862,14 @@ class ArenaClient:
|
|
|
477
862
|
"size": f.get("size"),
|
|
478
863
|
"format": f.get("format"),
|
|
479
864
|
"category": (f.get("category") or {}).get("name"),
|
|
480
|
-
"edition": f.get("edition"),
|
|
865
|
+
"edition": f.get("edition") or str(edition),
|
|
481
866
|
"lastModifiedDateTime": f.get("lastModifiedDateTime"),
|
|
482
867
|
},
|
|
483
|
-
"downloadUrl":
|
|
868
|
+
"downloadUrl": (
|
|
869
|
+
f"{self._api_base()}/files/{(f or {}).get('guid')}/content"
|
|
870
|
+
if f.get("guid")
|
|
871
|
+
else None
|
|
872
|
+
),
|
|
484
873
|
}
|
|
485
874
|
|
|
486
875
|
def _api_resolve_item_guid(self, item_number: str) -> str:
|
|
@@ -493,11 +882,272 @@ class ArenaClient:
|
|
|
493
882
|
results = data.get("results") if isinstance(data, dict) else data
|
|
494
883
|
if not results:
|
|
495
884
|
raise ArenaError(f"Item number {item_number} not found")
|
|
496
|
-
guid = (
|
|
885
|
+
guid = (
|
|
886
|
+
results[0].get("guid") or results[0].get("id") or results[0].get("itemId")
|
|
887
|
+
)
|
|
497
888
|
if not guid:
|
|
498
889
|
raise ArenaError("API response missing item GUID")
|
|
499
890
|
return guid
|
|
500
891
|
|
|
892
|
+
# --- helper: resolve File Category GUID by name (exact match under Settings) ---
|
|
893
|
+
def _api_resolve_file_category_guid(self, category_name: str) -> str:
|
|
894
|
+
cats_url = f"{self._api_base()}/settings/files/categories"
|
|
895
|
+
self._log(f"GET {cats_url}")
|
|
896
|
+
r = self.session.get(cats_url)
|
|
897
|
+
r.raise_for_status()
|
|
898
|
+
cats = self._ensure_json(r).get("results", [])
|
|
899
|
+
for c in cats:
|
|
900
|
+
if c.get("name") == category_name:
|
|
901
|
+
return c.get("guid")
|
|
902
|
+
raise ArenaError(f'File category "{category_name}" not found.')
|
|
903
|
+
|
|
904
|
+
# --- helper: create a WEB File (no binary content) and return its GUID ---
|
|
905
|
+
def _api_create_web_file(
|
|
906
|
+
self,
|
|
907
|
+
*,
|
|
908
|
+
category_guid: str,
|
|
909
|
+
title: str,
|
|
910
|
+
location_url: str,
|
|
911
|
+
edition: str,
|
|
912
|
+
description: Optional[str],
|
|
913
|
+
file_format: Optional[str],
|
|
914
|
+
private: bool = False,
|
|
915
|
+
) -> dict:
|
|
916
|
+
"""
|
|
917
|
+
POST /files (create File record with storageMethodName=WEB and a 'location')
|
|
918
|
+
"""
|
|
919
|
+
url = f"{self._api_base()}/files"
|
|
920
|
+
payload = {
|
|
921
|
+
"category": {"guid": category_guid},
|
|
922
|
+
"title": title,
|
|
923
|
+
"description": description or "",
|
|
924
|
+
"edition": str(edition),
|
|
925
|
+
"format": file_format or "url",
|
|
926
|
+
"private": bool(private),
|
|
927
|
+
"storageMethodName": "WEB",
|
|
928
|
+
"location": location_url,
|
|
929
|
+
}
|
|
930
|
+
self._log(f"POST {url} (create web file)")
|
|
931
|
+
r = self.session.post(url, json=payload)
|
|
932
|
+
r.raise_for_status()
|
|
933
|
+
data = self._ensure_json(r)
|
|
934
|
+
if not isinstance(data, dict) or not data.get("guid"):
|
|
935
|
+
raise ArenaError("File create (WEB) returned no GUID")
|
|
936
|
+
return data # includes "guid", "number", etc.
|
|
937
|
+
|
|
938
|
+
# --- helper: PUT File (update WEB metadata/location/edition) ---
|
|
939
|
+
def _api_update_web_file(
|
|
940
|
+
self,
|
|
941
|
+
*,
|
|
942
|
+
file_guid: str,
|
|
943
|
+
category_guid: str,
|
|
944
|
+
title: str,
|
|
945
|
+
location_url: str,
|
|
946
|
+
edition: str,
|
|
947
|
+
description: Optional[str],
|
|
948
|
+
file_format: Optional[str],
|
|
949
|
+
private: bool = False,
|
|
950
|
+
) -> dict:
|
|
951
|
+
"""
|
|
952
|
+
PUT /files/{guid} (update summary). For WEB/FTP/PLACE_HOLDER, include 'location'.
|
|
953
|
+
"""
|
|
954
|
+
url = f"{self._api_base()}/files/{file_guid}"
|
|
955
|
+
payload = {
|
|
956
|
+
"category": {"guid": category_guid},
|
|
957
|
+
"title": title,
|
|
958
|
+
"description": description or "",
|
|
959
|
+
"edition": str(edition),
|
|
960
|
+
"format": file_format or "url",
|
|
961
|
+
"private": bool(private),
|
|
962
|
+
"storageMethodName": "WEB",
|
|
963
|
+
"location": location_url,
|
|
964
|
+
}
|
|
965
|
+
self._log(f"PUT {url} (update web file)")
|
|
966
|
+
r = self.session.put(url, json=payload)
|
|
967
|
+
r.raise_for_status()
|
|
968
|
+
return self._ensure_json(r)
|
|
969
|
+
|
|
970
|
+
def _api_item_add_existing_file(
|
|
971
|
+
self,
|
|
972
|
+
*,
|
|
973
|
+
item_guid: str,
|
|
974
|
+
file_guid: str,
|
|
975
|
+
primary: bool,
|
|
976
|
+
latest_edition_association: bool,
|
|
977
|
+
reference: Optional[str] = None,
|
|
978
|
+
) -> dict:
|
|
979
|
+
url = f"{self._api_base()}/items/{item_guid}/files"
|
|
980
|
+
payload = {
|
|
981
|
+
"primary": bool(primary),
|
|
982
|
+
"latestEditionAssociation": bool(latest_edition_association),
|
|
983
|
+
"file": {"guid": file_guid},
|
|
984
|
+
}
|
|
985
|
+
if reference:
|
|
986
|
+
payload["reference"] = reference
|
|
987
|
+
r = self.session.post(url, json=payload)
|
|
988
|
+
r.raise_for_status()
|
|
989
|
+
return self._ensure_json(r)
|
|
990
|
+
|
|
991
|
+
def upload_weblink_to_working(
|
|
992
|
+
self,
|
|
993
|
+
*,
|
|
994
|
+
item_number: str,
|
|
995
|
+
url: str,
|
|
996
|
+
reference: Optional[str] = None, # (unused by "add existing"; kept for parity)
|
|
997
|
+
title: str,
|
|
998
|
+
category_name: str = "Web Link",
|
|
999
|
+
file_format: Optional[str] = "url",
|
|
1000
|
+
description: Optional[str] = None,
|
|
1001
|
+
primary: bool = True,
|
|
1002
|
+
latest_edition_association: bool = True,
|
|
1003
|
+
edition: Optional[str] = None,
|
|
1004
|
+
) -> Dict:
|
|
1005
|
+
"""
|
|
1006
|
+
Idempotent "upsert" of a WEB-link File on the WORKING revision of `item_number`.
|
|
1007
|
+
|
|
1008
|
+
Match rules (WORKING first, then EFFECTIVE):
|
|
1009
|
+
- any association whose File has storageMethodName in {"WEB","FTP"} AND
|
|
1010
|
+
(File.title == title OR File.location == url)
|
|
1011
|
+
|
|
1012
|
+
If found -> PUT /files/{fileGuid} with storageMethodName=WEB + location + edition.
|
|
1013
|
+
Else -> POST /files (create) + POST /items/{workingGuid}/files (add existing).
|
|
1014
|
+
"""
|
|
1015
|
+
# Compute an edition if none is provided (SHA256 of the URL, truncated to 16)
|
|
1016
|
+
if not edition:
|
|
1017
|
+
edition = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
|
1018
|
+
edition = str(edition)[:16]
|
|
1019
|
+
|
|
1020
|
+
# Resolve item GUIDs
|
|
1021
|
+
effective_guid = self._api_resolve_item_guid(item_number)
|
|
1022
|
+
revs_url = f"{self._api_base()}/items/{effective_guid}/revisions"
|
|
1023
|
+
self._log(f"GET {revs_url}")
|
|
1024
|
+
r = self.session.get(revs_url)
|
|
1025
|
+
r.raise_for_status()
|
|
1026
|
+
revs = self._ensure_json(r).get("results", [])
|
|
1027
|
+
working_guid = None
|
|
1028
|
+
for rv in revs:
|
|
1029
|
+
if (str(rv.get("revisionStatus") or "").upper() == "WORKING") or (
|
|
1030
|
+
rv.get("status") == 0
|
|
1031
|
+
):
|
|
1032
|
+
working_guid = rv.get("guid")
|
|
1033
|
+
break
|
|
1034
|
+
if not working_guid:
|
|
1035
|
+
raise ArenaError(
|
|
1036
|
+
"No WORKING revision exists for this item. Create a working revision in Arena, then retry."
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Resolve category GUID
|
|
1040
|
+
cat_guid = self._api_resolve_file_category_guid(category_name)
|
|
1041
|
+
|
|
1042
|
+
# Helper to list associations for a given item/revision guid
|
|
1043
|
+
def _list_assocs(guid: str) -> list[dict]:
|
|
1044
|
+
url2 = f"{self._api_base()}/items/{guid}/files"
|
|
1045
|
+
self._log(f"GET {url2}")
|
|
1046
|
+
lr = self.session.get(url2)
|
|
1047
|
+
lr.raise_for_status()
|
|
1048
|
+
payload = self._ensure_json(lr)
|
|
1049
|
+
return payload.get("results", payload if isinstance(payload, list) else [])
|
|
1050
|
+
|
|
1051
|
+
# Try to find an existing WEB/FTP style file by title or URL
|
|
1052
|
+
def _pick_assoc_by_title_or_url(assocs: list[dict]) -> Optional[dict]:
|
|
1053
|
+
pick = None
|
|
1054
|
+
for a in assocs:
|
|
1055
|
+
f = a.get("file") or {}
|
|
1056
|
+
smn = str(
|
|
1057
|
+
f.get("storageMethodName") or f.get("storageMethod") or ""
|
|
1058
|
+
).upper()
|
|
1059
|
+
if smn not in {"WEB", "FTP"}:
|
|
1060
|
+
continue
|
|
1061
|
+
f_title = (f.get("title") or "").strip()
|
|
1062
|
+
f_loc = (f.get("location") or "").strip()
|
|
1063
|
+
if (f_title and f_title == title) or (f_loc and f_loc == url):
|
|
1064
|
+
if not pick:
|
|
1065
|
+
pick = a
|
|
1066
|
+
continue
|
|
1067
|
+
# prefer latestEditionAssociation + primary
|
|
1068
|
+
if (
|
|
1069
|
+
a.get("latestEditionAssociation") and a.get("primary")
|
|
1070
|
+
) and not (
|
|
1071
|
+
pick.get("latestEditionAssociation") and pick.get("primary")
|
|
1072
|
+
):
|
|
1073
|
+
pick = a
|
|
1074
|
+
return pick
|
|
1075
|
+
|
|
1076
|
+
assoc = _pick_assoc_by_title_or_url(
|
|
1077
|
+
_list_assocs(working_guid)
|
|
1078
|
+
) or _pick_assoc_by_title_or_url(_list_assocs(effective_guid))
|
|
1079
|
+
|
|
1080
|
+
# If found: update the File summary (ensures storageMethodName=WEB + new location/edition)
|
|
1081
|
+
if assoc:
|
|
1082
|
+
file_guid = (assoc.get("file") or {}).get("guid")
|
|
1083
|
+
if not file_guid:
|
|
1084
|
+
raise ArenaError(
|
|
1085
|
+
"Existing web-link association found but missing file.guid"
|
|
1086
|
+
)
|
|
1087
|
+
updated = self._api_update_web_file(
|
|
1088
|
+
file_guid=file_guid,
|
|
1089
|
+
category_guid=cat_guid,
|
|
1090
|
+
title=title,
|
|
1091
|
+
location_url=url,
|
|
1092
|
+
edition=str(edition),
|
|
1093
|
+
description=description,
|
|
1094
|
+
file_format=file_format,
|
|
1095
|
+
private=False,
|
|
1096
|
+
)
|
|
1097
|
+
# Normalize to a consistent response
|
|
1098
|
+
return {
|
|
1099
|
+
"ok": True,
|
|
1100
|
+
"action": "updated",
|
|
1101
|
+
"file": {
|
|
1102
|
+
"guid": updated.get("guid"),
|
|
1103
|
+
"number": updated.get("number"),
|
|
1104
|
+
"title": updated.get("title"),
|
|
1105
|
+
"edition": updated.get("edition"),
|
|
1106
|
+
"storageMethodName": updated.get("storageMethodName"),
|
|
1107
|
+
"location": updated.get("location"),
|
|
1108
|
+
},
|
|
1109
|
+
"associationGuid": assoc.get("guid"),
|
|
1110
|
+
"primary": assoc.get("primary"),
|
|
1111
|
+
"latestEditionAssociation": assoc.get("latestEditionAssociation"),
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
# Else: create a new WEB file, then associate it on WORKING
|
|
1115
|
+
created = self._api_create_web_file(
|
|
1116
|
+
category_guid=cat_guid,
|
|
1117
|
+
title=title,
|
|
1118
|
+
location_url=url,
|
|
1119
|
+
edition=str(edition),
|
|
1120
|
+
description=description,
|
|
1121
|
+
file_format=file_format,
|
|
1122
|
+
private=False,
|
|
1123
|
+
)
|
|
1124
|
+
file_guid = created.get("guid")
|
|
1125
|
+
assoc_resp = self._api_item_add_existing_file(
|
|
1126
|
+
item_guid=working_guid,
|
|
1127
|
+
file_guid=file_guid,
|
|
1128
|
+
primary=primary,
|
|
1129
|
+
latest_edition_association=latest_edition_association,
|
|
1130
|
+
reference=reference,
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
return {
|
|
1134
|
+
"ok": True,
|
|
1135
|
+
"action": "created",
|
|
1136
|
+
"associationGuid": assoc_resp.get("guid"),
|
|
1137
|
+
"primary": assoc_resp.get("primary"),
|
|
1138
|
+
"latestEditionAssociation": assoc_resp.get("latestEditionAssociation"),
|
|
1139
|
+
"file": {
|
|
1140
|
+
"guid": file_guid,
|
|
1141
|
+
"number": created.get("number"),
|
|
1142
|
+
"title": created.get("title"),
|
|
1143
|
+
"edition": created.get("edition"),
|
|
1144
|
+
"storageMethodName": created.get("storageMethodName") or "WEB",
|
|
1145
|
+
"location": created.get("location") or url,
|
|
1146
|
+
},
|
|
1147
|
+
}
|
|
1148
|
+
|
|
501
1149
|
def _run(self, cmd: str) -> Tuple[int, str, str]:
|
|
502
|
-
proc = subprocess.run(
|
|
1150
|
+
proc = subprocess.run(
|
|
1151
|
+
cmd, shell=True, check=False, capture_output=True, text=True
|
|
1152
|
+
)
|
|
503
1153
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|