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

254 statements  

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

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5from __future__ import annotations 

6 

7from collections import defaultdict 

8from typing import TYPE_CHECKING, Any 

9 

10import validators 

11from flask import ( 

12 Blueprint, 

13 Response, 

14 current_app, 

15 flash, 

16 jsonify, 

17 redirect, 

18 render_template, 

19 request, 

20 url_for, 

21) 

22from flask_babel import gettext 

23from flask_login import current_user, login_required 

24from markupsafe import Markup 

25from rdflib import URIRef 

26from SPARQLWrapper import JSON 

27 

28from heritrace.apis.orcid import get_responsible_agent_uri 

29from heritrace.editor import Editor, EndpointConfig 

30from heritrace.extensions import ( 

31 get_counter_handler, 

32 get_custom_filter, 

33 get_dataset_endpoint, 

34 get_dataset_is_quadstore, 

35 get_provenance_endpoint, 

36 get_sparql, 

37) 

38from heritrace.sparql import get_sparql_bindings 

39from heritrace.utils.display_rules_utils import ( 

40 get_highest_priority_class, 

41 get_similarity_properties, 

42) 

43from heritrace.utils.primary_source_utils import ( 

44 get_user_default_primary_source, 

45 save_user_default_primary_source, 

46) 

47from heritrace.utils.shacl_utils import determine_shape_for_classes 

48from heritrace.utils.sparql_utils import get_entity_types, import_entity_graph 

49from heritrace.utils.virtuoso_utils import is_virtuoso 

50 

51if TYPE_CHECKING: 

52 from werkzeug.wrappers import Response as WerkzeugResponse 

53 

54merge_bp = Blueprint("merge", __name__) 

55 

56 

57def get_entity_details( 

58 entity_uri: URIRef, 

59) -> tuple[dict[str, list[dict[str, Any]]] | None, list[str]]: 

60 """ 

61 Fetches all properties (predicates and objects) for a given entity URI, 

62 grouped by predicate, along with its types. 

63 

64 Args: 

65 entity_uri: The URI of the entity to fetch details for. 

66 

67 Returns: 

68 A tuple containing: 

69 - A dictionary where keys are predicate URIs and values are lists of 

70 object dictionaries (containing 'value', 'type', 'lang', 'datatype'). 

71 Returns None if an error occurs. 

72 - A list of entity type URIs. Returns an empty list if an error occurs 

73 or no types are found. 

74 """ 

75 sparql = get_sparql() 

76 custom_filter = get_custom_filter() 

77 grouped_properties: dict[str, list[dict[str, Any]]] = {} 

78 entity_types: list[str] = [] 

79 

80 try: 

81 entity_types = get_entity_types(entity_uri) 

82 if not entity_types: 

83 current_app.logger.warning("No types found for entity: %s", entity_uri) 

84 

85 query = f""" 

86 SELECT DISTINCT ?p ?o WHERE {{ 

87 <{entity_uri}> ?p ?o . 

88 }} 

89 """ 

90 sparql.setQuery(query) 

91 sparql.setReturnFormat(JSON) 

92 results = sparql.query().convert() 

93 

94 bindings = get_sparql_bindings(results) 

95 for binding in bindings: 

96 predicate = binding["p"]["value"] 

97 obj_node = binding["o"] 

98 obj_details = { 

99 "value": obj_node["value"], 

100 "type": obj_node["type"], 

101 "lang": obj_node.get("xml:lang"), 

102 "datatype": obj_node.get("datatype"), 

103 "readable_label": None, 

104 } 

105 if obj_details["type"] == "uri": 

106 obj_types = get_entity_types(URIRef(obj_details["value"])) 

107 obj_type = get_highest_priority_class(obj_types) 

108 if obj_type: 

109 obj_details["readable_label"] = custom_filter.human_readable_entity( 

110 obj_details["value"], (obj_type, None) 

111 ) 

112 else: 

113 obj_details["readable_label"] = obj_details["value"] 

114 else: 

115 obj_details["readable_label"] = obj_details["value"] 

116 

117 if predicate not in grouped_properties: 

118 grouped_properties[predicate] = [] 

119 grouped_properties[predicate].append(obj_details) 

120 

121 except Exception: 

122 current_app.logger.exception( 

123 "Error fetching details for %s", 

124 entity_uri, 

125 ) 

