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

249 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 08:34 +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 

49 

50if TYPE_CHECKING: 

51 from werkzeug.wrappers import Response as WerkzeugResponse 

52 

53merge_bp = Blueprint("merge", __name__) 

54 

55 

56def get_entity_details( 

57 entity_uri: URIRef, 

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

59 """ 

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

61 grouped by predicate, along with its types. 

62 

63 Args: 

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

65 

66 Returns: 

67 A tuple containing: 

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

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

70 Returns None if an error occurs. 

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

72 or no types are found. 

73 """ 

74 sparql = get_sparql() 

75 custom_filter = get_custom_filter() 

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

77 entity_types: list[str] = [] 

78 

79 try: 

80 entity_types = get_entity_types(entity_uri) 

81 if not entity_types: 

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

83 

84 query = f""" 

85 SELECT DISTINCT ?p ?o WHERE {{ 

86 <{entity_uri}> ?p ?o . 

87 }} 

88 """ 

89 sparql.setQuery(query) 

90 sparql.setReturnFormat(JSON) 

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

92 

93 bindings = get_sparql_bindings(results) 

94 for binding in bindings: 

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

96 obj_node = binding["o"] 

97 obj_details = { 

98 "value": obj_node["value"], 

99 "type": obj_node["type"], 

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

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

102 "readable_label": None, 

103 } 

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

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

106 obj_type = get_highest_priority_class(obj_types) 

107 if obj_type: 

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

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

110 ) 

111 else: 

112 obj_details["readable_label"] = obj_details["value"] 

113 else: 

114 obj_details["readable_label"] = obj_details["value"] 

115 

116 if predicate not in grouped_properties: 

117 grouped_properties[predicate] = [] 

118 grouped_properties[predicate].append(obj_details) 

119 

120 except Exception: 

121 current_app.logger.exception( 

122 "Error fetching details for %s", 

123 entity_uri, 

124 ) 

125 return None, [] 

126 else: 

127 return grouped_properties, entity_types 

128 

129 

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

131@login_required 

132def execute_merge() -> WerkzeugResponse: 

133 """ 

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

135 to ensure provenance and data model agnosticism. 

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

137 """ 

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

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

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

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

142 

143 # TODO(arcangelo): Implement CSRF validation 

144 # if using Flask-WTF 

145 

146 if not entity1_uri_str or not entity2_uri_str: 

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

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

149 

150 entity1_uri = URIRef(entity1_uri_str) 

151 entity2_uri = URIRef(entity2_uri_str) 

152 

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

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

155 return redirect( 

156 url_for( 

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

158 ) 

159 ) 

160 

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

162 save_user_default_primary_source(current_user.orcid, primary_source) 

163 

164 try: 

165 custom_filter = get_custom_filter() 

166 

167 _, entity1_types = get_entity_details(entity1_uri) 

168 _, entity2_types = get_entity_details(entity2_uri) 

169 

170 entity1_type = get_highest_priority_class(entity1_types) 

171 entity2_type = get_highest_priority_class(entity2_types) 

172 entity1_shape = determine_shape_for_classes(entity1_types) 

173 entity2_shape = determine_shape_for_classes(entity2_types) 

174 entity1_label = ( 

175 custom_filter.human_readable_entity( 

176 entity1_uri, (entity1_type, entity1_shape) 

177 ) 

178 if entity1_type 

179 else entity1_uri 

180 ) 

181 entity2_label = ( 

182 custom_filter.human_readable_entity( 

183 entity2_uri, (entity2_type, entity2_shape) 

184 ) 

185 if entity2_type 

186 else entity2_uri 

187 ) 

188 

189 counter_handler = get_counter_handler() 

190 resp_agent = get_responsible_agent_uri(current_user.orcid) 

191 

192 dataset_endpoint = get_dataset_endpoint() 

193 provenance_endpoint = get_provenance_endpoint() 

194 dataset_is_quadstore = get_dataset_is_quadstore() 

195 

196 editor = Editor( 

197 EndpointConfig( 

198 dataset=dataset_endpoint, 

199 provenance=provenance_endpoint, 

200 is_quadstore=dataset_is_quadstore, 

201 ), 

202 counter_handler, 

203 resp_agent, 

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

205 current_app.config["DATASET_GENERATION_TIME"], 

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

207 ) 

208 

209 editor = import_entity_graph(editor, entity1_uri) 

210 editor = import_entity_graph(editor, entity2_uri) 

