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

503 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-10-13 17:12 +0000

1# heritrace/routes/api.py 

2 

3import traceback 

4from typing import Dict, Optional 

5 

6import validators 

7from flask import (Blueprint, current_app, g, jsonify, render_template_string, 

8 request) 

9from flask_babel import gettext 

10from flask_login import current_user, login_required 

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

12 

13from heritrace.apis.orcid import get_responsible_agent_uri 

14from heritrace.editor import Editor 

15from heritrace.extensions import (get_custom_filter, get_dataset_endpoint, 

16 get_form_fields, get_provenance_endpoint) 

17from heritrace.services.resource_lock_manager import LockStatus 

18from heritrace.utils.datatypes import DATATYPE_MAPPING 

19from heritrace.utils.primary_source_utils import \ 

20 save_user_default_primary_source 

21from heritrace.utils.shacl_utils import determine_shape_for_classes 

22from heritrace.utils.shacl_validation import validate_new_triple 

23from heritrace.utils.sparql_utils import (find_orphaned_entities, 

24 get_available_classes, 

25 get_catalog_data, 

26 get_deleted_entities_with_filtering, 

27 import_entity_graph, 

28 import_referenced_entities) 

29from heritrace.utils.strategies import (OrphanHandlingStrategy, 

30 ProxyHandlingStrategy) 

31from heritrace.utils.uri_utils import generate_unique_uri 

32from heritrace.utils.virtual_properties import \ 

33 transform_changes_with_virtual_properties 

34 

35api_bp = Blueprint("api", __name__) 

36 

37 

38@api_bp.route("/catalogue") 

39@login_required 

40def catalogue_api(): 

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

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

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

44 per_page = int(request.args.get("per_page", current_app.config["CATALOGUE_DEFAULT_PER_PAGE"])) 

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

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

47 

48 allowed_per_page = current_app.config["CATALOGUE_ALLOWED_PER_PAGE"] 

49 if per_page not in allowed_per_page: 

50 per_page = current_app.config["CATALOGUE_DEFAULT_PER_PAGE"] 

51 

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

53 sort_property = None 

54 

55 available_classes = get_available_classes() 

56 

57 catalog_data = get_catalog_data( 

58 selected_class=selected_class, 

59 page=page, 

60 per_page=per_page, 

61 sort_property=sort_property, 

62 sort_direction=sort_direction, 

63 selected_shape=selected_shape 

64 ) 

65 

66 catalog_data["available_classes"] = available_classes 

67 return jsonify(catalog_data) 

68 

69 

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

71@login_required 

72def get_deleted_entities_api(): 

73 """ 

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

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

76 """ 

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

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

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

80 per_page = int(request.args.get("per_page", current_app.config["CATALOGUE_DEFAULT_PER_PAGE"])) 

81 sort_property = request.args.get("sort_property", "deletionTime") 

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

83 

84 allowed_per_page = current_app.config["CATALOGUE_ALLOWED_PER_PAGE"] 

85 if per_page not in allowed_per_page: 

86 per_page = current_app.config["CATALOGUE_DEFAULT_PER_PAGE"] 

87 

88 deleted_entities, available_classes, selected_class, selected_shape, sortable_properties, total_count = ( 

89 get_deleted_entities_with_filtering( 

90 page, per_page, sort_property, sort_direction, selected_class, selected_shape 

91 ) 

92 ) 

93 

94 return jsonify( 

95 { 

96 "entities": deleted_entities, 

97 "total_pages": (total_count + per_page - 1) // per_page if total_count > 0 else 0, 

98 "current_page": page, 

99 "per_page": per_page, 

100 "total_count": total_count, 

101 "sort_property": sort_property, 

102 "sort_direction": sort_direction, 

103 "selected_class": selected_class, 

104 "selected_shape": selected_shape, 

105 "available_classes": available_classes, 

106 "sortable_properties": sortable_properties, 

107 } 

108 ) 

109 

110 

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

112@login_required 

113def check_lock(): 

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

115 try: 

116 data = request.get_json() 

117 resource_uri = data.get("resource_uri") 

118 

119 if not resource_uri: 

120 return ( 

121 jsonify( 

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

123 ), 

124 400, 

125 ) 

126 

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

128 

129 if status == LockStatus.LOCKED: 

130 return jsonify( 

131 { 

132 "status": "locked", 

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

134 "message": gettext( 

135 "This resource is currently being edited by %(user)s [%(orcid)s]", 

136 user=lock_info.user_name, 

137 orcid=lock_info.user_id, 

138 ), 

139 } 

140 ) 

141 elif status == LockStatus.ERROR: 

142 return ( 

143 jsonify( 

144 { 

145 "status": "error", 

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

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

148 } 

149 ), 

150 500, 

151 ) 

152 else: 

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

154 

155 except Exception as e: 

156 current_app.logger.error(f"Error in check_lock: {str(e)}") 

157 return ( 

158 jsonify( 

159 { 

160 "status": "error", 

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

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

163 } 

164 ), 

165 500, 

166 ) 

167 

168 

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

170@login_required 

171def acquire_lock(): 

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

173 try: 

174 data = request.get_json() 

175 resource_uri = data.get("resource_uri") 

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

177 

178 if not resource_uri: 

179 return ( 

180 jsonify( 

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

182 ), 

183 400, 

184 ) 

