Coverage for heritrace/routes/api.py: 99%

559 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-13 22:01 +0000

1# SPDX-FileCopyrightText: 2024-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import traceback 

6from dataclasses import dataclass 

7from typing import TypedDict, cast 

8 

9from flask import ( 

10 Blueprint, 

11 Response, 

12 current_app, 

13 g, 

14 jsonify, 

15 render_template_string, 

16 request, 

17) 

18from flask_babel import gettext 

19from flask_login import current_user, login_required 

20from rdflib import RDF, XSD, Graph, Literal, URIRef 

21 

22from heritrace.apis.orcid import get_responsible_agent_uri 

23from heritrace.editor import Editor, EndpointConfig 

24from heritrace.extensions import ( 

25 get_custom_filter, 

26 get_dataset_endpoint, 

27 get_form_fields, 

28 get_provenance_endpoint, 

29) 

30from heritrace.services.resource_lock_manager import LockStatus 

31from heritrace.utils.datatypes import DATATYPE_MAPPING 

32from heritrace.utils.draft_labels import draft_label 

33from heritrace.utils.primary_source_utils import save_user_default_primary_source 

34from heritrace.utils.shacl_utils import determine_shape_for_classes 

35from heritrace.utils.shacl_validation import validate_new_triple 

36from heritrace.utils.sparql_utils import ( 

37 CatalogQuery, 

38 DeletedEntitiesQuery, 

39 find_orphaned_entities, 

40 get_available_classes, 

41 get_catalog_data, 

42 get_deleted_entities_with_filtering, 

43 get_triples_from_graph, 

44 import_entity_graph, 

45 import_referenced_entities, 

46) 

47from heritrace.utils.strategies import OrphanHandlingStrategy, ProxyHandlingStrategy 

48from heritrace.utils.uri_utils import generate_unique_uri, is_valid_url 

49from heritrace.utils.virtual_properties import transform_changes_with_virtual_properties 

50 

51 

52@dataclass(frozen=True, slots=True) 

53class ChangeOperation: 

54 editor: Editor 

55 subject: URIRef 

56 graph_uri: URIRef | None = None 

57 entity_type: str | None = None 

58 entity_shape: str | None = None 

59 

60 

61api_bp = Blueprint("api", __name__) 

62 

63 

64@api_bp.route("/catalogue") 

65@login_required 

66def catalogue_api() -> Response: 

67 selected_class = request.args.get("class") 

68 selected_shape = request.args.get("shape") 

69 page = int(request.args.get("page", 1)) 

70 per_page = int( 

71 request.args.get("per_page", current_app.config["CATALOGUE_DEFAULT_PER_PAGE"]) 

72 ) 

73 sort_property = request.args.get("sort_property") 

74 sort_direction = request.args.get("sort_direction", "ASC") 

75 

76 allowed_per_page = current_app.config["CATALOGUE_ALLOWED_PER_PAGE"] 

77 if per_page not in allowed_per_page: 

78 per_page = current_app.config["CATALOGUE_DEFAULT_PER_PAGE"] 

79 

80 if not sort_property or sort_property.lower() == "null": 

81 sort_property = None 

82 

83 available_classes = get_available_classes() 

84 

85 catalog_data = get_catalog_data( 

86 CatalogQuery( 

87 selected_class=selected_class, 

88 page=page, 

89 per_page=per_page, 

90 sort_property=sort_property, 

91 sort_direction=sort_direction, 

92 selected_shape=selected_shape, 

93 ), 

94 available_classes, 

95 ) 

96 

97 catalog_data["available_classes"] = available_classes 

98 return jsonify(catalog_data) 

99 

100 

101@api_bp.route("/time-vault") 

102@login_required 

103def get_deleted_entities_api() -> Response: 

104 """ 

105 API endpoint to retrieve deleted entities with pagination and sorting. 

106 Only processes and returns entities whose classes are marked as visible. 

107 """ 

108 selected_class = request.args.get("class") 

109 selected_shape = request.args.get("shape") 

110 page = int(request.args.get("page", 1)) 

111 per_page = int( 

112 request.args.get("per_page", current_app.config["CATALOGUE_DEFAULT_PER_PAGE"]) 

113 ) 

114 sort_direction = request.args.get("sort_direction", "DESC") 

115 

116 allowed_per_page = current_app.config["CATALOGUE_ALLOWED_PER_PAGE"] 

117 if per_page not in allowed_per_page: 

118 per_page = current_app.config["CATALOGUE_DEFAULT_PER_PAGE"] 

119 

120 ( 

121 deleted_entities, 

122 available_classes, 

123 selected_class, 

124 selected_shape, 

125 sortable_properties, 

126 total_count, 

127 ) = get_deleted_entities_with_filtering( 

128 DeletedEntitiesQuery( 

129 page, 

130 per_page, 

131 sort_direction, 

132 selected_class, 

133 selected_shape, 

134 ) 

135 ) 

136 

137 return jsonify( 

138 { 

139 "entities": deleted_entities, 

140 "total_pages": (total_count + per_page - 1) // per_page 

141 if total_count > 0 

142 else 0, 

143 "current_page": page, 

144 "per_page": per_page, 

145 "total_count": total_count, 

146 "sort_property": sortable_properties[0]["property"], 

147 "sort_direction": sort_direction, 

148 "selected_class": selected_class, 

149 "selected_shape": selected_shape, 

150 "available_classes": available_classes, 

151 "sortable_properties": sortable_properties, 

152 } 

153 ) 

154 

155 

156@api_bp.route("/check-lock", methods=["POST"]) 

157@login_required 

158def check_lock() -> Response | tuple[Response, int]: 

159 """Check if a resource is locked.""" 

160 try: 

161 data = request.get_json() 

162 resource_uri = data.get("resource_uri") 

163 

164 if not resource_uri: 

165 return ( 

166 jsonify( 

167 {"status": "error", "message": gettext("No resource URI provided")} 

168 ), 

169 400, 

170 ) 

171 

172 status, lock_info = g.resource_lock_manager.check_lock_status(resource_uri) 

173 

174 if status == LockStatus.LOCKED: 

175 return jsonify( 

176 { 

177 "status": "locked", 

178 "title": gettext("Resource Locked"), 

179 "message": gettext( 

180 "This resource is currently being" 

181 " edited by %(user)s [%(orcid)s]", 

182 user=lock_info.user_name, 

183 orcid=lock_info.user_id, 

184 ), 

185 } 

186 ) 

187 if status == LockStatus.ERROR: 

188 return ( 

189 jsonify( 

190 { 

191 "status": "error", 

192 "title": gettext("Error"), 

193 "message": gettext("An error occurred while checking the lock"), 

194 } 

195 ), 

196 500, 

197 ) 

