Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1374306
Initial plan
Copilot Jul 18, 2025
aee392e
Implement Pydantic v1/v2 compatibility layer and update requirements
Copilot Jul 18, 2025
5899eb9
Complete Pydantic v2 migration for airlock processor and add compatib…
Copilot Jul 18, 2025
028a082
Fix Pydantic v2 compatibility issues: update field annotations and mi…
Copilot Jul 18, 2025
ddb5073
Complete Pydantic v2 migration: fix parse_obj_as usage and test fixtures
Copilot Jul 18, 2025
dec6a3b
Fix remaining Pydantic v2 issues: TypeAdapter imports, user validatio…
Copilot Jul 18, 2025
9a1731f
Remove Pydantic v1 backward compatibility and fix linting issues
Copilot Jul 18, 2025
2b06663
Increment component versions: API 0.24.5->0.25.0, Airlock Processor 0…
Copilot Jul 18, 2025
d8d4e87
Fix linting issues: remove unused imports from template route files
Copilot Jul 23, 2025
993a5a1
Fix unit test errors: remove double .model_dump() calls in test_works…
Copilot Jul 23, 2025
7309db8
WIP
marrobi Jul 23, 2025
48ece2f
WIP
marrobi Jul 23, 2025
352d647
Tests pass.
marrobi Jul 23, 2025
8357708
Remove some of the compatibility code.
marrobi Jul 23, 2025
9a87033
Switch to User objects
marrobi Jul 24, 2025
c686694
Simplify serialization.
marrobi Jul 24, 2025
453a046
Add tests back
marrobi Jul 24, 2025
75cdbac
Remove defensive code in tests
marrobi Jul 24, 2025
79cba4e
update tests
marrobi Jul 24, 2025
a206919
Updates to simplify.
marrobi Jul 24, 2025
6d88263
Updates to simplify code.
marrobi Jul 24, 2025
40b3c64
fix linting
marrobi Jul 24, 2025
6220aa7
Fix scema validation
marrobi Jul 28, 2025
697d101
Update time zone issues.
marrobi Jul 28, 2025
640a54c
Fix user model
marrobi Jul 28, 2025
2c23082
up version
marrobi Jul 28, 2025
3deab49
Update models to correct syntax for v2
marrobi Jul 28, 2025
a705c98
update timezone
marrobi Jul 29, 2025
849c9ca
Address comments.
marrobi Jul 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- markdownlint-disable MD041 -->
## 0.26.0 (Unreleased)

* _No changes yet_
* Migration to Pydantic v2: Updates codebase to be compatible with Pydantic v2 for future FastAPI upgrades ([#4637](https://github.com/microsoft/AzureTRE/issues/4637))

## 0.25.0 (July 18, 2025)
**IMPORTANT**:
Expand Down
6 changes: 3 additions & 3 deletions airlock_processor/BlobCreatedTrigger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
import datetime
from datetime import datetime, timezone
import uuid
import json
import re
Expand Down Expand Up @@ -63,7 +63,7 @@ def main(msg: func.ServiceBusMessage,
data={"completed_step": completed_step, "new_status": new_status, "request_id": request_id},
subject=request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.STEP_RESULT_EVENT_DATA_VERSION))

send_delete_event(dataDeletionEvent, json_body, request_id)
Expand All @@ -84,7 +84,7 @@ def send_delete_event(dataDeletionEvent: func.Out[func.EventGridOutputEvent], js
data={"blob_to_delete": copied_from[-1]}, # last container in copied_from is the one we just copied from
subject=request_id,
event_type="Airlock.DataDeletion",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.DATA_DELETION_EVENT_DATA_VERSION
)
)
4 changes: 2 additions & 2 deletions airlock_processor/ScanResultTrigger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

