Coverage for app/routers/nodes/attribute.py: 85%

186 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_NODE_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=["Node 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 

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

26 summary="Get all attributes in the constellation", 

27 description="Get all attributes in the constellation specified by its UUID", 

28 response_description="List of attributes", 

29 responses={ 

30 200: { 

31 "description": "List of attributes", 

32 "content": { 

33 "application/json": { 

34 "example": { 

35 "success": True, 

36 "data": [ 

37 "attribute1", 

38 "attribute2", 

39 "attribute3" 

40 ], 

41 "message": "Attributes retrieved", 

42 "error": None 

43 } 

44 } 

45 } 

46 }, 

47 **constellation_check_error 

48 } 

49) 

50async def read_attributes(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

51 token: str = Depends(oauth2_scheme)): 

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

53 if test is not None: 

54 return test 

55 

56 with driver.session() as session: 

57 # Get all atributes of the nodes and links from the current constellation 

58 result = session.run(""" 

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

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

61 UNWIND keys(n) + keys(r) AS propertyKey 

62 RETURN DISTINCT propertyKey 

63 """, 

64 constellation_uuid=constellation_uuid, 

65 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

66 ) 

67 final_result: JSONValue = [record["propertyKey"] for record in result] 

68 driver.close() 

69 

70 return generate_response( 

71 status_code=200, 

72 data=final_result, 

73 message="Attributes retrieved" 

74 ) 

75 

76# Get all attributes of a specific node 

77@router.get("/constellation/{constellation_uuid}/node/{node_uuid}/attributes", 

78 summary="Get all attributes of a node", 

79 description="Get all attributes of the node specified by its UUID", 

80 response_description="List of attributes", 

81 responses={ 

82 200: { 

83 "description": "List of attributes", 

84 "content": { 

85 "application/json": { 

86 "example": { 

87 "success": True, 

88 "data": [ 

89 "attribute1", 

90 "attribute2", 

91 "attribute3" 

92 ], 

93 "message": "Attributes retrieved", 

94 "error": None 

95 } 

96 } 

97 } 

98 }, 

99 **constellation_check_error 

100 } 

101) 

102async def read_node_attributes(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

103 node_uuid: str = Path(..., title="The UUID of the node"), 

104 token: str = Depends(oauth2_scheme)): 

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

106 if test is not None: 

107 return test 

108 

109 with driver.session() as session: 

110 # check if the node exists 

111 result = session.run(""" 

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

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

114 RETURN n 

115 """, 

116 constellation_uuid=constellation_uuid, 

117 node_uuid=node_uuid, 

118 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

119 ) 

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

121 return generate_error_response( 

122 status_code=404, 

123 error_code="NOT_FOUND", 

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

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

126 ) 

127 

128 result = session.run(""" 

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

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

131 RETURN keys(n) 

132 """, 

133 constellation_uuid=constellation_uuid, 

134 node_uuid=node_uuid, 

135 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

136 ) 

137 final_result: JSONValue = [record["keys(n)"] for record in result] 

138 driver.close() 

139 

140 return generate_response( 

141 status_code=200, 

142 data=final_result, 

143 message="Attributes retrieved" 

144 ) 

145 

146# Get the value of a specific attribute of a specific node 

147@router.get("/constellation/{constellation_uuid}/node/{node_uuid}/attribute/{attribute}", 

148 summary="Get the value of a specific attribute of a node", 

149 description="Get the value of a specific attribute of the node specified by its UUID", 

150 response_description="Value of the attribute", 

151 responses={ 

152 200: { 

153 "description": "Value of the attribute", 

154 "content": { 

155 "application/json": { 

156 "example": { 

157 "success": True, 

158 "data": [ 

159 "value" 

160 ], 

161 "message": "Attribute retrieved", 

162 "error": None 

163 } 

164 } 

165 } 

166 }, 

167 **constellation_check_error 

168 } 

169) 

170async def read_node_attribute(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

171 node_uuid: str = Path(..., title="The UUID of the node"), 

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

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

174 token: str = Depends(oauth2_scheme)): 

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

