Coverage for app/routers/links/attribute.py: 84%

171 statements  

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

1from app.config.config import NEO4J_HOST, NEO4J_PORT, NEO4J_USER, NEO4J_PASSWORD, RESERVED_LINK_ATTRIBUTE_NAMES, RESERVED_NODE_LABEL_NAMES 

2from fastapi import APIRouter 

3from fastapi import Depends, Path, Query, Body 

4from fastapi.security import OAuth2PasswordBearer 

5from neo4j import GraphDatabase, basic_auth 

6from loguru import logger 

7 

8from app.utils.check_token import check_constellation_access, constellation_check_error 

9from app.utils.send_sse_notification import send_constellation_notification 

10from app.utils.response_format import generate_response, generate_error_response 

11from app.utils.decode_ydoc import decode_ydoc, DecodeType, decode_attributes_inplace 

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

13 

14router = APIRouter(tags=["Link Attribute"]) 

15 

16# Define the OAuth2 schema to get the JWT 

17oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 

18 

19# Define the Neo4j driver 

20driver = GraphDatabase.driver( 

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

22 auth=basic_auth(NEO4J_USER, NEO4J_PASSWORD)) 

23 

24# Get all attributes of a specific link 

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

26 summary="Get all attributes of a specific link", 

27 description="Get all attributes of the link specified by its UUID.", 

28 response_description="All attributes of the link", 

29 responses={ 

30 200: { 

31 "description": "All attributes successfully returned", 

32 "content": { 

33 "application/json": { 

34 "example": { 

35 "success": True, 

36 "data": [ 

37 ["attribute1", "attribute2", "attribute3"] 

38 ], 

39 "message": "Attributes retrieved", 

40 "error": None 

41 } 

42 } 

43 } 

44 }, 

45 **constellation_check_error, 

46 } 

47) 

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

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

50 token: str = Depends(oauth2_scheme)): 

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

52 if test is not None: 

53 return test 

54 

55 with driver.session() as session: 

56 result = session.run(""" 

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

58 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)) 

59 RETURN keys(r) 

60 """, 

61 constellation_uuid=constellation_uuid, 

62 link_uuid=link_uuid, 

63 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

64 ) 

65 

66 final_result: JSONValue = [record["keys(r)"] for record in result] 

67 

68 if len(final_result) == 0: 

69 return generate_error_response( 

70 status_code=404, 

71 error_code="NOT_FOUND", 

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

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

74 ) 

75 driver.close() 

76 

77 return generate_response( 

78 status_code=200, 

79 data=final_result, 

80 message="Attributes retrieved" 

81 ) 

82 

83# Get the value of a specific attribute of a specific link 

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

85 summary="Get the value of a specific attribute of a specific link", 

86 description="Get the value of a specific attribute of the link specified by its UUID.", 

87 response_description="The value of the attribute of the link", 

88 responses={ 

89 200: { 

90 "description": "The value of the attribute successfully returned", 

91 "content": { 

92 "application/json": { 

93 "example": { 

94 "success": True, 

95 "data": [ 

96 "value1", 

97 "value2", 

98 "value3" 

99 ], 

100 "message": "Attribute retrieved", 

101 "error": None 

102 } 

103 } 

104 } 

105 }, 

106 **constellation_check_error, 

107 } 

108) 

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

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

111 attribute: str = Path(..., description="The attribute to get the value of"), 

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

113 token: str = Depends(oauth2_scheme)): 

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

115 if test is not None: 

116 return test 

117 

118 with driver.session() as session: 

119 # check if the link exists 

120 result = session.run(""" 

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

122 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)) 

123 RETURN r 

124 """, 

125 constellation_uuid=constellation_uuid, 

126 link_uuid=link_uuid, 

127 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

128 ) 

129 if len(result.data()) == 0: 

130 return generate_error_response( 

131 status_code=404, 

132 error_code="NOT_FOUND", 

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

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

135 ) 

136 

137 result = session.run(""" 

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

139 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)) 

140 RETURN r[$attribute] AS attribute 

141 """, 

142 constellation_uuid=constellation_uuid, 

143 link_uuid=link_uuid, 

144 attribute=attribute, 

145 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

146 ) 