126 return None, [] 

127 else: 

128 return grouped_properties, entity_types 

129 

130 

131@merge_bp.route("/execute-merge", methods=["POST"]) 

132@login_required 

133def execute_merge() -> WerkzeugResponse: 

134 """ 

135 Handles the actual merging of two entities using the Editor class 

136 to ensure provenance and data model agnosticism. 

137 Entity 1 (keep) absorbs Entity 2 (delete). 

138 """ 

139 entity1_uri_str = request.form.get("entity1_uri") 

140 entity2_uri_str = request.form.get("entity2_uri") 

141 primary_source = request.form.get("primary_source") 

142 save_default_source = request.form.get("save_default_source") == "true" 

143 

144 # TODO(arcangelo): Implement CSRF validation 

145 # if using Flask-WTF 

146 

147 if not entity1_uri_str or not entity2_uri_str: 

148 flash(gettext("Missing entity URIs for merge."), "danger") 

149 return redirect(url_for("main.catalogue")) 

150 

151 entity1_uri = URIRef(entity1_uri_str) 

152 entity2_uri = URIRef(entity2_uri_str) 

153 

154 if primary_source and not validators.url(primary_source): # type: ignore[arg-type] 

155 flash(gettext("Invalid primary source URL provided."), "danger") 

156 return redirect( 

157 url_for( 

158 ".compare_and_merge", subject=entity1_uri, other_subject=entity2_uri 

159 ) 

160 ) 

161 

162 if save_default_source and primary_source and validators.url(primary_source): # type: ignore[arg-type] 

163 save_user_default_primary_source(current_user.orcid, primary_source) 

164 

165 try: 

166 custom_filter = get_custom_filter() 

167 

168 _, entity1_types = get_entity_details(entity1_uri) 

169 _, entity2_types = get_entity_details(entity2_uri) 

170 

171 entity1_type = get_highest_priority_class(entity1_types) 

172 entity2_type = get_highest_priority_class(entity2_types) 

173 entity1_shape = determine_shape_for_classes(entity1_types) 

174 entity2_shape = determine_shape_for_classes(entity2_types) 

175 entity1_label = ( 

176 custom_filter.human_readable_entity( 

177 entity1_uri, (entity1_type, entity1_shape) 

178 ) 

179 if entity1_type 

180 else entity1_uri 

181 ) 

182 entity2_label = ( 

183 custom_filter.human_readable_entity( 

184 entity2_uri, (entity2_type, entity2_shape) 

185 ) 

186 if entity2_type 

187 else entity2_uri 

188 ) 

189 

190 counter_handler = get_counter_handler() 

191 resp_agent = get_responsible_agent_uri(current_user.orcid) 

192 

193 dataset_endpoint = get_dataset_endpoint() 

194 provenance_endpoint = get_provenance_endpoint() 

195 dataset_is_quadstore = get_dataset_is_quadstore() 

196 

197 editor = Editor( 

198 EndpointConfig( 

199 dataset=dataset_endpoint, 

200 provenance=provenance_endpoint, 

201 is_quadstore=dataset_is_quadstore, 

202 ), 

203 counter_handler, 

204 resp_agent, 

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

206 current_app.config["DATASET_GENERATION_TIME"], 

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

208 ) 

209 

210 editor = import_entity_graph(editor, entity1_uri) 

211 editor = import_entity_graph(editor, entity2_uri) 

212 editor.merge( 

213 keep_entity_uri=entity1_uri, 

214 delete_entity_uri=entity2_uri, 

215 primary_source=URIRef(primary_source) if primary_source else None, 

216 ) 

217 

218 entity1_url = url_for("entity.about", subject=entity1_uri) 

219 entity2_url = url_for("entity.about", subject=entity2_uri) 

220 flash_message_html = gettext( 

221 "Entities merged successfully. " 

222 "<a href='%(entity2_url)s' target='_blank'>%(entity2)s</a> " 

223 "has been deleted and its references now point to " 

224 "<a href='%(entity1_url)s' target='_blank'>%(entity1)s</a>.", 

225 entity1=entity1_label, 

226 entity2=entity2_label, 

227 entity1_url=entity1_url, 

228 entity2_url=entity2_url, 

229 ) 

230 

231 flash(Markup(flash_message_html), "success") # noqa: S704 

