Coverage for app/routers/link.py: 91%

174 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2026-02-19 12:47 +0000

1from fastapi import APIRouter 

2from fastapi import Depends, Path, Query, Body 

3from fastapi.security import OAuth2PasswordBearer 

4from neo4j import GraphDatabase, basic_auth 

5from app.config.config import NEO4J_HOST, NEO4J_PORT, NEO4J_USER, NEO4J_PASSWORD, RESERVED_NODE_LABEL_NAMES 

6from uuid import uuid4 

7from loguru import logger 

8 

9from app.routers.links import attribute 

10from app.utils.check_token import check_constellation_access, constellation_check_error 

11from app.utils.send_sse_notification import send_constellation_notification 

12from app.utils.response_format import generate_response, generate_error_response 

13from app.utils.decode_ydoc import decode_attributes_inplace, DecodeType 

14from app.utils.typing import Optional, List, JSONValue, serialize_value 

15 

16router = APIRouter() 

17 

18# Define the OAuth2 schema to get the JWT 

19oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 

20 

21# Define the Neo4j driver 

22driver = GraphDatabase.driver( 

23 f"neo4j://{NEO4J_HOST}:{NEO4J_PORT}", 

24 auth=basic_auth(NEO4J_USER, NEO4J_PASSWORD)) 

25 

26router.include_router(attribute.router) 

27 

28# Get all links 

29@router.get("/constellation/{constellation_uuid}/links", 

30 summary="Get all links", 

31 description="Get all links in the constellation.", 

32 response_description="All links in the constellation", 

33 responses={ 

34 200: { 

35 "description": "All links successfully returned", 

36 "content": { 

37 "application/json": { 

38 "example": { 

39 "success": True, 

40 "data": [ 

41 { 

42 "start_node": 1, 

43 "end_node": 2, 

44 "type": "example_type", 

45 "attributes": { 

46 "link_uuid": 1 

47 } 

48 } 

49 ], 

50 "message": "All links successfully returned", 

51 "error": None 

52 } 

53 } 

54 } 

55 }, 

56 **constellation_check_error, 

57 }, 

58 tags=["Link Base"] 

59) 

60async def read_links(constellation_uuid: str = Path(..., description="The UUID of the constellation to get the links from"), 

61 decode: Optional[DecodeType] = Query(default=None, description="Whether to decode the attributes or not. Options: 'xml', 'plain_text'"), 

62 in_filter: Optional[List[str]] = Query(default=None, description="Filter to include only specific attributes in the final results"), 

63 out_filter: Optional[List[str]] = Query(default=None, description="Filter to exclude specific attributes from the final results"), 

64 token: str = Depends(oauth2_scheme)): 

65 test = check_constellation_access(token, constellation_uuid, "READ") 

66 if test is not None: 

67 return test 

68 

69 if in_filter is None: 

70 in_filter = [] 

71 if out_filter is None: 

72 out_filter = [] 

73 

74 with driver.session() as session: 

75 result = session.run(""" 

76 MATCH (n {constellation_uuid: $constellation_uuid})-[r {constellation_uuid: $constellation_uuid}]-(m {constellation_uuid: $constellation_uuid}) 

77 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

78 RETURN n,m,r, properties(r) AS attributes 

79 """, 

80 constellation_uuid=constellation_uuid, 

81 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

82 ) 

83 # get the node_uuid of the start and end nodes of the link and the type of the link 

84 final_result: JSONValue = [] 

85 already_added_links: List[int] = [] 

86 for record in result: 

87 if record["r"].id not in already_added_links: 

88 already_added_links.append(record["r"].id) 

89 if record["n"].id == record["r"].nodes[0].id: 

90 final_result.append({"start_node": record["n"]["node_uuid"], "end_node": record["m"]["node_uuid"], "type": record["r"].type, "attributes": { 

91 key: serialize_value(value) 

92 for key, value in record["attributes"].items() 

93 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

94 }}) 

95 else: 

96 final_result.append({"start_node": record["m"]["node_uuid"], "end_node": record["n"]["node_uuid"], "type": record["r"].type, "attributes": { 

97 key: serialize_value(value) 

98 for key, value in record["attributes"].items() 

99 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

100 }}) 

101 driver.close() 

102 

103 final_result = decode_attributes_inplace(final_result, decode) 

104 

105 return generate_response( 

106 status_code=200, 

107 data=final_result, 

108 message="All links successfully returned" 

109 ) 

110 