147 final_result: JSONValue = [serialize_value(record["attribute"]) for record in result] 

148 driver.close() 

149 

150 if final_result[0] is None: 

151 return generate_error_response( 

152 status_code=404, 

153 error_code="NOT_FOUND", 

154 error_message=f"Attribute {attribute} not found in link {link_uuid}", 

155 message=f"Attribute {attribute} not found in link {link_uuid}" 

156 ) 

157 

158 if decode: 

159 # Decode the attributes if requested 

160 final_result = [decode_ydoc(value, decode == DecodeType.PLAIN_TEXT) if isinstance(value, str) else value for value in final_result] 

161 

162 return generate_response( 

163 status_code=200, 

164 data=final_result, 

165 message="Attribute retrieved" 

166 ) 

167 

168# Set the value of a specific attribute of a specific link 

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

170 summary="Set the value of a specific attribute of a specific link", 

171 description="Set the value of a specific attribute of the link specified by its UUID.", 

172 response_description="The updated link", 

173 responses={ 

174 200: { 

175 "description": "The attribute successfully updated", 

176 "content": { 

177 "application/json": { 

178 "example": { 

179 "success": True, 

180 "data": [ 

181 { 

182 "start_node": 1, 

183 "end_node": 2, 

184 "type": "LINK", 

185 "attributes": { 

186 "link_uuid": 1, 

187 "attribute": "value" 

188 } 

189 } 

190 ], 

191 "message": "Attribute updated", 

192 "error": None 

193 } 

194 } 

195 } 

196 }, 

197 **constellation_check_error, 

198 } 

199) 

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

201 link_uuid: str = Path(..., description="The UUID of the link to set the attribute of"), 

202 attribute: str = Path(..., description="The attribute to set the value of"), 

203 value: str = Body(..., description="The value to set", embed=True), 

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

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

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

207 token: str = Depends(oauth2_scheme)): 

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

209 if test is not None: 

210 return test 

211 

212 if in_filter is None: 

213 in_filter = [] 

214 if out_filter is None: 

215 out_filter = [] 

216 

217 # Validate the attribute name 

218 if attribute in RESERVED_LINK_ATTRIBUTE_NAMES: 

219 return generate_error_response( 

220 status_code=400, 

221 error_code="BAD_REQUEST", 

222 error_message=f"Attribute {attribute} cannot be set", 

223 message=f"Attribute {attribute} cannot be set" 

224 ) 

225 

226 with driver.session() as session: 

227 # check if the link exists 

228 result = session.run(""" 

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

230 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)) 

231 RETURN r 

232 """, 

233 constellation_uuid=constellation_uuid, 

234 link_uuid=link_uuid, 

235 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

236 ) 

237 if len(result.data()) == 0: 

238 return generate_error_response( 

239 status_code=404, 

240 error_code="NOT_FOUND", 

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

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

243 ) 

244 

245 # Check if the attribute exists 

246 result = session.run(""" 

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

248 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)) 

249 RETURN r[$attribute] AS attribute, coalesce(r.word_count, -1) AS word_count, properties(r) AS currrentAttributes 

250 """, 

251 constellation_uuid=constellation_uuid, 

252 link_uuid=link_uuid, 

253 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

254 attribute=attribute 

255 ) 

256 records = [record for record in result] 

257 oldAttributeValue = [record["attribute"] for record in records][0] 

258 if oldAttributeValue is None: 

259 return generate_error_response( 

260 status_code=404, 

261 error_code="NOT_FOUND", 

262 error_message=f"Attribute {attribute} not found in link {link_uuid}", 

263 message=f"Attribute {attribute} not found in link {link_uuid}" 

264 ) 

265 

266 # Compute the new word count 

267 currentWordCount = [record["word_count"] for record in records][0] 

268 currentAttributes = [record["currrentAttributes"] for record in records][0] 

269 

270 if currentWordCount == -1: # word_count has not been computed yet 

271 currentWordCount = 0 

272 for key in currentAttributes.keys(): # Add all current attributes word count 

273 if key not in RESERVED_LINK_ATTRIBUTE_NAMES: 