211 editor.merge( 

212 keep_entity_uri=entity1_uri, 

213 delete_entity_uri=entity2_uri, 

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

215 ) 

216 

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

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

219 flash_message_html = gettext( 

220 "Entities merged successfully. " 

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

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

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

224 entity1=entity1_label, 

225 entity2=entity2_label, 

226 entity1_url=entity1_url, 

227 entity2_url=entity2_url, 

228 ) 

229 

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

231 

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

233 

234 except ValueError as ve: 

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

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

237 return redirect( 

238 url_for( 

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

240 ) 

241 ) 

242 

243 except Exception: 

244 current_app.logger.exception( 

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

246 entity1_uri, 

247 entity2_uri, 

248 ) 

249 flash( 

250 gettext( 

251 "An error occurred during the merge" 

252 " operation. Please check the logs." 

253 " No changes were made." 

254 ), 

255 "danger", 

256 ) 

257 return redirect( 

258 url_for( 

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

260 ) 

261 ) 

262 

263 

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

265@login_required 

266def compare_and_merge() -> str | WerkzeugResponse: 

267 """ 

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

269 """ 

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

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

272 custom_filter = get_custom_filter() 

273 

274 if not entity1_uri_str or not entity2_uri_str: 

275 flash( 

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

277 ) 

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

279 

280 entity1_uri = URIRef(entity1_uri_str) 

281 entity2_uri = URIRef(entity2_uri_str) 

282 

283 entity1_props, entity1_types = get_entity_details(entity1_uri) 

284 entity2_props, entity2_types = get_entity_details(entity2_uri) 

285 

286 if entity1_props is None or entity2_props is None: 

287 flash( 

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

289 "danger", 

290 ) 

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

292 

293 entity1_type = get_highest_priority_class(entity1_types) 

294 entity2_type = get_highest_priority_class(entity2_types) 

295 entity1_shape = determine_shape_for_classes(entity1_types) 

296 entity2_shape = determine_shape_for_classes(entity2_types) 

297 entity1_label = ( 

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

299 if entity1_type 

300 else entity1_uri 

301 ) 

302 entity2_label = ( 

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

304 if entity2_type 

305 else entity2_uri 

306 ) 

307 

308 entity1_data = { 

309 "uri": entity1_uri, 

310 "label": entity1_label, 

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

312 "type": entity1_type, 

313 "shape": entity1_shape, 

314 "properties": entity1_props, 

315 } 

316 entity2_data = { 

317 "uri": entity2_uri, 

318 "label": entity2_label, 

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

320 "type": entity2_type, 

321 "shape": entity2_shape, 

322 "properties": entity2_props, 

323 } 

324 

325 default_primary_source = get_user_default_primary_source(current_user.orcid) 

326 

327 return render_template( 

328 "entity/merge_confirm.jinja", 

329 entity1=entity1_data, 

330 entity2=entity2_data, 

331 default_primary_source=default_primary_source, 

332 ) 

333 

334 

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

336 value = node["value"] 

337 value_type = node["type"] 

338 if value_type == "uri": 

339 return f"<{value}>" 

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

341 datatype = node.get("datatype") 

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

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

344 if datatype: 

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

346 if lang: 

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

348 return f'"{escaped_value}"' 

349 return None 

350 

351 