232 

233 return redirect(url_for("entity.about", subject=entity1_uri)) 

234 

235 except ValueError as ve: 

236 current_app.logger.warning("Merge attempt failed: %s", ve) 

237 flash(str(ve), "warning") 

238 return redirect( 

239 url_for( 

240 ".compare_and_merge", subject=entity1_uri, other_subject=entity2_uri 

241 ) 

242 ) 

243 

244 except Exception: 

245 current_app.logger.exception( 

246 "Error executing Editor merge for <%s> and <%s>", 

247 entity1_uri, 

248 entity2_uri, 

249 ) 

250 flash( 

251 gettext( 

252 "An error occurred during the merge" 

253 " operation. Please check the logs." 

254 " No changes were made." 

255 ), 

256 "danger", 

257 ) 

258 return redirect( 

259 url_for( 

260 ".compare_and_merge", subject=entity1_uri, other_subject=entity2_uri 

261 ) 

262 ) 

263 

264 

265@merge_bp.route("/compare-and-merge") 

266@login_required 

267def compare_and_merge() -> str | WerkzeugResponse: 

268 """ 

269 Route to display details of two entities side-by-side for merge confirmation. 

270 """ 

271 entity1_uri_str = request.args.get("subject") 

272 entity2_uri_str = request.args.get("other_subject") 

273 custom_filter = get_custom_filter() 

274 

275 if not entity1_uri_str or not entity2_uri_str: 

276 flash( 

277 gettext("Two entities must be selected for merging/comparison."), "warning" 

278 ) 

279 return redirect(url_for("main.catalogue")) 

280 

281 entity1_uri = URIRef(entity1_uri_str) 

282 entity2_uri = URIRef(entity2_uri_str) 

283 

284 entity1_props, entity1_types = get_entity_details(entity1_uri) 

285 entity2_props, entity2_types = get_entity_details(entity2_uri) 

286 

287 if entity1_props is None or entity2_props is None: 

288 flash( 

289 gettext("Could not retrieve details for one or both entities. Check logs."), 

290 "danger", 

291 ) 

292 return redirect(url_for("main.catalogue")) 

293 

294 entity1_type = get_highest_priority_class(entity1_types) 

295 entity2_type = get_highest_priority_class(entity2_types) 

296 entity1_shape = determine_shape_for_classes(entity1_types) 

297 entity2_shape = determine_shape_for_classes(entity2_types) 

298 entity1_label = ( 

299 custom_filter.human_readable_entity(entity1_uri, (entity1_type, entity1_shape)) 

300 if entity1_type 

301 else entity1_uri 

302 ) 

303 entity2_label = ( 

304 custom_filter.human_readable_entity(entity2_uri, (entity2_type, entity2_shape)) 

305 if entity2_type 

306 else entity2_uri 

307 ) 

308 

309 entity1_data = { 

310 "uri": entity1_uri, 

311 "label": entity1_label, 

312 "type_label": custom_filter.human_readable_class((entity1_type, entity1_shape)), 

313 "type": entity1_type, 

314 "shape": entity1_shape, 

315 "properties": entity1_props, 

316 } 

317 entity2_data = { 

318 "uri": entity2_uri, 

319 "label": entity2_label, 

320 "type_label": custom_filter.human_readable_class((entity2_type, entity2_shape)), 

321 "type": entity2_type, 

322 "shape": entity2_shape, 

323 "properties": entity2_props, 

324 } 

325 

326 default_primary_source = get_user_default_primary_source(current_user.orcid) 

327 

328 return render_template( 

329 "entity/merge_confirm.jinja", 

330 entity1=entity1_data, 

331 entity2=entity2_data, 

332 default_primary_source=default_primary_source, 

333 ) 

334 

335 

336def _format_rdf_term(node: dict[str, str]) -> str | None: 

337 value = node["value"] 

338 value_type = node["type"] 

339 if value_type == "uri": 

340 return f"<{value}>" 

341 if value_type in {"literal", "typed-literal"}: 

342 datatype = node.get("datatype") 

343 lang = node.get("xml:lang") 

344 escaped_value = value.replace("\\", "\\\\").replace('"', '\\"') 

345 if datatype: 

346 return f'"{escaped_value}"^^<{datatype}>' 

347 if lang: 