185 

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

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

188 if status == LockStatus.LOCKED: 

189 return ( 

190 jsonify( 

191 { 

192 "status": "locked", 

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

194 "message": gettext( 

195 "This resource is currently being edited by %(user)s [%(orcid)s]", 

196 user=lock_info.user_name, 

197 orcid=lock_info.user_id, 

198 ), 

199 } 

200 ), 

201 200, 

202 ) 

203 

204 # Use the provided linked_resources 

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

206 

207 if success: 

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

209 

210 return ( 

211 jsonify( 

212 { 

213 "status": "error", 

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

215 } 

216 ), 

217 423, 

218 ) 

219 

220 except Exception as e: 

221 current_app.logger.error(f"Error in acquire_lock: {str(e)}") 

222 return ( 

223 jsonify( 

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

225 ), 

226 500, 

227 ) 

228 

229 

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

231@login_required 

232def release_lock(): 

233 """Release a lock on a resource.""" 

234 try: 

235 data = request.get_json() 

236 resource_uri = data.get("resource_uri") 

237 

238 if not resource_uri: 

239 return ( 

240 jsonify( 

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

242 ), 

243 400, 

244 ) 

245 

246 success = g.resource_lock_manager.release_lock(resource_uri) 

247 

248 if success: 

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

250 

251 return ( 

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

253 400, 

254 ) 

255 

256 except Exception as e: 

257 current_app.logger.error(f"Error in release_lock: {str(e)}") 

258 return ( 

259 jsonify( 

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

261 ), 

262 500, 

263 ) 

264 

265 

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

267@login_required 

268def renew_lock(): 

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

270 try: 

271 data = request.get_json() 

272 resource_uri = data.get("resource_uri") 

273 

274 if not resource_uri: 

275 return ( 

276 jsonify( 

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

278 ), 

279 400, 

280 ) 

281 

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

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

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

285 

286 if success: 

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

288 

289 return ( 

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

291 423, 

292 ) 

293 

294 except Exception as e: 

295 current_app.logger.error(f"Error in renew_lock: {str(e)}") 

296 return ( 

297 jsonify( 

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

299 ), 

300 500, 

301 ) 

302 

303 

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

305@login_required 

306def validate_literal(): 

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

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

309 if not value: 

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

311 

312 matching_datatypes = [] 

313 for datatype, validation_func, _ in DATATYPE_MAPPING: 

314 if validation_func(value): 

315 matching_datatypes.append(str(datatype)) 

316 

317 if not matching_datatypes: 

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

319 

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

321 

322 

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

324@login_required 

325def check_orphans(): 

326 """ 

327 Check for orphaned entities and intermediate relations (proxies) that would result from the requested changes. 

328 Applies separate handling strategies for orphans and proxies, but returns a unified report. 

329 """ 

330 try: 

331 # Get strategies from configuration 

332 orphan_strategy = current_app.config.get( 

333 "ORPHAN_HANDLING_STRATEGY", OrphanHandlingStrategy.KEEP 

334 ) 

335 proxy_strategy = current_app.config.get( 

336 "PROXY_HANDLING_STRATEGY", ProxyHandlingStrategy.KEEP 

337 ) 

338 

339 data = request.json 

340 # Validate required fields 

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

342 return ( 

343 jsonify( 

344 { 

345 "status": "error", 

346 "error_type": "validation", 

347 "message": gettext( 

348 "Invalid request: 'changes' and 'entity_type' are required fields" 

349 ), 

350 } 

351 ), 

352 400, 

353 ) 

354 

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

356 entity_type = data.get("entity_type") 

357 entity_shape = data.get("entity_shape") 

358 custom_filter = get_custom_filter() 

359 

360 orphans = [] 

361 intermediate_orphans = [] 

362 

363 # Check for orphans and proxies based on their respective strategies 

364 check_for_orphans = orphan_strategy in ( 

365 OrphanHandlingStrategy.DELETE, 

366 OrphanHandlingStrategy.ASK, 

367 ) 

368 check_for_proxies = proxy_strategy in ( 

369 ProxyHandlingStrategy.DELETE, 

370 ProxyHandlingStrategy.ASK, 

371 ) 

372 if check_for_orphans or check_for_proxies: 

373 for change in changes: 

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

375 found_orphans, found_intermediates = find_orphaned_entities( 

376 change["subject"], 

377 entity_type, 

378 change.get("predicate"), 

379 change.get("object"), 

380 ) 

381 # Only collect orphans if we need to handle them 

382 if check_for_orphans: 

383 orphans.extend(found_orphans) 

384 

385 # Only collect proxies if we need to handle them 

386 if check_for_proxies: 

387 intermediate_orphans.extend(found_intermediates) 

388 

389 # If both strategies are KEEP or no entities found, return empty result 

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

391 proxy_strategy == ProxyHandlingStrategy.KEEP or not intermediate_orphans 

392 ): 

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

394 

395 # Format entities for display 

396 def format_entities(entities, is_intermediate=False): 

397 return [ 

398 { 

399 "uri": entity["uri"], 

400 "label": custom_filter.human_readable_entity( 

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

402 ), 

403 "type": custom_filter.human_readable_class( 

404 (entity["type"], entity_shape) 

405 ), 

406 "is_intermediate": is_intermediate, 

407 } 

408 for entity in entities 

409 ] 