198 return jsonify({"status": "available"}) 

199 

200 except Exception: 

201 current_app.logger.exception("Error in check_lock") 

202 return ( 

203 jsonify( 

204 { 

205 "status": "error", 

206 "title": gettext("Error"), 

207 "message": gettext("An unexpected error occurred"), 

208 } 

209 ), 

210 500, 

211 ) 

212 

213 

214@api_bp.route("/acquire-lock", methods=["POST"]) 

215@login_required 

216def acquire_lock() -> Response | tuple[Response, int]: 

217 """Try to acquire a lock on a resource.""" 

218 try: 

219 data = request.get_json() 

220 resource_uri = data.get("resource_uri") 

221 linked_resources = data.get("linked_resources", []) 

222 

223 if not resource_uri: 

224 return ( 

225 jsonify( 

226 {"status": "error", "message": gettext("No resource URI provided")} 

227 ), 

228 400, 

229 ) 

230 

231 # First check if the resource or any related resource is locked by another user 

232 status, lock_info = g.resource_lock_manager.check_lock_status(resource_uri) 

233 if status == LockStatus.LOCKED: 

234 return ( 

235 jsonify( 

236 { 

237 "status": "locked", 

238 "title": gettext("Resource Locked"), 

239 "message": gettext( 

240 "This resource is currently" 

241 " being edited by" 

242 " %(user)s [%(orcid)s]", 

243 user=lock_info.user_name, 

244 orcid=lock_info.user_id, 

245 ), 

246 } 

247 ), 

248 200, 

249 ) 

250 

251 # Use the provided linked_resources 

252 success = g.resource_lock_manager.acquire_lock(resource_uri, linked_resources) 

253 

254 if success: 

255 return jsonify({"status": "success"}) 

256 

257 return ( 

258 jsonify( 

259 { 

260 "status": "error", 

261 "message": gettext("Resource is locked by another user"), 

262 } 

263 ), 

264 423, 

265 ) 

266 

267 except Exception: 

268 current_app.logger.exception("Error in acquire_lock") 

269 return ( 

270 jsonify( 

271 {"status": "error", "message": gettext("An unexpected error occurred")} 

272 ), 

273 500, 

274 ) 

275 

276 

277@api_bp.route("/release-lock", methods=["POST"]) 

278@login_required 

279def release_lock() -> Response | tuple[Response, int]: 

280 """Release a lock on a resource.""" 

281 try: 

282 data = request.get_json() 

283 resource_uri = data.get("resource_uri") 

284 

285 if not resource_uri: 

286 return ( 

287 jsonify( 

288 {"status": "error", "message": gettext("No resource URI provided")} 

289 ), 

290 400, 

291 ) 

292 

293 success = g.resource_lock_manager.release_lock(resource_uri) 

294 

295 if success: 

296 return jsonify({"status": "success"}) 

297 

298 return ( 

299 jsonify({"status": "error", "message": gettext("Unable to release lock")}), 

300 400, 

301 ) 

302 

303 except Exception: 

304 current_app.logger.exception("Error in release_lock") 

305 return ( 

306 jsonify( 

307 {"status": "error", "message": gettext("An unexpected error occurred")} 

308 ), 

309 500, 

310 ) 

311 

312 

313@api_bp.route("/renew-lock", methods=["POST"]) 

314@login_required 

315def renew_lock() -> Response | tuple[Response, int]: 

316 """Renew an existing lock on a resource.""" 

317 try: 

318 data = request.get_json() 

319 resource_uri = data.get("resource_uri") 

320 

321 if not resource_uri: 

322 return ( 

323 jsonify( 

324 {"status": "error", "message": gettext("No resource URI provided")} 

325 ), 

326 400, 

327 ) 

328 

329 # When renewing a lock, we don't need to check for linked resources again 

330 # Just pass an empty list as we're only refreshing the existing lock 

331 success = g.resource_lock_manager.acquire_lock(resource_uri, []) 

332 

333 if success: 

334 return jsonify({"status": "success"}) 

335 

336 return ( 

337 jsonify({"status": "error", "message": gettext("Unable to renew lock")}), 

338 423, 

339 ) 

340 

341 except Exception: 

342 current_app.logger.exception("Error in renew_lock") 

343 return ( 

344 jsonify( 

345 {"status": "error", "message": gettext("An unexpected error occurred")} 

346 ), 

347 500, 

348 ) 

349 

350 

351@api_bp.route("/validate-literal", methods=["POST"]) 

352@login_required 

353def validate_literal() -> tuple[Response, int]: 

354 """Validate a literal value and suggest appropriate datatypes.""" 

355 value = request.json.get("value") 

356 if not value: 

357 return jsonify({"error": gettext("Value is required.")}), 400 

358 

359 matching_datatypes = [] 

360 for datatype, validation_func, _ in DATATYPE_MAPPING: 

361 if validation_func(value): 

362 matching_datatypes.append(str(datatype)) 

363 

364 if not matching_datatypes: 

365 return jsonify({"error": gettext("No matching datatypes found.")}), 400 

366 

367 return jsonify({"valid_datatypes": matching_datatypes}), 200 

368 

369 

370def _collect_affected_entities( 

371 changes: list[dict], 

372 entity_type: str, 

373 *, 

374 check_for_orphans: bool, 

375 check_for_proxies: bool, 

376) -> tuple[list[dict[str, str]], list[dict[str, str]]]: 

377 orphans: list[dict[str, str]] = [] 

378 intermediate_orphans: list[dict[str, str]] = [] 

379 for change in changes: 

380 if change["action"] == "delete": 

381 found_orphans, found_intermediates = find_orphaned_entities( 

382 URIRef(change["subject"]), 

383 entity_type, 

384 URIRef(change["predicate"]) if change.get("predicate") else None, 

385 change.get("object"), 

386 ) 

387 if check_for_orphans: 

388 orphans.extend(found_orphans) 

389 if check_for_proxies: 

390 intermediate_orphans.extend(found_intermediates) 

391 return orphans, intermediate_orphans 

392 

393 

394def _format_orphan_response( 

395 orphans: list[dict[str, str]], 

396 intermediate_orphans: list[dict[str, str]], 

397 entity_shape: str | None, 

398 orphan_strategy: OrphanHandlingStrategy, 

399 proxy_strategy: ProxyHandlingStrategy, 

400) -> Response: 

401 custom_filter = get_custom_filter() 

402 

403 def format_entities( 

404 entities: list[dict[str, str]], 

405 *, 

406 is_intermediate: bool = False, 

407 ) -> list[dict[str, str | bool]]: 