348 return f'"{escaped_value}"@{lang}' 

349 return f'"{escaped_value}"' 

350 return None 

351 

352 

353def _fetch_subject_values( 

354 subject_uri: str, 

355 similarity_config: list, 

356) -> defaultdict[str, list[str]] | None: 

357 sparql = get_sparql() 

358 

359 all_props_in_config: set[str] = set() 

360 for item in similarity_config: 

361 if isinstance(item, str): 

362 all_props_in_config.add(item) 

363 elif isinstance(item, dict) and "and" in item: 

364 all_props_in_config.update(item["and"]) 

365 

366 if not all_props_in_config: 

367 current_app.logger.warning( 

368 "Empty properties list derived from similarity config for type %s", 

369 subject_uri, 

370 ) 

371 return None 

372 

373 prop_uris_formatted_for_filter = [f"<{p}>" for p in all_props_in_config] 

374 property_filter_for_subject = ( 

375 f"FILTER(?p IN ({', '.join(prop_uris_formatted_for_filter)}))" 

376 ) 

377 

378 fetch_comparison_values_query = f""" 

379 SELECT DISTINCT ?p ?o WHERE {{ 

380 <{subject_uri}> ?p ?o . 

381 {property_filter_for_subject} 

382 }} 

383 """ 

384 

385 sparql.setQuery(fetch_comparison_values_query) 

386 sparql.setReturnFormat(JSON) 

387 subject_values_results = sparql.query().convert() 

388 subject_bindings = get_sparql_bindings(subject_values_results) 

389 

390 if not subject_bindings: 

391 return None 

392 

393 subject_values_by_prop: defaultdict[str, list[str]] = defaultdict(list) 

394 for binding in subject_bindings: 

395 formatted_value = _format_rdf_term(binding["o"]) 

396 if formatted_value: 

397 subject_values_by_prop[binding["p"]["value"]].append(formatted_value) 

398 

399 return subject_values_by_prop 

400 

401 

402def _build_similarity_pattern(prop_uri: str, values: list[str], index: int) -> str: 

403 variable = f"?o_{index}" 

404 if not is_virtuoso(): 

405 return ( 

406 f"VALUES {variable} {{ {' '.join(values)} }} " 

407 f"?similar <{prop_uri}> {variable} ." 

408 ) 

409 return ( 

410 f"?similar <{prop_uri}> {variable} . " 

411 f"FILTER({variable} IN ({', '.join(values)}))" 

412 ) 

413 

414 

415def _build_union_blocks( 

416 similarity_config: list, 

417 subject_values_by_prop: defaultdict[str, list[str]], 

418 subject_uri: str, 

419) -> list[str]: 

420 union_blocks: list[str] = [] 

421 var_counter = 0 

422 

423 for condition in similarity_config: 

424 if isinstance(condition, str): 

425 prop_values = subject_values_by_prop.get(condition) 

426 if prop_values: 

427 var_counter += 1 

428 pattern = _build_similarity_pattern(condition, prop_values, var_counter) 

429 union_blocks.append(f" {{ {pattern} }}") 

430 elif isinstance(condition, dict) and "and" in condition: 

431 block = _build_and_block( 

432 condition["and"], subject_values_by_prop, subject_uri, var_counter 

433 ) 

434 if block is not None: 

435 text, var_counter = block 

436 union_blocks.append(text) 

437 else: 

438 var_counter += len(condition["and"]) 

439 

440 return union_blocks 

441 

442 

443def _build_and_block( 

444 and_props: list[str], 

445 subject_values_by_prop: defaultdict[str, list[str]], 

446 subject_uri: str, 

447 var_counter: int, 

448) -> tuple[str, int] | None: 

449 if not all(p in subject_values_by_prop for p in and_props): 

450 current_app.logger.debug( 

451 "Skipping AND group %s because" 

452 " subject %s lacks values for" 

453 " all its properties.", 

454 and_props, 

455 subject_uri, 

456 ) 

457 return None 

458 

459 and_patterns = [] 

460 for prop_uri in and_props: 

461 prop_values = subject_values_by_prop[prop_uri] 

462 var_counter += 1 

463 and_patterns.append( 

464 f" {_build_similarity_pattern(prop_uri, prop_values, var_counter)}" 

465 ) 

466 

467 patterns_str = "\n".join(and_patterns) 