274 currentWordCount += len(decode_ydoc(currentAttributes[key], True).split()) 

275 

276 oldAttributeValueWordCount = len(decode_ydoc(oldAttributeValue, True).split()) if isinstance(oldAttributeValue, str) else 0 

277 newAttributeValueWordCount = len(decode_ydoc(value, True).split()) 

278 wordCountDifference = newAttributeValueWordCount - oldAttributeValueWordCount 

279 currentWordCount += wordCountDifference 

280 

281 result = session.run(""" 

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

283 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)) 

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

285 SET r.updated_at = datetime() 

286 SET r.word_count = $word_count 

287 SET r[$attribute] = $value 

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

289 """, 

290 constellation_uuid=constellation_uuid, 

291 link_uuid=link_uuid, 

292 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

293 word_count=currentWordCount, 

294 attribute=attribute, 

295 value=value 

296 ) 

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

298 key: serialize_value(value) 

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

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

301 }} for record in result] 

302 driver.close() 

303 

304 final_result = decode_attributes_inplace(final_result, decode) 

305 

306 # Send a notification to the SSE server 

307 notification_result = send_constellation_notification( 

308 constellation_uuid=constellation_uuid, 

309 data=final_result, 

310 message="Link attribute updated", 

311 data_type="LINK_ATTRIBUTE_UPDATED", 

312 token=token 

313 ) 

314 if not notification_result: 

315 logger.warning("Set link attribute: Error sending notification to SSE server") 

316 

317 return generate_response( 

318 status_code=200, 

319 data=final_result, 

320 message="Attribute updated" 

321 ) 

322 

323# Delete a specific attribute of a specific link 

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

325 summary="Delete a specific attribute of a specific link", 

326 description="Delete a specific attribute of the link specified by its UUID.", 

327 response_description="The updated link", 

328 responses={ 

329 200: { 

330 "description": "The attribute successfully deleted", 

331 "content": { 

332 "application/json": { 

333 "example": { 

334 "success": True, 

335 "data": [ 

336 { 

337 "start_node": 1, 

338 "end_node": 2, 

339 "type": "LINK", 

340 "attributes": { 

341 "link_uuid": 1, 

342 "attribute": "value" 

343 } 

344 } 

345 ], 

346 "message": "Attribute deleted", 

347 "error": None 

348 } 

349 } 

350 } 

351 }, 

352 **constellation_check_error, 

353 } 

354) 

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

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

357 attribute: str = Path(..., description="The attribute to delete"), 

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

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

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

361 token: str = Depends(oauth2_scheme)): 

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

363 if test is not None: 

364 return test 

365 

366 if in_filter is None: 

367 in_filter = [] 

368 if out_filter is None: 

369 out_filter = [] 

370 

371 # Validate the attribute name 

372 if attribute in RESERVED_LINK_ATTRIBUTE_NAMES: 

373 return generate_error_response( 

374 status_code=400, 

375 error_code="BAD_REQUEST", 

376 error_message=f"Attribute {attribute} cannot be deleted", 

377 message=f"Attribute {attribute} cannot be deleted" 

378 ) 

379 

380 with driver.session() as session: 

381 # check if the link exists 

382 result = session.run(""" 

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

384 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)) 

385 RETURN r 

386 """, 

387 constellation_uuid=constellation_uuid, 

388 link_uuid=link_uuid, 

389 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

390 ) 

391 if len(result.data()) == 0: 

392 return generate_error_response( 

393 status_code=404, 

394 error_code="NOT_FOUND", 

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

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

397 ) 

398 

399 # Check if the attribute exists 

400 result = session.run(""" 

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

402 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)) 

403 RETURN r[$attribute] AS attribute, coalesce(r.word_count, -1) AS word_count, properties(r) AS currrentAttributes 

404 """, 

405 constellation_uuid=constellation_uuid, 

406 link_uuid=link_uuid, 

407 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

408 attribute=attribute 

409 ) 

410 records = [record for record in result] 

411 oldAttributeValue = [record["attribute"] for record in records][0] 

412 if oldAttributeValue is None: 