111# Get a specific link (all link to/from a specific node) 

112@router.get("/constellation/{constellation_uuid}/link/node/{node_uuid}", 

113 summary="Get all links to/from a specific node", 

114 description="Get all links to/from a specific node.", 

115 response_description="All links to/from a specific node", 

116 responses={ 

117 200: { 

118 "description": "All links successfully returned", 

119 "content": { 

120 "application/json": { 

121 "example": { 

122 "success": True, 

123 "data": [ 

124 { 

125 "start_node": 1, 

126 "end_node": 2, 

127 "type": "example_type", 

128 "attributes": { 

129 "link_uuid": 1 

130 } 

131 } 

132 ], 

133 "message": "All links successfully returned", 

134 "error": None 

135 } 

136 } 

137 } 

138 }, 

139 **constellation_check_error, 

140 }, 

141 tags=["Link Base"] 

142) 

143async def read_link(constellation_uuid: str = Path(..., description="The UUID of the constellation to get the links from"), 

144 node_uuid: str = Path(..., description="The UUID of the node to get the links from"), 

145 decode: Optional[DecodeType] = Query(default=None, description="Whether to decode the attributes or not. Options: 'xml', 'plain_text'"), 

146 in_filter: Optional[List[str]] = Query(default=None, description="Filter to include only specific attributes in the final results"), 

147 out_filter: Optional[List[str]] = Query(default=None, description="Filter to exclude specific attributes from the final results"), 

148 token: str = Depends(oauth2_scheme)): 

149 test = check_constellation_access(token, constellation_uuid, "READ") 

150 if test is not None: 

151 return test 

152 

153 if in_filter is None: 

154 in_filter = [] 

155 if out_filter is None: 

156 out_filter = [] 

157 

158 with driver.session() as session: 

159 # Check if the node exists 

160 result = session.run(""" 

161 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $node_uuid}) 

162 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) 

163 RETURN n 

164 """, 

165 constellation_uuid=constellation_uuid, 

166 node_uuid=node_uuid, 

167 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

168 ) 

169 if len([record for record in result]) != 1: 

170 return generate_error_response( 

171 status_code=404, 

172 error_code="NOT_FOUND", 

173 error_message=f"Node {node_uuid} not found", 

174 message=f"Node {node_uuid} not found" 

175 ) 

176 

177 result = session.run(""" 

178 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $node_uuid})-[r {constellation_uuid: $constellation_uuid}]-(m {constellation_uuid: $constellation_uuid}) 

179 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

180 RETURN n,m,r, properties(r) AS attributes 

181 """, 

182 constellation_uuid=constellation_uuid, 

183 node_uuid=node_uuid, 

184 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

185 ) 

186 final_result: JSONValue = [] 

187 already_added_links: list[int] = [] 

188 for record in result: 

189 if record["r"].id not in already_added_links: 

190 already_added_links.append(record["r"].id) 

191 if record["n"].id == record["r"].nodes[0].id: 

192 final_result.append({"start_node": record["n"]["node_uuid"], "end_node": record["m"]["node_uuid"], "type": record["r"].type, "attributes": { 

193 key: serialize_value(value) 

194 for key, value in record["attributes"].items() 

195 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

196 }}) 

197 else: 

198 final_result.append({"start_node": record["m"]["node_uuid"], "end_node": record["n"]["node_uuid"], "type": record["r"].type, "attributes": { 

199 key: serialize_value(value) 

200 for key, value in record["attributes"].items() 

201 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

202 }}) 

203 driver.close() 

204 

205 final_result = decode_attributes_inplace(final_result, decode) 

206 

207 return generate_response( 

208 status_code=200, 

209 data=final_result, 

210 message="All links successfully returned" 

211 ) 

212 

213# Get a specific link by its UUID 

214@router.get("/constellation/{constellation_uuid}/link/{link_uuid}", 

215 summary="Get a link by UUID", 

216 description="Get a link by its UUID.", 

217 response_description="The link specified by its UUID", 

218 responses={ 

219 200: { 

220 "description": "Link successfully returned", 

221 "content": { 

222 "application/json": { 

223 "example": { 

224 "success": True, 

225 "data": [ 

226 { 

227 "start_node": 1, 

228 "end_node": 2, 

229 "type": "example_type", 

230 "attributes": { 

231 "link_uuid": 1 

232 } 

233 } 

234 ], 

235 "message": "Link successfully returned", 

236 "error": None 

237 } 

238 } 

239 } 

240 }, 

241 **constellation_check_error, 

242 }, 

243 tags=["Link Base"] 

244) 