468 return f" {{\n{patterns_str}\n }}", var_counter 

469 

470 

471def _execute_similarity_query( 

472 union_blocks: list[str], 

473 entity_type: str, 

474 subject_uri: str, 

475 limit: int, 

476 offset: int, 

477) -> tuple[list[str], bool]: 

478 sparql = get_sparql() 

479 similarity_query_body = " UNION ".join(union_blocks) 

480 

481 query_limit = limit + 1 

482 final_query = f""" 

483 SELECT DISTINCT ?similar WHERE {{ 

484 ?similar a <{entity_type}> . 

485 FILTER(?similar != <{subject_uri}>) 

486 {{ 

487 {similarity_query_body} 

488 }} 

489 }} ORDER BY ?similar OFFSET {offset} LIMIT {query_limit} 

490 """ 

491 

492 sparql.setQuery(final_query) 

493 sparql.setReturnFormat(JSON) 

494 results = sparql.query().convert() 

495 

496 bindings = get_sparql_bindings(results) 

497 candidate_uris = [item["similar"]["value"] for item in bindings] 

498 

499 has_more = len(candidate_uris) > limit 

500 return candidate_uris[:limit], has_more 

501 

502 

503def _transform_results( 

504 uris: list[str], 

505 entity_type: str, 

506 shape_uri: str | None, 

507) -> list[dict[str, str]]: 

508 custom_filter = get_custom_filter() 

509 transformed: list[dict[str, str]] = [] 

510 for uri in uris: 

511 readable_label = ( 

512 custom_filter.human_readable_entity(uri, (entity_type, shape_uri)) 

513 if entity_type 

514 else uri 

515 ) 

516 transformed.append({"uri": uri, "label": readable_label or uri}) 

517 return transformed 

518 

519 

520@merge_bp.route("/find_similar", methods=["GET"]) 

521@login_required 

522def find_similar_resources() -> Response | tuple[Response, int]: # noqa: PLR0911 

523 subject_uri = request.args.get("subject_uri") 

524 entity_type = request.args.get("entity_type") 

525 shape_uri = request.args.get("shape_uri") 

526 try: 

527 limit = int(request.args.get("limit", 5)) 

528 offset = int(request.args.get("offset", 0)) 

529 except ValueError: 

530 return jsonify( 

531 {"status": "error", "message": gettext("Invalid limit or offset parameter")} 

532 ), 400 

533 

534 if not subject_uri or not entity_type: 

535 return jsonify( 

536 { 

537 "status": "error", 

538 "message": gettext( 

539 "Missing required parameters (subject_uri, entity_type)" 

540 ), 

541 } 

542 ), 400 

543 

544 if limit <= 0 or offset < 0: 

545 return jsonify( 

546 { 

547 "status": "error", 

548 "message": gettext("Limit must be positive and offset non-negative"), 

549 } 

550 ), 400 

551 

552 try: 

553 entity_key = (entity_type, shape_uri) 

554 similarity_config = get_similarity_properties(entity_key) 

555 

556 if not similarity_config or not isinstance(similarity_config, list): 

557 return jsonify({"status": "success", "results": [], "has_more": False}) 

558 

559 subject_values_by_prop = _fetch_subject_values(subject_uri, similarity_config) 

560 if subject_values_by_prop is None: 

561 return jsonify({"status": "success", "results": [], "has_more": False}) 

562 

563 union_blocks = _build_union_blocks( 

564 similarity_config, subject_values_by_prop, subject_uri 

565 ) 

566 if not union_blocks: 

567 return jsonify({"status": "success", "results": [], "has_more": False}) 

568 

569 result_uris, has_more = _execute_similarity_query( 

570 union_blocks, entity_type, subject_uri, limit, offset 

571 ) 

572 transformed_results = _transform_results(result_uris, entity_type, shape_uri) 

573 

574 return jsonify( 

575 { 

576 "status": "success", 

577 "results": transformed_results, 

578 "has_more": has_more, 

579 } 

580 ) 

581 

582 except Exception: 

583 current_app.logger.exception( 

584 "Error finding similar resources for %s", subject_uri 

585 ) 

586 return jsonify( 

587 { 

588 "status": "error", 

589 "message": gettext("An error occurred while finding similar resources"), 

590 } 

591 ), 500