413 return generate_error_response( 

414 status_code=404, 

415 error_code="NOT_FOUND", 

416 error_message=f"Attribute {attribute} not found in link {link_uuid}", 

417 message=f"Attribute {attribute} not found in link {link_uuid}" 

418 ) 

419 

420 # Compute the new word count 

421 currentWordCount = [record["word_count"] for record in records][0] 

422 currentAttributes = [record["currrentAttributes"] for record in records][0] 

423 

424 if currentWordCount == -1: # word_count has not been computed yet 

425 currentWordCount = 0 

426 for key in currentAttributes.keys(): # Add all current attributes word count 

427 if key not in RESERVED_LINK_ATTRIBUTE_NAMES: 

428 currentWordCount += len(decode_ydoc(currentAttributes[key], True).split()) 

429 

430 oldAttributeValueWordCount = len(decode_ydoc(oldAttributeValue, True).split()) if isinstance(oldAttributeValue, str) else 0 

431 currentWordCount -= oldAttributeValueWordCount 

432 

433 result = session.run(""" 

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

435 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)) 

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

437 SET r.updated_at = datetime() 

438 SET r.word_count = $word_count 

439 REMOVE r[$attribute] 

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

441 """, 

442 constellation_uuid=constellation_uuid, 

443 link_uuid=link_uuid, 

444 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

445 word_count=currentWordCount, 

446 attribute=attribute 

447 ) 

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

449 key: serialize_value(value) 

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

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

452 }} for record in result] 

453 driver.close() 

454 

455 final_result = decode_attributes_inplace(final_result, decode) 

456 

457 # Send a notification to the SSE server 

458 notification_result = send_constellation_notification( 

459 constellation_uuid=constellation_uuid, 

460 data=final_result, 

461 message="Link attribute deleted", 

462 data_type="LINK_ATTRIBUTE_DELETED", 

463 token=token 

464 ) 

465 if not notification_result: 

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

467 

468 return generate_response( 

469 status_code=200, 

470 data=final_result, 

471 message="Attribute deleted" 

472 ) 

473 

474# Get all links with a specific attribute 

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

476 summary="Get all links with a specific attribute", 

477 description="Get all links with the attribute specified.", 

478 response_description="All links with the attribute", 

479 responses={ 

480 200: { 

481 "description": "All links with the attribute successfully returned", 

482 "content": { 

483 "application/json": { 

484 "example": { 

485 "success": True, 

486 "data": [ 

487 { 

488 "start_node": 1, 

489 "end_node": 2, 

490 "type": "LINK", 

491 "attributes": { 

492 "link_uuid": 1, 

493 "attribute": "value" 

494 } 

495 } 

496 ], 

497 "message": "Links retrieved", 

498 "error": None 

499 } 

500 } 

501 } 

502 }, 

503 **constellation_check_error, 

504 } 

505) 

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

507 attribute: str = Path(..., description="The attribute to search for"), 

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

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

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

511 token: str = Depends(oauth2_scheme)): 

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

513 if test is not None: 

514 return test 

515 

516 if in_filter is None: 

517 in_filter = [] 

518 if out_filter is None: 

519 out_filter = [] 

520 

521 with driver.session() as session: 

522 result = session.run(""" 

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

524 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)) AND r[$attribute] IS NOT NULL 

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

526 """, 

527 constellation_uuid=constellation_uuid, 

528 attribute=attribute, 

529 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

530 ) 

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

532 key: serialize_value(value) 

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

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

535 }} for record in result] 

536 driver.close() 

537 

538 final_result = decode_attributes_inplace(final_result, decode) 

539 

540 return generate_response( 

541 status_code=200, 

542 data=final_result, 

543 message="Links retrieved" 

544 ) 

545 

546# Add a new attribute to a link 

547@router.post("/constellation/{constellation_uuid}/link/{link_uuid}/attribute/{attribute}", 

548 summary="Add a new attribute to a link", 

549 description="Add a new attribute to the link specified by its UUID.", 

550 response_description="The updated link", 

551 responses={ 

552 200: { 

553 "description": "The attribute successfully added", 

554 "content": { 

555 "application/json": { 

556 "example": { 

557 "success": True, 

558 "data": [ 

559 { 

560 "start_node": 1, 

561 "end_node": 2, 

562 "type": "LINK", 

563 "attributes": { 

564 "link_uuid": 1, 

565 "attribute": "value" 

566 } 

567 } 

568 ], 

569 "message": "Attribute added", 

570 "error": None 

571 } 

572 } 

573 } 

574 }, 

575 **constellation_check_error, 

576 } 

577) 

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