176 if test is not None: 

177 return test 

178 

179 with driver.session() as session: 

180 # check if the node exists 

181 result = session.run(""" 

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

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

184 RETURN n 

185 """, 

186 constellation_uuid=constellation_uuid, 

187 node_uuid=node_uuid, 

188 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

189 ) 

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

191 return generate_error_response( 

192 status_code=404, 

193 error_code="NOT_FOUND", 

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

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

196 ) 

197 

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

199 

200 result = session.run(""" 

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

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

203 RETURN n[$attribute] AS attribute 

204 """, 

205 constellation_uuid=constellation_uuid, 

206 node_uuid=node_uuid, 

207 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

208 attribute=attribute 

209 ) 

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

211 driver.close() 

212 

213 if final_result[0] is None: 

214 return generate_error_response( 

215 status_code=404, 

216 error_code="NOT_FOUND", 

217 error_message=f"Attribute {attribute} not found for node {node_uuid}", 

218 message=f"Attribute {attribute} not found for node {node_uuid}" 

219 ) 

220 

221 if decode: 

222 # Decode the attributes if requested 

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

224 

225 return generate_response( 

226 status_code=200, 

227 data=final_result, 

228 message="Attribute retrieved" 

229 ) 

230 

231# Set the value of a specific attribute of a specific node 

232@router.patch("/constellation/{constellation_uuid}/node/{node_uuid}/attribute/{attribute}", 

233 summary="Set the value of a specific attribute of a node", 

234 description="Set the value of a specific attribute of the node specified by its UUID", 

235 response_description="Node with the updated attribute", 

236 responses={ 

237 200: { 

238 "description": "Node with the updated attribute", 

239 "content": { 

240 "application/json": { 

241 "example": { 

242 "success": True, 

243 "data": [ 

244 { 

245 "attributes": { 

246 "node_uuid": 1, 

247 "title": "example_title", 

248 "content": "example_content" 

249 }, 

250 "labels": [ 

251 "Node" 

252 ] 

253 } 

254 ], 

255 "message": "Attribute updated", 

256 "error": None 

257 } 

258 } 

259 } 

260 }, 

261 **constellation_check_error 

262 } 

263) 

264async def set_node_attribute(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

265 node_uuid: str = Path(..., title="The UUID of the node"), 

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

267 value: str = Body(..., title="The value to set", embed=True), 

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

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

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

271 token: str = Depends(oauth2_scheme)): 

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

273 if test is not None: 

274 return test 

275 

276 if in_filter is None: 

277 in_filter = [] 

278 if out_filter is None: 

279 out_filter = [] 

280 

281 # Validate the attribute name 

282 if attribute in RESERVED_NODE_ATTRIBUTE_NAMES: 

283 return generate_error_response( 

284 status_code=400, 

285 error_code="BAD_REQUEST", 

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

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

288 ) 

289 

290 with driver.session() as session: 

291 # check if the node exists 

292 result = session.run(""" 

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

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

295 RETURN n 

296 """, 

297 constellation_uuid=constellation_uuid, 

298 node_uuid=node_uuid, 

299 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

300 ) 

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

302 return generate_error_response( 

303 status_code=404, 

304 error_code="NOT_FOUND", 

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

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

307 ) 

308 

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

310 

311 # Check if the attribute exists 

312 result = session.run(""" 

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

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

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

316 """, 

317 constellation_uuid=constellation_uuid, 

318 node_uuid=node_uuid, 

319 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

320 attribute=attribute 

321 ) 

322 records = [record for record in result] 

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

324 if oldAttributeValue is None: 

325 return generate_error_response( 

326 status_code=404, 

327 error_code="NOT_FOUND", 

328 error_message=f"Attribute {attribute} not found for node {node_uuid}", 

329 message=f"Attribute {attribute} not found for node {node_uuid}" 

330 ) 

331 

332 # Compute the new word count 

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

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

335 

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

337 currentWordCount = 0 

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

339 if key not in RESERVED_NODE_ATTRIBUTE_NAMES: 

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