408 return [ 

409 { 

410 "uri": entity["uri"], 

411 "label": custom_filter.human_readable_entity( 

412 entity["uri"], (entity["type"], entity_shape) 

413 ), 

414 "type": custom_filter.human_readable_class( 

415 (entity["type"], entity_shape) 

416 ), 

417 "is_intermediate": is_intermediate, 

418 } 

419 for entity in entities 

420 ] 

421 

422 affected_entities = format_entities(orphans) + format_entities( 

423 intermediate_orphans, is_intermediate=True 

424 ) 

425 

426 should_delete = ( 

427 orphan_strategy == OrphanHandlingStrategy.DELETE 

428 and proxy_strategy == ProxyHandlingStrategy.DELETE 

429 ) 

430 

431 return jsonify( 

432 { 

433 "status": "success", 

434 "affected_entities": affected_entities, 

435 "should_delete": should_delete, 

436 "orphan_strategy": orphan_strategy.value, 

437 "proxy_strategy": proxy_strategy.value, 

438 } 

439 ) 

440 

441 

442@api_bp.route("/check_orphans", methods=["POST"]) 

443@login_required 

444def check_orphans() -> Response | tuple[Response, int]: 

445 try: 

446 orphan_strategy = current_app.config.get( 

447 "ORPHAN_HANDLING_STRATEGY", OrphanHandlingStrategy.KEEP 

448 ) 

449 proxy_strategy = current_app.config.get( 

450 "PROXY_HANDLING_STRATEGY", ProxyHandlingStrategy.KEEP 

451 ) 

452 

453 data = request.json 

454 if not data or "changes" not in data or "entity_type" not in data: 

455 return ( 

456 jsonify( 

457 { 

458 "status": "error", 

459 "error_type": "validation", 

460 "message": gettext( 

461 "Invalid request: 'changes' and" 

462 " 'entity_type' are required fields" 

463 ), 

464 } 

465 ), 

466 400, 

467 ) 

468 

469 changes = data.get("changes", []) 

470 entity_type = data.get("entity_type") 

471 entity_shape = data.get("entity_shape") 

472 

473 check_for_orphans = orphan_strategy in ( 

474 OrphanHandlingStrategy.DELETE, 

475 OrphanHandlingStrategy.ASK, 

476 ) 

477 check_for_proxies = proxy_strategy in ( 

478 ProxyHandlingStrategy.DELETE, 

479 ProxyHandlingStrategy.ASK, 

480 ) 

481 

482 orphans: list[dict[str, str]] = [] 

483 intermediate_orphans: list[dict[str, str]] = [] 

484 if check_for_orphans or check_for_proxies: 

485 orphans, intermediate_orphans = _collect_affected_entities( 

486 changes, 

487 entity_type, 

488 check_for_orphans=check_for_orphans, 

489 check_for_proxies=check_for_proxies, 

490 ) 

491 

492 if (orphan_strategy == OrphanHandlingStrategy.KEEP or not orphans) and ( 

493 proxy_strategy == ProxyHandlingStrategy.KEEP or not intermediate_orphans 

494 ): 

495 return jsonify({"status": "success", "affected_entities": []}) 

496 

497 return _format_orphan_response( 

498 orphans, 

499 intermediate_orphans, 

500 entity_shape, 

501 orphan_strategy, 

502 proxy_strategy, 

503 ) 

504 except ValueError as e: 

505 error_message = str(e) 

506 current_app.logger.warning( 

507 "Validation error in check_orphans: %s", error_message 

508 ) 

509 return ( 

510 jsonify( 

511 { 

512 "status": "error", 

513 "error_type": "validation", 

514 "message": gettext( 

515 "An error occurred while checking for orphaned entities" 

516 ), 

517 } 

518 ), 

519 400, 

520 ) 

521 except Exception as e: 

522 error_message = f"Error checking orphans: {e!s}" 

523 current_app.logger.exception("%s\n%s", error_message, traceback.format_exc()) 

524 return ( 

525 jsonify( 

526 { 

527 "status": "error", 

528 "error_type": "system", 

529 "message": gettext( 

530 "An error occurred while checking for orphaned entities" 

531 ), 

532 } 

533 ), 

534 500, 

535 ) 

536 

537 

538def _parse_change_request( 

539 changes: list[dict], 

540) -> tuple[URIRef, list[dict], bool, str | None, bool]: 

541 first_change = changes[0] if changes else {} 

542 subject = URIRef(first_change.get("subject", "")) 

543 affected_entities = first_change.get("affected_entities", []) 

544 delete_affected = first_change.get("delete_affected", False) 

545 primary_source = first_change.get("primary_source") 

546 save_default_source = first_change.get("save_default_source", False) 

547 return ( 

548 subject, 

549 affected_entities, 

550 delete_affected, 

551 primary_source, 

552 save_default_source, 

553 ) 

554 

555 

556def _affected_entity_will_be_deleted(entity: dict, *, delete_affected: bool) -> bool: 

557 orphan_strategy = current_app.config["ORPHAN_HANDLING_STRATEGY"] 

558 proxy_strategy = current_app.config["PROXY_HANDLING_STRATEGY"] 

559 

560 if entity["is_intermediate"]: 

561 return delete_affected and proxy_strategy in ( 

562 ProxyHandlingStrategy.DELETE, 

563 ProxyHandlingStrategy.ASK, 

564 ) 

565 return delete_affected and orphan_strategy in ( 

566 OrphanHandlingStrategy.DELETE, 

567 OrphanHandlingStrategy.ASK, 

568 ) 

569 

570 

571def _collect_entity_deletion_subjects( 

572 changes: list[dict], 

573 affected_entities: list[dict], 

574 *, 

575 delete_affected: bool, 

576) -> set[URIRef]: 

577 deletion_subjects = { 

578 URIRef(change["subject"]) 

579 for change in changes 

580 if change["action"] == "delete" and not change.get("predicate") 

581 } 

582 

583 for entity in affected_entities: 

584 if _affected_entity_will_be_deleted(entity, delete_affected=delete_affected): 

585 deletion_subjects.add(URIRef(entity["uri"])) 

586 

587 return deletion_subjects 

588 

589 

590def _setup_editor( 

591 primary_source: str | None, 

592 changes: list[dict], 

593 subject: URIRef, 

594 affected_entities: list[dict], 

595 *, 

596 delete_affected: bool, 

597) -> tuple[Editor, URIRef | None]: 

598 resp_agent = get_responsible_agent_uri(current_user.orcid) 

599 editor = Editor( 

600 EndpointConfig( 

601 dataset=get_dataset_endpoint(), 

602 provenance=get_provenance_endpoint(), 

603 is_quadstore=current_app.config["DATASET_IS_QUADSTORE"], 

604 ), 

605 current_app.config["COUNTER_HANDLER"], 

606 resp_agent, 

607 URIRef(current_app.config["PRIMARY_SOURCE"]), 

608 current_app.config["DATASET_GENERATION_TIME"], 

609 save_plugin=current_app.config.get("SAVE_PLUGIN"), 

610 ) 