579 link_uuid: str = Path(..., description="The UUID of the link to add the attribute to"), 

580 attribute: str = Path(..., description="The attribute to add"), 

581 value: str = Body(..., description="The value of the attribute", embed=True), 

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

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

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

585 token: str = Depends(oauth2_scheme)): 

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

587 if test is not None: 

588 return test 

589 

590 if in_filter is None: 

591 in_filter = [] 

592 if out_filter is None: 

593 out_filter = [] 

594 

595 # Validate the attribute name 

596 if attribute in RESERVED_LINK_ATTRIBUTE_NAMES: 

597 return generate_error_response( 

598 status_code=400, 

599 error_code="BAD_REQUEST", 

600 error_message=f"Attribute {attribute} cannot be added", 

601 message=f"Attribute {attribute} cannot be added" 

602 ) 

603 

604 with driver.session() as session: 

605 # check if the link exists 

606 result = session.run(""" 

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

608 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)) 

609 RETURN r 

610 """, 

611 constellation_uuid=constellation_uuid, 

612 link_uuid=link_uuid, 

613 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

614 ) 

615 if len(result.data()) == 0: 

616 return generate_error_response( 

617 status_code=404, 

618 error_code="NOT_FOUND", 

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

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

621 ) 

622 

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

624 

625 # Check if the attribute already exists 

626 result = session.run(""" 

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

628 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)) 

629 RETURN r[$attribute] AS attribute, coalesce(r.word_count, -1) AS word_count, properties(r) AS currrentAttributes 

630 """, 

631 constellation_uuid=constellation_uuid, 

632 link_uuid=link_uuid, 

633 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

634 attribute=attribute 

635 ) 

636 records = [record for record in result] 

637 if [record["attribute"] for record in records][0] is not None: 

638 return generate_error_response( 

639 status_code=400, 

640 error_code="BAD_REQUEST", 

641 error_message=f"Attribute {attribute} already exists for link {link_uuid}", 

642 message=f"Attribute {attribute} already exists for link {link_uuid}" 

643 ) 

644 

645 # Compute the new word count 

646 currentWordCount = [record["word_count"] for record in records][0] 

647 currentAttributes = [record["currrentAttributes"] for record in records][0] 

648 

649 if currentWordCount == -1: # word_count has not been computed yet 

650 currentWordCount = 0 

651 for key in currentAttributes.keys(): # Add all current attributes word count 

652 if key not in RESERVED_LINK_ATTRIBUTE_NAMES: 

653 currentWordCount += len(decode_ydoc(currentAttributes[key], True).split()) 

654 

655 newAttributeValueWordCount = len(decode_ydoc(value, True).split()) 

656 currentWordCount += newAttributeValueWordCount 

657 

658 result = session.run(""" 

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

660 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)) 

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

662 SET r.updated_at = datetime() 

663 SET r.word_count = $word_count 

664 SET r[$attribute] = $value 

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

666 """, 

667 constellation_uuid=constellation_uuid, 

668 link_uuid=link_uuid, 

669 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

670 word_count=currentWordCount, 

671 attribute=attribute, 

672 value=value 

673 ) 

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

675 key: serialize_value(value) 

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

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

678 }} for record in result] 

679 driver.close() 

680 

681 final_result = decode_attributes_inplace(final_result, decode) 

682 

683 # Send a notification to the SSE server 

684 notification_result = send_constellation_notification( 

685 constellation_uuid=constellation_uuid, 

686 data=final_result, 

687 message="Link attribute added", 

688 data_type="LINK_ATTRIBUTE_ADDED", 

689 token=token 

690 ) 

691 if not notification_result: 

692 logger.warning("Add link attribute: Error sending notification to SSE server") 

693 

694 return generate_response( 

695 status_code=200, 

696 data=final_result, 

697 message="Attribute added" 

698 )