245async def read_link_by_uuid(constellation_uuid: str = Path(..., description="The UUID of the constellation to get the link from"), 

246 link_uuid: str = Path(..., description="The UUID of the link to get"), 

247 decode: Optional[DecodeType] = Query(default=None, description="Whether to decode the attributes or not. Options: 'xml', 'plain_text'"), 

248 in_filter: Optional[List[str]] = Query(default=None, description="Filter to include only specific attributes in the final results"), 

249 out_filter: Optional[List[str]] = Query(default=None, description="Filter to exclude specific attributes from the final results"), 

250 token: str = Depends(oauth2_scheme)): 

251 test = check_constellation_access(token, constellation_uuid, "READ") 

252 if test is not None: 

253 return test 

254 

255 if in_filter is None: 

256 in_filter = [] 

257 if out_filter is None: 

258 out_filter = [] 

259 

260 with driver.session() as session: 

261 result = session.run(""" 

262 MATCH (n {constellation_uuid: $constellation_uuid})-[r {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m {constellation_uuid: $constellation_uuid}) 

263 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

264 RETURN r, n, m, properties(r) AS attributes 

265 """, 

266 constellation_uuid=constellation_uuid, 

267 link_uuid=link_uuid, 

268 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

269 ) 

270 final_result: JSONValue = [{"start_node": record["n"]["node_uuid"], "end_node": record["m"]["node_uuid"], "type": record["r"].type, "attributes": { 

271 key: serialize_value(value) 

272 for key, value in record["attributes"].items() 

273 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

274 }} for record in result] 

275 driver.close() 

276 

277 if len(final_result) == 0: 

278 return generate_error_response( 

279 status_code=404, 

280 error_code="NOT_FOUND", 

281 error_message=f"Link {link_uuid} not found", 

282 message=f"Link {link_uuid} not found" 

283 ) 

284 

285 final_result = decode_attributes_inplace(final_result, decode) 

286 

287 return generate_response( 

288 status_code=200, 

289 data=final_result, 

290 message="Link successfully returned" 

291 ) 

292 

293# Create a new link 

294@router.post("/constellation/{constellation_uuid}/link", 

295 summary="Create a new link", 

296 description="Create a new link between two nodes.", 

297 response_description="The new link", 

298 responses={ 

299 200: { 

300 "description": "Link successfully created", 

301 "content": { 

302 "application/json": { 

303 "example": { 

304 "success": True, 

305 "data": [ 

306 { 

307 "start_node": 1, 

308 "end_node": 2, 

309 "type": "example_type", 

310 "attributes": { 

311 "link_uuid": 1 

312 } 

313 } 

314 ], 

315 "message": "Link successfully created", 

316 "error": None 

317 } 

318 } 

319 } 

320 }, 

321 **constellation_check_error, 

322 }, 

323 tags=["Link Base"] 

324) 

325async def create_link(constellation_uuid: str = Path(..., description="The UUID of the constellation to create the link in"), 

326 start_node: str = Body(..., description="The UUID of the start node"), 

327 end_node: str = Body(..., description="The UUID of the end node"), 

328 link_type: str = Body(..., description="The type of the link"), 

329 decode: Optional[DecodeType] = Query(default=None, description="Whether to decode the attributes or not. Options: 'xml', 'plain_text'"), 

330 in_filter: Optional[List[str]] = Query(default=None, description="Filter to include only specific attributes in the final results"), 

331 out_filter: Optional[List[str]] = Query(default=None, description="Filter to exclude specific attributes from the final results"), 

332 token: str = Depends(oauth2_scheme)): 

333 test = check_constellation_access(token, constellation_uuid, "WRITE") 

334 if test is not None: 

335 return test 

336 

337 if in_filter is None: 

338 in_filter = [] 

339 if out_filter is None: 

340 out_filter = [] 

341 

342 if link_type == "": 

343 return generate_error_response( 

344 status_code=400, 

345 error_code="BAD_REQUEST", 

346 error_message="Link type cannot be empty", 

347 message="Link type cannot be empty" 

348 ) 

349 

350 link_type = link_type.replace("`", "``") # Replace backticks with double backticks for Neo4j compatibility 

351 

352 with driver.session() as session: 

353 # Check if n and m exists 

354 result = session.run(""" 

355 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node}), (m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

356 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

357 RETURN n, m 

358 """, 

359 constellation_uuid=constellation_uuid, 

360 start_node=start_node, 

361 end_node=end_node, 

362 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

363 ) 