611 

612 deletion_subjects = _collect_entity_deletion_subjects( 

613 changes, affected_entities, delete_affected=delete_affected 

614 ) 

615 

616 editor = import_entity_graph( 

617 editor, 

618 subject, 

619 include_referencing_entities=subject in deletion_subjects, 

620 ) 

621 for deletion_subject in sorted(deletion_subjects - {subject}, key=str): 

622 editor = import_entity_graph( 

623 editor, deletion_subject, include_referencing_entities=True 

624 ) 

625 

626 for change in changes: 

627 if change["action"] == "create": 

628 data = change.get("data") 

629 if data: 

630 import_referenced_entities(editor, data) 

631 

632 editor.preexisting_finished() 

633 editor.set_primary_source(URIRef(primary_source) if primary_source else None) 

634 

635 graph_uri: URIRef | None = None 

636 if editor.dataset_is_quadstore: 

637 for quad in editor.g_set.quads((subject, None, None, None)): # type: ignore[union-attr] 

638 graph_uri = get_graph_uri_from_context(cast("Graph | URIRef", quad[3])) 

639 break 

640 

641 return editor, graph_uri 

642 

643 

644def _process_creates( 

645 editor: Editor, 

646 changes: list[dict], 

647 graph_uri: URIRef | None, 

648 subject: URIRef, 

649) -> tuple[dict[str, str], URIRef]: 

650 temp_id_to_uri: dict[str, str] = {} 

651 for change in changes: 

652 if change["action"] == "create": 

653 data = change.get("data") 

654 if data: 

655 change_subject_str = change.get("subject") 

656 change_subject = ( 

657 URIRef(change_subject_str) if change_subject_str else None 

658 ) 

659 created_subject = create_logic( 

660 editor, 

661 data, 

662 change_subject, 

663 graph_uri, 

664 temp_id_to_uri=temp_id_to_uri, 

665 parent_entity_type=None, 

666 ) 

667 if change_subject is not None: 

668 subject = created_subject 

669 return temp_id_to_uri, subject 

670 

671 

672def _handle_affected_entities( 

673 editor: Editor, 

674 affected_entities: list[dict], 

675 *, 

676 delete_affected: bool, 

677 graph_uri: URIRef | None, 

678 deleted_entities: set[URIRef], 

679) -> None: 

680 orphan_strategy = current_app.config.get( 

681 "ORPHAN_HANDLING_STRATEGY", OrphanHandlingStrategy.KEEP 

682 ) 

683 proxy_strategy = current_app.config.get( 

684 "PROXY_HANDLING_STRATEGY", ProxyHandlingStrategy.KEEP 

685 ) 

686 # Separiamo le operazioni di delete in due fasi: 

687 # 1. Prima eliminiamo tutte le entità orfane/intermedie 

688 # 2. Poi eliminiamo le triple specifiche 

689 

690 # Fase 1: Elimina le entità orfane/intermedie 

691 if not (affected_entities and delete_affected): 

692 return 

693 

694 # Separa gli orfani dalle entità proxy 

695 orphans = [ 

696 entity for entity in affected_entities if not entity.get("is_intermediate") 

697 ] 

698 proxies = [entity for entity in affected_entities if entity.get("is_intermediate")] 

699 

700 # Gestione degli orfani secondo la strategia per gli orfani 

701 should_delete_orphans = orphan_strategy == OrphanHandlingStrategy.DELETE or ( 

702 orphan_strategy == OrphanHandlingStrategy.ASK and delete_affected 

703 ) 

704 

705 if should_delete_orphans and orphans: 

706 for orphan in orphans: 

707 orphan_uri = URIRef(orphan["uri"]) 

708 if orphan_uri in deleted_entities: 

709 continue 

710 

711 delete_logic( 

712 ChangeOperation(editor=editor, subject=orphan_uri, graph_uri=graph_uri) 

713 ) 

714 deleted_entities.add(orphan_uri) 

715 

716 # Gestione delle entità proxy secondo la strategia per i proxy 

717 should_delete_proxies = proxy_strategy == ProxyHandlingStrategy.DELETE or ( 

718 proxy_strategy == ProxyHandlingStrategy.ASK and delete_affected 

719 ) 

720 

721 if should_delete_proxies and proxies: 

722 for proxy in proxies: 

723 proxy_uri = URIRef(proxy["uri"]) 

724 if proxy_uri in deleted_entities: 

725 continue 

726 

727 delete_logic( 

728 ChangeOperation(editor=editor, subject=proxy_uri, graph_uri=graph_uri) 

729 ) 

730 deleted_entities.add(proxy_uri) 

731 

732 

733def _process_remaining_changes( 

734 editor: Editor, 

735 changes: list[dict], 

736 graph_uri: URIRef | None, 

737 deleted_entities: set[URIRef], 

738 temp_id_to_uri: dict[str, str], 

739) -> None: 

740 for change in changes: 

741 if change["action"] == "delete": 

742 _process_delete_change(editor, change, graph_uri, deleted_entities) 

743 elif change["action"] == "update": 

744 op = ChangeOperation( 

745 editor=editor, 

746 subject=URIRef(change["subject"]), 

747 graph_uri=graph_uri, 

748 entity_type=change.get("entity_type"), 

749 entity_shape=change.get("entity_shape"), 

750 ) 

751 update_logic( 

752 op, 

753 URIRef(change["predicate"]), 

754 change["object"], 

755 change["newObject"], 

756 ) 

757 elif change["action"] == "order": 

758 op = ChangeOperation( 

759 editor=editor, 

760 subject=URIRef(change["subject"]), 

761 graph_uri=graph_uri, 

762 ) 

763 order_logic( 

764 op, 

765 URIRef(change["predicate"]), 

766 change["object"], 

767 URIRef(change["newObject"]), 

768 temp_id_to_uri, 

769 ) 

770 

771 

772def _process_delete_change( 

773 editor: Editor, 

774 change: dict, 

775 graph_uri: URIRef | None, 

776 deleted_entities: set[URIRef], 

777) -> None: 

778 change_subject = URIRef(change["subject"]) 

779 change_predicate = URIRef(change["predicate"]) if change.get("predicate") else None 

780 raw_object_value = change.get("object") 

781 object_value = str(raw_object_value) if raw_object_value is not None else None 

782 

783 op = ChangeOperation( 

784 editor=editor, 

785 subject=change_subject, 

786 graph_uri=graph_uri, 

787 entity_type=change.get("entity_type"), 

788 entity_shape=change.get("entity_shape"), 

789 ) 

790 

791 if not change_predicate: 

792 if change_subject in deleted_entities: 

793 return 

794 