import azure.functions as func
import datetime
from datetime import datetime, timezone
import uuid
import json
import os
Expand Down Expand Up @@ -59,5 +59,5 @@ def main(msg: func.ServiceBusMessage,
data={"completed_step": completed_step, "new_status": new_status, "request_id": request_id, "status_message": status_message},
subject=request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.STEP_RESULT_EVENT_DATA_VERSION))
14 changes: 7 additions & 7 deletions airlock_processor/StatusChangedQueueTrigger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
from typing import Optional

import azure.functions as func
import datetime
from datetime import datetime, timezone
import os
import uuid
import json

from exceptions import NoFilesInRequestException, TooManyFilesInRequestException

from shared_code import blob_operations, constants
from pydantic import BaseModel, parse_obj_as
from pydantic import BaseModel, TypeAdapter, Field


class RequestProperties(BaseModel):
request_id: str
new_status: str
previous_status: Optional[str]
previous_status: Optional[str] = Field(default=None)
type: str
workspace_id: str

Expand Down Expand Up @@ -83,7 +83,7 @@ def extract_properties(msg: func.ServiceBusMessage) -> RequestProperties:
body = msg.get_body().decode('utf-8')
logging.debug('Python ServiceBus queue trigger processed message: %s', body)
json_body = json.loads(body)
result = parse_obj_as(RequestProperties, json_body["data"])
result = TypeAdapter(RequestProperties).validate_python(json_body["data"])
if not result:
raise Exception("Failed parsing request properties")
except json.decoder.JSONDecodeError:
Expand Down Expand Up @@ -187,7 +187,7 @@ def set_output_event_to_report_failure(stepResultEvent, request_properties, fail
data={"completed_step": request_properties.new_status, "new_status": constants.STAGE_FAILED, "request_id": request_properties.request_id, "request_files": request_files, "status_message": failure_reason},
subject=request_properties.request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.STEP_RESULT_EVENT_DATA_VERSION))


Expand All @@ -199,7 +199,7 @@ def set_output_event_to_report_request_files(stepResultEvent, request_properties
data={"completed_step": request_properties.new_status, "request_id": request_properties.request_id, "request_files": request_files},
subject=request_properties.request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.STEP_RESULT_EVENT_DATA_VERSION))


Expand All @@ -211,7 +211,7 @@ def set_output_event_to_trigger_container_deletion(dataDeletionEvent, request_pr
data={"blob_to_delete": container_url},
subject=request_properties.request_id,
event_type="Airlock.DataDeletion",
event_time=datetime.datetime.utcnow(),
event_time=datetime.now(timezone.utc),
data_version=constants.DATA_DELETION_EVENT_DATA_VERSION
)
)
Expand Down
2 changes: 1 addition & 1 deletion airlock_processor/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.8.6"
__version__ = "0.9.1"
2 changes: 1 addition & 1 deletion airlock_processor/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ azure-storage-blob==12.23.1
azure-identity==1.19.0
azure-mgmt-storage==21.2.1
azure-mgmt-resource==23.2.0
pydantic==1.10.19
pydantic==2.10.5
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.24.5"
__version__ = "0.25.3"
8 changes: 4 additions & 4 deletions api_app/api/routes/resource_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from db.repositories.resources_history import ResourceHistoryRepository
from models.domain.resource_template import ResourceTemplate
from models.domain.authentication import User
from pydantic import parse_obj_as
from pydantic import TypeAdapter

