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
« prev ^ index » next coverage.py v7.6.12, created at 2025-10-13 17:12 +0000
1# heritrace/routes/api.py
3import traceback
4from typing import Dict, Optional
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
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
35api_bp = Blueprint("api", __name__)
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")
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"]
52 if not sort_property or sort_property.lower() == "null":
53 sort_property = None
55 available_classes = get_available_classes()
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 )
66 catalog_data["available_classes"] = available_classes
67 return jsonify(catalog_data)
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")
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"]
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 )
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 )
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")
119 if not resource_uri:
120 return (
121 jsonify(
122 {"status": "error", "message": gettext("No resource URI provided")}
123 ),
124 400,
125 )
127 status, lock_info = g.resource_lock_manager.check_lock_status(resource_uri)
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"})
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 )
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", [])
178 if not resource_uri:
179 return (
180 jsonify(
181 {"status": "error", "message": gettext("No resource URI provided")}
182 ),
183 400,
184 )
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 )
204 # Use the provided linked_resources
205 success = g.resource_lock_manager.acquire_lock(resource_uri, linked_resources)
207 if success:
208 return jsonify({"status": "success"})
210 return (
211 jsonify(
212 {
213 "status": "error",
214 "message": gettext("Resource is locked by another user"),
215 }
216 ),
217 423,
218 )
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 )
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")
238 if not resource_uri:
239 return (
240 jsonify(
241 {"status": "error", "message": gettext("No resource URI provided")}
242 ),
243 400,
244 )
246 success = g.resource_lock_manager.release_lock(resource_uri)
248 if success:
249 return jsonify({"status": "success"})
251 return (
252 jsonify({"status": "error", "message": gettext("Unable to release lock")}),
253 400,
254 )
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 )
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")
274 if not resource_uri:
275 return (
276 jsonify(
277 {"status": "error", "message": gettext("No resource URI provided")}
278 ),
279 400,
280 )
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, [])
286 if success:
287 return jsonify({"status": "success"})
289 return (
290 jsonify({"status": "error", "message": gettext("Unable to renew lock")}),
291 423,
292 )
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 )
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
312 matching_datatypes = []
313 for datatype, validation_func, _ in DATATYPE_MAPPING:
314 if validation_func(value):
315 matching_datatypes.append(str(datatype))
317 if not matching_datatypes:
318 return jsonify({"error": gettext("No matching datatypes found.")}), 400
320 return jsonify({"valid_datatypes": matching_datatypes}), 200
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 )
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 )
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()
360 orphans = []
361 intermediate_orphans = []
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)
385 # Only collect proxies if we need to handle them
386 if check_for_proxies:
387 intermediate_orphans.extend(found_intermediates)
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": []})
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 ]
411 # Create a unified list of affected entities
412 affected_entities = format_entities(orphans) + format_entities(
413 intermediate_orphans, is_intermediate=True
414 )
416 # Determine if we should automatically delete entities
417 should_delete_orphans = orphan_strategy == OrphanHandlingStrategy.DELETE
418 should_delete_proxies = proxy_strategy == ProxyHandlingStrategy.DELETE
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 )
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 )
478@api_bp.route("/apply_changes", methods=["POST"])
479@login_required
480def apply_changes():
481 """Apply changes to entities.
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 }
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
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)
510 if primary_source and not validators.url(primary_source):
511 return jsonify({"error": "Invalid primary source URL"}), 400
513 if save_default_source and primary_source and validators.url(primary_source):
514 save_user_default_primary_source(current_user.orcid, primary_source)
516 changes = transform_changes_with_virtual_properties(changes)
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 )
529 if primary_source and validators.url(primary_source):
530 editor.set_primary_source(primary_source)
532 has_entity_deletion = any(
533 change["action"] == "delete" and not change.get("predicate")
534 for change in changes
535 )
537 editor = import_entity_graph(
538 editor,
539 subject,
540 include_referencing_entities=has_entity_deletion
541 )
543 for change in changes:
544 if change["action"] == "create":
545 data = change.get("data")
546 if data:
547 import_referenced_entities(editor, data)
549 editor.preexisting_finished()
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
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
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
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")]
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 )
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
604 delete_logic(editor, orphan_uri, graph_uri=graph_uri)
605 deleted_entities.add(orphan_uri)
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 )
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
619 delete_logic(editor, proxy_uri, graph_uri=graph_uri)
620 deleted_entities.add(proxy_uri)
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")
629 # Se stiamo eliminando un'intera entità
630 if not predicate:
631 if subject_uri in deleted_entities:
632 continue
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
642 delete_logic(editor, subject_uri, predicate, object_value, graph_uri, change.get("entity_type"), change.get("entity_shape"))
644 # La gestione degli orfani e dei proxy è stata spostata all'inizio del ciclo
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 )
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
684 return (
685 jsonify(
686 {
687 "status": "success",
688 "message": gettext("Changes applied successfully"),
689 }
690 ),
691 200,
692 )
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 )
726def get_graph_uri_from_context(graph_context):
727 """Extract the graph URI from a graph context.
729 Args:
730 graph_context: Either a Graph object or a direct URI reference
732 Returns:
733 The graph URI
734 """
735 if isinstance(graph_context, Graph):
736 return graph_context.identifier
737 else:
738 return graph_context
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
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.
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.
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.
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")
802 if subject is None:
803 subject = generate_unique_uri(entity_type, data)
805 if temp_id and temp_id_to_uri is not None:
806 temp_id_to_uri[temp_id] = str(subject)
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)
816 if type_value is not None:
817 editor.create(URIRef(subject), RDF.type, type_value, graph_uri)
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)
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 )
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']}")
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)
906 if object_value is not None:
907 editor.create(
908 URIRef(subject), URIRef(predicate), object_value, graph_uri
909 )
911 return subject
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)
930 editor.update(URIRef(subject), URIRef(predicate), old_value, new_value, graph_uri)
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.
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)
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)
959 return editor
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
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)
984 editor.delete(subject_uri, predicate_uri, object_value, graph_uri)
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 ]
1004 # Dizionario per mappare le vecchie entità alle nuove
1005 old_to_new_mapping = {}
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)))
1013 entity_type = next(
1014 (o for _, p, o in entity_properties if p == RDF.type), None
1015 )
1017 if entity_type is None:
1018 raise ValueError(
1019 f"Impossibile determinare il tipo dell'entità per {old_entity}"
1020 )
1022 # Crea una nuova entità
1023 new_entity_uri = generate_unique_uri(entity_type)
1024 old_to_new_mapping[old_entity] = new_entity_uri
1026 # Cancella la vecchia entità
1027 editor.delete(subject_uri, predicate_uri, old_entity, graph_uri)
1028 editor.delete(old_entity, graph=graph_uri)
1030 # Ricrea il collegamento tra il soggetto principale e la nuova entità
1031 editor.create(subject_uri, predicate_uri, new_entity_uri, graph_uri)
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)
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)
1046 # Ricostruisci l'ordine
1047 if ordered_entities:
1048 rebuild_entity_order(editor, ordered_by_uri, ordered_entities, graph_uri)
1050 return editor
1053@api_bp.route("/human-readable-entity", methods=["POST"])
1054@login_required
1055def get_human_readable_entity():
1056 custom_filter = get_custom_filter()
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
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
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')
1081 if not source_url or not validators.url(source_url):
1082 return jsonify({"error": gettext("Invalid or missing URL")}), 400
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})
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.
1101 Query parameters:
1102 entity_class: URI of the entity class
1103 entity_shape: URI of the entity shape
1105 Returns:
1106 JSON response with form_fields for the specified entity
1107 """
1109 try:
1110 entity_class_decoded = request.args.get('entity_class')
1111 entity_shape_decoded = request.args.get('entity_shape')
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
1119 all_form_fields = get_form_fields()
1121 if not all_form_fields:
1122 return jsonify({
1123 'status': 'error',
1124 'message': 'Form fields not initialized'
1125 }), 500
1127 entity_key = (entity_class_decoded, entity_shape_decoded)
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
1135 entity_form_fields = all_form_fields[entity_key]
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])
1142 return jsonify({
1143 'status': 'success',
1144 'form_fields': ordered_properties,
1145 'entity_key': [entity_class_decoded, entity_shape_decoded]
1146 })
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()}")
1152 return jsonify({
1153 'status': 'error',
1154 'message': f'Failed to load form fields: {str(e)}'
1155 }), 500
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.
1164 Expects JSON payload with:
1165 - entity_key: [entity_class, entity_shape] array
1167 Returns:
1168 HTML string of the rendered form fields
1169 """
1170 try:
1171 data = request.get_json()
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
1179 entity_key = data['entity_key'] # This is [entity_class, entity_shape] array
1180 entity_class, entity_shape = entity_key
1182 all_form_fields = get_form_fields()
1184 if not all_form_fields:
1185 return jsonify({
1186 'status': 'error',
1187 'message': 'Form fields not initialized'
1188 }), 500
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
1197 entity_form_fields = all_form_fields[tuple_key]
1199 form_fields_array = [[prop, details_list] for prop, details_list in entity_form_fields.items()]
1201 template_string = '''
1202 {% from 'macros.jinja' import render_form_field with context %}
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 '''
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 )
1226 return html
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()}")
1232 return jsonify({
1233 'status': 'error',
1234 'message': f'Failed to render form fields: {str(e)}'
1235 }), 500
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.
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
1253 Returns:
1254 HTML string of the rendered nested form
1255 """
1256 try:
1257 data = request.get_json()
1259 required_fields = ['parent_entity_class', 'parent_entity_shape', 'entity_class', 'entity_shape', 'predicate_uri', 'depth']
1261 if not data:
1262 return jsonify({
1263 'status': 'error',
1264 'message': 'No JSON data provided'
1265 }), 400
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
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)
1282 all_form_fields = get_form_fields()
1284 if not all_form_fields:
1285 return jsonify({
1286 'status': 'error',
1287 'message': 'Form fields not initialized'
1288 }), 500
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
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
1304 field_details_list = parent_fields[predicate_uri]
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
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
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 '''
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 )
1338 return html
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()}")
1344 return jsonify({
1345 'status': 'error',
1346 'message': f'Failed to render nested form: {str(e)}'
1347 }), 500