795 delete_logic(op) 

796 deleted_entities.add(change_subject) 

797 elif object_value is not None: 

798 if is_valid_url(object_value) and URIRef(object_value) in deleted_entities: 

799 return 

800 

801 delete_logic(op, change_predicate, object_value) 

802 

803 

804def _save_and_respond(editor: Editor) -> tuple[Response, int]: 

805 try: 

806 editor.save() 

807 except ValueError: 

808 current_app.logger.exception("Error during save operation") 

809 raise 

810 except Exception as save_error: 

811 current_app.logger.exception("Error during save operation") 

812 return jsonify( 

813 { 

814 "status": "error", 

815 "error_type": "database", 

816 "message": gettext("Failed to save changes to the database: {}").format( 

817 str(save_error) 

818 ), 

819 } 

820 ), 500 

821 

822 return ( 

823 jsonify( 

824 { 

825 "status": "success", 

826 "message": gettext("Changes applied successfully"), 

827 } 

828 ), 

829 200, 

830 ) 

831 

832 

833@api_bp.route("/apply_changes", methods=["POST"]) 

834@login_required 

835def apply_changes() -> tuple[Response, int]: 

836 """Apply changes to entities. 

837 

838 Request body: 

839 { 

840 "subject": (str) Main entity URI being modified, 

841 "changes": (list) List of changes to apply, 

842 "primary_source": (str) Primary source to use for provenance, 

843 "save_default_source": (bool) Whether to save primary_source as default for 

844 current user, 

845 "affected_entities": (list) Entities potentially affected by delete operations, 

846 "delete_affected": (bool) Whether to delete affected entities 

847 } 

848 

849 Responses: 

850 200 OK: Changes applied successfully 

851 400 Bad Request: Invalid request or validation error 

852 500 Internal Server Error: Server error while applying changes 

853 """ 

854 try: 

855 changes = request.get_json() 

856 if not changes: 

857 return jsonify({"error": "No request data provided"}), 400 

858 

859 ( 

860 subject, 

861 affected_entities, 

862 delete_affected, 

863 primary_source, 

864 save_default_source, 

865 ) = _parse_change_request(changes) 

866 

867 if primary_source and not is_valid_url(primary_source): 

868 return jsonify({"error": "Invalid primary source URL"}), 400 

869 

870 if save_default_source and primary_source and is_valid_url(primary_source): 

871 save_user_default_primary_source(current_user.orcid, primary_source) 

872 

873 changes = transform_changes_with_virtual_properties(changes) 

874 

875 editor, graph_uri = _setup_editor( 

876 primary_source, 

877 changes, 

878 subject, 

879 affected_entities, 

880 delete_affected=delete_affected, 

881 ) 

882 

883 temp_id_to_uri, subject = _process_creates(editor, changes, graph_uri, subject) 

884 

885 deleted_entities: set[URIRef] = set() 

886 _handle_affected_entities( 

887 editor, 

888 affected_entities, 

889 delete_affected=delete_affected, 

890 graph_uri=graph_uri, 

891 deleted_entities=deleted_entities, 

892 ) 

893 _process_remaining_changes( 

894 editor, changes, graph_uri, deleted_entities, temp_id_to_uri 

895 ) 

896 

897 return _save_and_respond(editor) 

898 

899 except ValueError as e: 

900 error_message = str(e) 

901 current_app.logger.warning("Validation error: %s", error_message) 

902 return ( 

903 jsonify( 

904 { 

905 "status": "error", 

906 "error_type": "validation", 

907 "message": error_message, 

908 } 

909 ), 

910 400, 

911 ) 

912 except Exception as e: 

913 error_message = f"Error while applying changes: {e!s}\n{traceback.format_exc()}" 

914 current_app.logger.exception(error_message) 

915 return ( 

916 jsonify( 

917 { 

918 "status": "error", 

919 "error_type": "system", 

920 "message": gettext("An error occurred while applying changes"), 

921 } 

922 ), 

923 500, 

924 ) 

925 

926 

927def get_graph_uri_from_context(graph_context: Graph | URIRef) -> URIRef: 

928 if isinstance(graph_context, Graph): 

929 return cast("URIRef", graph_context.identifier) 

930 return cast("URIRef", graph_context) 

931 

932 

933class CreateEntityData(TypedDict, total=False): 

934 entity_type: str 

935 # TODO(arcangelo): tighten this type after normalizing 

936 # the frontend payload to a consistent shape 

937 properties: dict[str, list | dict | str] 

938 tempId: str 

939 

940 

941@dataclass 

942class _CreateContext: 

943 editor: Editor 

944 graph_uri: URIRef | None 

945 entity_type: str | None 

946 temp_id_to_uri: dict[str, str] | None 

947 

948 

949def _handle_property_value( 

950 ctx: _CreateContext, 

951 value: dict | str, 

952 subject: URIRef, 

953 predicate: URIRef, 

954) -> None: 

955 if isinstance(value, dict) and "entity_type" in value: 

956 nested_subject = generate_unique_uri(value["entity_type"]) 

957 create_logic( 

958 ctx.editor, 

959 cast("CreateEntityData", value), 

960 nested_subject, 

961 ctx.graph_uri, 

962 subject, 

963 predicate, 

964 ctx.temp_id_to_uri, 

965 parent_entity_type=ctx.entity_type, 

966 ) 

967 elif isinstance(value, dict) and value.get("is_existing_entity", False): 

968 entity_uri = value.get("entity_uri") 

969 if entity_uri: 

970 ctx.editor.create(subject, predicate, URIRef(entity_uri), ctx.graph_uri) 

971 else: 

972 msg = "Missing entity_uri in existing entity reference" 

973 raise ValueError(msg) 

974 elif isinstance(value, dict) and value.get("is_custom_property", False): 

975 if value["type"] == "uri": 

976 object_value = URIRef(value["value"]) 

977 elif value["type"] == "literal": 

978 datatype = URIRef(value["datatype"]) if "datatype" in value else XSD.string 

979 object_value = Literal(value["value"], datatype=datatype) 

980 else: 

981 msg = f"Unknown custom property type: {value['type']}" 

982 raise ValueError(msg) 

983 

984 ctx.editor.create(subject, predicate, object_value, ctx.graph_uri) 

985 else: 

986 object_value, _, error_message = validate_new_triple( 

987 subject, 

988 predicate, 

989 str(value), 

990 "create", 

991 entity_types=ctx.entity_type, 

992 ) 

993 if error_message: 

994 raise ValueError(error_message) 

995 

996 if object_value is not None: 

997 ctx.editor.create(subject, predicate, object_value, ctx.graph_uri) 

998 

999 