364 records = [record for record in result] 

365 if len(records) == 0 or records[0]["n"] is None or records[0]["m"] is None: 

366 return generate_error_response( 

367 status_code=404, 

368 error_code="NOT_FOUND", 

369 error_message=f"Node {start_node} or Node {end_node} not found", 

370 message=f"Node {start_node} or Node {end_node} not found" 

371 ) 

372 

373 # Check if the link already exists 

374 existing_links = session.run(""" 

375 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node})-[r]->(m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

376 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

377 RETURN r 

378 """, 

379 constellation_uuid=constellation_uuid, 

380 start_node=start_node, 

381 end_node=end_node, 

382 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

383 ) 

384 if existing_links: 

385 for link in existing_links: 

386 if link["r"].type == link_type: 

387 return generate_error_response( 

388 status_code=400, 

389 error_code="LINK_ALREADY_EXISTS", 

390 error_message=f"Link {link_type} already exists between the two nodes", 

391 message=f"Link {link_type} already exists between the two nodes" 

392 ) 

393 

394 # Create the link 

395 result = session.run(""" 

396 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node}), (m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

397 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

398 CREATE (n)-[r:$($link_type) {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m) 

399 SET r.created_at = datetime() 

400 SET r.updated_at = datetime() 

401 SET r.word_count = 0 

402 SET n.link_count = COUNT { (n)--() } 

403 SET m.link_count = CASE 

404 WHEN n = m THEN n.link_count 

405 ELSE COUNT { (m)--() } 

406 END 

407 RETURN n,m,r, properties(r) AS attributes 

408 """, 

409 constellation_uuid=constellation_uuid, 

410 start_node=start_node, 

411 end_node=end_node, 

412 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

413 link_type=link_type, 

414 link_uuid=str(uuid4()), 

415 ) 

416 final_result: JSONValue = [{"start_node": record["n"]["node_uuid"], "end_node": record["m"]["node_uuid"], "type": record["r"].type, "attributes": { 

417 key: serialize_value(value) 

418 for key, value in record["attributes"].items() 

419 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

420 }} for record in result] 

421 driver.close() 

422 

423 final_result = decode_attributes_inplace(final_result, decode) 

424 

425 # Send a notification to the SSE server 

426 notification_result = send_constellation_notification( 

427 constellation_uuid=constellation_uuid, 

428 data=final_result, 

429 message="Link created", 

430 data_type="LINK_CREATED", 

431 token=token 

432 ) 

433 if not notification_result: 

434 logger.warning("Create link: Error sending notification to SSE server") 

435 

436 return generate_response( 

437 status_code=200, 

438 data=final_result, 

439 message="Link successfully created" 

440 ) 

441 

442# Delete a link 

443@router.delete("/constellation/{constellation_uuid}/link/{link_uuid}", 

444 summary="Delete a link", 

445 description="Delete a link between two nodes.", 

446 response_description="The deleted link", 

447 responses={ 

448 200: { 

449 "description": "Link successfully deleted", 

450 "content": { 

451 "application/json": { 

452 "example": { 

453 "success": True, 

454 "data": None, 

455 "message": "Link successfully deleted", 

456 "error": None 

457 } 

458 } 

459 } 

460 }, 

461 **constellation_check_error, 

462 }, 

463 tags=["Link Base"] 

464) 

465async def delete_link(constellation_uuid: str = Path(..., description="The UUID of the constellation to delete the link in"), 

466 link_uuid: str = Path(..., description="The UUID of the link to delete"), 

467 token: str = Depends(oauth2_scheme)): 

468 test = check_constellation_access(token, constellation_uuid, "WRITE") 

469 if test is not None: 

470 return test 

471 

472 with driver.session() as session: 

473 # Check if the link exists 

474 result = session.run(""" 

475 MATCH (n)-[r {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m) 

476 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

477 RETURN n,m,r 

478 """, 

479 constellation_uuid=constellation_uuid, 

480 link_uuid=link_uuid, 

481 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

482 ) 

483 if len([record for record in result]) != 1: 

484 return generate_error_response( 

485 status_code=404, 

486 error_code="NOT_FOUND", 

487 error_message="Link not found", 

488 message="Link not found" 

489 ) 

490 

