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
« 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
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
14router = APIRouter(tags=["Link Attribute"])
16# Define the OAuth2 schema to get the JWT
17oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
19# Define the Neo4j driver
20driver = GraphDatabase.driver(
21 f"neo4j://{NEO4J_HOST}:{NEO4J_PORT}",
22 auth=basic_auth(NEO4J_USER, NEO4J_PASSWORD))
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
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 )
66 final_result: JSONValue = [record["keys(r)"] for record in result]
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()
77 return generate_response(
78 status_code=200,
79 data=final_result,
80 message="Attributes retrieved"
81 )
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
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 )
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()
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 )
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]
162 return generate_response(
163 status_code=200,
164 data=final_result,
165 message="Attribute retrieved"
166 )
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
212 if in_filter is None:
213 in_filter = []
214 if out_filter is None:
215 out_filter = []
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 )
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 )
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 )
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]
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())
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
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()
304 final_result = decode_attributes_inplace(final_result, decode)
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")
317 return generate_response(
318 status_code=200,
319 data=final_result,
320 message="Attribute updated"
321 )
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
366 if in_filter is None:
367 in_filter = []
368 if out_filter is None:
369 out_filter = []
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 )
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 )
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 )
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]
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())
430 oldAttributeValueWordCount = len(decode_ydoc(oldAttributeValue, True).split()) if isinstance(oldAttributeValue, str) else 0
431 currentWordCount -= oldAttributeValueWordCount
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()
455 final_result = decode_attributes_inplace(final_result, decode)
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")
468 return generate_response(
469 status_code=200,
470 data=final_result,
471 message="Attribute deleted"
472 )
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
516 if in_filter is None:
517 in_filter = []
518 if out_filter is None:
519 out_filter = []
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()
538 final_result = decode_attributes_inplace(final_result, decode)
540 return generate_response(
541 status_code=200,
542 data=final_result,
543 message="Links retrieved"
544 )
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
590 if in_filter is None:
591 in_filter = []
592 if out_filter is None:
593 out_filter = []
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 )
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 )
623 attribute = attribute.replace("`", "``") # Replace backticks with double backticks for Neo4j compatibility
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 )
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]
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())
655 newAttributeValueWordCount = len(decode_ydoc(value, True).split())
656 currentWordCount += newAttributeValueWordCount
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()
681 final_result = decode_attributes_inplace(final_result, decode)
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")
694 return generate_response(
695 status_code=200,
696 data=final_result,
697 message="Attribute added"
698 )