1000def _setup_parent_relations( 

1001 ctx: _CreateContext, 

1002 subject: URIRef, 

1003 parent_subject: URIRef, 

1004 parent_predicate: URIRef | None, 

1005 parent_entity_type: str | None, 

1006) -> None: 

1007 type_value, _, error_message = validate_new_triple( 

1008 subject, RDF.type, ctx.entity_type, "create", entity_types=ctx.entity_type 

1009 ) 

1010 if error_message: 

1011 raise ValueError(error_message) 

1012 

1013 if type_value is not None: 

1014 ctx.editor.create(subject, RDF.type, type_value, ctx.graph_uri) 

1015 

1016 if parent_predicate: 

1017 parent_value, _, error_message = validate_new_triple( 

1018 parent_subject, 

1019 parent_predicate, 

1020 subject, 

1021 "create", 

1022 entity_types=parent_entity_type, 

1023 ) 

1024 if error_message: 

1025 raise ValueError(error_message) 

1026 

1027 if parent_value is not None: 

1028 ctx.editor.create( 

1029 parent_subject, parent_predicate, parent_value, ctx.graph_uri 

1030 ) 

1031 

1032 

1033def create_logic( # noqa: PLR0913 

1034 editor: Editor, 

1035 data: CreateEntityData, 

1036 subject: URIRef | None = None, 

1037 graph_uri: URIRef | None = None, 

1038 parent_subject: URIRef | None = None, 

1039 parent_predicate: URIRef | None = None, 

1040 temp_id_to_uri: dict[str, str] | None = None, 

1041 parent_entity_type: str | None = None, 

1042) -> URIRef: 

1043 entity_type: str | None = data.get("entity_type") 

1044 properties: dict = data.get("properties", {}) 

1045 temp_id: str | None = data.get("tempId") 

1046 

1047 if subject is None: 

1048 subject = generate_unique_uri(entity_type, cast("dict", data)) 

1049 

1050 if temp_id and temp_id_to_uri is not None: 

1051 temp_id_to_uri[temp_id] = str(subject) 

1052 

1053 ctx = _CreateContext(editor, graph_uri, entity_type, temp_id_to_uri) 

1054 

1055 if parent_subject is not None: 

1056 _setup_parent_relations( 

1057 ctx, subject, parent_subject, parent_predicate, parent_entity_type 

1058 ) 

1059 

1060 for predicate_str, values in properties.items(): 

1061 predicate = URIRef(predicate_str) 

1062 values_list = values if isinstance(values, list) else [values] 

1063 for value in values_list: 

1064 _handle_property_value(ctx, value, subject, predicate) 

1065 

1066 return subject 

1067 

1068 

1069def update_logic( 

1070 op: ChangeOperation, 

1071 predicate: URIRef, 

1072 old_value: str, 

1073 new_value: str, 

1074) -> None: 

1075 old_value_rdf: URIRef | Literal = ( 

1076 URIRef(old_value) if is_valid_url(old_value) else Literal(old_value) 

1077 ) 

1078 validated_new, validated_old, error_message = validate_new_triple( 

1079 op.subject, 

1080 predicate, 

1081 new_value, 

1082 "update", 

1083 old_value_rdf, 

1084 entity_types=op.entity_type, 

1085 ) 

1086 if error_message: 

1087 raise ValueError(error_message) 

1088 

1089 op.editor.update( 

1090 op.subject, 

1091 predicate, 

1092 cast("Literal | URIRef", validated_old), 

1093 cast("Literal | URIRef", validated_new), 

1094 op.graph_uri, 

1095 ) 

1096 

1097 

1098def rebuild_entity_order( 

1099 editor: Editor, 

1100 ordered_by_uri: URIRef, 

1101 entities: list[URIRef], 

1102 graph_uri: URIRef | None = None, 

1103) -> Editor: 

1104 for entity in entities: 

1105 for _s, _p, o in list( 

1106 get_triples_from_graph(editor.g_set, (entity, ordered_by_uri, None)) 

1107 ): 

1108 editor.delete( 

1109 entity, ordered_by_uri, cast("Literal | URIRef", o), graph_uri 

1110 ) 

1111 

1112 # Then rebuild the chain with the entities 

1113 for i in range(len(entities) - 1): 

1114 current_entity = entities[i] 

1115 next_entity = entities[i + 1] 

1116 editor.create(current_entity, ordered_by_uri, next_entity, graph_uri) 

1117 

1118 return editor 

1119 

1120 

1121def delete_logic( 

1122 op: ChangeOperation, 

1123 predicate: URIRef | None = None, 

1124 object_value: str | None = None, 

1125) -> None: 

1126 resolved_value: URIRef | Literal | None = None 

1127 if predicate and object_value: 

1128 old_val_rdf: URIRef | Literal = ( 

1129 URIRef(object_value) 

1130 if is_valid_url(object_value) 

1131 else Literal(object_value) 

1132 ) 

1133 _, resolved_value, error_message = validate_new_triple( 

1134 op.subject, 

1135 predicate, 

1136 None, 

1137 "delete", 

1138 old_val_rdf, 

1139 entity_types=op.entity_type, 

1140 ) 

1141 if error_message: 

1142 raise ValueError(error_message) 

1143 

1144 op.editor.delete( 

1145 op.subject, 

1146 predicate, 

1147 cast("Literal | URIRef | None", resolved_value), 

1148 op.graph_uri, 

1149 ) 

1150 

1151 

1152def order_logic( 

1153 op: ChangeOperation, 

1154 predicate: URIRef, 

1155 new_order: list[str], 

1156 ordered_by: URIRef, 

1157 temp_id_to_uri: dict[str, str] | None = None, 

1158) -> Editor: 

1159 current_entities = [ 

1160 o 

1161 for _, _, o in get_triples_from_graph( 

1162 op.editor.g_set, (op.subject, predicate, None) 

1163 ) 

1164 ] 

1165 

1166 old_to_new_mapping = {} 

1167 

1168 for old_entity in current_entities: 

1169 if str(old_entity) in new_order: 

1170 entity_properties = list( 

1171 get_triples_from_graph( 

1172 op.editor.g_set, 

1173 (cast("URIRef", old_entity), None, None), 

1174 ) 

1175 ) 

1176 

1177 entity_type = next( 

1178 (o for _, p, o in entity_properties if p == RDF.type), None 

1179 ) 

1180 

1181 if entity_type is None: 

1182 msg = f"Impossibile determinare il tipo dell'entità per {old_entity}" 

1183 raise ValueError(msg) 

1184 

1185 new_entity_uri = generate_unique_uri(str(entity_type)) 

1186 old_to_new_mapping[old_entity] = new_entity_uri 

1187 

1188 op.editor.delete( 

1189 op.subject, 

1190 predicate, 

1191 cast("Literal | URIRef", old_entity), 

1192 op.graph_uri, 

1193 ) 