410 

411 # Create a unified list of affected entities 

412 affected_entities = format_entities(orphans) + format_entities( 

413 intermediate_orphans, is_intermediate=True 

414 ) 

415 

416 # Determine if we should automatically delete entities 

417 should_delete_orphans = orphan_strategy == OrphanHandlingStrategy.DELETE 

418 should_delete_proxies = proxy_strategy == ProxyHandlingStrategy.DELETE 

419 

420 # If both strategies are DELETE, we can automatically delete everything 

421 if should_delete_orphans and should_delete_proxies: 

422 return jsonify( 

423 { 

424 "status": "success", 

425 "affected_entities": affected_entities, 

426 "should_delete": True, 

427 "orphan_strategy": orphan_strategy.value, 

428 "proxy_strategy": proxy_strategy.value, 

429 } 

430 ) 

431 

432 # If at least one strategy is ASK, we need to ask the user 

433 return jsonify( 

434 { 

435 "status": "success", 

436 "affected_entities": affected_entities, 

437 "should_delete": False, 

438 "orphan_strategy": orphan_strategy.value, 

439 "proxy_strategy": proxy_strategy.value, 

440 } 

441 ) 

442 except ValueError as e: 

443 # Handle validation errors specifically 

444 error_message = str(e) 

445 current_app.logger.warning( 

446 f"Validation error in check_orphans: {error_message}" 

447 ) 

448 return ( 

449 jsonify( 

450 { 

451 "status": "error", 

452 "error_type": "validation", 

453 "message": gettext( 

454 "An error occurred while checking for orphaned entities" 

455 ), 

456 } 

457 ), 

458 400, 

459 ) 

460 except Exception as e: 

461 # Handle other errors 

462 error_message = f"Error checking orphans: {str(e)}" 

463 current_app.logger.error(f"{error_message}\n{traceback.format_exc()}") 

464 return ( 

465 jsonify( 

466 { 

467 "status": "error", 

468 "error_type": "system", 

469 "message": gettext( 

470 "An error occurred while checking for orphaned entities" 

471 ), 

472 } 

473 ), 

474 500, 

475 ) 

476 

477 

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

479@login_required 

480def apply_changes(): 

481 """Apply changes to entities. 

482 

483 Request body: 

484 { 

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

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

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

488 "save_default_source": (bool) Whether to save primary_source as default for current user, 

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

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

491 } 

492 

493 Responses: 

494 200 OK: Changes applied successfully 

495 400 Bad Request: Invalid request or validation error 

496 500 Internal Server Error: Server error while applying changes 

497 """ 

498 try: 

499 changes = request.get_json() 

500 if not changes: 

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

502 

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

504 subject = first_change.get("subject") 

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

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

507 primary_source = first_change.get("primary_source") 

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

509 

510 if primary_source and not validators.url(primary_source): 

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

512 

513 if save_default_source and primary_source and validators.url(primary_source): 

514 save_user_default_primary_source(current_user.orcid, primary_source) 

515 

516 changes = transform_changes_with_virtual_properties(changes) 

517 

518 deleted_entities = set() 

519 editor = Editor( 

520 get_dataset_endpoint(), 

521 get_provenance_endpoint(), 

522 current_app.config["COUNTER_HANDLER"], 

523 URIRef(get_responsible_agent_uri(current_user.orcid)), 

524 current_app.config["PRIMARY_SOURCE"], 

525 current_app.config["DATASET_GENERATION_TIME"], 

526 dataset_is_quadstore=current_app.config["DATASET_IS_QUADSTORE"], 

527 ) 

528 

529 if primary_source and validators.url(primary_source): 

530 editor.set_primary_source(primary_source) 

531 

532 has_entity_deletion = any( 

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

534 for change in changes 

535 ) 

536 

537 editor = import_entity_graph( 

538 editor, 

539 subject, 

540 include_referencing_entities=has_entity_deletion 

541 ) 

542 

543 for change in changes: 

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

545 data = change.get("data") 

546 if data: 

547 import_referenced_entities(editor, data) 

548 

549 editor.preexisting_finished() 

550 

551 graph_uri = None 

552 if editor.dataset_is_quadstore: 

553 for quad in editor.g_set.quads((URIRef(subject), None, None, None)): 

554 graph_context = quad[3] 

555 graph_uri = get_graph_uri_from_context(graph_context) 

556 break 

557 

558 temp_id_to_uri = {} 

559 for change in changes: 

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

561 data = change.get("data") 

562 if data: 

563 change_subject = change.get("subject") 

564 created_subject = create_logic( 

565 editor, 

566 data, 

567 change_subject, 

568 graph_uri, 

569 temp_id_to_uri=temp_id_to_uri, 

570 parent_entity_type=None, 

571 ) 

572 # Only update the main subject if this is the main entity 

573 if change_subject is not None: 

574 subject = created_subject 

575 

576 orphan_strategy = current_app.config.get( 

577 "ORPHAN_HANDLING_STRATEGY", OrphanHandlingStrategy.KEEP 

578 ) 

579 proxy_strategy = current_app.config.get( 

580 "PROXY_HANDLING_STRATEGY", ProxyHandlingStrategy.KEEP 

581 ) 

582 # Separiamo le operazioni di delete in due fasi: 

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

584 # 2. Poi eliminiamo le triple specifiche 

585 

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