491 result = session.run(""" 

492 MATCH (n)-[r {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m) 

493 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

494 DELETE r 

495 WITH n, m 

496 SET n.link_count = COUNT { (n)--() } 

497 SET m.link_count = CASE 

498 WHEN n = m THEN n.link_count 

499 ELSE COUNT { (m)--() } 

500 END 

501 """, 

502 constellation_uuid=constellation_uuid, 

503 link_uuid=link_uuid, 

504 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

505 ) 

506 driver.close() 

507 

508 # Send a notification to the SSE server 

509 notification_result = send_constellation_notification( 

510 constellation_uuid=constellation_uuid, 

511 data={ 

512 "link_uuid": link_uuid, 

513 "message": "Link deleted successfully" 

514 }, 

515 message="Link deleted", 

516 data_type="LINK_DELETED", 

517 token=token 

518 ) 

519 if not notification_result: 

520 logger.warning("Delete link: Error sending notification to SSE server") 

521 

522 return generate_response( 

523 status_code=200, 

524 data=None, 

525 message="Link successfully deleted" 

526 ) 

527 

528# Update a link 

529@router.patch("/constellation/{constellation_uuid}/link/{link_uuid}", 

530 summary="Update a link", 

531 description="Update a link between two nodes.", 

532 response_description="The updated link", 

533 responses={ 

534 200: { 

535 "description": "Link successfully updated", 

536 "content": { 

537 "application/json": { 

538 "example": { 

539 "success": True, 

540 "data": [ 

541 { 

542 "start_node": 1, 

543 "end_node": 2, 

544 "type": "example_type", 

545 "attributes": { 

546 "link_uuid": 1 

547 } 

548 } 

549 ], 

550 "message": "Link successfully updated", 

551 "error": None 

552 } 

553 } 

554 } 

555 }, 

556 **constellation_check_error, 

557 }, 

558 tags=["Link Base"] 

559) 

560async def update_link(constellation_uuid: str = Path(..., description="The UUID of the constellation to update the link in"), 

561 link_uuid: str = Path(..., description="The UUID of the link to update"), 

562 new_start_node: Optional[str] = Body(None, description="The new UUID of the start node (optional)"), 

563 new_end_node: Optional[str] = Body(None, description="The new UUID of the end node (optional)"), 

564 new_link_type: Optional[str] = Body(None, description="The new type of the link (optional)"), 

565 decode: Optional[DecodeType] = Query(default=None, description="Whether to decode the attributes or not. Options: 'xml', 'plain_text'"), 

566 in_filter: Optional[List[str]] = Query(default=None, description="Filter to include only specific attributes in the final results"), 

567 out_filter: Optional[List[str]] = Query(default=None, description="Filter to exclude specific attributes from the final results"), 

568 token: str = Depends(oauth2_scheme)): 

569 test = check_constellation_access(token, constellation_uuid, "WRITE") 

570 if test is not None: 

571 return test 

572 

573 if in_filter is None: 

574 in_filter = [] 

575 if out_filter is None: 

576 out_filter = [] 

577 

578 if (new_start_node is None or new_start_node == "") and \ 

579 (new_end_node is None or new_end_node == "") and \ 

580 (new_link_type is None or new_link_type == ""): 

581 return generate_error_response( 

582 status_code=400, 

583 error_code="BAD_REQUEST", 

584 error_message="No changes provided", 

585 message="No changes provided" 

586 ) 

587 

588 # Delete the old link and create a new one 

589 with driver.session() as session: 

590 # Check if the link exists 

591 result = session.run(""" 

592 MATCH (n)-[r {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m) 

593 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

594 RETURN n,m,r, properties(r) AS attributes 

595 """, 

596 constellation_uuid=constellation_uuid, 

597 link_uuid=link_uuid, 

598 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

599 ) 

600 check_result = [record for record in result] 

601 if len(check_result) == 0: 

602 return generate_error_response( 

603 status_code=404, 

604 error_code="NOT_FOUND", 

605 error_message="Link not found", 

606 message="Link not found" 

607 ) 

608 

609 start_node = check_result[0]["n"]["node_uuid"] 

610 end_node = check_result[0]["m"]["node_uuid"] 

611 link_type = check_result[0]["r"].type 

612 link_attributes = check_result[0]["attributes"] 

613 

614 logger.debug(f"Old link data: start_node={start_node}, end_node={end_node}, link_type={link_type}, attributes={link_attributes}") 

615 

616 # Check if the new start and end nodes exist and if they don't already have a link of the same type 

617 check_start_node = start_node if new_start_node is None or new_start_node == "" else new_start_node 