341 

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

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

344 wordCountDifference = newAttributeValueWordCount - oldAttributeValueWordCount 

345 currentWordCount += wordCountDifference 

346 

347 result = session.run(""" 

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

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

350 SET n.created_at = coalesce(n.created_at, datetime()) 

351 SET n.updated_at = datetime() 

352 SET n.word_count = $word_count 

353 SET n[$attribute] = $value 

354 RETURN properties(n) AS attributes, labels(n) as labels 

355 """, 

356 constellation_uuid=constellation_uuid, 

357 node_uuid=node_uuid, 

358 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

359 word_count=currentWordCount, 

360 attribute=attribute, 

361 value=value 

362 ) 

363 final_result: JSONValue = [{"attributes": { 

364 key: serialize_value(value) 

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

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

367 }, "labels": record["labels"]} for record in result] 

368 driver.close() 

369 

370 final_result = decode_attributes_inplace(final_result, decode) 

371 

372 # Send a notification to the SSE server 

373 notification_result = send_constellation_notification( 

374 constellation_uuid=constellation_uuid, 

375 data=final_result, 

376 message="Node attribute updated", 

377 data_type="NODE_ATTRIBUTE_UPDATED", 

378 token=token 

379 ) 

380 if not notification_result: 

381 logger.warning("Set node attribute: Error sending notification to SSE server") 

382 

383 return generate_response( 

384 status_code=200, 

385 data=final_result, 

386 message="Attribute updated" 

387 ) 

388 

389# Delete a specific attribute of a specific node 

390@router.delete("/constellation/{constellation_uuid}/node/{node_uuid}/attribute/{attribute}", 

391 summary="Delete a specific attribute of a node", 

392 description="Delete a specific attribute of the node specified by its UUID", 

393 response_description="Node without the deleted attribute", 

394 responses={ 

395 200: { 

396 "description": "Node without the deleted attribute", 

397 "content": { 

398 "application/json": { 

399 "example": { 

400 "success": True, 

401 "data": [ 

402 { 

403 "attributes": { 

404 "node_uuid": 1, 

405 "title": "example_title", 

406 "content": "example_content" 

407 }, 

408 "labels": [ 

409 "Node" 

410 ] 

411 } 

412 ], 

413 "message": "Attribute deleted", 

414 "error": None 

415 } 

416 } 

417 } 

418 }, 

419 **constellation_check_error 

420 } 

421) 

422async def delete_node_attribute(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

423 node_uuid: str = Path(..., title="The UUID of the node"), 

424 attribute: str = Path(..., title="The attribute to delete"), 

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

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

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

428 token: str = Depends(oauth2_scheme)): 

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

430 if test is not None: 

431 return test 

432 

433 if in_filter is None: 

434 in_filter = [] 

435 if out_filter is None: 

436 out_filter = [] 

437 

438 # Validate the attribute name 

439 if attribute in RESERVED_NODE_ATTRIBUTE_NAMES: 

440 return generate_error_response( 

441 status_code=400, 

442 error_code="BAD_REQUEST", 

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

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

445 ) 

446 

447 with driver.session() as session: 

448 # check if the node exists 

449 result = session.run(""" 

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

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

452 RETURN n 

453 """, 

454 constellation_uuid=constellation_uuid, 

455 node_uuid=node_uuid, 

456 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

457 ) 

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

459 return generate_error_response( 

460 status_code=404, 

461 error_code="NOT_FOUND", 

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

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

464 ) 

465 

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

467 

468 # Check if the attribute exists 

469 result = session.run(""" 

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

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

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

473 """, 

474 constellation_uuid=constellation_uuid, 

475 node_uuid=node_uuid, 

476 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

477 attribute=attribute 

478 ) 

479 records = [record for record in result] 

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

481 if oldAttributeValue is None: 

482 return generate_error_response( 

483 status_code=404, 

484 error_code="NOT_FOUND", 

485 error_message=f"Attribute {attribute} not found for node {node_uuid}", 

486 message=f"Attribute {attribute} not found for node {node_uuid}" 

487 ) 