587 if affected_entities and delete_affected: 

588 # Separa gli orfani dalle entità proxy 

589 orphans = [entity for entity in affected_entities if not entity.get("is_intermediate")] 

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

591 

592 # Gestione degli orfani secondo la strategia per gli orfani 

593 should_delete_orphans = ( 

594 orphan_strategy == OrphanHandlingStrategy.DELETE 

595 or (orphan_strategy == OrphanHandlingStrategy.ASK and delete_affected) 

596 ) 

597 

598 if should_delete_orphans and orphans: 

599 for orphan in orphans: 

600 orphan_uri = orphan["uri"] 

601 if orphan_uri in deleted_entities: 

602 continue 

603 

604 delete_logic(editor, orphan_uri, graph_uri=graph_uri) 

605 deleted_entities.add(orphan_uri) 

606 

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

608 should_delete_proxies = ( 

609 proxy_strategy == ProxyHandlingStrategy.DELETE 

610 or (proxy_strategy == ProxyHandlingStrategy.ASK and delete_affected) 

611 ) 

612 

613 if should_delete_proxies and proxies: 

614 for proxy in proxies: 

615 proxy_uri = proxy["uri"] 

616 if proxy_uri in deleted_entities: 

617 continue 

618 

619 delete_logic(editor, proxy_uri, graph_uri=graph_uri) 

620 deleted_entities.add(proxy_uri) 

621 

622 # Fase 2: Processa tutte le altre modifiche 

623 for change in changes: 

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

625 subject_uri = change["subject"] 

626 predicate = change.get("predicate") 

627 object_value = change.get("object") 

628 

629 # Se stiamo eliminando un'intera entità 

630 if not predicate: 

631 if subject_uri in deleted_entities: 

632 continue 

633 

634 delete_logic(editor, subject_uri, graph_uri=graph_uri, entity_type=change.get("entity_type"), entity_shape=change.get("entity_shape")) 

635 deleted_entities.add(subject_uri) 

636 # Se stiamo eliminando una tripla specifica 

637 elif object_value: 

638 # Controlla se l'oggetto è un'entità che è già stata eliminata 

639 if object_value in deleted_entities: 

640 continue 

641 

642 delete_logic(editor, subject_uri, predicate, object_value, graph_uri, change.get("entity_type"), change.get("entity_shape")) 

643 

644 # La gestione degli orfani e dei proxy è stata spostata all'inizio del ciclo 

645 

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

647 update_logic( 

648 editor, 

649 change["subject"], 

650 change["predicate"], 

651 change["object"], 

652 change["newObject"], 

653 graph_uri, 

654 change.get("entity_type"), 

655 change.get("entity_shape"), 

656 ) 

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

658 order_logic( 

659 editor, 

660 change["subject"], 

661 change["predicate"], 

662 change["object"], 

663 change["newObject"], 

664 graph_uri, 

665 temp_id_to_uri, 

666 ) 

667 

668 try: 

669 editor.save() 

670 except ValueError as ve: 

671 # Re-raise ValueError so it can be caught by the outer try-except block 

672 current_app.logger.error(f"Error during save operation: {str(ve)}") 

673 raise 

674 except Exception as save_error: 

675 current_app.logger.error(f"Error during save operation: {str(save_error)}") 

676 return jsonify( 

677 { 

678 "status": "error", 

679 "error_type": "database", 

680 "message": gettext("Failed to save changes to the database: {}").format(str(save_error)), 

681 } 

682 ), 500 

683 

684 return ( 

685 jsonify( 

686 { 

687 "status": "success", 

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

689 } 

690 ), 

691 200, 

692 ) 

693 

694 except ValueError as e: 

695 # Handle validation errors specifically 

696 error_message = str(e) 

697 current_app.logger.warning(f"Validation error: {error_message}") 

698 return ( 

699 jsonify( 

700 { 

701 "status": "error", 

702 "error_type": "validation", 

703 "message": error_message, 

704 } 

705 ), 

706 400, 

707 ) 

708 except Exception as e: 

709 # Handle other errors 

710 error_message = ( 

711 f"Error while applying changes: {str(e)}\n{traceback.format_exc()}" 

712 ) 

713 current_app.logger.error(error_message) 

714 return ( 

715 jsonify( 

716 { 

717 "status": "error", 

718 "error_type": "system", 

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

720 } 

721 ), 

722 500, 

723 ) 

724 

725 

726def get_graph_uri_from_context(graph_context): 

727 """Extract the graph URI from a graph context. 

728  

729 Args: 

730 graph_context: Either a Graph object or a direct URI reference 

731  

732 Returns: 

733 The graph URI 

734 """ 

735 if isinstance(graph_context, Graph): 

736 return graph_context.identifier 

737 else: 

738 return graph_context 

739 

740 

741def determine_datatype(value, datatype_uris): 

742 for datatype_uri in datatype_uris: 

743 validation_func = next( 

744 (d[1] for d in DATATYPE_MAPPING if str(d[0]) == str(datatype_uri)), None 

745 ) 

746 if validation_func and validation_func(value): 

747 return URIRef(datatype_uri) 

748 # If none match, default to XSD.string 

749 return XSD.string 

750 

751 

