Coverage for heritrace/routes/linked_resources.py: 100%
106 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-01 22:12 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-01 22:12 +0000
1import traceback
3from flask import Blueprint, current_app, jsonify, request
4from flask_babel import gettext
5from flask_login import login_required
6from heritrace.extensions import (get_custom_filter, get_display_rules,
7 get_sparql)
8from heritrace.utils.display_rules_utils import get_highest_priority_class
9from heritrace.utils.shacl_utils import determine_shape_for_classes
10from heritrace.utils.sparql_utils import get_entity_types
11from heritrace.utils.virtuoso_utils import (VIRTUOSO_EXCLUDED_GRAPHS,
12 is_virtuoso)
13from SPARQLWrapper import JSON
15linked_resources_bp = Blueprint("linked_resources", __name__, url_prefix="/api/linked-resources")
18def _is_proxy_entity(entity_types: list) -> tuple[bool, str]:
19 """
20 Check if an entity is a proxy (intermediate relation) entity based on display rules.
22 Args:
23 entity_types: List of entity types
25 Returns:
26 Tuple of (is_proxy, connecting_predicate)
27 """
28 if not entity_types:
29 return False, ""
31 display_rules = get_display_rules()
32 if not display_rules:
33 return False, ""
35 for entity_type in entity_types:
36 for rule in display_rules:
37 for prop in rule.get("displayProperties", []):
38 intermediate_config = prop.get("intermediateRelation")
39 if intermediate_config and intermediate_config.get("class") == entity_type:
40 return True, prop["property"]
42 for display_rule in prop.get("displayRules", []):
43 nested_intermediate_config = display_rule.get("intermediateRelation")
44 if nested_intermediate_config and nested_intermediate_config.get("class") == entity_type:
45 return True, prop["property"]
47 return False, ""
50def _resolve_proxy_entity(subject_uri: str, predicate: str, connecting_predicate: str) -> tuple[str, str]:
51 """
52 Resolve proxy entities to their source entities.
54 Args:
55 subject_uri: URI of the proxy entity
56 predicate: Original predicate
57 connecting_predicate: The predicate that connects source to proxy
59 Returns:
60 Tuple of (final_subject_uri, final_predicate)
61 """
62 # Query to find the source entity that points to this proxy
63 sparql = get_sparql()
64 proxy_query_parts = [
65 "SELECT DISTINCT ?source WHERE {",
66 ]
68 if is_virtuoso:
69 proxy_query_parts.extend([
70 " GRAPH ?g {",
71 f" ?source <{connecting_predicate}> <{subject_uri}> .",
72 " }",
73 f" FILTER(?g NOT IN (<{'>, <'.join(VIRTUOSO_EXCLUDED_GRAPHS)}>))"
74 ])
75 else:
76 proxy_query_parts.extend([
77 f" ?source <{connecting_predicate}> <{subject_uri}> ."
78 ])
80 proxy_query_parts.append("} LIMIT 1")
81 proxy_query = "\n".join(proxy_query_parts)
83 try:
84 sparql.setQuery(proxy_query)
85 sparql.setReturnFormat(JSON)
86 proxy_results = sparql.query().convert()
88 proxy_bindings = proxy_results.get("results", {}).get("bindings", [])
89 if proxy_bindings:
90 source_uri = proxy_bindings[0]["source"]["value"]
92 return source_uri, connecting_predicate
94 except Exception as e:
95 current_app.logger.error(f"Error resolving proxy entity {subject_uri}: {e}")
97 return subject_uri, predicate
99def get_paginated_inverse_references(subject_uri: str, limit: int, offset: int) -> tuple[list[dict], bool]:
100 """
101 Get paginated entities that reference this entity using the limit+1 strategy.
103 Args:
104 subject_uri: URI of the entity to find references to.
105 limit: Maximum number of references to return per page.
106 offset: Number of references to skip.
108 Returns:
109 A tuple containing:
110 - List of dictionaries containing reference information (max 'limit' items).
111 - Boolean indicating if there are more references.
112 """
113 sparql = get_sparql()
114 custom_filter = get_custom_filter()
115 references = []
116 query_limit = limit + 1
118 try:
119 query_parts = [
120 "SELECT DISTINCT ?s ?p WHERE {",
121 ]
122 if is_virtuoso:
123 query_parts.append(" GRAPH ?g { ?s ?p ?o . }")
124 query_parts.append(f" FILTER(?g NOT IN (<{'>, <'.join(VIRTUOSO_EXCLUDED_GRAPHS)}>))")
125 else:
126 query_parts.append(" ?s ?p ?o .")
128 query_parts.extend([
129 f" FILTER(?o = <{subject_uri}>)",
130 " FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)",
131 f"}} ORDER BY ?s OFFSET {offset} LIMIT {query_limit}" # Use query_limit
132 ])
133 main_query = "\n".join(query_parts)
135 sparql.setQuery(main_query)
136 sparql.setReturnFormat(JSON)
137 results = sparql.query().convert()
139 bindings = results.get("results", {}).get("bindings", [])
141 # Determine if there are more results
142 has_more = len(bindings) > limit
144 # Process only up to 'limit' results
145 results_to_process = bindings[:limit]
147 for result in results_to_process:
148 subject = result["s"]["value"]
149 predicate = result["p"]["value"]
151 types = get_entity_types(subject)
152 highest_priority_type = get_highest_priority_class(types)
153 shape = determine_shape_for_classes(types)
155 is_proxy, connecting_predicate = _is_proxy_entity(types)
156 if is_proxy:
157 final_subject, final_predicate = _resolve_proxy_entity(subject, predicate, connecting_predicate)
158 else:
159 final_subject, final_predicate = subject, predicate
161 if final_subject != subject:
162 final_types = get_entity_types(final_subject)
163 final_highest_priority_type = get_highest_priority_class(final_types)
164 final_shape = determine_shape_for_classes(final_types)
165 else:
166 final_types = types
167 final_highest_priority_type = highest_priority_type
168 final_shape = shape
170 label = custom_filter.human_readable_entity(final_subject, (final_highest_priority_type, final_shape))
171 type_label = custom_filter.human_readable_class((final_highest_priority_type, final_shape)) if final_highest_priority_type else None
173 references.append({
174 "subject": final_subject,
175 "predicate": final_predicate,
176 "predicate_label": custom_filter.human_readable_predicate(final_predicate, (final_highest_priority_type, final_shape)),
177 "type_label": type_label,
178 "label": label
179 })
181 return references, has_more
183 except Exception as e:
184 tb_str = traceback.format_exc()
185 current_app.logger.error(f"Error fetching inverse references for {subject_uri}: {e}\n{tb_str}")
186 return [], False
188@linked_resources_bp.route("/", methods=["GET"])
189@login_required
190def get_linked_resources_api():
191 """API endpoint to fetch paginated linked resources (inverse references)."""
192 subject_uri = request.args.get("subject_uri")
193 try:
194 limit = int(request.args.get("limit", 5))
195 offset = int(request.args.get("offset", 0))
196 except ValueError:
197 return jsonify({"status": "error", "message": gettext("Invalid limit or offset parameter")}), 400
199 if not subject_uri:
200 return jsonify({"status": "error", "message": gettext("Missing subject_uri parameter")}), 400
202 if limit <= 0 or offset < 0:
203 return jsonify({"status": "error", "message": gettext("Limit must be positive and offset non-negative")}), 400
205 references, has_more = get_paginated_inverse_references(subject_uri, limit, offset)
207 return jsonify({
208 "status": "success",
209 "results": references,
210 "has_more": has_more
211 })