Skip to content

Commit 9b5ef69

Browse files
authored
LIF-771: Expose export transformation group endpoint (LIF-Initiative#879)
Introduces a new export transformation group endpoint, enabling users to download a given transformation group and it's transformations in a JSON format suitable for external use. There will be a follow up PR(s) to harden the endpoint and possibly expand the testing, but this will allow the basic functionality so the FE can be wired in. ##### Type of Change - [x] New feature (non-breaking change which adds functionality) ##### Project Area(s) Affected - [x] bases/ - [x] components/ - [x] API endpoints - [ ] Documentation - [x] Testing --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [ ] documentation is changed or added (in /docs directory) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [ ] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully - [ ] database schema changes: migration files created and CHANGELOG.md updated - [ ] API changes: base (Python code) documentation in `docs/` and project README updated - [ ] configuration changes: relevant folder README updated - [ ] breaking changes: added to MIGRATION.md with upgrade instructions and CHANGELOG.md entry ##### Testing <!-- Describe the testing you've done --> - [x] Manual testing performed - [x] Automated tests added/updated - [ ] Integration testing completed
1 parent 0ac1caa commit 9b5ef69

File tree

5 files changed

+406
-44
lines changed

5 files changed

+406
-44
lines changed

bases/lif/mdr_restapi/transformation_endpoint.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import json
12
from typing import Any, Dict, List, Optional
23

34
from fastapi import APIRouter, Depends, Query, Request, Response, status
5+
from fastapi.encoders import jsonable_encoder
46
from lif.mdr_dto.transformation_dto import (
57
CreateTransformationDTO,
68
CreateTransformationGroupDTO,
@@ -225,6 +227,20 @@ async def get_all_transformation_groups(
225227
return {"total": total_count, "data": transformations}
226228

227229

230+
@router.get("/{transformation_group_id}/export")
231+
async def export_transformation_group(transformation_group_id: int, session: AsyncSession = Depends(get_session)):
232+
_, group_data = await transformation_service.get_paginated_transformations_for_a_group(
233+
session=session, group_id=transformation_group_id, pagination=False, make_exportable=True
234+
)
235+
# Normalize to JSON-safe Python objects
236+
encoded = jsonable_encoder(group_data)
237+
# Force download as .json
238+
filename = f"transformation-group-{transformation_group_id}.json"
239+
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
240+
payload = json.dumps(encoded, indent=2)
241+
return Response(content=payload, media_type="application/json", headers=headers)
242+
243+
228244
@router.get("/{transformation_group_id}", response_model=Dict[str, Any])
229245
async def get_all_transformations_for_a_group(
230246
request: Request,

components/lif/mdr_services/transformation_service.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
DatamodelElementType,
77
DataModelType,
88
EntityAttributeAssociation,
9+
ExpressionLanguageType,
910
Transformation,
1011
TransformationAttribute,
1112
TransformationGroup,
@@ -1023,8 +1024,53 @@ async def get_transformation_group_by_id(session: AsyncSession, id: int):
10231024
return transformation_group
10241025

10251026

1027+
async def _resolve_entity_id_path_to_named_path(
1028+
session: AsyncSession, id_path: str, cache: dict[tuple[str, int], str]
1029+
) -> str:
1030+
from lif.datatypes.mdr_sql_model import Attribute, Entity
1031+
1032+
ids = parse_transformation_path(id_path)
1033+
segments: list[str] = []
1034+
1035+
for i, raw_id in enumerate(ids):
1036+
is_last = i == len(ids) - 1
1037+
if not is_last and raw_id < 0:
1038+
raise HTTPException(
1039+
status_code=400,
1040+
detail=f"Unable to export - invalid path '{id_path}': non-terminal ID '{raw_id}' must be positive",
1041+
)
1042+
is_attribute = is_last and raw_id < 0
1043+
cleaned_id = abs(raw_id)
1044+
cache_key = ("attribute" if is_attribute else "entity", cleaned_id)
1045+
1046+
if cache_key not in cache:
1047+
record = await session.get(Attribute, cleaned_id) if is_attribute else await session.get(Entity, cleaned_id)
1048+
record_type = cache_key[0].capitalize()
1049+
if not record:
1050+
raise HTTPException(
1051+
status_code=404,
1052+
detail=f"Unable to export - {record_type} ID {cleaned_id} in path '{id_path}' not found",
1053+
)
1054+
if record.Deleted == True:
1055+
raise HTTPException(
1056+
status_code=404,
1057+
detail=f"Unable to export - {record_type} ID {cleaned_id} in path '{id_path}' is deleted",
1058+
)
1059+
record_type_flag = "~" if is_attribute else ""
1060+
cache[cache_key] = f"{record.DataModelId}:{record_type_flag}{record.UniqueName}"
1061+
1062+
segments.append(cache[cache_key])
1063+
1064+
return ",".join(segments)
1065+
1066+
10261067
async def get_paginated_transformations_for_a_group(
1027-
session: AsyncSession, group_id: int, offset: int = 0, limit: int = 10, pagination: bool = True
1068+
session: AsyncSession,
1069+
group_id: int,
1070+
offset: int = 0,
1071+
limit: int = 10,
1072+
pagination: bool = True,
1073+
make_exportable: bool = False, # Only is honored when pagination is False and this is set to True.
10281074
):
10291075
transformation_group = await get_transformation_group_by_id(session=session, id=group_id)
10301076
transformation_group_dto = TransformationGroupDTO.from_orm(transformation_group)
@@ -1053,15 +1099,14 @@ async def get_paginated_transformations_for_a_group(
10531099
.limit(limit)
10541100
)
10551101
else:
1056-
transformations_query = (
1057-
select(Transformation)
1058-
.where(Transformation.TransformationGroupId == group_id, Transformation.Deleted == False)
1059-
.order_by(Transformation.Id)
1060-
)
1102+
where_expressions = [Transformation.TransformationGroupId == group_id, Transformation.Deleted == False]
1103+
if make_exportable:
1104+
where_expressions.append(Transformation.ExpressionLanguage == ExpressionLanguageType.JSONata)
1105+
transformations_query = select(Transformation).where(*where_expressions).order_by(Transformation.Id)
10611106

10621107
result = await session.execute(transformations_query)
10631108
transformations = result.scalars().all()
1064-
1109+
entity_attribute_cache: dict[tuple[str, int], str] = {}
10651110
for transformation in transformations:
10661111
# Get related transformation attributes
10671112
query = select(TransformationAttribute).where(
@@ -1101,6 +1146,11 @@ async def get_paginated_transformations_for_a_group(
11011146
EntityIdPath=transformation_attribute.EntityIdPath,
11021147
)
11031148

1149+
if make_exportable:
1150+
attribute_dto.EntityIdPath = await _resolve_entity_id_path_to_named_path(
1151+
session=session, id_path=transformation_attribute.EntityIdPath, cache=entity_attribute_cache
1152+
)
1153+
11041154
# Assign based on the attribute type (Source or Target)
11051155
if transformation_attribute.AttributeType == "Source":
11061156
source_attribute_dtos.append(attribute_dto)

0 commit comments

Comments
 (0)