752def create_logic( 

753 editor: Editor, 

754 data: Dict[str, dict], 

755 subject=None, 

756 graph_uri=None, 

757 parent_subject=None, 

758 parent_predicate=None, 

759 temp_id_to_uri=None, 

760 parent_entity_type=None, 

761): 

762 """ 

763 Recursively creates an entity and its properties based on a dictionary. 

764 

765 This function handles the creation of a main entity and any nested entities 

766 defined within its properties. It validates each triple before creation and 

767 can link the new entity to a parent entity. 

768 

769 Args: 

770 editor (Editor): The editor instance for graph operations. 

771 data (Dict[str, dict]): A dictionary describing the entity to create. 

772 subject (URIRef, optional): The subject URI of the entity. If None, a new URI is generated. 

773 graph_uri (str, optional): The named graph URI for the operations. 

774 parent_subject (URIRef, optional): The subject URI of the parent entity. 

775 parent_predicate (URIRef, optional): The predicate URI linking the parent to this entity. 

776 temp_id_to_uri (Dict, optional): A dictionary mapping temporary frontend IDs to backend URIs. 

777 parent_entity_type (str, optional): The RDF type of the parent entity, for validation. 

778 

779 Example of `data` structure: 

780 { 

781 "entity_type": "http://purl.org/spar/fabio/JournalArticle", 

782 "properties": { 

783 "http://purl.org/spar/pro/isDocumentContextFor": [ 

784 { 

785 "entity_type": "http://purl.org/spar/pro/RoleInTime", 

786 "properties": { 

787 "http://purl.org/spar/pro/isHeldBy": [ 

788 "https://w3id.org/oc/meta/ra/09110374" 

789 ], 

790 "http://purl.org/spar/pro/withRole": "http://purl.org/spar/pro/author" 

791 }, 

792 "tempId": "temp-1" 

793 } 

794 ] 

795 } 

796 } 

797 """ 

798 entity_type = data.get("entity_type") 

799 properties = data.get("properties", {}) 

800 temp_id = data.get("tempId") 

801 

802 if subject is None: 

803 subject = generate_unique_uri(entity_type, data) 

804 

805 if temp_id and temp_id_to_uri is not None: 

806 temp_id_to_uri[temp_id] = str(subject) 

807 

808 # Create the entity type using validate_new_triple 

809 if parent_subject is not None: 

810 type_value, _, error_message = validate_new_triple( 

811 subject, RDF.type, entity_type, "create", entity_types=entity_type 

812 ) 

813 if error_message: 

814 raise ValueError(error_message) 

815 

816 if type_value is not None: 

817 editor.create(URIRef(subject), RDF.type, type_value, graph_uri) 

818 

819 # Create the relationship to the parent using validate_new_triple 

820 if parent_subject and parent_predicate: 

821 # When creating a relationship, we need to validate that the parent can have this relationship 

822 # with an entity of our type. Pass our entity_type as the object_entity_type for validation 

823 parent_value, _, error_message = validate_new_triple( 

824 parent_subject, 

825 parent_predicate, 

826 subject, 

827 "create", 

828 entity_types=parent_entity_type, 

829 ) 

830 if error_message: 

831 raise ValueError(error_message) 

832 

833 if parent_value is not None: 

834 editor.create( 

835 URIRef(parent_subject), 

836 URIRef(parent_predicate), 

837 parent_value, 

838 graph_uri, 

839 ) 

840 

841 for predicate, values in properties.items(): 

842 if not isinstance(values, list): 

843 values = [values] 

844 for value in values: 

845 # CASE 1: Nested Entity. 

846 # If the value is a dictionary containing 'entity_type', it's a nested entity 

847 # that needs to be created recursively. 

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

849 # A new URI is generated for the nested entity. 

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

851 # The function calls itself to create the nested entity. The current entity 

852 # becomes the parent for the nested one. 

853 create_logic( 

854 editor, 

855 value, 

856 nested_subject, 

857 graph_uri, 

858 subject, # Current entity is the parent subject 

859 predicate, # The predicate linking parent to child 

860 temp_id_to_uri, 

861 parent_entity_type=entity_type, 

862 ) 

863 # CASE 2: Existing Entity Reference. 

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

865 entity_uri = value.get("entity_uri") 

866 if entity_uri: 

867 object_value = URIRef(entity_uri) 

868 editor.create( 

869 URIRef(subject), URIRef(predicate), object_value, graph_uri 

870 ) 

871 else: 

872 raise ValueError("Missing entity_uri in existing entity reference") 

873 # CASE 3: Custom Property. 

874 # If the value is a dictionary marked as 'is_custom_property', it's a property 

875 # that is not defined in the SHACL shape and should be added without validation. 

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

877 # The property is created directly based on its type ('uri' or 'literal'). 

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

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

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

881 datatype = ( 

882 URIRef(value["datatype"]) 

883 if "datatype" in value 

884 else XSD.string 

885 ) 

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

887 else: 

888 raise ValueError(f"Unknown custom property type: {value['type']}") 

889 

890 editor.create( 

891 URIRef(subject), URIRef(predicate), object_value, graph_uri 

892 ) 

893 # CASE 4: Standard Property. 

894 # This is the default case for all other properties. The value can be a 

895 # simple literal (e.g., a string, number) or a URI string. 

896 else: 

897 # The value is validated against the SHACL shape for the current entity type. 

898 # `validate_new_triple` checks if the triple is valid and returns the 