618 check_end_node = end_node if new_end_node is None or new_end_node == "" else new_end_node 

619 check_link_type = link_type if new_link_type is None or new_link_type == "" else new_link_type 

620 

621 # Check the start and end nodes exist 

622 result = session.run(""" 

623 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node}), (m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

624 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

625 RETURN n, m 

626 """, 

627 constellation_uuid=constellation_uuid, 

628 start_node=check_start_node, 

629 end_node=check_end_node, 

630 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

631 ) 

632 records = [record for record in result] 

633 if len(records) == 0 or records[0]["n"] is None or records[0]["m"] is None: 

634 return generate_error_response( 

635 status_code=404, 

636 error_code="NODE_NOT_FOUND", 

637 error_message=f"Node {check_start_node} or Node {check_end_node} not found", 

638 message=f"Node {check_start_node} or Node {check_end_node} not found" 

639 ) 

640 # Check if the link already exists 

641 existing_links = session.run(""" 

642 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node})-[r]->(m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

643 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

644 RETURN r 

645 """, 

646 constellation_uuid=constellation_uuid, 

647 start_node=check_start_node, 

648 end_node=check_end_node, 

649 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

650 ) 

651 if existing_links: 

652 for link in existing_links: 

653 if link["r"].type == check_link_type and link["r"]["link_uuid"] != link_uuid: 

654 return generate_error_response( 

655 status_code=400, 

656 error_code="LINK_ALREADY_EXISTS", 

657 error_message=f"Link {check_link_type} already exists between the two nodes", 

658 message=f"Link {check_link_type} already exists between the two nodes" 

659 ) 

660 

661 # Delete the link 

662 result = session.run(""" 

663 MATCH (n)-[r {constellation_uuid: $constellation_uuid, link_uuid: $link_uuid}]->(m) 

664 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

665 DELETE r 

666 WITH n, m 

667 SET n.link_count = COUNT { (n)--() } 

668 SET m.link_count = CASE 

669 WHEN n = m THEN n.link_count 

670 ELSE COUNT { (m)--() } 

671 END 

672 """, 

673 constellation_uuid=constellation_uuid, 

674 link_uuid=link_uuid, 

675 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

676 ) 

677 

678 if new_start_node is None or new_start_node == "": 

679 new_start_node = start_node 

680 if new_end_node is None or new_end_node == "": 

681 new_end_node = end_node 

682 if new_link_type is None or new_link_type == "": 

683 new_link_type = link_type 

684 

685 # Recreate the link with the new data 

686 result = session.run(""" 

687 MATCH (n {constellation_uuid: $constellation_uuid, node_uuid: $start_node}), (m {constellation_uuid: $constellation_uuid, node_uuid: $end_node}) 

688 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(m)) 

689 CREATE (n)-[r:$($new_link_type) $link_attributes]->(m) 

690 SET r.created_at = coalesce(r.created_at, datetime()) 

691 SET r.updated_at = datetime() 

692 SET n.link_count = COUNT { (n)--() } 

693 SET m.link_count = CASE 

694 WHEN n = m THEN n.link_count 

695 ELSE COUNT { (m)--() } 

696 END 

697 RETURN n,m,r, properties(r) AS attributes 

698 """, 

699 constellation_uuid=constellation_uuid, 

700 start_node=new_start_node, 

701 end_node=new_end_node, 

702 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

703 new_link_type=new_link_type, 

704 link_attributes=link_attributes, 

705 ) 

706 final_result: JSONValue = [{"start_node": record["n"]["node_uuid"], "end_node": record["m"]["node_uuid"], "type": record["r"].type, "attributes": { 

707 key: serialize_value(value) 

708 for key, value in record["attributes"].items() 

709 if (len(in_filter) == 0 or key in in_filter) and (len(out_filter) == 0 or key not in out_filter) 

710 }} for record in result] 

711 driver.close() 

712 

713 final_result = decode_attributes_inplace(final_result, decode) 

714 

715 # Send a notification to the SSE server 

716 notification_result = send_constellation_notification( 

717 constellation_uuid=constellation_uuid, 

718 data=final_result, 

719 message="Link updated", 

720 data_type="LINK_UPDATED", 

721 token=token 

722 ) 

723 if not notification_result: 

724 logger.warning("Update link: Error sending notification to SSE server") 

725 

726 return generate_response( 

727 status_code=200, 

728 data=final_result, 

729 message="Link successfully updated" 

730 )