1194 op.editor.delete(cast("URIRef", old_entity), graph=op.graph_uri) 

1195 

1196 op.editor.create(op.subject, predicate, new_entity_uri, op.graph_uri) 

1197 

1198 for _, p, o in entity_properties: 

1199 if p not in (predicate, ordered_by): 

1200 op.editor.create( 

1201 new_entity_uri, 

1202 cast("URIRef", p), 

1203 cast("Literal | URIRef", o), 

1204 op.graph_uri, 

1205 ) 

1206 

1207 ordered_entities = [] 

1208 for entity in new_order: 

1209 new_entity_uri = old_to_new_mapping.get(URIRef(entity)) 

1210 if not new_entity_uri: 

1211 new_entity_uri = URIRef( 

1212 temp_id_to_uri.get(entity, entity) if temp_id_to_uri else entity 

1213 ) 

1214 ordered_entities.append(new_entity_uri) 

1215 

1216 if ordered_entities: 

1217 rebuild_entity_order(op.editor, ordered_by, ordered_entities, op.graph_uri) 

1218 

1219 return op.editor 

1220 

1221 

1222@api_bp.route("/human-readable-entity", methods=["POST"]) 

1223@login_required 

1224def get_human_readable_entity() -> str | tuple[Response, int]: 

1225 custom_filter = get_custom_filter() 

1226 

1227 # Check if required parameters are present 

1228 if "uri" not in request.form or "entity_class" not in request.form: 

1229 return jsonify( 

1230 {"status": "error", "message": "Missing required parameters"} 

1231 ), 400 

1232 

1233 uri = request.form["uri"] 

1234 entity_class = request.form["entity_class"] 

1235 shape = determine_shape_for_classes([entity_class]) 

1236 filter_instance = custom_filter 

1237 return filter_instance.human_readable_entity(uri, (entity_class, shape)) 

1238 

1239 

1240@api_bp.route("/form-fields", methods=["GET"]) 

1241@login_required 

1242def get_form_fields_for_entity() -> Response | tuple[Response, int]: 

1243 """ 

1244 Get form_fields for a specific entity class and shape combination. 

1245 Returns only the requested entity + immediate sub-entities (depth=2) to improve 

1246 performance. 

1247 

1248 Query parameters: 

1249 entity_class: URI of the entity class 

1250 entity_shape: URI of the entity shape 

1251 

1252 Returns: 

1253 JSON response with form_fields for the specified entity 

1254 """ 

1255 

1256 try: 

1257 entity_class_decoded = request.args.get("entity_class") 

1258 entity_shape_decoded = request.args.get("entity_shape") 

1259 

1260 if not entity_class_decoded or not entity_shape_decoded: 

1261 return jsonify( 

1262 { 

1263 "status": "error", 

1264 "message": ( 

1265 "Missing required parameters: entity_class and entity_shape" 

1266 ), 

1267 } 

1268 ), 400 

1269 

1270 all_form_fields = get_form_fields() 

1271 

1272 if not all_form_fields: 

1273 return jsonify( 

1274 {"status": "error", "message": "Form fields not initialized"} 

1275 ), 500 

1276 

1277 entity_key = (entity_class_decoded, entity_shape_decoded) 

1278 

1279 if entity_key not in all_form_fields: 

1280 return jsonify( 

1281 { 

1282 "status": "error", 

1283 "message": ( 

1284 f"No form fields found for entity class" 

1285 f" {entity_class_decoded} with shape" 

1286 f" {entity_shape_decoded}" 

1287 ), 

1288 } 

1289 ), 404 

1290 

1291 entity_form_fields = all_form_fields[entity_key] 

1292 

1293 # Convert OrderedDict to list of [property, details] pairs to preserve order 

1294 ordered_properties = [] 

1295 for prop, details_list in entity_form_fields.items(): 

1296 ordered_properties.append([prop, details_list]) 

1297 

1298 return jsonify( 

1299 { 

1300 "status": "success", 

1301 "form_fields": ordered_properties, 

1302 "entity_key": [entity_class_decoded, entity_shape_decoded], 

1303 } 

1304 ) 

1305 

1306 except Exception as e: 

1307 current_app.logger.exception( 

1308 "Error loading form fields for %s/%s", 

1309 entity_class_decoded, 

1310 entity_shape_decoded, 

1311 ) 

1312 

1313 return jsonify( 

1314 {"status": "error", "message": f"Failed to load form fields: {e!s}"} 

1315 ), 500 

1316 

1317 

1318@api_bp.route("/render-form-fields", methods=["POST"]) 

1319@login_required 

1320def render_form_fields_html() -> str | tuple[Response, int]: 

1321 """ 

1322 Render form fields as HTML for dynamic loading. 

1323 

1324 Expects JSON payload with: 

1325 - entity_key: [entity_class, entity_shape] array 

1326 

1327 Returns: 

1328 HTML string of the rendered form fields 

1329 """ 

1330 try: 

1331 data = request.get_json() 

1332 

1333 if not data or "entity_key" not in data: 

1334 return jsonify( 

1335 {"status": "error", "message": "Missing required field: entity_key"} 

1336 ), 400 

1337 

1338 entity_key = data["entity_key"] # This is [entity_class, entity_shape] array 

1339 entity_class, entity_shape = entity_key 

1340 

1341 all_form_fields = get_form_fields() 

1342 

1343 if not all_form_fields: 

1344 return jsonify( 

1345 {"status": "error", "message": "Form fields not initialized"} 

1346 ), 500 

1347 

1348 tuple_key = (entity_class, entity_shape) 

1349 if tuple_key not in all_form_fields: 

1350 return jsonify( 

1351 { 

1352 "status": "error", 

1353 "message": ( 

1354 f"No form fields found for entity" 

1355 f" {entity_class} with shape" 

1356 f" {entity_shape}" 

1357 ), 

1358 } 

1359 ), 404 

1360 

1361 entity_form_fields = all_form_fields[tuple_key] 

1362 

1363 form_fields_array = [ 

1364 [prop, details_list] for prop, details_list in entity_form_fields.items() 

1365 ] 

1366 

1367 template_string = """ 

1368 {% from 'macros.jinja' import render_form_field with context %} 

1369 

1370 {% set entity_type = entity_class %} 

1371 {% set entity_shape = entity_shape %} 

1372 {% set group_id = ((entity_type, entity_shape) | human_readable_class + 

1373 "_group") | replace(" ", "_") %} 

1374 <div class="property-group mb-3" id="{{ group_id }}" data-uri="{{ entity_type 

1375 }}" data-shape="{{ entity_shape }}"> 

1376 {% for prop_data in ordered_form_fields %} 

1377 {% set prop = prop_data[0] %} 

1378 {% set details_list = prop_data[1] %} 

1379 {% for details in details_list %} 

1380 {{ render_form_field(entity_type, prop, details, all_form_fields) }} 

1381 {% endfor %} 

1382 {% endfor %} 

1383 </div> 

1384 """ 