899 # correctly typed RDF object (e.g., Literal, URIRef). 

900 object_value, _, error_message = validate_new_triple( 

901 subject, predicate, value, "create", entity_types=entity_type 

902 ) 

903 if error_message: 

904 raise ValueError(error_message) 

905 

906 if object_value is not None: 

907 editor.create( 

908 URIRef(subject), URIRef(predicate), object_value, graph_uri 

909 ) 

910 

911 return subject 

912 

913 

914def update_logic( 

915 editor: Editor, 

916 subject, 

917 predicate, 

918 old_value, 

919 new_value, 

920 graph_uri=None, 

921 entity_type=None, 

922 entity_shape=None, 

923): 

924 new_value, old_value, error_message = validate_new_triple( 

925 subject, predicate, new_value, "update", old_value, entity_types=entity_type, entity_shape=entity_shape 

926 ) 

927 if error_message: 

928 raise ValueError(error_message) 

929 

930 editor.update(URIRef(subject), URIRef(predicate), old_value, new_value, graph_uri) 

931 

932 

933def rebuild_entity_order( 

934 editor: Editor, 

935 ordered_by_uri: URIRef, 

936 entities: list, 

937 graph_uri=None 

938): 

939 """ 

940 Rebuild the ordering chain for a list of entities. 

941  

942 Args: 

943 editor: The editor instance 

944 ordered_by_uri: The property used for ordering 

945 entities: List of entities to be ordered 

946 graph_uri: Optional graph URI 

947 """ 

948 # First, remove all existing ordering relationships 

949 for entity in entities: 

950 for s, p, o in list(editor.g_set.triples((entity, ordered_by_uri, None))): 

951 editor.delete(entity, ordered_by_uri, o, graph_uri) 

952 

953 # Then rebuild the chain with the entities 

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

955 current_entity = entities[i] 

956 next_entity = entities[i + 1] 

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

958 

959 return editor 

960 

961 

962def delete_logic( 

963 editor: Editor, 

964 subject, 

965 predicate=None, 

966 object_value=None, 

967 graph_uri=None, 

968 entity_type=None, 

969 entity_shape=None, 

970): 

971 # Ensure we have the correct data types for all values 

972 subject_uri = URIRef(subject) 

973 predicate_uri = URIRef(predicate) if predicate else None 

974 

975 # Validate and get correctly typed object value if we have a predicate 

976 if predicate and object_value: 

977 # Use validate_new_triple to validate the deletion and get the correctly typed object 

978 _, object_value, error_message = validate_new_triple( 

979 subject, predicate, None, "delete", object_value, entity_types=entity_type, entity_shape=entity_shape 

980 ) 

981 if error_message: 

982 raise ValueError(error_message) 

983 

984 editor.delete(subject_uri, predicate_uri, object_value, graph_uri) 

985 

986 

987def order_logic( 

988 editor: Editor, 

989 subject, 

990 predicate, 

991 new_order, 

992 ordered_by, 

993 graph_uri=None, 

994 temp_id_to_uri: Optional[Dict] = None, 

995): 

996 subject_uri = URIRef(subject) 

997 predicate_uri = URIRef(predicate) 

998 ordered_by_uri = URIRef(ordered_by) 

999 # Ottieni tutte le entità ordinate attuali direttamente dall'editor 

1000 current_entities = [ 

1001 o for _, _, o in editor.g_set.triples((subject_uri, predicate_uri, None)) 

1002 ] 

1003 

1004 # Dizionario per mappare le vecchie entità alle nuove 

1005 old_to_new_mapping = {} 

1006 

1007 # Per ogni entità attuale 

1008 for old_entity in current_entities: 

1009 if str(old_entity) in new_order: # Processa solo le entità preesistenti 

1010 # Memorizza tutte le proprietà dell'entità attuale 

1011 entity_properties = list(editor.g_set.triples((old_entity, None, None))) 

1012 

1013 entity_type = next( 

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

1015 ) 

1016 

1017 if entity_type is None: 

1018 raise ValueError( 

1019 f"Impossibile determinare il tipo dell'entità per {old_entity}" 

1020 ) 

1021 

1022 # Crea una nuova entità 

1023 new_entity_uri = generate_unique_uri(entity_type) 

1024 old_to_new_mapping[old_entity] = new_entity_uri 

1025 

1026 # Cancella la vecchia entità 

1027 editor.delete(subject_uri, predicate_uri, old_entity, graph_uri) 

1028 editor.delete(old_entity, graph=graph_uri) 

1029 

1030 # Ricrea il collegamento tra il soggetto principale e la nuova entità 

1031 editor.create(subject_uri, predicate_uri, new_entity_uri, graph_uri) 

1032 

1033 # Ripristina tutte le altre proprietà per la nuova entità 

1034 for _, p, o in entity_properties: 

1035 if p != predicate_uri and p != ordered_by_uri: 

1036 editor.create(new_entity_uri, p, o, graph_uri) 

1037 

1038 # Prepara la lista delle entità nel nuovo ordine 

1039 ordered_entities = [] 

1040 for entity in new_order: 

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

1042 if not new_entity_uri: 

1043 new_entity_uri = URIRef(temp_id_to_uri.get(entity, entity)) 

1044 ordered_entities.append(new_entity_uri) 

1045 

1046 # Ricostruisci l'ordine 

1047 if ordered_entities: 