488 

489 # Compute the new word count 

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

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

492 

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

494 currentWordCount = 0 

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

496 if key not in RESERVED_NODE_ATTRIBUTE_NAMES: 

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

498 

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

500 currentWordCount -= oldAttributeValueWordCount 

501 

502 result = session.run(""" 

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

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

505 SET n.created_at = coalesce(n.created_at, datetime()) 

506 SET n.updated_at = datetime() 

507 SET n.word_count = $word_count 

508 REMOVE n[$attribute] 

509 RETURN properties(n) AS attributes, labels(n) as labels 

510 """, 

511 constellation_uuid=constellation_uuid, 

512 node_uuid=node_uuid, 

513 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

514 word_count=currentWordCount, 

515 attribute=attribute 

516 ) 

517 final_result: JSONValue = [{"attributes": { 

518 key: serialize_value(value) 

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

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

521 }, "labels": record["labels"]} for record in result] 

522 driver.close() 

523 

524 final_result = decode_attributes_inplace(final_result, decode) 

525 

526 # Send a notification to the SSE server 

527 notification_result = send_constellation_notification( 

528 constellation_uuid=constellation_uuid, 

529 data=final_result, 

530 message="Node attribute deleted", 

531 data_type="NODE_ATTRIBUTE_DELETED", 

532 token=token 

533 ) 

534 if not notification_result: 

535 logger.warning("Delete node attribute: Error sending notification to SSE server") 

536 

537 return generate_response( 

538 status_code=200, 

539 data=final_result, 

540 message="Attribute deleted" 

541 ) 

542 

543# Get all nodes with a specific attribute 

544@router.get("/constellation/{constellation_uuid}/nodes/attribute/{attribute}", 

545 summary="Get all nodes with a specific attribute", 

546 description="Get all nodes with the attribute given in parameter", 

547 response_description="List of nodes", 

548 responses={ 

549 200: { 

550 "description": "List of nodes", 

551 "content": { 

552 "application/json": { 

553 "example": { 

554 "success": True, 

555 "data": [ 

556 { 

557 "attributes": { 

558 "node_uuid": 1, 

559 "title": "example_title", 

560 "content": "example_content" 

561 }, 

562 "labels": [ 

563 "Node" 

564 ] 

565 } 

566 ], 

567 "message": "Nodes retrieved", 

568 "error": None 

569 } 

570 } 

571 } 

572 }, 

573 **constellation_check_error 

574 } 

575) 

576async def read_nodes_with_attribute(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

577 attribute: str = Path(..., title="The attribute to search for"), 

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

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

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

581 token: str = Depends(oauth2_scheme)): 

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

583 if test is not None: 

584 return test 

585 

586 if in_filter is None: 

587 in_filter = [] 

588 if out_filter is None: 

589 out_filter = [] 

590 

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

592 

593 with driver.session() as session: 

594 result = session.run(""" 

595 MATCH (n {constellation_uuid: $constellation_uuid}) 

596 WHERE ALL(label IN $RESERVED_NODE_LABEL_NAMES WHERE NOT label IN labels(n)) AND n[$attribute] IS NOT NULL 

597 RETURN properties(n) AS attributes, labels(n) as labels 

598 """, 

599 constellation_uuid=constellation_uuid, 

600 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

601 attribute=attribute 

602 ) 

603 final_result: JSONValue = [{"attributes": { 

604 key: serialize_value(value) 

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

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

607 }, "labels": record["labels"]} for record in result] 

608 driver.close() 

609 

610 final_result = decode_attributes_inplace(final_result, decode) 

611 

612 return generate_response( 

613 status_code=200, 

614 data=final_result, 

615 message="Nodes retrieved" 

616 ) 

617 

618# Add an attribute to a node 