352def _fetch_subject_values( 

353 subject_uri: str, 

354 similarity_config: list, 

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

356 sparql = get_sparql() 

357 

358 all_props_in_config: set[str] = set() 

359 for item in similarity_config: 

360 if isinstance(item, str): 

361 all_props_in_config.add(item) 

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

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

364 

365 if not all_props_in_config: 

366 current_app.logger.warning( 

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

368 subject_uri, 

369 ) 

370 return None 

371 

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

373 property_filter_for_subject = ( 

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

375 ) 

376 

377 fetch_comparison_values_query = f""" 

378 SELECT DISTINCT ?p ?o WHERE {{ 

379 <{subject_uri}> ?p ?o . 

380 {property_filter_for_subject} 

381 }} 

382 """ 

383 

384 sparql.setQuery(fetch_comparison_values_query) 

385 sparql.setReturnFormat(JSON) 

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

387 subject_bindings = get_sparql_bindings(subject_values_results) 

388 

389 if not subject_bindings: 

390 return None 

391 

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

393 for binding in subject_bindings: 

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

395 if formatted_value: 

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

397 

398 return subject_values_by_prop 

399 

400 

401def _build_union_blocks( 

402 similarity_config: list, 

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

404 subject_uri: str, 

405) -> list[str]: 

406 union_blocks: list[str] = [] 

407 var_counter = 0 

408 

409 for condition in similarity_config: 

410 if isinstance(condition, str): 

411 prop_values = subject_values_by_prop.get(condition) 

412 if prop_values: 

413 var_counter += 1 

414 values_filter = ", ".join(prop_values) 

415 union_blocks.append( 

416 f" {{ ?similar <{condition}>" 

417 f" ?o_{var_counter} ." 

418 f" FILTER(?o_{var_counter}" 

419 f" IN ({values_filter})) }}" 

420 ) 

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

422 block = _build_and_block( 

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

424 ) 

425 if block is not None: 

426 text, var_counter = block 

427 union_blocks.append(text) 

428 else: 

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

430 

431 return union_blocks 

432 

433 

434def _build_and_block( 

435 and_props: list[str], 

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

437 subject_uri: str, 

438 var_counter: int, 

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

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

441 current_app.logger.debug( 

442 "Skipping AND group %s because" 

443 " subject %s lacks values for" 

444 " all its properties.", 

445 and_props, 

446 subject_uri, 

447 ) 

448 return None 

449 

450 and_patterns = [] 

451 for prop_uri in and_props: 

452 prop_values = subject_values_by_prop[prop_uri] 

453 var_counter += 1 

454 values_filter = ", ".join(prop_values) 

455 and_patterns.append( 

456 f" ?similar <{prop_uri}>" 

457 f" ?o_{var_counter} ." 

458 f" FILTER(?o_{var_counter}" 

459 f" IN ({values_filter})) ." 

460 ) 

461 

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

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

464 

465 

466def _execute_similarity_query( 

467 union_blocks: list[str], 

468 entity_type: str, 

469 subject_uri: str, 

470 limit: int, 

471 offset: int, 

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

473 sparql = get_sparql() 

474 similarity_query_body = " UNION ".join(union_blocks) 

475 

476 query_limit = limit + 1 

477 final_query = f""" 

478 SELECT DISTINCT ?similar WHERE {{ 

479 ?similar a <{entity_type}> . 

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

481 {{ 

482 {similarity_query_body} 

483 }} 

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

485 """ 

486 

487 sparql.setQuery(final_query) 

488 sparql.setReturnFormat(JSON) 

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

490 

491 bindings = get_sparql_bindings(results) 

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

493 

494 has_more = len(candidate_uris) > limit 

495 return candidate_uris[:limit], has_more 

496 

497 

498def _transform_results( 

499 uris: list[str], 

500 entity_type: str, 

501 shape_uri: str | None, 

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

503 custom_filter = get_custom_filter() 

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

505 for uri in uris: 

506 readable_label = ( 

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

508 if entity_type 

509 else uri 

510 ) 

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

512 return transformed 

513 

514 

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

516@login_required 

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

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

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

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

521 try: 

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

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

524 except ValueError: 

525 return jsonify( 

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

527 ), 400 

528 

529 if not subject_uri or not entity_type: 

530 return jsonify( 

531 { 

532 "status": "error", 

533 "message": gettext( 

534 "Missing required parameters (subject_uri, entity_type)" 

535 ), 

536 } 

537 ), 400 

538 

539 if limit <= 0 or offset < 0: 

540 return jsonify( 

541 { 

542 "status": "error", 

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

544 } 

545 ), 400 

546 

547 try: 

548 entity_key = (entity_type, shape_uri) 

549 similarity_config = get_similarity_properties(entity_key) 

550 

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

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

553 

554 subject_values_by_prop = _fetch_subject_values(subject_uri, similarity_config) 

555 if subject_values_by_prop is None: 

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

557 

558 union_blocks = _build_union_blocks( 

559 similarity_config, subject_values_by_prop, subject_uri 

560 ) 

561 if not union_blocks: 

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

563 

564 result_uris, has_more = _execute_similarity_query( 

565 union_blocks, entity_type, subject_uri, limit, offset 

566 ) 

567 transformed_results = _transform_results(result_uris, entity_type, shape_uri) 

568 

569 return jsonify( 

570 { 

571 "status": "success", 

572 "results": transformed_results, 

573 "has_more": has_more, 

574 } 

575 ) 

576 

577 except Exception: 

578 current_app.logger.exception( 

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

580 ) 

581 return jsonify( 

582 { 

583 "status": "error", 

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

585 } 

586 ), 500