Coverage for heritrace / routes / linked_resources.py: 97%
124 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-21 12:56 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-21 12:56 +0000
1# SPDX-FileCopyrightText: 2025 Arcangelo Massari <arcangelo.massari@unibo.it>
2#
3# SPDX-License-Identifier: ISC
5import traceback
7from flask import Blueprint, current_app, jsonify, request
8from flask_babel import gettext
9from flask_login import login_required
10from heritrace.extensions import (get_custom_filter, get_display_rules,
11 get_sparql)
12from heritrace.utils.display_rules_utils import get_highest_priority_class
13from heritrace.utils.shacl_utils import determine_shape_for_classes
14from heritrace.utils.sparql_utils import get_entity_types
15from heritrace.utils.virtuoso_utils import (VIRTUOSO_EXCLUDED_GRAPHS,
16 is_virtuoso)
17from SPARQLWrapper import JSON
19linked_resources_bp = Blueprint("linked_resources", __name__, url_prefix="/api/linked-resources")
22def _is_virtual_property_intermediate_entity(entity_types: list) -> bool:
23 """
24 Check if an entity is an intermediate entity created for virtual properties.
26 An intermediate entity for virtual properties is identified by:
27 1. Its type matches a virtual property target class
29 Args:
30 entity_types: List of entity types for the referencing entity
32 Returns:
33 True if this is a virtual property intermediate entity, False otherwise
34 """
35 if not entity_types:
36 return False
38 # Get all display rules to check for virtual properties
39 display_rules = get_display_rules()
40 if not display_rules:
41 return False
43 # Check if any of the entity types matches a virtual property target class
44 for entity_type in entity_types:
45 for rule in display_rules:
46 for prop_config in rule.get('displayProperties', []):
47 if prop_config.get('isVirtual'):
48 implementation = prop_config.get('implementedVia', {})
49 target_config = implementation.get('target', {})
51 if target_config.get('class') == entity_type:
52 current_app.logger.debug(f"Found virtual property target entity type: {entity_type}")
53 return True
55 return False
58def _is_proxy_entity(entity_types: list) -> tuple[bool, str]:
59 """
60 Check if an entity is a proxy (intermediate relation) entity based on display rules.
62 Args:
63 entity_types: List of entity types
65 Returns:
66 Tuple of (is_proxy, connecting_predicate)
67 """
68 if not entity_types:
69 return False, ""
71 display_rules = get_display_rules()
72 if not display_rules:
73 return False, ""
75 for entity_type in entity_types:
76 for rule in display_rules:
77 for prop in rule.get("displayProperties", []):
78 intermediate_config = prop.get("intermediateRelation")
79 if intermediate_config and intermediate_config.get("class") == entity_type:
80 return True, prop["property"]
82 for display_rule in prop.get("displayRules", []):
83 nested_intermediate_config = display_rule.get("intermediateRelation")
84 if nested_intermediate_config and nested_intermediate_config.get("class") == entity_type:
85 return True, prop["property"]
87 return False, ""
90def _resolve_proxy_entity(subject_uri: str, predicate: str, connecting_predicate: str) -> tuple[str, str]:
91 """
92 Resolve proxy entities to their source entities.
94 Args:
95 subject_uri: URI of the proxy entity
96 predicate: Original predicate
97 connecting_predicate: The predicate that connects source to proxy
99 Returns:
100 Tuple of (final_subject_uri, final_predicate)
101 """
102 # Query to find the source entity that points to this proxy
103 sparql = get_sparql()
104 proxy_query_parts = [
105 "SELECT DISTINCT ?source WHERE {",
106 ]
108 if is_virtuoso:
109 proxy_query_parts.extend([
110 " GRAPH ?g {",
111 f" ?source <{connecting_predicate}> <{subject_uri}> .",
112 " }",
113 f" FILTER(?g NOT IN (<{'>, <'.join(VIRTUOSO_EXCLUDED_GRAPHS)}>))"
114 ])
115 else:
116 proxy_query_parts.extend([
117 f" ?source <{connecting_predicate}> <{subject_uri}> ."
118 ])
120 proxy_query_parts.append("} LIMIT 1")
121 proxy_query = "\n".join(proxy_query_parts)
123 try:
124 sparql.setQuery(proxy_query)
125 sparql.setReturnFormat(JSON)
126 proxy_results = sparql.query().convert()
128 proxy_bindings = proxy_results.get("results", {}).get("bindings", [])
129 if proxy_bindings:
130 source_uri = proxy_bindings[0]["source"]["value"]
132 return source_uri, connecting_predicate
134 except Exception as e:
135 current_app.logger.error(f"Error resolving proxy entity {subject_uri}: {e}")
137 return subject_uri, predicate
139def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int) -> tuple[list[dict], bool]:
140 """
141 Get paginated entities that reference this entity using the limit+1 strategy.
143 Args:
144 subject_uri: URI of the entity to find references to.
145 limit: Maximum number of references to return per page.
146 offset: Number of references to skip.
148 Returns:
149 A tuple containing:
150 - List of dictionaries containing reference information (max 'limit' items).
151 - Boolean indicating if there are more references.
152 """
153 sparql = get_sparql()
154 custom_filter = get_custom_filter()
155 references = []
156 query_limit = limit + 1
158 try:
159 query_parts = [
160 "SELECT DISTINCT ?s ?p WHERE {",
161 ]
162 if is_virtuoso:
163 query_parts.append(" GRAPH ?g { ?s ?p ?o . }")
164 query_parts.append(f" FILTER(?g NOT IN (<{'>, <'.join(VIRTUOSO_EXCLUDED_GRAPHS)}>))")
165 else:
166 query_parts.append(" ?s ?p ?o .")
168 query_parts.extend([
169 f" FILTER(?o = <{subject_uri}>)",
170 " FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)",
171 f"}} ORDER BY ?s OFFSET {offset} LIMIT {query_limit}" # Use query_limit
172 ])
173 main_query = "\n".join(query_parts)
175 sparql.setQuery(main_query)
176 sparql.setReturnFormat(JSON)
177 results = sparql.query().convert()
179 bindings = results.get("results", {}).get("bindings", [])
181 # Determine if there are more results
182 has_more = len(bindings) > limit
184 # Process only up to 'limit' results
185 results_to_process = bindings[:limit]
187 for result in results_to_process:
188 subject = result["s"]["value"]
189 predicate = result["p"]["value"]
191 types = get_entity_types(subject)
193 if _is_virtual_property_intermediate_entity(types):
194 continue
196 highest_priority_type = get_highest_priority_class(types)
197 shape = determine_shape_for_classes(types)
199 is_proxy, connecting_predicate = _is_proxy_entity(types)
200 if is_proxy:
201 final_subject, final_predicate = _resolve_proxy_entity(subject, predicate, connecting_predicate)
202 else:
203 final_subject, final_predicate = subject, predicate
205 if final_subject != subject:
206 final_types = get_entity_types(final_subject)
207 final_highest_priority_type = get_highest_priority_class(final_types)
208 final_shape = determine_shape_for_classes(final_types)
209 else:
210 final_types = types
211 final_highest_priority_type = highest_priority_type
212 final_shape = shape
214 label = custom_filter.human_readable_entity(final_subject, (final_highest_priority_type, final_shape))
215 type_label = custom_filter.human_readable_class((final_highest_priority_type, final_shape)) if final_highest_priority_type else None
217 references.append({
218 "subject": final_subject,
219 "predicate": final_predicate,
220 "predicate_label": custom_filter.human_readable_predicate(final_predicate, (final_highest_priority_type, final_shape)),
221 "type_label": type_label,
222 "label": label
223 })
225 return references, has_more
227 except Exception as e:
228 tb_str = traceback.format_exc()
229 current_app.logger.error(f"Error fetching inverse references for {subject_uri}: {e}\n{tb_str}")
230 return [], False
232@linked_resources_bp.route("/", methods=["GET"])
233@login_required
234def get_linked_resources_api():
235 """API endpoint to fetch paginated linked resources (inverse references)."""
236 subject_uri = request.args.get("subject_uri")
237 try:
238 limit = int(request.args.get("limit", 5))
239 offset = int(request.args.get("offset", 0))
240 except ValueError:
241 return jsonify({"status": "error", "message": gettext("Invalid limit or offset parameter")}), 400
243 if not subject_uri:
244 return jsonify({"status": "error", "message": gettext("Missing subject_uri parameter")}), 400
246 if limit <= 0 or offset < 0:
247 return jsonify({"status": "error", "message": gettext("Limit must be positive and offset non-negative")}), 400
249 references, has_more = get_paginated_inverse_references(subject_uri, limit, offset)
251 return jsonify({
252 "status": "success",
253 "results": references,
254 "has_more": has_more
255 })