1048 rebuild_entity_order(editor, ordered_by_uri, ordered_entities, graph_uri) 

1049 

1050 return editor 

1051 

1052 

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

1054@login_required 

1055def get_human_readable_entity(): 

1056 custom_filter = get_custom_filter() 

1057 

1058 # Check if required parameters are present 

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

1060 return jsonify({"status": "error", "message": "Missing required parameters"}), 400 

1061 

1062 uri = request.form["uri"] 

1063 entity_class = request.form["entity_class"] 

1064 shape = determine_shape_for_classes([entity_class]) 

1065 filter_instance = custom_filter 

1066 readable = filter_instance.human_readable_entity(uri, (entity_class, shape)) 

1067 return readable 

1068 

1069 

1070@api_bp.route('/format-source', methods=['POST']) 

1071@login_required 

1072def format_source_api(): 

1073 """ 

1074 API endpoint to format a source URL using the application's filters. 

1075 Accepts POST request with JSON body: {"url": "source_url"} 

1076 Returns JSON: {"formatted_html": "html_string"} 

1077 """ 

1078 data = request.get_json() 

1079 source_url = data.get('url') 

1080 

1081 if not source_url or not validators.url(source_url): 

1082 return jsonify({"error": gettext("Invalid or missing URL")}), 400 

1083 

1084 try: 

1085 custom_filter = get_custom_filter() 

1086 formatted_html = custom_filter.format_source_reference(source_url) 

1087 return jsonify({"formatted_html": formatted_html}) 

1088 except Exception as e: 

1089 current_app.logger.error(f"Error formatting source URL '{source_url}': {e}") 

1090 fallback_html = f'<a href="{source_url}" target="_blank">{source_url}</a>' 

1091 return jsonify({"formatted_html": fallback_html}) 

1092 

1093 

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

1095@login_required 

1096def get_form_fields_for_entity(): 

1097 """ 

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

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

1100 

1101 Query parameters: 

1102 entity_class: URI of the entity class 

1103 entity_shape: URI of the entity shape 

1104 

1105 Returns: 

1106 JSON response with form_fields for the specified entity 

1107 """ 

1108 

1109 try: 

1110 entity_class_decoded = request.args.get('entity_class') 

1111 entity_shape_decoded = request.args.get('entity_shape') 

1112 

1113 if not entity_class_decoded or not entity_shape_decoded: 

1114 return jsonify({ 

1115 'status': 'error', 

1116 'message': 'Missing required parameters: entity_class and entity_shape' 

1117 }), 400 

1118 

1119 all_form_fields = get_form_fields() 

1120 

1121 if not all_form_fields: 

1122 return jsonify({ 

1123 'status': 'error', 

1124 'message': 'Form fields not initialized' 

1125 }), 500 

1126 

1127 entity_key = (entity_class_decoded, entity_shape_decoded) 

1128 

1129 if entity_key not in all_form_fields: 

1130 return jsonify({ 

1131 'status': 'error', 

1132 'message': f'No form fields found for entity class {entity_class_decoded} with shape {entity_shape_decoded}' 

1133 }), 404 

1134 

1135 entity_form_fields = all_form_fields[entity_key] 

1136 

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

1138 ordered_properties = [] 

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

1140 ordered_properties.append([prop, details_list]) 

1141 

1142 return jsonify({ 

1143 'status': 'success', 

1144 'form_fields': ordered_properties, 

1145 'entity_key': [entity_class_decoded, entity_shape_decoded] 

1146 }) 

1147 

1148 except Exception as e: 

1149 current_app.logger.error(f"Error loading form fields for {entity_class_decoded}/{entity_shape_decoded}: {e}") 

1150 current_app.logger.error(f"Full traceback: {traceback.format_exc()}") 

1151 

1152 return jsonify({ 

1153 'status': 'error', 

1154 'message': f'Failed to load form fields: {str(e)}' 

1155 }), 500 

1156 

1157 

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

1159@login_required 

1160def render_form_fields_html(): 

1161 """ 

1162 Render form fields as HTML for dynamic loading. 

1163 

1164 Expects JSON payload with: 

1165 - entity_key: [entity_class, entity_shape] array 

1166 

1167 Returns: 

1168 HTML string of the rendered form fields 

1169 """ 

1170 try: 

1171 data = request.get_json() 

1172 

1173 if not data or 'entity_key' not in data: 

1174 return jsonify({ 

1175 'status': 'error', 

1176 'message': 'Missing required field: entity_key' 

1177 }), 400 

1178 

1179 entity_key = data['entity_key'] # This is [entity_class, entity_shape] array 

1180 entity_class, entity_shape = entity_key 

1181 

1182 all_form_fields = get_form_fields() 

1183 

1184 if not all_form_fields: 

1185 return jsonify({ 

1186 'status': 'error', 

1187 'message': 'Form fields not initialized' 

1188 }), 500 

1189 

1190 tuple_key = (entity_class, entity_shape) 

1191 if tuple_key not in all_form_fields: 

1192 return jsonify({ 

1193 'status': 'error', 

1194 'message': f'No form fields found for entity {entity_class} with shape {entity_shape}' 

1195 }), 404 

1196 

1197 entity_form_fields = all_form_fields[tuple_key] 

1198 

1199 form_fields_array = [[prop, details_list] for prop, details_list in entity_form_fields.items()] 