619@router.post("/constellation/{constellation_uuid}/node/{node_uuid}/attribute/{attribute}", 

620 summary="Add an attribute to a node", 

621 description="Add the attribute given in parameter to the node specified by its UUID", 

622 response_description="Node with the added attribute", 

623 responses={ 

624 200: { 

625 "description": "Node with the added attribute", 

626 "content": { 

627 "application/json": { 

628 "example": { 

629 "success": True, 

630 "data": [ 

631 { 

632 "attributes": { 

633 "node_uuid": 1, 

634 "title": "example_title", 

635 "content": "example_content" 

636 }, 

637 "labels": [ 

638 "Node" 

639 ] 

640 } 

641 ], 

642 "message": "Attribute added", 

643 "error": None 

644 } 

645 } 

646 } 

647 }, 

648 **constellation_check_error 

649 } 

650) 

651async def add_attribute(constellation_uuid: str = Path(..., title="The UUID of the constellation"), 

652 node_uuid: str = Path(..., title="The UUID of the node"), 

653 attribute: str = Path(..., title="The attribute to add"), 

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

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

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

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

658 token: str = Depends(oauth2_scheme)): 

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

660 if test is not None: 

661 return test 

662 

663 if in_filter is None: 

664 in_filter = [] 

665 if out_filter is None: 

666 out_filter = [] 

667 

668 # Validate the attribute name 

669 if attribute in RESERVED_NODE_ATTRIBUTE_NAMES: 

670 return generate_error_response( 

671 status_code=400, 

672 error_code="BAD_REQUEST", 

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

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

675 ) 

676 

677 with driver.session() as session: 

678 # check if the node exists 

679 result = session.run(""" 

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

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

682 RETURN n 

683 """, 

684 constellation_uuid=constellation_uuid, 

685 node_uuid=node_uuid, 

686 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES 

687 ) 

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

689 return generate_error_response( 

690 status_code=404, 

691 error_code="NOT_FOUND", 

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

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

694 ) 

695 

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

697 

698 # Check if the attribute already exists 

699 result = session.run(""" 

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

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

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

703 """, 

704 constellation_uuid=constellation_uuid, 

705 node_uuid=node_uuid, 

706 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

707 attribute=attribute 

708 ) 

709 records = [record for record in result] 

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

711 return generate_error_response( 

712 status_code=400, 

713 error_code="BAD_REQUEST", 

714 error_message=f"Attribute {attribute} already exists for node {node_uuid}", 

715 message=f"Attribute {attribute} already exists for node {node_uuid}" 

716 ) 

717 

718 # Compute the new word count 

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

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

721 

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

723 currentWordCount = 0 

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

725 if key not in RESERVED_NODE_ATTRIBUTE_NAMES: 

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

727 

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

729 currentWordCount += newAttributeValueWordCount 

730 

731 # Add the attribute to the node 

732 result = session.run(""" 

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

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

735 SET n.created_at = coalesce(n.created_at, datetime()) 

736 SET n.updated_at = datetime() 

737 SET n.word_count = $word_count 

738 SET n[$attribute] = $value 

739 RETURN properties(n) AS attributes, labels(n) as labels 

740 """, 

741 constellation_uuid=constellation_uuid, 

742 node_uuid=node_uuid, 

743 RESERVED_NODE_LABEL_NAMES=RESERVED_NODE_LABEL_NAMES, 

744 word_count=currentWordCount, 

745 attribute=attribute, 

746 value=value 

747 ) 

748 final_result: JSONValue = [{"attributes": { 

749 key: serialize_value(value) 

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

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

752 }, "labels": record["labels"]} for record in result] 

753 driver.close() 

754 

755 final_result = decode_attributes_inplace(final_result, decode) 

756 

757 # Send a notification to the SSE server 

758 notification_result = send_constellation_notification( 

759 constellation_uuid=constellation_uuid, 

760 data=final_result, 

761 message="Node attribute added", 

762 data_type="NODE_ATTRIBUTE_ADDED", 

763 token=token 

764 ) 

765 if not notification_result: 

766 logger.warning("Add node attribute: Error sending notification to SSE server") 

767 

768 return generate_response( 

769 status_code=200, 

770 data=final_result, 

771 message="Attribute added" 

772 )