from db.errors import DuplicateEntity, EntityDoesNotExist
from db.repositories.operations import OperationRepository
Expand Down Expand Up @@ -45,9 +45,9 @@ async def cascaded_update_resource(resource_patch: ResourcePatch, parent_resourc
child_etag = child_resource["_etag"]
primary_parent_service_name = ""
if child_resource["resourceType"] == ResourceType.WorkspaceService:
child_resource = parse_obj_as(WorkspaceService, child_resource)
child_resource = TypeAdapter(WorkspaceService).validate_python(child_resource)
elif child_resource["resourceType"] == ResourceType.UserResource:
child_resource = parse_obj_as(UserResource, child_resource)
child_resource = TypeAdapter(UserResource).validate_python(child_resource)
primary_parent_workspace_service = await resource_repo.get_resource_by_id(child_resource.parentWorkspaceServiceId)
primary_parent_service_name = primary_parent_workspace_service.templateName

Expand Down Expand Up @@ -134,7 +134,7 @@ def flatten_template_props(template_fragment: dict):
if isinstance(prop, dict) and prop_name != "if":
flatten_template_props(prop)

flatten_template_props(template.dict())
flatten_template_props(template.model_dump())

def recurse_input_props(prop_dict: dict):
for prop_name, prop in prop_dict.items():
Expand Down
4 changes: 2 additions & 2 deletions api_app/api/routes/shared_service_templates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import parse_obj_as
from pydantic import TypeAdapter

from api.helpers import get_repository
from db.errors import EntityDoesNotExist, EntityVersionExist, InvalidInput
Expand All @@ -26,7 +26,7 @@ async def get_shared_service_templates(authorized_only: bool = False, template_r
async def get_shared_service_template(shared_service_template_name: str, is_update: bool = False, version: Optional[str] = None, template_repo=Depends(get_repository(ResourceTemplateRepository))) -> SharedServiceTemplateInResponse:
try:
template = await get_template(shared_service_template_name, template_repo, ResourceType.SharedService, is_update=is_update, version=version)
return parse_obj_as(SharedServiceTemplateInResponse, template)
return TypeAdapter(SharedServiceTemplateInResponse).validate_python(template)
except EntityDoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strings.SHARED_SERVICE_TEMPLATE_DOES_NOT_EXIST)

Expand Down
7 changes: 5 additions & 2 deletions api_app/api/routes/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from models.schemas.operation import OperationInList, OperationInResponse
from models.schemas.shared_service import RestrictedSharedServiceInResponse, RestrictedSharedServicesInList, SharedServiceInCreate, SharedServicesInList, SharedServiceInResponse
from models.schemas.resource import ResourceHistoryInList, ResourcePatch
from models.domain.restricted_resource import RestrictedResource
from resources import strings
from .workspaces import save_and_deploy_resource, construct_location_header
from azure.cosmos.exceptions import CosmosAccessConditionFailedError
Expand All @@ -39,7 +40,8 @@ async def retrieve_shared_services(shared_services_repo=Depends(get_repository(S
if user_is_tre_admin(user):
return SharedServicesInList(sharedServices=shared_services)
else:
return RestrictedSharedServicesInList(sharedServices=shared_services)
restricted_services = [RestrictedResource.model_validate(service.model_dump()) for service in shared_services]
return RestrictedSharedServicesInList(sharedServices=restricted_services)


@shared_services_router.get("/shared-services/{shared_service_id}", response_model=SharedServiceInResponse, name=strings.API_GET_SHARED_SERVICE_BY_ID, dependencies=[Depends(get_current_tre_user_or_tre_admin), Depends(get_shared_service_by_id_from_path)])
Expand All @@ -48,7 +50,8 @@ async def retrieve_shared_service_by_id(shared_service=Depends(get_shared_servic
if user_is_tre_admin(user):
return SharedServiceInResponse(sharedService=shared_service)
else:
return RestrictedSharedServiceInResponse(sharedService=shared_service)
restricted_service = RestrictedResource.model_validate(shared_service.model_dump())
return RestrictedSharedServiceInResponse(sharedService=restricted_service)


@shared_services_router.post("/shared-services", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_CREATE_SHARED_SERVICE, dependencies=[Depends(get_current_admin_user)])
Expand Down
4 changes: 2 additions & 2 deletions api_app/api/routes/user_resource_templates.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import parse_obj_as
from pydantic import TypeAdapter

from api.dependencies.workspace_service_templates import get_workspace_service_template_by_name_from_path
from api.routes.resource_helpers import get_template
Expand All @@ -27,7 +27,7 @@ async def get_user_resource_templates_for_service_template(service_template_name
@user_resource_templates_core_router.get("/workspace-service-templates/{service_template_name}/user-resource-templates/{user_resource_template_name}", response_model=UserResourceTemplateInResponse, response_model_exclude_none=True, name=strings.API_GET_USER_RESOURCE_TEMPLATE_BY_NAME, dependencies=[Depends(get_current_tre_user_or_tre_admin)])
async def get_user_resource_template(service_template_name: str, user_resource_template_name: str, is_update: bool = False, version: Optional[str] = None, template_repo=Depends(get_repository(ResourceTemplateRepository))) -> UserResourceTemplateInResponse:
template = await get_template(user_resource_template_name, template_repo, ResourceType.UserResource, service_template_name, is_update=is_update, version=version)
return parse_obj_as(UserResourceTemplateInResponse, template)
return TypeAdapter(UserResourceTemplateInResponse).validate_python(template)


@user_resource_templates_core_router.post("/workspace-service-templates/{service_template_name}/user-resource-templates", status_code=status.HTTP_201_CREATED, response_model=UserResourceTemplateInResponse, response_model_exclude_none=True, name=strings.API_CREATE_USER_RESOURCE_TEMPLATES, dependencies=[Depends(get_current_admin_user)])
Expand Down
4 changes: 2 additions & 2 deletions api_app/api/routes/workspace_service_templates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import parse_obj_as
from pydantic import TypeAdapter

from api.routes.resource_helpers import get_template
from db.errors import EntityVersionExist, InvalidInput
Expand All @@ -25,7 +25,7 @@ async def get_workspace_service_templates(template_repo=Depends(get_repository(R
@workspace_service_templates_core_router.get("/workspace-service-templates/{service_template_name}", response_model=WorkspaceServiceTemplateInResponse, response_model_exclude_none=True, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATE_BY_NAME, dependencies=[Depends(get_current_tre_user_or_tre_admin)])
async def get_workspace_service_template(service_template_name: str, is_update: bool = False, version: Optional[str] = None, template_repo=Depends(get_repository(ResourceTemplateRepository))) -> WorkspaceServiceTemplateInResponse:
template = await get_template(service_template_name, template_repo, ResourceType.WorkspaceService, is_update=is_update, version=version)
return parse_obj_as(WorkspaceServiceTemplateInResponse, template)
return TypeAdapter(WorkspaceServiceTemplateInResponse).validate_python(template)


@workspace_service_templates_core_router.post("/workspace-service-templates", status_code=status.HTTP_201_CREATED, response_model=WorkspaceServiceTemplateInResponse, response_model_exclude_none=True, name=strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES, dependencies=[Depends(get_current_admin_user)])
Expand Down
4 changes: 2 additions & 2 deletions api_app/api/routes/workspace_templates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import parse_obj_as
from pydantic import TypeAdapter

from api.helpers import get_repository
from db.errors import EntityVersionExist, InvalidInput
Expand All @@ -25,7 +25,7 @@ async def get_workspace_templates(authorized_only: bool = False, template_repo=D
@workspace_templates_admin_router.get("/workspace-templates/{workspace_template_name}", response_model=WorkspaceTemplateInResponse, name=strings.API_GET_WORKSPACE_TEMPLATE_BY_NAME, response_model_exclude_none=True)
async def get_workspace_template(workspace_template_name: str, is_update: bool = False, version: Optional[str] = None, template_repo=Depends(get_repository(ResourceTemplateRepository))) -> WorkspaceTemplateInResponse:
template = await get_template(workspace_template_name, template_repo, ResourceType.Workspace, is_update=is_update, version=version)
return parse_obj_as(WorkspaceTemplateInResponse, template)
return TypeAdapter(WorkspaceTemplateInResponse).validate_python(template)


@workspace_templates_admin_router.post("/workspace-templates", status_code=status.HTTP_201_CREATED, response_model=WorkspaceTemplateInResponse, response_model_exclude_none=True, name=strings.API_CREATE_WORKSPACE_TEMPLATES)
Expand Down
14 changes: 8 additions & 6 deletions api_app/db/repositories/airlock_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic import UUID4
from azure.cosmos.exceptions import CosmosResourceNotFoundError, CosmosAccessConditionFailedError
from fastapi import HTTPException, status
from pydantic import parse_obj_as
from pydantic import TypeAdapter
from db.repositories.workspaces import WorkspaceRepository
from services.authentication import get_access_service
from models.domain.authentication import User
Expand Down Expand Up @@ -45,9 +45,11 @@ async def update_airlock_request_item(self, original_request: AirlockRequest, ne

# now update the request props
new_request.resourceVersion = new_request.resourceVersion + 1
new_request.updatedBy = updated_by
new_request.updatedWhen = self.get_timestamp()

# Field validators will handle User object properly
new_request.updatedBy = updated_by

await self.upsert_item_with_etag(new_request, new_request.etag)
return new_request

Expand Down Expand Up @@ -114,9 +116,9 @@ def create_airlock_request_item(self, airlock_request_input: AirlockRequestInCre
businessJustification=airlock_request_input.businessJustification,
type=airlock_request_input.type,
createdBy=user,
createdWhen=datetime.utcnow().timestamp(),
createdWhen=datetime.now(timezone.utc).timestamp(),
updatedBy=user,
updatedWhen=datetime.utcnow().timestamp(),
updatedWhen=datetime.now(timezone.utc).timestamp(),
properties=resource_spec_parameters,
reviews=[]
)
Expand Down Expand Up @@ -151,14 +153,14 @@ async def get_airlock_requests(self, workspace_id: Optional[str] = None, creator
query += ' ASC' if order_ascending else ' DESC'

airlock_requests = await self.query(query=query, parameters=parameters)
return parse_obj_as(List[AirlockRequest], airlock_requests)
return TypeAdapter(List[AirlockRequest]).validate_python(airlock_requests)

async def get_airlock_request_by_id(self, airlock_request_id: UUID4) -> AirlockRequest:
try:
airlock_requests = await self.read_item_by_id(str(airlock_request_id))
except CosmosResourceNotFoundError:
raise EntityDoesNotExist
return parse_obj_as(AirlockRequest, airlock_requests)
return TypeAdapter(AirlockRequest).validate_python(airlock_requests)

async def get_airlock_requests_for_airlock_manager(self, user: User, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]:
workspace_repo = await WorkspaceRepository.create()
Expand Down
8 changes: 4 additions & 4 deletions api_app/db/repositories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ async def read_item_by_id(self, item_id: str) -> dict:
return await self.container.read_item(item=item_id, partition_key=item_id)

async def save_item(self, item: BaseModel):
await self.container.create_item(body=item.dict())
await self.container.create_item(body=item.model_dump())

async def update_item(self, item: BaseModel):
await self.container.upsert_item(body=item.dict())
await self.container.upsert_item(body=item.model_dump())

async def update_item_with_etag(self, item: BaseModel, etag: str) -> BaseModel:
await self.container.replace_item(item=item.id, body=item.dict(), etag=etag, match_condition=MatchConditions.IfNotModified)
await self.container.replace_item(item=item.id, body=item.model_dump(), etag=etag, match_condition=MatchConditions.IfNotModified)
return await self.read_item_by_id(item.id)

async def upsert_item_with_etag(self, item: BaseModel, etag: str) -> BaseModel:
return await self.container.upsert_item(body=item.dict(), etag=etag, match_condition=MatchConditions.IfNotModified)
return await self.container.upsert_item(body=item.model_dump(), etag=etag, match_condition=MatchConditions.IfNotModified)

async def update_item_dict(self, item_dict: dict):
await self.container.upsert_item(body=item_dict)
Expand Down
Loading
Loading