1200 

1201 template_string = ''' 

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

1203 

1204 {% set entity_type = entity_class %} 

1205 {% set entity_shape = entity_shape %} 

1206 {% set group_id = ((entity_type, entity_shape) | human_readable_class + "_group") | replace(" ", "_") %} 

1207 <div class="property-group mb-3" id="{{ group_id }}" data-uri="{{ entity_type }}" data-shape="{{ entity_shape }}"> 

1208 {% for prop_data in ordered_form_fields %} 

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

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

1211 {% for details in details_list %} 

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

1213 {% endfor %} 

1214 {% endfor %} 

1215 </div> 

1216 ''' 

1217 

1218 html = render_template_string( 

1219 template_string, 

1220 entity_class=entity_class, 

1221 entity_shape=entity_shape, 

1222 ordered_form_fields=form_fields_array, 

1223 all_form_fields=all_form_fields 

1224 ) 

1225 

1226 return html 

1227 

1228 except Exception as e: 

1229 current_app.logger.error(f"Error rendering form fields HTML: {e}") 

1230 current_app.logger.error(f"Full traceback: {traceback.format_exc()}") 

1231 

1232 return jsonify({ 

1233 'status': 'error', 

1234 'message': f'Failed to render form fields: {str(e)}' 

1235 }), 500 

1236 

1237 

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

1239@login_required 

1240def render_nested_form_html(): 

1241 """ 

1242 Render a single nested form for lazy loading in sh:or contexts. 

1243 

1244 Expects JSON payload with: 

1245 - parent_entity_class: The parent entity class URI 

1246 - parent_entity_shape: The parent entity shape URI 

1247 - entity_class: The sub-entity class URI to render 

1248 - entity_shape: The sub-entity shape URI to render 

1249 - predicate_uri: The predicate URI linking parent to sub-entity 

1250 - depth: The nesting depth 

1251 - is_template: Whether this is a template item 

1252 

1253 Returns: 

1254 HTML string of the rendered nested form 

1255 """ 

1256 try: 

1257 data = request.get_json() 

1258 

1259 required_fields = ['parent_entity_class', 'parent_entity_shape', 'entity_class', 'entity_shape', 'predicate_uri', 'depth'] 

1260 

1261 if not data: 

1262 return jsonify({ 

1263 'status': 'error', 

1264 'message': 'No JSON data provided' 

1265 }), 400 

1266 

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

1268 if missing_fields: 

1269 return jsonify({ 

1270 'status': 'error', 

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

1272 }), 400 

1273 

1274 parent_entity_class = data['parent_entity_class'] 

1275 parent_entity_shape = data['parent_entity_shape'] 

1276 entity_class = data['entity_class'] 

1277 entity_shape = data['entity_shape'] 

1278 predicate_uri = data['predicate_uri'] 

1279 depth = int(data['depth']) 

1280 is_template = data.get('is_template', False) 

1281 

1282 all_form_fields = get_form_fields() 

1283 

1284 if not all_form_fields: 

1285 return jsonify({ 

1286 'status': 'error', 

1287 'message': 'Form fields not initialized' 

1288 }), 500 

1289 

1290 parent_entity_key = (parent_entity_class, parent_entity_shape) 

1291 if parent_entity_key not in all_form_fields: 

1292 return jsonify({ 

1293 'status': 'error', 

1294 'message': f'No form fields found for parent entity {parent_entity_class} with shape {parent_entity_shape}' 

1295 }), 404 

1296 

1297 parent_fields = all_form_fields[parent_entity_key] 

1298 if predicate_uri not in parent_fields: 

1299 return jsonify({ 

1300 'status': 'error', 

1301 'message': f'No field definition found for predicate {predicate_uri} in parent entity' 

1302 }), 404 

1303 

1304 field_details_list = parent_fields[predicate_uri] 

1305 

1306 target_details = None 

1307 for details in field_details_list: 

1308 if details.get('or'): 

1309 for shape_info in details['or']: 

1310 if (shape_info.get('entityType') == entity_class and 

1311 shape_info.get('nodeShape') == entity_shape): 

1312 target_details = shape_info 

1313 break 

1314 if target_details: 

1315 break 

1316 

1317 if not target_details: 

1318 return jsonify({ 

1319 'status': 'error', 

1320 'message': f'No matching shape info found for {entity_class}/{entity_shape} in parent predicate {predicate_uri}' 

1321 }), 404 

1322 

1323 template_string = ''' 

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

1325 {{ render_form_field(parent_entity_class, predicate_uri, shape_info, all_form_fields, depth, is_template=is_template) }} 

1326 ''' 

1327 

1328 html = render_template_string( 

1329 template_string, 

1330 parent_entity_class=parent_entity_class, 

1331 predicate_uri=predicate_uri, 

1332 shape_info=target_details, 

1333 all_form_fields=all_form_fields, 

1334 depth=depth, 

1335 is_template=is_template 

1336 ) 

1337 

1338 return html 

1339 

1340 except Exception as e: 

1341 current_app.logger.error(f"Error rendering nested form HTML: {e}") 

1342 current_app.logger.error(f"Full traceback: {traceback.format_exc()}") 

1343 

1344 return jsonify({ 

1345 'status': 'error', 

1346 'message': f'Failed to render nested form: {str(e)}' 

1347 }), 500