1385 

1386 return render_template_string( 

1387 template_string, 

1388 entity_class=entity_class, 

1389 entity_shape=entity_shape, 

1390 ordered_form_fields=form_fields_array, 

1391 all_form_fields=all_form_fields, 

1392 ) 

1393 

1394 except Exception as e: 

1395 current_app.logger.exception("Error rendering form fields HTML") 

1396 

1397 return jsonify( 

1398 {"status": "error", "message": f"Failed to render form fields: {e!s}"} 

1399 ), 500 

1400 

1401 

1402def _validate_nested_form_request() -> ( 

1403 tuple[str, str, str, str, str, int, bool, dict] | tuple[Response, int] 

1404): 

1405 data = request.get_json() 

1406 

1407 required_fields = [ 

1408 "parent_entity_class", 

1409 "parent_entity_shape", 

1410 "entity_class", 

1411 "entity_shape", 

1412 "predicate_uri", 

1413 "depth", 

1414 ] 

1415 

1416 if not data: 

1417 return jsonify({"status": "error", "message": "No JSON data provided"}), 400 

1418 

1419 missing_fields = [field for field in required_fields if field not in data] 

1420 if missing_fields: 

1421 return jsonify( 

1422 { 

1423 "status": "error", 

1424 "message": f"Missing required fields: {', '.join(required_fields)}", 

1425 } 

1426 ), 400 

1427 

1428 parent_entity_class = data["parent_entity_class"] 

1429 parent_entity_shape = data["parent_entity_shape"] 

1430 entity_class = data["entity_class"] 

1431 entity_shape = data["entity_shape"] 

1432 predicate_uri = data["predicate_uri"] 

1433 depth = int(data["depth"]) 

1434 is_template = data.get("is_template", False) 

1435 

1436 all_form_fields = get_form_fields() 

1437 

1438 if not all_form_fields: 

1439 return jsonify( 

1440 {"status": "error", "message": "Form fields not initialized"} 

1441 ), 500 

1442 

1443 parent_entity_key = (parent_entity_class, parent_entity_shape) 

1444 if parent_entity_key not in all_form_fields: 

1445 return jsonify( 

1446 { 

1447 "status": "error", 

1448 "message": ( 

1449 "No form fields found for parent" 

1450 f" entity {parent_entity_class}" 

1451 f" with shape {parent_entity_shape}" 

1452 ), 

1453 } 

1454 ), 404 

1455 

1456 parent_fields = all_form_fields[parent_entity_key] 

1457 if predicate_uri not in parent_fields: 

1458 return jsonify( 

1459 { 

1460 "status": "error", 

1461 "message": ( 

1462 "No field definition found for" 

1463 f" predicate {predicate_uri}" 

1464 " in parent entity" 

1465 ), 

1466 } 

1467 ), 404 

1468 

1469 return ( 

1470 parent_entity_class, 

1471 parent_entity_shape, 

1472 entity_class, 

1473 entity_shape, 

1474 predicate_uri, 

1475 depth, 

1476 is_template, 

1477 all_form_fields, 

1478 ) 

1479 

1480 

1481@api_bp.route("/render-nested-form", methods=["POST"]) 

1482@login_required 

1483def render_nested_form_html() -> str | tuple[Response, int]: 

1484 try: 

1485 validated = _validate_nested_form_request() 

1486 if isinstance(validated[0], Response): 

1487 return validated # type: ignore[return-value] 

1488 

1489 ( 

1490 parent_entity_class, 

1491 parent_entity_shape, 

1492 entity_class, 

1493 entity_shape, 

1494 predicate_uri, 

1495 depth, 

1496 is_template, 

1497 all_form_fields, 

1498 ) = cast("tuple[str, str, str, str, str, int, bool, dict]", validated) 

1499 

1500 parent_entity_key = (parent_entity_class, parent_entity_shape) 

1501 parent_fields = all_form_fields[parent_entity_key] 

1502 field_details_list = parent_fields[predicate_uri] 

1503 

1504 target_details = None 

1505 for details in field_details_list: 

1506 if details.get("or"): 

1507 for shape_info in details["or"]: 

1508 if ( 

1509 shape_info.get("entityType") == entity_class 

1510 and shape_info.get("nodeShape") == entity_shape 

1511 ): 

1512 target_details = shape_info 

1513 break 

1514 if target_details: 

1515 break 

1516 

1517 if not target_details: 

1518 return jsonify( 

1519 { 

1520 "status": "error", 

1521 "message": ( 

1522 "No matching shape info found for" 

1523 f" {entity_class}/{entity_shape}" 

1524 f" in parent predicate" 

1525 f" {predicate_uri}" 

1526 ), 

1527 } 

1528 ), 404 

1529 

1530 template_string = """ 

1531 {% from 'macros.jinja' import render_form_field with context %} 

1532 {{ render_form_field(parent_entity_class, predicate_uri, shape_info, 

1533 all_form_fields, depth, is_template=is_template) }} 

1534 """ 

1535 

1536 return render_template_string( 

1537 template_string, 

1538 parent_entity_class=parent_entity_class, 

1539 predicate_uri=predicate_uri, 

1540 shape_info=target_details, 

1541 all_form_fields=all_form_fields, 

1542 depth=depth, 

1543 is_template=is_template, 

1544 ) 

1545 

1546 except Exception as e: 

1547 current_app.logger.exception("Error rendering nested form HTML") 

1548 

1549 return jsonify( 

1550 {"status": "error", "message": f"Failed to render nested form: {e!s}"} 

1551 ), 500 

1552 

1553 

1554@api_bp.route("/draft-labels", methods=["POST"]) 

1555@login_required 

1556def preview_draft_labels() -> Response | tuple[Response, int]: 

1557 data = request.get_json() 

1558 if not isinstance(data, dict) or not isinstance(data.get("entries"), list): 

1559 return jsonify({"error": "Expected a list of draft entries"}), 400 

1560 for entry in data["entries"]: 

1561 if not isinstance(entry, dict) or not all( 

1562 key in entry 

1563 for key in ("parent_class", "parent_shape", "predicate", "entity") 

1564 ): 

1565 return jsonify({"error": "Invalid draft entry"}), 400 

1566 entity = entry["entity"] 

1567 if not isinstance(entity, dict) or not all( 

1568 key in entity for key in ("entity_type", "entity_shape", "properties") 

1569 ): 

1570 return jsonify({"error": "Invalid draft entity"}), 400 

1571 return jsonify({"labels": [draft_label(entry) for entry in data["entries"]]})