diff --git a/CHANGELOG.md b/CHANGELOG.md index 9671ff0a5b..cbc5bbb9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ENHANCEMENTS: * Strengthen TRE API authentication with a layered `auth/` package (typed exceptions, `PyJWKClient`-backed token validation, immutable `AuthenticatedUser` model, composable RBAC factories), remove the redundant `AccessService` abstraction, and add Event Grid publish resilience with distinct Graph/publish failure reporting. ([#4989](https://github.com/microsoft/AzureTRE/pull/4989)) * Add support for formatting UI code via `pre-commit` and fix existing formatting issues. ([#4955](https://github.com/microsoft/AzureTRE/issues/4955)) * Update the version of `super-linter` used in the `build_validation_develop` workflow to 8.7.0 ([#4957](https://github.com/microsoft/AzureTRE/issues/4957)) +* Migration to Pydantic v2: Updates codebase to be compatible with Pydantic v2 for future FastAPI upgrades ([#4637](https://github.com/microsoft/AzureTRE/issues/4637)) BUG FIXES: * Ignore changes to `ip_tags` on public IP resources to unblock deployments where these tags are set by Azure policy. (`core` 0.16.17, `tre-shared-service-certs` 0.7.11) ([#5019](https://github.com/microsoft/AzureTRE/issues/5019)) diff --git a/airlock_processor/StatusChangedQueueTrigger/__init__.py b/airlock_processor/StatusChangedQueueTrigger/__init__.py index 11830e7ffb..9dc6ef3aa9 100644 --- a/airlock_processor/StatusChangedQueueTrigger/__init__.py +++ b/airlock_processor/StatusChangedQueueTrigger/__init__.py @@ -10,13 +10,13 @@ from exceptions import NoFilesInRequestException, TooManyFilesInRequestException from shared_code import blob_operations, constants -from pydantic import BaseModel, parse_obj_as +from pydantic import BaseModel, TypeAdapter class RequestProperties(BaseModel): request_id: str new_status: str - previous_status: Optional[str] + previous_status: Optional[str] = None type: str workspace_id: str @@ -86,7 +86,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: diff --git a/airlock_processor/_version.py b/airlock_processor/_version.py index 1d16920cdb..cb4382b891 100644 --- a/airlock_processor/_version.py +++ b/airlock_processor/_version.py @@ -1 +1 @@ -__version__ = "0.8.11" +__version__ = "0.8.12" diff --git a/airlock_processor/requirements.txt b/airlock_processor/requirements.txt index 81dee31801..f08ef15178 100644 --- a/airlock_processor/requirements.txt +++ b/airlock_processor/requirements.txt @@ -5,4 +5,4 @@ azure-storage-blob==12.27.1 azure-identity==1.25.1 azure-mgmt-storage==24.0.0 azure-mgmt-resource==24.0.0 -pydantic==1.10.26 +pydantic==2.13.4 diff --git a/airlock_processor/tests/test_status_change_queue_trigger.py b/airlock_processor/tests/test_status_change_queue_trigger.py index 696c1f241b..3714f79665 100644 --- a/airlock_processor/tests/test_status_change_queue_trigger.py +++ b/airlock_processor/tests/test_status_change_queue_trigger.py @@ -20,6 +20,14 @@ def test_extract_prop_valid_body_return_all_values(self): assert req_prop.type == "101112" assert req_prop.workspace_id == "ws1" + def test_extract_prop_defaults_missing_previous_status_to_none(self): + message_body = "{ \"data\": { \"request_id\":\"123\",\"new_status\":\"draft\", \"type\":\"export\", \"workspace_id\":\"ws1\" }}" + message = _mock_service_bus_message(body=message_body) + + req_prop = extract_properties(message) + + assert req_prop.previous_status is None + def test_extract_prop_missing_arg_throws(self): message_body = "{ \"data\": { \"status\":\"456\" , \"type\":\"789\", \"workspace_id\":\"ws1\" }}" message = _mock_service_bus_message(body=message_body) diff --git a/api_app/_version.py b/api_app/_version.py index 7c4a9591e1..025f4c5d0b 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.26.0" +__version__ = "0.26.1" diff --git a/api_app/api/routes/resource_helpers.py b/api_app/api/routes/resource_helpers.py index a32e84bc3f..eb9262663d 100644 --- a/api_app/api/routes/resource_helpers.py +++ b/api_app/api/routes/resource_helpers.py @@ -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 @@ -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 @@ -65,7 +65,7 @@ async def save_and_deploy_resource( resource_template: ResourceTemplate, ) -> Operation: try: - resource.user = user + resource.user = user.model_dump() resource.updatedWhen = get_timestamp() # Making a copy to save with secrets masked @@ -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(): diff --git a/api_app/api/routes/shared_service_templates.py b/api_app/api/routes/shared_service_templates.py index 7a487dc1b2..4bb3bde5a9 100644 --- a/api_app/api/routes/shared_service_templates.py +++ b/api_app/api/routes/shared_service_templates.py @@ -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 @@ -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) diff --git a/api_app/api/routes/shared_services.py b/api_app/api/routes/shared_services.py index 9ffa89d9b4..e26ae05c4b 100644 --- a/api_app/api/routes/shared_services.py +++ b/api_app/api/routes/shared_services.py @@ -32,23 +32,23 @@ def user_is_tre_admin(user): return False -@shared_services_router.get("/shared-services", response_model=SharedServicesInList, name=strings.API_GET_ALL_SHARED_SERVICES, dependencies=[Depends(require_tre_user_or_admin)]) -async def retrieve_shared_services(shared_services_repo=Depends(get_repository(SharedServiceRepository)), user=Depends(require_tre_user_or_admin), resource_template_repo=Depends(get_repository(ResourceTemplateRepository))) -> SharedServicesInList: +@shared_services_router.get("/shared-services", response_model=SharedServicesInList | RestrictedSharedServicesInList, name=strings.API_GET_ALL_SHARED_SERVICES, dependencies=[Depends(require_tre_user_or_admin)]) +async def retrieve_shared_services(shared_services_repo=Depends(get_repository(SharedServiceRepository)), user=Depends(require_tre_user_or_admin), resource_template_repo=Depends(get_repository(ResourceTemplateRepository))) -> SharedServicesInList | RestrictedSharedServicesInList: shared_services = await shared_services_repo.get_active_shared_services() await asyncio.gather(*[enrich_resource_with_available_upgrades(shared_service, resource_template_repo) for shared_service in shared_services]) if user_is_tre_admin(user): return SharedServicesInList(sharedServices=shared_services) else: - return RestrictedSharedServicesInList(sharedServices=shared_services) + return RestrictedSharedServicesInList(sharedServices=[service.model_dump() for service in shared_services]) -@shared_services_router.get("/shared-services/{shared_service_id}", response_model=SharedServiceInResponse, name=strings.API_GET_SHARED_SERVICE_BY_ID, dependencies=[Depends(require_tre_user_or_admin), Depends(get_shared_service_by_id_from_path)]) -async def retrieve_shared_service_by_id(shared_service=Depends(get_shared_service_by_id_from_path), user=Depends(require_tre_user_or_admin), resource_template_repo=Depends(get_repository(ResourceTemplateRepository))): +@shared_services_router.get("/shared-services/{shared_service_id}", response_model=SharedServiceInResponse | RestrictedSharedServiceInResponse, name=strings.API_GET_SHARED_SERVICE_BY_ID, dependencies=[Depends(require_tre_user_or_admin), Depends(get_shared_service_by_id_from_path)]) +async def retrieve_shared_service_by_id(shared_service=Depends(get_shared_service_by_id_from_path), user=Depends(require_tre_user_or_admin), resource_template_repo=Depends(get_repository(ResourceTemplateRepository))) -> SharedServiceInResponse | RestrictedSharedServiceInResponse: await enrich_resource_with_available_upgrades(shared_service, resource_template_repo) if user_is_tre_admin(user): return SharedServiceInResponse(sharedService=shared_service) else: - return RestrictedSharedServiceInResponse(sharedService=shared_service) + return RestrictedSharedServiceInResponse(sharedService=shared_service.model_dump()) @shared_services_router.post("/shared-services", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_CREATE_SHARED_SERVICE, dependencies=[Depends(require_tre_admin)]) diff --git a/api_app/api/routes/user_resource_templates.py b/api_app/api/routes/user_resource_templates.py index 2ae18bfabf..d5c950f1d4 100644 --- a/api_app/api/routes/user_resource_templates.py +++ b/api_app/api/routes/user_resource_templates.py @@ -1,15 +1,15 @@ 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 -from db.errors import EntityVersionExist, InvalidInput from api.helpers import get_repository +from db.errors import EntityVersionExist, InvalidInput from db.repositories.resource_templates import ResourceTemplateRepository from models.domain.resource import ResourceType -from models.schemas.user_resource_template import UserResourceTemplateInResponse, UserResourceTemplateInCreate +from models.schemas.user_resource_template import UserResourceTemplateInCreate, UserResourceTemplateInResponse from models.schemas.resource_template import ResourceTemplateInformationInList from resources import strings from auth.rbac import require_tre_admin, require_tre_user_or_admin @@ -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(require_tre_user_or_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(require_tre_admin)]) diff --git a/api_app/api/routes/workspace_service_templates.py b/api_app/api/routes/workspace_service_templates.py index 48acb4aad7..e61b947951 100644 --- a/api_app/api/routes/workspace_service_templates.py +++ b/api_app/api/routes/workspace_service_templates.py @@ -1,10 +1,10 @@ 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 from api.helpers import get_repository +from db.errors import EntityVersionExist, InvalidInput from db.repositories.resource_templates import ResourceTemplateRepository from models.domain.resource import ResourceType from models.schemas.resource_template import ResourceTemplateInResponse, ResourceTemplateInformationInList @@ -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(require_tre_user_or_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(require_tre_admin)]) diff --git a/api_app/api/routes/workspace_templates.py b/api_app/api/routes/workspace_templates.py index 32aefbf787..acf504c9cb 100644 --- a/api_app/api/routes/workspace_templates.py +++ b/api_app/api/routes/workspace_templates.py @@ -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 @@ -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) diff --git a/api_app/auth/models.py b/api_app/auth/models.py index 209c3126be..9a92d161b7 100644 --- a/api_app/auth/models.py +++ b/api_app/auth/models.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import Optional, Tuple, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class TRERole(StrEnum): @@ -25,6 +25,8 @@ class AuthenticatedUser(BaseModel): in place (e.g. ``roles.append(...)``) after creation. """ + model_config = ConfigDict(frozen=True) + id: str name: str email: Optional[str] = None @@ -32,9 +34,6 @@ class AuthenticatedUser(BaseModel): audience: str = "" is_workspace_token: bool = False - class Config: - frozen = True - def has_any_role(self, *roles: Union[TRERole, WorkspaceAccessRole]) -> bool: """Return *True* if the user holds at least one of *roles*.""" role_values = {r.value for r in roles} diff --git a/api_app/db/repositories/airlock_requests.py b/api_app/db/repositories/airlock_requests.py index 2cc37144bb..21184c6e61 100644 --- a/api_app/db/repositories/airlock_requests.py +++ b/api_app/db/repositories/airlock_requests.py @@ -2,11 +2,11 @@ import uuid from datetime import datetime, timezone, UTC -from typing import List, Optional +from typing import List, Optional, Union 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_aad_service from models.domain.authentication import User @@ -34,7 +34,7 @@ def get_resource_base_spec_params(): def get_timestamp(self) -> float: return datetime.now(timezone.utc).timestamp() - async def update_airlock_request_item(self, original_request: AirlockRequest, new_request: AirlockRequest, updated_by: User, request_properties: dict) -> AirlockRequest: + async def update_airlock_request_item(self, original_request: AirlockRequest, new_request: AirlockRequest, updated_by: Union[User, dict], request_properties: dict) -> AirlockRequest: history_item = AirlockRequestHistoryItem( resourceVersion=original_request.resourceVersion, updatedWhen=original_request.updatedWhen, @@ -45,7 +45,12 @@ 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 + if hasattr(updated_by, "model_dump"): + new_request.updatedBy = updated_by.model_dump() + elif isinstance(updated_by, dict): + new_request.updatedBy = updated_by + else: + raise TypeError("updated_by must be a User model or dict") new_request.updatedWhen = self.get_timestamp() await self.upsert_item_with_etag(new_request, new_request.etag) @@ -151,14 +156,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_id: str, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]: workspace_repo = await WorkspaceRepository.create() @@ -184,7 +189,7 @@ async def get_airlock_requests_for_airlock_manager(self, user_id: str, type: Opt async def update_airlock_request( self, original_request: AirlockRequest, - updated_by: User, + updated_by: Union[User, dict], new_status: Optional[AirlockRequestStatus] = None, request_files: Optional[List[AirlockFile]] = None, status_message: Optional[str] = None, @@ -246,7 +251,7 @@ def _build_updated_request( status_message: Optional[Optional[str]] = None, airlock_review: Optional[AirlockReview] = None, review_user_resource: Optional[AirlockReviewUserResource] = None, - updated_by: Optional[User] = None) -> AirlockRequest: + updated_by: Optional[Union[User, dict]] = None) -> AirlockRequest: updated_request = copy.deepcopy(original_request) if new_status is not None: @@ -266,7 +271,9 @@ def _build_updated_request( updated_request.reviews.append(airlock_review) if review_user_resource is not None and updated_by is not None: - updated_request.reviewUserResources[updated_by.id] = review_user_resource + reviewer_id = updated_by.id if hasattr(updated_by, "id") else updated_by.get("id") + if reviewer_id: + updated_request.reviewUserResources[reviewer_id] = review_user_resource return updated_request diff --git a/api_app/db/repositories/base.py b/api_app/db/repositories/base.py index 7fe5371b5c..a4cd5aed4b 100644 --- a/api_app/db/repositories/base.py +++ b/api_app/db/repositories/base.py @@ -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) diff --git a/api_app/db/repositories/operations.py b/api_app/db/repositories/operations.py index 73badc9ca4..f85d69af68 100644 --- a/api_app/db/repositories/operations.py +++ b/api_app/db/repositories/operations.py @@ -2,7 +2,7 @@ import uuid from typing import List -from pydantic import parse_obj_as +from pydantic import TypeAdapter from db.repositories.resource_templates import ResourceTemplateRepository from resources import strings from models.domain.request_action import RequestAction @@ -64,7 +64,7 @@ async def create_operation_item(self, resource_id: str, resource_list: List, act primary_parent_workspace_service = await resource_repo.get_resource_by_id(resource["parentWorkspaceServiceId"]) primary_parent_service_name = primary_parent_workspace_service.templateName resource_template = await resource_template_repo.get_template_by_name_and_version(name, version, resource_type, primary_parent_service_name) - resource_template_dict = resource_template.dict(exclude_none=True) + resource_template_dict = resource_template.model_dump(exclude_none=True) # if the template has a pipeline defined for this action, copy over all the steps to the ops document steps = await self.build_step_list( steps=[], @@ -93,7 +93,7 @@ async def create_operation_item(self, resource_id: str, resource_list: List, act updatedWhen=timestamp, action=action, message=message, - user=user, + user=user.model_dump(), steps=all_steps ) @@ -178,17 +178,17 @@ async def get_operation_by_id(self, operation_id: str) -> Operation: operation = await self.query(query=query) if not operation: raise EntityDoesNotExist - return parse_obj_as(Operation, operation[0]) + return TypeAdapter(Operation).validate_python(operation[0]) async def get_my_operations(self, user_id: str) -> List[Operation]: query = self.operations_query() + f' c.user.id = "{user_id}" AND c.status IN ("{Status.AwaitingAction}", "{Status.InvokingAction}", "{Status.AwaitingDeployment}", "{Status.Deploying}", "{Status.AwaitingDeletion}", "{Status.Deleting}", "{Status.AwaitingUpdate}", "{Status.Updating}", "{Status.PipelineRunning}") ORDER BY c.createdWhen ASC' operations = await self.query(query=query) - return parse_obj_as(List[Operation], operations) + return TypeAdapter(List[Operation]).validate_python(operations) async def get_operations_by_resource_id(self, resource_id: str) -> List[Operation]: query = self.operations_query() + f' c.resourceId = "{resource_id}"' operations = await self.query(query=query) - return parse_obj_as(List[Operation], operations) + return TypeAdapter(List[Operation]).validate_python(operations) async def resource_has_deployed_operation(self, resource_id: str) -> bool: query = self.operations_query() + f' c.resourceId = "{resource_id}" AND ((c.action = "{RequestAction.Install}" AND c.status = "{Status.Deployed}") OR (c.action = "{RequestAction.Upgrade}" AND c.status = "{Status.Updated}"))' diff --git a/api_app/db/repositories/resource_templates.py b/api_app/db/repositories/resource_templates.py index 655f494684..36ac905b94 100644 --- a/api_app/db/repositories/resource_templates.py +++ b/api_app/db/repositories/resource_templates.py @@ -1,7 +1,7 @@ import uuid from typing import List, Optional, Union -from pydantic import parse_obj_as +from pydantic import TypeAdapter from core import config from db.errors import DuplicateEntity, EntityDoesNotExist, EntityVersionExist, InvalidInput @@ -55,7 +55,7 @@ async def get_templates_information(self, resource_type: ResourceType, user_role query += ' AND c.parentWorkspaceService = @parentWorkspaceService' parameters.append({'name': '@parentWorkspaceService', 'value': parent_service_name}) template_infos = await self.query(query=query, parameters=parameters) - templates = [parse_obj_as(ResourceTemplateInformation, info) for info in template_infos] + templates = [TypeAdapter(ResourceTemplateInformation).validate_python(info) for info in template_infos] if not user_roles: return templates @@ -77,9 +77,9 @@ async def get_current_template(self, template_name: str, resource_type: Resource if len(templates) > 1: raise DuplicateEntity if resource_type == ResourceType.UserResource: - return parse_obj_as(UserResourceTemplate, templates[0]) + return TypeAdapter(UserResourceTemplate).validate_python(templates[0]) else: - return parse_obj_as(ResourceTemplate, templates[0]) + return TypeAdapter(ResourceTemplate).validate_python(templates[0]) async def get_template_by_name_and_version(self, name: str, version: str, resource_type: ResourceType, parent_service_name: Optional[str] = None) -> Union[ResourceTemplate, UserResourceTemplate]: """ @@ -104,9 +104,9 @@ async def get_template_by_name_and_version(self, name: str, version: str, resour if len(templates) != 1: raise EntityDoesNotExist if resource_type == ResourceType.UserResource: - return parse_obj_as(UserResourceTemplate, templates[0]) + return TypeAdapter(UserResourceTemplate).validate_python(templates[0]) else: - return parse_obj_as(ResourceTemplate, templates[0]) + return TypeAdapter(ResourceTemplate).validate_python(templates[0]) async def get_all_template_versions(self, template_name: str) -> List[str]: query = 'SELECT VALUE c.version FROM c where c.name = @template_name' @@ -145,9 +145,9 @@ async def create_template(self, template_input: ResourceTemplateInCreate, resour if resource_type == ResourceType.UserResource: template["parentWorkspaceService"] = parent_service_name - template = parse_obj_as(UserResourceTemplate, template) + template = TypeAdapter(UserResourceTemplate).validate_python(template) else: - template = parse_obj_as(ResourceTemplate, template) + template = TypeAdapter(ResourceTemplate).validate_python(template) await self.save_item(template) return template diff --git a/api_app/db/repositories/resources.py b/api_app/db/repositories/resources.py index efa8af841e..3762b1ab04 100644 --- a/api_app/db/repositories/resources.py +++ b/api_app/db/repositories/resources.py @@ -20,7 +20,7 @@ from models.domain.workspace import Workspace from models.domain.workspace_service import WorkspaceService from models.schemas.resource import ResourcePatch -from pydantic import UUID4, parse_obj_as +from pydantic import UUID4, TypeAdapter class ResourceRepository(BaseRepository): @@ -46,9 +46,37 @@ def _active_resources_by_id_query(self, resource_id: str): ] return query, parameters + @staticmethod + def _normalize_template_schema(resource_template: dict) -> dict: + """Remove invalid legacy nested $id values from template schemas. + + jsonschema>=4.25 rejects non-empty fragment identifiers for $id. + Historical templates include property-level values with non-empty + fragments, such as "#/properties/foo" and "#properties/foo", which + are not required for validation. + """ + normalized_template = copy.deepcopy(resource_template) + + def _walk(node, is_root=False): + if isinstance(node, dict): + # Keep top-level $id intact; nested $id values with non-empty + # fragments are invalid under newer JSON Schema metaschemas. + schema_id = node.get("$id") + if not is_root and isinstance(schema_id, str) and schema_id.partition("#")[2]: + node.pop("$id", None) + for value in node.values(): + _walk(value) + elif isinstance(node, list): + for value in node: + _walk(value) + + _walk(normalized_template, is_root=True) + return normalized_template + @staticmethod def _validate_resource_parameters(resource_input, resource_template): - validate(instance=resource_input["properties"], schema=resource_template) + normalized_template = ResourceRepository._normalize_template_schema(resource_template) + validate(instance=resource_input["properties"], schema=normalized_template) async def _get_enriched_template(self, template_name: str, resource_type: ResourceType, parent_template_name: str = "") -> dict: template_repo = await ResourceTemplateRepository.create() @@ -70,15 +98,14 @@ async def get_resource_by_id(self, resource_id: UUID4) -> Resource: resource = await self.get_resource_dict_by_id(resource_id) if resource["resourceType"] == ResourceType.SharedService: - return parse_obj_as(SharedService, resource) + return TypeAdapter(SharedService).validate_python(resource) if resource["resourceType"] == ResourceType.Workspace: - return parse_obj_as(Workspace, resource) + return TypeAdapter(Workspace).validate_python(resource) if resource["resourceType"] == ResourceType.WorkspaceService: - return parse_obj_as(WorkspaceService, resource) + return TypeAdapter(WorkspaceService).validate_python(resource) if resource["resourceType"] == ResourceType.UserResource: - return parse_obj_as(UserResource, resource) - - return parse_obj_as(Resource, resource) + return TypeAdapter(UserResource).validate_python(resource) + return TypeAdapter(Resource).validate_python(resource) async def get_active_resource_by_template_name(self, template_name: str) -> Resource: query = "SELECT TOP 1 * FROM c WHERE c.templateName = @templateName AND c.deploymentStatus != @deletedStatus AND c.deploymentStatus != @failedStatus" @@ -90,7 +117,7 @@ async def get_active_resource_by_template_name(self, template_name: str) -> Reso resources = await self.query(query=query, parameters=parameters) if not resources: raise EntityDoesNotExist - return parse_obj_as(Resource, resources[0]) + return TypeAdapter(Resource).validate_python(resources[0]) async def validate_input_against_template(self, template_name: str, resource_input, resource_type: ResourceType, user_roles: Optional[List[str]] = None, parent_template_name: Optional[str] = None) -> ResourceTemplate: try: @@ -107,15 +134,14 @@ async def validate_input_against_template(self, template_name: str, resource_inp if len(set(template["authorizedRoles"]).intersection(set(user_roles))) == 0: raise UserNotAuthorizedToUseTemplate(f"User not authorized to use template {template_name}") - self._validate_resource_parameters(resource_input.dict(), template) - - return parse_obj_as(ResourceTemplate, template) + self._validate_resource_parameters(resource_input.model_dump(), template) + return TypeAdapter(ResourceTemplate).validate_python(template) async def patch_resource(self, resource: Resource, resource_patch: ResourcePatch, resource_template: ResourceTemplate, etag: str, resource_template_repo: ResourceTemplateRepository, resource_history_repo: ResourceHistoryRepository, user: User, resource_action: str, force_version_update: bool = False) -> Tuple[Resource, ResourceTemplate]: await resource_history_repo.create_resource_history_item(resource) # now update the resource props resource.resourceVersion = resource.resourceVersion + 1 - resource.user = user + resource.user = user.model_dump() if hasattr(user, "model_dump") else user resource.updatedWhen = self.get_timestamp() if resource_patch.isEnabled is not None: @@ -195,7 +221,7 @@ def validate_patch(self, resource_patch: ResourcePatch, resource_template_repo: if (resource_action == RESOURCE_ACTION_INSTALL or prop.get("updateable", False) is True): update_template["properties"][prop_name] = prop - self._validate_resource_parameters(resource_patch.dict(), update_template) + self._validate_resource_parameters(resource_patch.model_dump(), update_template) def get_timestamp(self) -> float: return datetime.now(UTC).timestamp() diff --git a/api_app/db/repositories/resources_history.py b/api_app/db/repositories/resources_history.py index 306f5255a1..4a2044bb36 100644 --- a/api_app/db/repositories/resources_history.py +++ b/api_app/db/repositories/resources_history.py @@ -1,6 +1,6 @@ from typing import List import uuid -from pydantic import parse_obj_as +from pydantic import TypeAdapter from db.errors import EntityDoesNotExist from db.repositories.base import BaseRepository @@ -41,13 +41,12 @@ async def get_resource_history_by_resource_id(self, resource_id: str) -> List[Re except EntityDoesNotExist: logger.info(f"No history for resource {resource_id}") resource_history_items = [] - return parse_obj_as(List[ResourceHistoryItem], resource_history_items) + return TypeAdapter(List[ResourceHistoryItem]).validate_python(resource_history_items) async def create_resource_history_item(self, resource: Resource) -> ResourceHistoryItem: logger.info(f"Creating a new history item for resource {resource.id}") - resource_history_item_id = str(uuid.uuid4()) resource_history_item = ResourceHistoryItem( - id=resource_history_item_id, + id=str(uuid.uuid4()), resourceId=resource.id, isEnabled=resource.isEnabled, properties=resource.properties, diff --git a/api_app/db/repositories/shared_services.py b/api_app/db/repositories/shared_services.py index 4d877fb53e..7d2a0d9a78 100644 --- a/api_app/db/repositories/shared_services.py +++ b/api_app/db/repositories/shared_services.py @@ -2,7 +2,7 @@ from typing import List, Tuple import uuid -from pydantic import parse_obj_as +from pydantic import TypeAdapter import resources.strings as strings from models.domain.resource_template import ResourceTemplate from models.domain.authentication import User @@ -60,7 +60,7 @@ async def get_shared_service_by_id(self, shared_service_id: str): shared_services = await self.query(query=query, parameters=parameters) if not shared_services: raise EntityDoesNotExist - return parse_obj_as(SharedService, shared_services[0]) + return TypeAdapter(SharedService).validate_python(shared_services[0]) async def get_active_shared_services(self) -> List[SharedService]: """ @@ -68,7 +68,7 @@ async def get_active_shared_services(self) -> List[SharedService]: """ query, parameters = SharedServiceRepository.active_shared_services_query() shared_services = await self.query(query=query, parameters=parameters) - return parse_obj_as(List[SharedService], shared_services) + return TypeAdapter(List[SharedService]).validate_python(shared_services) def get_shared_service_spec_params(self): return self.get_resource_base_spec_params() diff --git a/api_app/db/repositories/user_resources.py b/api_app/db/repositories/user_resources.py index 0ba896281a..6df46adcc2 100644 --- a/api_app/db/repositories/user_resources.py +++ b/api_app/db/repositories/user_resources.py @@ -1,7 +1,7 @@ import uuid from typing import List, Tuple -from pydantic import parse_obj_as +from pydantic import TypeAdapter from db.repositories.resources_history import ResourceHistoryRepository from models.domain.resource_template import ResourceTemplate from models.domain.authentication import User @@ -73,7 +73,7 @@ async def get_user_resources_for_workspace_service(self, workspace_id: str, serv """ query, parameters = self.active_user_resources_query(str(workspace_id), str(service_id)) user_resources = await self.query(query=query, parameters=parameters) - return parse_obj_as(List[UserResource], user_resources) + return TypeAdapter(List[UserResource]).validate_python(user_resources) async def get_user_resource_by_id(self, workspace_id: str, service_id: str, resource_id: str) -> UserResource: query, parameters = self.user_resources_query(str(workspace_id), str(service_id)) @@ -83,7 +83,7 @@ async def get_user_resource_by_id(self, workspace_id: str, service_id: str, reso user_resources = await self.query(query=query, parameters=parameters) if not user_resources: raise EntityDoesNotExist - return parse_obj_as(UserResource, user_resources[0]) + return TypeAdapter(UserResource).validate_python(user_resources[0]) def get_user_resource_spec_params(self): return self.get_resource_base_spec_params() diff --git a/api_app/db/repositories/workspace_services.py b/api_app/db/repositories/workspace_services.py index 0b2def4cdd..d4b5bcbfbf 100644 --- a/api_app/db/repositories/workspace_services.py +++ b/api_app/db/repositories/workspace_services.py @@ -1,7 +1,7 @@ import uuid from typing import List, Tuple -from pydantic import parse_obj_as +from pydantic import TypeAdapter from db.repositories.resources_history import ResourceHistoryRepository from models.domain.resource_template import ResourceTemplate from models.domain.authentication import User @@ -50,7 +50,7 @@ async def get_active_workspace_services_for_workspace(self, workspace_id: str) - """ query, parameters = WorkspaceServiceRepository.active_workspace_services_query(str(workspace_id)) workspace_services = await self.query(query=query, parameters=parameters) - return parse_obj_as(List[WorkspaceService], workspace_services) + return TypeAdapter(List[WorkspaceService]).validate_python(workspace_services) async def get_deployed_workspace_service_by_id(self, workspace_id: str, service_id: str, operations_repo: OperationRepository) -> WorkspaceService: workspace_service = await self.get_workspace_service_by_id(workspace_id, service_id) @@ -68,7 +68,7 @@ async def get_workspace_service_by_id(self, workspace_id: str, service_id: str) workspace_services = await self.query(query=query, parameters=parameters) if not workspace_services: raise EntityDoesNotExist - return parse_obj_as(WorkspaceService, workspace_services[0]) + return TypeAdapter(WorkspaceService).validate_python(workspace_services[0]) def get_workspace_service_spec_params(self): return self.get_resource_base_spec_params() diff --git a/api_app/db/repositories/workspaces.py b/api_app/db/repositories/workspaces.py index f63c1ec0f4..14656c0a63 100644 --- a/api_app/db/repositories/workspaces.py +++ b/api_app/db/repositories/workspaces.py @@ -3,7 +3,7 @@ import asyncio from azure.mgmt.storage.aio import StorageManagementClient -from pydantic import parse_obj_as +from pydantic import TypeAdapter from db.repositories.resources_history import ResourceHistoryRepository from models.domain.resource_template import ResourceTemplate from models.domain.authentication import User @@ -58,12 +58,12 @@ def active_workspaces_query_string(): async def get_workspaces(self) -> List[Workspace]: query, parameters = WorkspaceRepository.workspaces_query_string() workspaces = await self.query(query=query, parameters=parameters) - return parse_obj_as(List[Workspace], workspaces) + return TypeAdapter(List[Workspace]).validate_python(workspaces) async def get_active_workspaces(self) -> List[Workspace]: query, parameters = WorkspaceRepository.active_workspaces_query_string() workspaces = await self.query(query=query, parameters=parameters) - return parse_obj_as(List[Workspace], workspaces) + return TypeAdapter(List[Workspace]).validate_python(workspaces) async def get_deployed_workspace_by_id(self, workspace_id: str, operations_repo: OperationRepository) -> Workspace: workspace = await self.get_workspace_by_id(workspace_id) @@ -81,7 +81,7 @@ async def get_workspace_by_id(self, workspace_id: str) -> Workspace: workspaces = await self.query(query=query, parameters=parameters) if not workspaces: raise EntityDoesNotExist - return parse_obj_as(Workspace, workspaces[0]) + return TypeAdapter(Workspace).validate_python(workspaces[0]) # Remove this method once not using last 4 digits for naming - https://github.com/microsoft/AzureTRE/issues/3666 async def is_workspace_storage_account_available(self, credential, workspace_id: str) -> bool: diff --git a/api_app/event_grid/event_sender.py b/api_app/event_grid/event_sender.py index 1821c65589..588a74e20a 100644 --- a/api_app/event_grid/event_sender.py +++ b/api_app/event_grid/event_sender.py @@ -1,5 +1,4 @@ import re -import json from typing import Dict, Optional from azure.eventgrid import EventGridEvent @@ -20,7 +19,7 @@ async def send_status_changed_event(airlock_request: AirlockRequest, previous_st status_changed_event = EventGridEvent( event_type="statusChanged", - data=StatusChangedData(request_id=request_id, new_status=new_status, previous_status=previous_status, type=request_type, workspace_id=short_workspace_id).__dict__, + data=StatusChangedData(request_id=request_id, new_status=new_status, previous_status=previous_status, type=request_type, workspace_id=short_workspace_id).model_dump(mode="json"), subject=f"{request_id}/statusChanged", data_version="2.0" ) @@ -56,9 +55,8 @@ def to_snake_case(string: str): ) # For EventGridEvent, data should be a Dict[str, object] - # Becuase data has nested objects, they all need to be recursively converted to dict - # To do that, we use a json() method implemented for all objects in AzureTREModel, and convert it back from json - data_dict = json.loads(data.json()) + # Because data has nested objects, use JSON mode to recursively produce Event Grid-safe values + data_dict = data.model_dump(mode="json") airlock_notification = EventGridEvent( event_type="airlockNotification", diff --git a/api_app/models/domain/airlock_operations.py b/api_app/models/domain/airlock_operations.py index eda5d6f494..ad397c94fe 100644 --- a/api_app/models/domain/airlock_operations.py +++ b/api_app/models/domain/airlock_operations.py @@ -7,10 +7,10 @@ class EventGridMessageData(AzureTREModel): completed_step: str = Field(title="", description="") - new_status: Optional[str] = Field(title="", description="") + new_status: Optional[str] = Field(default=None, title="", description="") request_id: str = Field(title="", description="") - request_files: Optional[List[AirlockFile]] = Field(title="", description="") - status_message: Optional[str] = Field(title="", description="") + request_files: Optional[List[AirlockFile]] = Field(default=None, title="", description="") + status_message: Optional[str] = Field(default=None, title="", description="") class StepResultStatusUpdateMessage(AzureTREModel): diff --git a/api_app/models/domain/airlock_request.py b/api_app/models/domain/airlock_request.py index 37fe67f646..23ab8a626c 100644 --- a/api_app/models/domain/airlock_request.py +++ b/api_app/models/domain/airlock_request.py @@ -2,7 +2,8 @@ from typing import List, Dict, Optional from models.domain.azuretremodel import AzureTREModel -from pydantic import Field, validator +from pydantic import field_validator, Field + from resources import strings @@ -53,10 +54,17 @@ class AirlockReview(AzureTREModel): Airlock review """ id: str = Field(title="Id", description="GUID identifying the review") - reviewer: dict = {} - dateCreated: float = 0 - reviewDecision: AirlockReviewDecision = Field("", title="Airlock review decision") - decisionExplanation: str = Field(False, title="Explanation why the request was approved/rejected") + reviewer: dict = Field(default_factory=dict) + dateCreated: float = 0.0 + reviewDecision: AirlockReviewDecision = Field(title="Airlock review decision") + decisionExplanation: str = Field(default="", title="Explanation why the request was approved/rejected") + + @field_validator("reviewer", mode="before") + @classmethod + def convert_reviewer_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value class AirlockRequestHistoryItem(AzureTREModel): @@ -65,8 +73,15 @@ class AirlockRequestHistoryItem(AzureTREModel): """ resourceVersion: int updatedWhen: float - updatedBy: dict = {} - properties: dict = {} + updatedBy: dict = Field(default_factory=dict) + properties: dict = Field(default_factory=dict) + + @field_validator("updatedBy", mode="before") + @classmethod + def convert_updated_by_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value class AirlockReviewUserResource(AzureTREModel): @@ -84,24 +99,39 @@ class AirlockRequest(AzureTREModel): """ id: str = Field(title="Id", description="GUID identifying the resource") resourceVersion: int = 0 - createdBy: dict = {} - createdWhen: float = Field(None, title="Creation time of the request") - updatedBy: dict = {} - updatedWhen: float = 0 - history: List[AirlockRequestHistoryItem] = [] - workspaceId: str = Field("", title="Workspace ID", description="Service target Workspace id") - type: AirlockRequestType = Field("", title="Airlock request type") - files: List[AirlockFile] = Field([], title="Files of the request") - title: str = Field("Airlock Request", title="Brief title for the request") - businessJustification: str = Field("Business Justification", title="Explanation that will be provided to the request reviewer") - status = AirlockRequestStatus.Draft - statusMessage: Optional[str] = Field(title="Optional - contains additional information about the current status.") - reviews: Optional[List[AirlockReview]] - etag: Optional[str] = Field(title="_etag", alias="_etag") - reviewUserResources: Dict[str, AirlockReviewUserResource] = Field({}, title="User resources created for Airlock Reviews") + createdBy: dict = Field(default_factory=dict) + createdWhen: Optional[float] = Field(None, title="Creation time of the request") + updatedBy: dict = Field(default_factory=dict) + updatedWhen: float = 0.0 + history: List[AirlockRequestHistoryItem] = Field(default_factory=list) + workspaceId: str = Field(default="", title="Workspace ID", description="Service target Workspace id") + type: Optional[AirlockRequestType] = Field(None, title="Airlock request type") + files: List[AirlockFile] = Field(default_factory=list, title="Files of the request") + title: str = Field(default="Airlock Request", title="Brief title for the request") + businessJustification: str = Field(default="Business Justification", title="Explanation that will be provided to the request reviewer") + status: AirlockRequestStatus = AirlockRequestStatus.Draft + statusMessage: Optional[str] = Field(None, title="Optional - contains additional information about the current status.") + reviews: Optional[List[AirlockReview]] = None + etag: Optional[str] = Field(None, title="_etag", alias="_etag") + reviewUserResources: Dict[str, AirlockReviewUserResource] = Field(default_factory=dict, title="User resources created for Airlock Reviews") # SQL API CosmosDB saves ETag as an escaped string: https://github.com/microsoft/AzureTRE/issues/1931 - @validator("etag", pre=True) + @field_validator("etag", mode="before") + @classmethod def parse_etag_to_remove_escaped_quotes(cls, value): if value: return value.replace('\"', '') + + @field_validator("createdBy", mode="before") + @classmethod + def convert_created_by_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + @field_validator("updatedBy", mode="before") + @classmethod + def convert_updated_by_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value diff --git a/api_app/models/domain/authentication.py b/api_app/models/domain/authentication.py index 99513ef361..fa402bd4a0 100644 --- a/api_app/models/domain/authentication.py +++ b/api_app/models/domain/authentication.py @@ -1,5 +1,5 @@ from collections import namedtuple -from typing import List +from typing import List, Optional from pydantic import BaseModel, Field RoleAssignment = namedtuple("RoleAssignment", "resource_id, role_id") @@ -8,6 +8,6 @@ class User(BaseModel): id: str name: str - email: str = Field(None) - roles: List[str] = Field([]) - roleAssignments: List[RoleAssignment] = Field([]) + email: Optional[str] = Field(default=None) + roles: List[str] = Field(default_factory=list) + roleAssignments: List[RoleAssignment] = Field(default_factory=list) diff --git a/api_app/models/domain/azuretremodel.py b/api_app/models/domain/azuretremodel.py index dd7dde690c..ed596f0904 100644 --- a/api_app/models/domain/azuretremodel.py +++ b/api_app/models/domain/azuretremodel.py @@ -1,7 +1,8 @@ -from pydantic import BaseConfig, BaseModel +from pydantic import BaseModel, ConfigDict class AzureTREModel(BaseModel): - class Config(BaseConfig): - allow_population_by_field_name = True - arbitrary_types_allowed = True + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True + ) diff --git a/api_app/models/domain/costs.py b/api_app/models/domain/costs.py index 192454b177..2abda8bc0e 100644 --- a/api_app/models/domain/costs.py +++ b/api_app/models/domain/costs.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta, date as Date from typing import List, Optional from pydantic import BaseModel from enum import StrEnum @@ -93,7 +93,7 @@ def generate_workspace_cost_report_dict_example(name: str, granularity: Granular class CostRow(BaseModel): cost: float currency: str - date: Optional[date] + date: Optional[Date] = None class CostItem(BaseModel): diff --git a/api_app/models/domain/events.py b/api_app/models/domain/events.py index 76d7c557c9..6ec48888c2 100644 --- a/api_app/models/domain/events.py +++ b/api_app/models/domain/events.py @@ -6,7 +6,7 @@ class AirlockNotificationUserData(AzureTREModel): name: str - email: str + email: Optional[str] = None class AirlockNotificationRequestData(AzureTREModel): @@ -37,6 +37,6 @@ class AirlockNotificationData(AzureTREModel): class StatusChangedData(AzureTREModel): request_id: str new_status: str - previous_status: Optional[str] + previous_status: Optional[str] = None type: str workspace_id: str diff --git a/api_app/models/domain/operation.py b/api_app/models/domain/operation.py index f8c2dd36dc..9cb7b258ff 100644 --- a/api_app/models/domain/operation.py +++ b/api_app/models/domain/operation.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import List, Optional -from pydantic import Field +from pydantic import Field, field_validator from pydantic.types import UUID4 from models.domain.azuretremodel import AzureTREModel @@ -44,16 +44,16 @@ class OperationStep(AzureTREModel): """ id: str = Field(title="Id", description="Unique id identifying the step") templateStepId: str = Field(title="templateStepId", description="Unique id identifying the step") - stepTitle: Optional[str] = Field(title="stepTitle", description="Human readable title of what the step is for") - resourceId: Optional[str] = Field(title="resourceId", description="Id of the resource to update") + stepTitle: Optional[str] = Field(default=None, title="stepTitle", description="Human readable title of what the step is for") + resourceId: Optional[str] = Field(default=None, title="resourceId", description="Id of the resource to update") resourceTemplateName: Optional[str] = Field("", title="resourceTemplateName", description="Name of the template for the resource under change") - resourceType: Optional[ResourceType] = Field(title="resourceType", description="Type of resource under change") - resourceAction: Optional[str] = Field(title="resourceAction", description="Action - install / upgrade / uninstall etc") + resourceType: Optional[ResourceType] = Field(default=None, title="resourceType", description="Type of resource under change") + resourceAction: Optional[str] = Field(default=None, title="resourceAction", description="Action - install / upgrade / uninstall etc") status: Optional[Status] = Field(None, title="Operation step status") message: Optional[str] = Field("", title="Additional operation step status information") - updatedWhen: Optional[float] = Field("", title="POSIX Timestamp for When the operation step was updated") + updatedWhen: Optional[float] = Field(default=None, title="POSIX Timestamp for When the operation step was updated") # An example for this property will be if we have a step that is responsible for updating the firewall, and its origin was the guacamole workspace service, the id here will be the guacamole id - sourceTemplateResourceId: Optional[str] = Field(title="sourceTemplateResourceId", description="Id of the parent of the resource to update") + sourceTemplateResourceId: Optional[str] = Field(default=None, title="sourceTemplateResourceId", description="Id of the parent of the resource to update") def is_success(self) -> bool: return self.status in ( @@ -87,14 +87,21 @@ class Operation(AzureTREModel): resourceId: str = Field(title="resourceId", description="GUID identifying the resource") resourcePath: str = Field(title="resourcePath", description="Path of the resource undergoing change, i.e. '/workspaces/guid/workspace-services/guid/'") resourceVersion: int = Field(0, title="resourceVersion", description="Version of the resource this operation relates to") - status: Status = Field(None, title="Operation status") + status: Status = Field(Status.AwaitingDeployment, title="Operation status") action: str = Field(title="action", description="Name of the action being performed on the resource, i.e. install, uninstall, start") message: str = Field("", title="Additional operation status information") - createdWhen: float = Field("", title="POSIX Timestamp for when the operation was submitted") - updatedWhen: float = Field("", title="POSIX Timestamp for When the operation was updated") - user: dict = {} + createdWhen: float = Field(0.0, title="POSIX Timestamp for when the operation was submitted") + updatedWhen: float = Field(0.0, title="POSIX Timestamp for When the operation was updated") + user: dict = Field(default_factory=dict) steps: Optional[List[OperationStep]] = Field(None, title="Operation Steps") + @field_validator("user", mode="before") + @classmethod + def convert_user_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value + class DeploymentStatusUpdateMessage(AzureTREModel): """ @@ -105,4 +112,4 @@ class DeploymentStatusUpdateMessage(AzureTREModel): id: UUID4 = Field(title="", description="") status: Status = Field(title="", description="") message: str = Field(title="", description="") - outputs: List[Output] = Field(title="", description="", default=[]) + outputs: List[Output] = Field(title="", description="", default_factory=list) diff --git a/api_app/models/domain/resource.py b/api_app/models/domain/resource.py index 1e660059ba..7677f96b13 100644 --- a/api_app/models/domain/resource.py +++ b/api_app/models/domain/resource.py @@ -1,6 +1,6 @@ from enum import StrEnum from typing import Optional, Union, List -from pydantic import BaseModel, Field, validator +from pydantic import field_validator, BaseModel, Field from models.domain.azuretremodel import AzureTREModel from models.domain.request_action import RequestAction from resources import strings @@ -22,12 +22,20 @@ class ResourceHistoryItem(AzureTREModel): """ id: str = Field(title="Id", description="GUID identifying the resource request") resourceId: str = Field(title="Id", description="GUID identifying the resource request") - properties: dict = Field({}, title="Resource template parameters", description="Parameters for the deployment") + properties: dict = Field(default_factory=dict, title="Resource template parameters", description="Parameters for the deployment") isEnabled: bool = True resourceVersion: int = 0 - updatedWhen: float = 0 - user: dict = {} - templateVersion: Optional[str] = Field(title="Resource template version", description="The version of the resource template (bundle) to deploy") + updatedWhen: float = 0.0 + user: dict = Field(default_factory=dict) + + @field_validator("user", mode="before") + @classmethod + def convert_user_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + templateVersion: Optional[str] = Field(None, title="Resource template version", description="The version of the resource template (bundle) to deploy") class AvailableUpgrade(BaseModel): @@ -42,16 +50,34 @@ class Resource(AzureTREModel): id: str = Field(title="Id", description="GUID identifying the resource request") templateName: str = Field(title="Resource template name", description="The resource template (bundle) to deploy") templateVersion: str = Field(title="Resource template version", description="The version of the resource template (bundle) to deploy") - properties: dict = Field({}, title="Resource template parameters", description="Parameters for the deployment") - availableUpgrades: Optional[List[AvailableUpgrade]] = Field(title="Available template upgrades", description="Versions of the template that are available for upgrade") + properties: dict = Field(default_factory=dict, title="Resource template parameters", description="Parameters for the deployment") + availableUpgrades: Optional[List[AvailableUpgrade]] = Field(None, title="Available template upgrades", description="Versions of the template that are available for upgrade") isEnabled: bool = True # Must be set before a resource can be deleted resourceType: ResourceType - deploymentStatus: Optional[str] = Field(title="Deployment Status", description="Overall deployment status of the resource") + deploymentStatus: Optional[str] = Field(None, title="Deployment Status", description="Overall deployment status of the resource") etag: str = Field(title="_etag", description="eTag of the document", alias="_etag") resourcePath: str = "" resourceVersion: int = 0 - user: dict = {} - updatedWhen: float = 0 + user: dict = Field(default_factory=dict) + updatedWhen: float = 0.0 + + @field_validator("properties", mode="before") + @classmethod + def convert_properties_to_dict(cls, value): + if value is None: + return {} + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + @field_validator("user", mode="before") + @classmethod + def convert_user_to_dict(cls, value): + if value is None: + return {} + if hasattr(value, "model_dump"): + return value.model_dump() + return value def get_resource_request_message_payload(self, operation_id: str, step_id: str, action: RequestAction) -> dict: payload = { @@ -76,12 +102,13 @@ def get_resource_request_message_payload(self, operation_id: str, step_id: str, # SQL API CosmosDB saves etag as an escaped string by default, with no apparent way to change it. # Removing escaped quotes on pydantic deserialization. https://github.com/microsoft/AzureTRE/issues/1931 - @validator("etag", pre=True) + @field_validator("etag", mode="before") + @classmethod def parse_etag_to_remove_escaped_quotes(cls, value): return value.replace('\"', '') class Output(AzureTREModel): Name: str = Field(title="", description="", alias="name") - Value: Union[list, dict, str] = Field(None, title="", description="", alias="value") + Value: Optional[Union[list, dict, str]] = Field(..., title="", description="", alias="value") Type: str = Field(title="", description="", alias="type") diff --git a/api_app/models/domain/resource_template.py b/api_app/models/domain/resource_template.py index aa213dedef..2b31a5d3ac 100644 --- a/api_app/models/domain/resource_template.py +++ b/api_app/models/domain/resource_template.py @@ -1,78 +1,114 @@ from typing import Dict, Any, List, Optional, Union -from pydantic import Field +from pydantic import ConfigDict, Field, model_serializer from models.domain.azuretremodel import AzureTREModel from models.domain.resource import ResourceType +def _strip_none_recursive(obj: Any) -> None: + if isinstance(obj, dict): + for key in list(obj.keys()): + if obj[key] is None and key not in {"const", "default"}: + del obj[key] + else: + _strip_none_recursive(obj[key]) + elif isinstance(obj, list): + for item in obj: + _strip_none_recursive(item) + + class Property(AzureTREModel): - type: str = Field(title="Property type") - title: str = Field("", title="Property description") - description: str = Field("", title="Property description") - default: Any = Field(None, title="Default value for the property") - enum: Optional[List[str]] = Field(None, title="Enum values") - const: Optional[Any] = Field(None, title="Constant value") - multipleOf: Optional[float] = Field(None, title="Multiple of") - maximum: Optional[float] = Field(None, title="Maximum value") - exclusiveMaximum: Optional[float] = Field(None, title="Exclusive maximum value") - minimum: Optional[float] = Field(None, title="Minimum value") - exclusiveMinimum: Optional[float] = Field(None, title="Exclusive minimum value") - maxLength: Optional[int] = Field(None, title="Maximum length") - minLength: Optional[int] = Field(None, title="Minimum length") - pattern: Optional[str] = Field(None, title="Pattern") - updateable: Optional[bool] = Field(None, title="Indicates that the field can be updated") - sensitive: Optional[bool] = Field(None, title="Indicates that the field is a sensitive value") - readOnly: Optional[bool] = Field(None, title="Indicates the field is read-only") + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") # extra preserves unknown JSON Schema keywords (e.g. $ref, oneOf, format) + + @model_serializer(mode='plain') + def _serialize(self) -> dict: + # Emit only explicitly-set fields plus extra keywords; strip None at all nesting levels + if not hasattr(self, 'model_fields_set'): + # Pydantic passed an uncoerced plain dict (e.g. via item-level assignment to properties) + result = dict(self.items()) + _strip_none_recursive(result) + return result + data = {k: v for k, v in ((f, getattr(self, f)) for f in self.model_fields_set)} + if self.__pydantic_extra__: + data.update(dict(self.__pydantic_extra__.items())) + _strip_none_recursive(data) + return data + + type: Optional[str] = Field(default=None, title="Property type") + title: str = Field(default="", title="Property description") + description: Optional[str] = Field(default=None, title="Property description") + default: Any = Field(default=None, title="Default value for the property") + enum: Optional[List[Any]] = Field(default=None, title="Enum values") + const: Optional[Any] = Field(default=None, title="Constant value") + multipleOf: Optional[float] = Field(default=None, title="Multiple of") + maximum: Optional[float] = Field(default=None, title="Maximum value") + exclusiveMaximum: Optional[float] = Field(default=None, title="Exclusive maximum value") + minimum: Optional[float] = Field(default=None, title="Minimum value") + exclusiveMinimum: Optional[float] = Field(default=None, title="Exclusive minimum value") + maxLength: Optional[int] = Field(default=None, title="Maximum length") + minLength: Optional[int] = Field(default=None, title="Minimum length") + pattern: Optional[str] = Field(default=None, title="Pattern") + updateable: Optional[bool] = Field(default=None, title="Indicates that the field can be updated") + sensitive: Optional[bool] = Field(default=None, title="Indicates that the field is a sensitive value") + readOnly: Optional[bool] = Field(default=None, title="Indicates the field is read-only") items: Optional[dict] = None # items can contain sub-properties properties: Optional[dict] = None class CustomAction(AzureTREModel): - name: str = Field(None, title="Custom action name") - description: str = Field("", title="Action description") + name: Optional[str] = Field(default=None, title="Custom action name") + description: str = Field(default="", title="Action description") class PipelineStepProperty(AzureTREModel): name: str = Field(title="name", description="name of the property to update") type: str = Field(title="type", description="data type of the property to update") - value: Union[dict, str] = Field(None, title="value", description="value to use in substitution for the property to update") - arraySubstitutionAction: Optional[str] = Field("", title="Array Substitution Action", description="How to treat existing values of this property in an array [overwrite | append | replace | remove]") - arrayMatchField: Optional[str] = Field("", title="Array match field", description="Name of the field to use for finding an item in an array - to replace/remove it") + value: Optional[Union[dict, str]] = Field(default=None, title="value", description="value to use in substitution for the property to update") + arraySubstitutionAction: Optional[str] = Field(default="", title="Array Substitution Action", description="How to treat existing values of this property in an array [overwrite | append | replace | remove]") + arrayMatchField: Optional[str] = Field(default="", title="Array match field", description="Name of the field to use for finding an item in an array - to replace/remove it") class PipelineStep(AzureTREModel): - stepId: Optional[str] = Field(title="stepId", description="Unique id identifying the step") - stepTitle: Optional[str] = Field(title="stepTitle", description="Human readable title of what the step is for") - resourceTemplateName: Optional[str] = Field(title="resourceTemplateName", description="Name of the template for the resource under change") - resourceType: Optional[ResourceType] = Field(title="resourceType", description="Type of resource under change") - resourceAction: Optional[str] = Field(title="resourceAction", description="Action - install / upgrade / uninstall etc") - properties: Optional[List[PipelineStepProperty]] + stepId: Optional[str] = Field(default=None, title="stepId", description="Unique id identifying the step") + stepTitle: Optional[str] = Field(default=None, title="stepTitle", description="Human readable title of what the step is for") + resourceTemplateName: Optional[str] = Field(default=None, title="resourceTemplateName", description="Name of the template for the resource under change") + resourceType: Optional[ResourceType] = Field(default=None, title="resourceType", description="Type of resource under change") + resourceAction: Optional[str] = Field(default=None, title="resourceAction", description="Action - install / upgrade / uninstall etc") + properties: Optional[List[PipelineStepProperty]] = None class Pipeline(AzureTREModel): - install: Optional[List[PipelineStep]] - upgrade: Optional[List[PipelineStep]] - uninstall: Optional[List[PipelineStep]] + install: Optional[List[PipelineStep]] = None + upgrade: Optional[List[PipelineStep]] = None + uninstall: Optional[List[PipelineStep]] = None class ResourceTemplate(AzureTREModel): + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True) + + @model_serializer(mode='wrap') + def _serialize(self, handler: Any, info: Any) -> dict: + data = handler(self) + _strip_none_recursive(data) # covers allOf and other plain-dict fields missed by exclude_none + return data + id: str name: str = Field(title="Unique template name") - title: str = Field("", title="Template title or friendly name") - description: str = Field(title="Template description") + title: str = Field(default="", title="Template title or friendly name") + description: str = Field(default="", title="Template description") version: str = Field(title="Template version") resourceType: ResourceType = Field(title="Type of resource this template is for (workspace/service)") current: bool = Field(title="Is this the current version of this template") type: str = "object" required: List[str] = Field(title="List of properties which must be provided") - authorizedRoles: Optional[List[str]] = Field(default=[], title="If not empty, the user is required to have one of these roles to install the template") + authorizedRoles: Optional[List[str]] = Field(default_factory=list, title="If not empty, the user is required to have one of these roles to install the template") properties: Dict[str, Property] = Field(title="Template properties") allOf: Optional[List[dict]] = Field(default=None, title="All Of", description="Used for conditionally showing and validating fields") - actions: List[CustomAction] = Field(default=[], title="Template actions") - customActions: List[CustomAction] = Field(default=[], title="Template custom actions") + actions: List[CustomAction] = Field(default_factory=list, title="Template actions") + customActions: List[CustomAction] = Field(default_factory=list, title="Template custom actions") pipeline: Optional[Pipeline] = Field(default=None, title="Template pipeline to define updates to other resources") - uiSchema: Optional[dict] = Field(default={}, title="Dict containing a uiSchema object, if any") + uiSchema: Optional[dict] = Field(default_factory=dict, title="Dict containing a uiSchema object, if any") # setting this to false means if extra, unexpected fields are supplied, the request is invalidated unevaluatedProperties: bool = Field(default=False, title="Prevent unspecified properties being applied") diff --git a/api_app/models/domain/restricted_resource.py b/api_app/models/domain/restricted_resource.py index 4f9c993f1e..ff8a9f2789 100644 --- a/api_app/models/domain/restricted_resource.py +++ b/api_app/models/domain/restricted_resource.py @@ -1,5 +1,5 @@ from typing import Optional, List -from pydantic import Field +from pydantic import Field, field_validator from models.domain.resource import AvailableUpgrade, ResourceType from models.domain.azuretremodel import AzureTREModel @@ -19,13 +19,20 @@ class RestrictedResource(AzureTREModel): id: str = Field(title="Id", description="GUID identifying the resource request") templateName: str = Field(title="Resource template name", description="The resource template (bundle) to deploy") templateVersion: str = Field(title="Resource template version", description="The version of the resource template (bundle) to deploy") - properties: RestrictedProperties = Field(None, title="Restricted Properties", description="Resource properties safe to share with non-admins") - availableUpgrades: Optional[List[AvailableUpgrade]] = Field(title="Available template upgrades", description="Versions of the template that are available for upgrade") + properties: RestrictedProperties = Field(default_factory=RestrictedProperties, title="Restricted Properties", description="Resource properties safe to share with non-admins") + availableUpgrades: Optional[List[AvailableUpgrade]] = Field(None, title="Available template upgrades", description="Versions of the template that are available for upgrade") isEnabled: bool = True # Must be set before a resource can be deleted resourceType: ResourceType - deploymentStatus: Optional[str] = Field(title="Deployment Status", description="Overall deployment status of the resource") + deploymentStatus: Optional[str] = Field(None, title="Deployment Status", description="Overall deployment status of the resource") etag: str = Field(title="_etag", description="eTag of the document", alias="_etag") resourcePath: str = "" resourceVersion: int = 0 - user: dict = {} - updatedWhen: float = 0 + user: dict = Field(default_factory=dict) + updatedWhen: float = 0.0 + + @field_validator("user", mode="before") + @classmethod + def convert_user_to_dict(cls, value): + if hasattr(value, "model_dump"): + return value.model_dump() + return value diff --git a/api_app/models/domain/user_resource.py b/api_app/models/domain/user_resource.py index 08a415c9dc..85f90b6ed6 100644 --- a/api_app/models/domain/user_resource.py +++ b/api_app/models/domain/user_resource.py @@ -7,8 +7,8 @@ class UserResource(Resource): """ User resource """ - workspaceId: str = Field("", title="Workspace ID", description="Service target Workspace id") - ownerId: str = Field("", title="Owner of the user resource") - parentWorkspaceServiceId: str = Field("", title="Parent Workspace Service ID", description="Service target Workspace Service id") - azureStatus: dict = Field({}, title="Azure Status", description="Azure status, varies per user resource") + workspaceId: str = Field(default="", title="Workspace ID", description="Service target Workspace id") + ownerId: str = Field(default="", title="Owner of the user resource") + parentWorkspaceServiceId: str = Field(default="", title="Parent Workspace Service ID", description="Service target Workspace Service id") + azureStatus: dict = Field(default_factory=dict, title="Azure Status", description="Azure status, varies per user resource") resourceType: ResourceType = ResourceType.UserResource diff --git a/api_app/models/domain/workspace_users.py b/api_app/models/domain/workspace_users.py index 794936e395..2ce12621b6 100644 --- a/api_app/models/domain/workspace_users.py +++ b/api_app/models/domain/workspace_users.py @@ -1,5 +1,5 @@ -from typing import List -from pydantic import BaseModel, Field +from typing import List, Optional +from pydantic import BaseModel, Field, field_validator from enum import Enum @@ -7,7 +7,7 @@ class AssignableUser(BaseModel): id: str displayName: str userPrincipalName: str - email: str = Field(default=None) + email: Optional[str] = Field(default=None) class AssignmentType(Enum): @@ -19,6 +19,13 @@ class Role(BaseModel): id: str displayName: str + @field_validator("id", mode="before") + @classmethod + def convert_id_to_string(cls, value): + if value is None: + raise ValueError("Role id must not be null") + return str(value) + def __eq__(self, other): if not isinstance(other, Role): return False @@ -32,5 +39,5 @@ class AssignedUser(BaseModel): id: str displayName: str userPrincipalName: str - email: str = Field(default=None) + email: Optional[str] = Field(default=None) roles: List[Role] = Field(default_factory=list) diff --git a/api_app/models/schemas/airlock_request.py b/api_app/models/schemas/airlock_request.py index c8e480e7ad..639dbf6834 100644 --- a/api_app/models/schemas/airlock_request.py +++ b/api_app/models/schemas/airlock_request.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.operation import Operation from models.schemas.operation import get_sample_operation from models.domain.airlock_request import AirlockActions, AirlockRequest, AirlockRequestType @@ -44,87 +44,73 @@ def get_sample_airlock_request_with_allowed_user_actions(workspace_id: str) -> d class AirlockRequestInResponse(BaseModel): airlockRequest: AirlockRequest - - class Config: - schema_extra = { - "example": { - "airlockRequest": get_sample_airlock_request("933ad738-7265-4b5f-9eae-a1a62928772e", "121e921f-a4aa-44b3-90a9-e8da030495ef") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "airlockRequest": get_sample_airlock_request("933ad738-7265-4b5f-9eae-a1a62928772e", "121e921f-a4aa-44b3-90a9-e8da030495ef") } + }) class AirlockRequestAndOperationInResponse(BaseModel): airlockRequest: AirlockRequest operation: Operation - - class Config: - schema_extra = { - "example": { - "airlockRequest": get_sample_airlock_request("933ad738-7265-4b5f-9eae-a1a62928772e", "121e921f-a4aa-44b3-90a9-e8da030495ef"), - "operation": get_sample_operation("121e921f-a4aa-44b3-90a9-e8da030495ef") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "airlockRequest": get_sample_airlock_request("933ad738-7265-4b5f-9eae-a1a62928772e", "121e921f-a4aa-44b3-90a9-e8da030495ef"), + "operation": get_sample_operation("121e921f-a4aa-44b3-90a9-e8da030495ef") } + }) class AirlockRequestWithAllowedUserActions(BaseModel): - airlockRequest: AirlockRequest = Field([], title="Airlock Request") - allowedUserActions: List[str] = Field([], title="actions that the requesting user can do on the request") - - class Config: - schema_extra = { - "example": get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e"), - } + airlockRequest: AirlockRequest = Field(title="Airlock Request") + allowedUserActions: List[str] = Field(default_factory=list, title="actions that the requesting user can do on the request") + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e"), + }) class AirlockRequestWithAllowedUserActionsInList(BaseModel): - airlockRequests: List[AirlockRequestWithAllowedUserActions] = Field([], title="Airlock Requests") - - class Config: - schema_extra = { - "example": { - "airlockRequests": [ - get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e"), - get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e") - ] - } + airlockRequests: List[AirlockRequestWithAllowedUserActions] = Field(default_factory=list, title="Airlock Requests") + model_config = ConfigDict(json_schema_extra={ + "example": { + "airlockRequests": [ + get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e"), + get_sample_airlock_request_with_allowed_user_actions("933ad738-7265-4b5f-9eae-a1a62928772e") + ] } + }) class AirlockRequestInCreate(BaseModel): - type: AirlockRequestType = Field("", title="Airlock request type", description="Specifies if this is an import or an export request") + type: AirlockRequestType = Field(title="Airlock request type", description="Specifies if this is an import or an export request") title: str = Field("Airlock Request", title="Brief title for the request") businessJustification: str = Field("Business Justifications", title="Explanation that will be provided to the request reviewer") - properties: dict = Field({}, title="Airlock request parameters", description="Values for the parameters required by the Airlock request specification") - - class Config: - schema_extra = { - "example": { - "type": "import", - "title": "a request title", - "businessJustification": "some business justification" - } + properties: dict = Field(default_factory=dict, title="Airlock request parameters", description="Values for the parameters required by the Airlock request specification") + model_config = ConfigDict(json_schema_extra={ + "example": { + "type": "import", + "title": "a request title", + "businessJustification": "some business justification" } + }) class AirlockReviewInCreate(BaseModel): - approval: bool = Field("", title="Airlock review decision", description="Airlock review decision") + approval: bool = Field(title="Airlock review decision", description="Airlock review decision") decisionExplanation: str = Field("Decision Explanation", title="Explanation of the reviewer for the reviews decision") - - class Config: - schema_extra = { - "example": { - "approval": "True", - "decisionExplanation": "the reason why this request was approved/rejected" - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "approval": True, + "decisionExplanation": "the reason why this request was approved/rejected" } + }) class AirlockRevokeInCreate(BaseModel): reason: str = Field(title="Reason for revoking the approved request") - - class Config: - schema_extra = { - "example": { - "reason": "Request was approved in error or security concerns identified" - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "reason": "Request was approved in error or security concerns identified" } + }) diff --git a/api_app/models/schemas/airlock_request_url.py b/api_app/models/schemas/airlock_request_url.py index a83e3ccfdd..07a61fb088 100644 --- a/api_app/models/schemas/airlock_request_url.py +++ b/api_app/models/schemas/airlock_request_url.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel def get_sample_airlock_request_container_url(container_url: str) -> dict: @@ -9,10 +9,6 @@ def get_sample_airlock_request_container_url(container_url: str) -> dict: class AirlockRequestTokenInResponse(BaseModel): containerUrl: str - - class Config: - schema_extra = { - "example": { - "container_url": get_sample_airlock_request_container_url("container_url") - } - } + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_airlock_request_container_url("container_url") + }) diff --git a/api_app/models/schemas/operation.py b/api_app/models/schemas/operation.py index bf5740eabe..d5bf3e7ce5 100644 --- a/api_app/models/schemas/operation.py +++ b/api_app/models/schemas/operation.py @@ -1,5 +1,5 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.operation import Operation @@ -32,24 +32,20 @@ def get_sample_operation(operation_id: str) -> dict: class OperationInResponse(BaseModel): operation: Operation - - class Config: - schema_extra = { - "example": { - "operation": get_sample_operation("7ac667f0-fd3f-4a6c-815b-82d0cb7a2132") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "operation": get_sample_operation("7ac667f0-fd3f-4a6c-815b-82d0cb7a2132") } + }) class OperationInList(BaseModel): - operations: List[Operation] = Field([], title="Operations") - - class Config: - schema_extra = { - "example": { - "operations": [ - get_sample_operation("7ac667f0-fd3f-4a6c-815b-82d0cb7a2132"), - get_sample_operation("640488fe-9408-4b9f-a239-3b03bc0c5df0") - ] - } + operations: List[Operation] = Field(default_factory=list, title="Operations") + model_config = ConfigDict(json_schema_extra={ + "example": { + "operations": [ + get_sample_operation("7ac667f0-fd3f-4a6c-815b-82d0cb7a2132"), + get_sample_operation("640488fe-9408-4b9f-a239-3b03bc0c5df0") + ] } + }) diff --git a/api_app/models/schemas/resource.py b/api_app/models/schemas/resource.py index 34d84c9cb6..1271b97daa 100644 --- a/api_app/models/schemas/resource.py +++ b/api_app/models/schemas/resource.py @@ -1,55 +1,49 @@ from typing import List, Optional -from pydantic import BaseModel, Field, Extra +from pydantic import ConfigDict, BaseModel, Field from models.domain.resource import ResourceHistoryItem class ResourcePatch(BaseModel): - isEnabled: Optional[bool] - properties: Optional[dict] - templateVersion: Optional[str] - - class Config: - extra = Extra.forbid - schema_extra = { - "example": { - "isEnabled": False, - "templateVersion": "1.0.1", - "properties": { - "display_name": "the display name", - "description": "a description", - "other_fields": "other properties defined by the resource template" - } + isEnabled: Optional[bool] = None + properties: Optional[dict] = None + templateVersion: Optional[str] = None + model_config = ConfigDict(extra="forbid", json_schema_extra={ + "example": { + "isEnabled": False, + "templateVersion": "1.0.1", + "properties": { + "display_name": "the display name", + "description": "a description", + "other_fields": "other properties defined by the resource template" } } + }) def get_sample_resource_history(resource_id: str) -> dict: return { "id": "abc9ru33-7265-4b5f-9eae-a1a62928772e", "resourceId": resource_id, - "templateName": "vm", "templateVersion": "0.1.0", "properties": { "display_name": "my user resource", "description": "some description", }, - "isEnabled": "true", - "resourceVersion": "1", - "updatedWhen": "", - "user": "" + "isEnabled": True, + "resourceVersion": 1, + "updatedWhen": 0.0, + "user": {} } class ResourceHistoryInList(BaseModel): - resource_history: List[ResourceHistoryItem] = Field([], title="Resource history") - - class Config: - schema_extra = { - "example": { - "resource_history": [ - get_sample_resource_history("2fdc9fba-726e-4db6-a1b8-9018a2165748"), - get_sample_resource_history("abcc9fba-726e-4db6-a1b8-9018a2165748") - ] - } + resource_history: List[ResourceHistoryItem] = Field(default_factory=list, title="Resource history") + model_config = ConfigDict(json_schema_extra={ + "example": { + "resource_history": [ + get_sample_resource_history("2fdc9fba-726e-4db6-a1b8-9018a2165748"), + get_sample_resource_history("abcc9fba-726e-4db6-a1b8-9018a2165748") + ] } + }) diff --git a/api_app/models/schemas/resource_template.py b/api_app/models/schemas/resource_template.py index dd3b75722e..4f95cdf283 100644 --- a/api_app/models/schemas/resource_template.py +++ b/api_app/models/schemas/resource_template.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.resource_template import CustomAction, ResourceTemplate, Property @@ -10,7 +10,7 @@ class ResourceTemplateInCreate(BaseModel): version: str = Field(title="Template version") current: bool = Field(title="Mark this version as current") json_schema: Dict = Field(title="JSON Schema compliant template") - customActions: List[CustomAction] = Field(default=[], title="Custom actions") + customActions: List[CustomAction] = Field(default_factory=list, title="Custom actions") class ResourceTemplateInResponse(ResourceTemplate): @@ -21,26 +21,24 @@ class ResourceTemplateInformation(BaseModel): name: str = Field(title="Template name") title: str = Field(title="Template title", default="") description: str = Field(title="Template description", default="") - authorizedRoles: Optional[List[str]] = Field(title="If not empty, the user is required to have one of these roles to install the template", default=[]) + authorizedRoles: Optional[List[str]] = Field(title="If not empty, the user is required to have one of these roles to install the template", default_factory=list) class ResourceTemplateInformationInList(BaseModel): templates: List[ResourceTemplateInformation] - - class Config: - schema_extra = { - "example": { - "templates": [ - { - "name": "tre-workspace-base", - "title": "Base Workspace", - "description": "base description" - }, - { - "name": "tre-workspace-base", - "title": "Base Workspace", - "description": "base description" - } - ] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "templates": [ + { + "name": "tre-workspace-base", + "title": "Base Workspace", + "description": "base description" + }, + { + "name": "tre-workspace-base", + "title": "Base Workspace", + "description": "base description" + } + ] } + }) diff --git a/api_app/models/schemas/shared_service.py b/api_app/models/schemas/shared_service.py index 6c194f3faf..58968fc0e1 100644 --- a/api_app/models/schemas/shared_service.py +++ b/api_app/models/schemas/shared_service.py @@ -1,7 +1,7 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.restricted_resource import RestrictedResource from models.domain.resource import ResourceType @@ -23,65 +23,55 @@ def get_sample_shared_service(shared_service_id: str) -> dict: class SharedServiceInResponse(BaseModel): sharedService: SharedService - - class Config: - schema_extra = { - "example": { - "shared_service": get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "sharedService": get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748") } + }) class RestrictedSharedServiceInResponse(BaseModel): sharedService: RestrictedResource - - class Config: - schema_extra = { - "example": { - "shared_service": get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "sharedService": get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748") } + }) class RestrictedSharedServicesInList(BaseModel): - sharedServices: List[RestrictedResource] = Field([], title="shared services") - - class Config: - schema_extra = { - "example": { - "sharedServices": [ - get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748"), - get_sample_shared_service("abcc9fba-726e-4db6-a1b8-9018a2165748") - ] - } + sharedServices: List[RestrictedResource] = Field(default_factory=list, title="shared services") + model_config = ConfigDict(json_schema_extra={ + "example": { + "sharedServices": [ + get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748"), + get_sample_shared_service("abcc9fba-726e-4db6-a1b8-9018a2165748") + ] } + }) class SharedServicesInList(BaseModel): - sharedServices: List[SharedService] = Field([], title="shared services") - - class Config: - schema_extra = { - "example": { - "sharedServices": [ - get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748"), - get_sample_shared_service("abcc9fba-726e-4db6-a1b8-9018a2165748") - ] - } + sharedServices: List[SharedService] = Field(default_factory=list, title="shared services") + model_config = ConfigDict(json_schema_extra={ + "example": { + "sharedServices": [ + get_sample_shared_service("2fdc9fba-726e-4db6-a1b8-9018a2165748"), + get_sample_shared_service("abcc9fba-726e-4db6-a1b8-9018a2165748") + ] } + }) class SharedServiceInCreate(BaseModel): templateName: str = Field(title="Shared service type", description="Bundle name") - properties: dict = Field({}, title="Shared service parameters", description="Values for the parameters required by the shared service resource specification") - - class Config: - schema_extra = { - "example": { - "templateName": "tre-shared-service-firewall", - "properties": { - "display_name": "My shared service", - "description": "Some description", - } + properties: dict = Field(default_factory=dict, title="Shared service parameters", description="Values for the parameters required by the shared service resource specification") + model_config = ConfigDict(json_schema_extra={ + "example": { + "templateName": "tre-shared-service-firewall", + "properties": { + "display_name": "My shared service", + "description": "Some description", } } + }) diff --git a/api_app/models/schemas/shared_service_template.py b/api_app/models/schemas/shared_service_template.py index 048973b374..60a407f3a9 100644 --- a/api_app/models/schemas/shared_service_template.py +++ b/api_app/models/schemas/shared_service_template.py @@ -1,6 +1,7 @@ from models.domain.resource import ResourceType from models.domain.resource_template import ResourceTemplate, Property, CustomAction from models.schemas.resource_template import ResourceTemplateInCreate, ResourceTemplateInResponse +from pydantic import ConfigDict def get_sample_shared_service_template_object(template_name: str = "tre-shared-service") -> ResourceTemplate: @@ -23,48 +24,46 @@ def get_sample_shared_service_template_object(template_name: str = "tre-shared-s def get_sample_shared_service_template() -> dict: - return get_sample_shared_service_template_object().dict() + return get_sample_shared_service_template_object().model_dump() def get_sample_shared_service_template_in_response() -> dict: shared_template = get_sample_shared_service_template() shared_template["system_properties"] = { - "tre_id": Property(type="string"), - "shared_service_id": Property(type="string"), - "azure_location": Property(type="string"), + "tre_id": Property(type="string").model_dump(), + "shared_service_id": Property(type="string").model_dump(), + "azure_location": Property(type="string").model_dump(), } return shared_template class SharedServiceTemplateInCreate(ResourceTemplateInCreate): - class Config: - schema_extra = { - "example": { - "name": "my-tre-shared-service", - "version": "0.0.1", - "current": "true", - "json_schema": { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/microsoft/AzureTRE/templates/shared_services/myshared_service/shared_service.json", - "type": "object", - "title": "My Shared Service Template", - "description": "These is a test shared service resource template schema", - "required": [], - "authorizedRoles": [], - "properties": {} - }, - "customActions": [ - { - "name": "disable", - "description": "Deallocates resources" - } - ] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "name": "my-tre-shared-service", + "version": "0.0.1", + "current": True, + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/shared_services/myshared_service/shared_service.json", + "type": "object", + "title": "My Shared Service Template", + "description": "These is a test shared service resource template schema", + "required": [], + "authorizedRoles": [], + "properties": {} + }, + "customActions": [ + { + "name": "disable", + "description": "Deallocates resources" + } + ] } + }) class SharedServiceTemplateInResponse(ResourceTemplateInResponse): - class Config: - schema_extra = { - "example": get_sample_shared_service_template_in_response() - } + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_shared_service_template_in_response() + }) diff --git a/api_app/models/schemas/user_resource.py b/api_app/models/schemas/user_resource.py index a39f69759f..17b475045f 100644 --- a/api_app/models/schemas/user_resource.py +++ b/api_app/models/schemas/user_resource.py @@ -1,6 +1,6 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.resource import ResourceType from models.domain.user_resource import UserResource @@ -27,40 +27,34 @@ def get_sample_user_resource(user_resource_id: str) -> dict: class UserResourceInResponse(BaseModel): userResource: UserResource - - class Config: - schema_extra = { - "example": { - "user_resource": get_sample_user_resource("933ad738-7265-4b5f-9eae-a1a62928772e") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "userResource": get_sample_user_resource("933ad738-7265-4b5f-9eae-a1a62928772e") } + }) class UserResourcesInList(BaseModel): - userResources: List[UserResource] = Field([], title="User resources") - - class Config: - schema_extra = { - "example": { - "userResources": [ - get_sample_user_resource("2fdc9fba-726e-4db6-a1b8-9018a2165748"), - get_sample_user_resource("abcc9fba-726e-4db6-a1b8-9018a2165748") - ] - } + userResources: List[UserResource] = Field(default_factory=list, title="User resources") + model_config = ConfigDict(json_schema_extra={ + "example": { + "userResources": [ + get_sample_user_resource("2fdc9fba-726e-4db6-a1b8-9018a2165748"), + get_sample_user_resource("abcc9fba-726e-4db6-a1b8-9018a2165748") + ] } + }) class UserResourceInCreate(BaseModel): templateName: str = Field(title="User resource type", description="Bundle name") - properties: dict = Field({}, title="User resource parameters", description="Values for the parameters required by the user resource specification") - - class Config: - schema_extra = { - "example": { - "templateName": "user-resource-type", - "properties": { - "display_name": "my user resource", - "description": "some description", - } + properties: dict = Field(default_factory=dict, title="User resource parameters", description="Values for the parameters required by the user resource specification") + model_config = ConfigDict(json_schema_extra={ + "example": { + "templateName": "user-resource-type", + "properties": { + "display_name": "my user resource", + "description": "some description", } } + }) diff --git a/api_app/models/schemas/user_resource_template.py b/api_app/models/schemas/user_resource_template.py index cc0013cd75..ec25a94d59 100644 --- a/api_app/models/schemas/user_resource_template.py +++ b/api_app/models/schemas/user_resource_template.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import ConfigDict, Field from models.domain.resource import ResourceType from models.domain.resource_template import CustomAction, Property @@ -27,50 +27,51 @@ def get_sample_user_resource_template_object(template_name: str = "guacamole-vm" def get_sample_user_resource_template() -> dict: - return get_sample_user_resource_template_object().dict() + return get_sample_user_resource_template_object().model_dump() def get_sample_user_resource_template_in_response() -> dict: workspace_template = get_sample_user_resource_template() + workspace_template["system_properties"] = { + "tre_id": Property(type="string").model_dump(), + "workspace_id": Property(type="string").model_dump(), + "azure_location": Property(type="string").model_dump(), + } return workspace_template class UserResourceTemplateInCreate(ResourceTemplateInCreate): - - class Config: - schema_extra = { - "example": { - "name": "my-tre-user-resource", - "version": "0.0.1", - "current": "true", - "json_schema": { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/user_resource.json", - "type": "object", - "title": "My User Resource Template", - "description": "These is a test user resource template schema", - "required": [], - "authorizedRoles": [], - "properties": {}, + model_config = ConfigDict(json_schema_extra={ + "example": { + "name": "my-tre-user-resource", + "version": "0.0.1", + "current": True, + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/user_resource.json", + "type": "object", + "title": "My User Resource Template", + "description": "These is a test user resource template schema", + "required": [], + "authorizedRoles": [], + "properties": {}, + }, + "customActions": [ + { + "name": "start", + "description": "Starts a VM" }, - "customActions": [ - { - "name": "start", - "description": "Starts a VM" - }, - { - "name": "stop", - "description": "Stops a VM" - } - ] - } + { + "name": "stop", + "description": "Stops a VM" + } + ] } + }) class UserResourceTemplateInResponse(ResourceTemplateInResponse): parentWorkspaceService: str = Field(title="Workspace type", description="Bundle name") - - class Config: - schema_extra = { - "example": get_sample_user_resource_template_in_response() - } + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_user_resource_template_in_response() + }) diff --git a/api_app/models/schemas/users.py b/api_app/models/schemas/users.py index b2792d0171..f829e6de60 100644 --- a/api_app/models/schemas/users.py +++ b/api_app/models/schemas/users.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from typing import List from models.domain.workspace_users import AssignedUser, AssignableUser @@ -6,40 +6,38 @@ class UsersInResponse(BaseModel): users: List[AssignedUser] = Field(..., title="Users", description="List of users assigned to the workspace") - - class Config: - schema_extra = { - "example": { - "users": [ - { - "id": 1, - "displayName": "John Doe", - "userPrincipalName": "john.doe@example.com", - "roles": [ - { - "id": 1, - "displayName": "WorkspaceOwner" - }, - { - "id": 2, - "displayName": "WorkspaceResearcher" - } - ] - }, - { - "id": 2, - "displayName": "Jane Smith", - "userPrincipalName": "jane.smith@example.com", - "roles": [ - { - "id": 2, - "displayName": "WorkspaceResearcher" - } - ] - } - ] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "users": [ + { + "id": "1", + "displayName": "John Doe", + "userPrincipalName": "john.doe@example.com", + "roles": [ + { + "id": "1", + "displayName": "WorkspaceOwner" + }, + { + "id": "2", + "displayName": "WorkspaceResearcher" + } + ] + }, + { + "id": "2", + "displayName": "Jane Smith", + "userPrincipalName": "jane.smith@example.com", + "roles": [ + { + "id": "2", + "displayName": "WorkspaceResearcher" + } + ] + } + ] } + }) class AssignableUsersInResponse(BaseModel): diff --git a/api_app/models/schemas/workspace.py b/api_app/models/schemas/workspace.py index 94c6bf861e..45b5c831a3 100644 --- a/api_app/models/schemas/workspace.py +++ b/api_app/models/schemas/workspace.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.resource import ResourceType from models.domain.workspace import Workspace, WorkspaceAuth @@ -32,60 +32,54 @@ class AuthProvider(StrEnum): class AuthenticationConfiguration(BaseModel): provider: AuthProvider = Field(AuthProvider.AAD, title="Authentication Provider") - data: dict = Field({}, title="Authentication information") + data: dict = Field(default_factory=dict, title="Authentication information") class WorkspaceInResponse(BaseModel): workspace: Workspace - - class Config: - schema_extra = { - "example": { - "workspace": get_sample_workspace("933ad738-7265-4b5f-9eae-a1a62928772e") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "workspace": get_sample_workspace("933ad738-7265-4b5f-9eae-a1a62928772e") } + }) class WorkspaceAuthInResponse(BaseModel): workspaceAuth: WorkspaceAuth - - class Config: - schema_extra = { - "example": { + model_config = ConfigDict(json_schema_extra={ + "example": { + "workspaceAuth": { "scopeId": "api://mytre-ws-1233456" } } + }) class WorkspacesInList(BaseModel): workspaces: List[Workspace] - - class Config: - schema_extra = { - "example": { - "workspaces": [ - get_sample_workspace("933ad738-7265-4b5f-9eae-a1a62928772e", "0001"), - get_sample_workspace("2fdc9fba-726e-4db6-a1b8-9018a2165748", "0002"), - ] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "workspaces": [ + get_sample_workspace("933ad738-7265-4b5f-9eae-a1a62928772e", "0001"), + get_sample_workspace("2fdc9fba-726e-4db6-a1b8-9018a2165748", "0002"), + ] } + }) class WorkspaceInCreate(BaseModel): templateName: str = Field(title="Workspace type", description="Bundle name") - properties: dict = Field({}, title="Workspace parameters", description="Values for the parameters required by the workspace resource specification") - - class Config: - schema_extra = { - "example": { - "templateName": "tre-workspace-base", - "properties": { - "display_name": "the workspace display name", - "description": "workspace description", - "auth_type": "Manual", - "client_id": "", - "client_secret": "", - "address_space_size": "small" - } + properties: dict = Field(default_factory=dict, title="Workspace parameters", description="Values for the parameters required by the workspace resource specification") + model_config = ConfigDict(json_schema_extra={ + "example": { + "templateName": "tre-workspace-base", + "properties": { + "display_name": "the workspace display name", + "description": "workspace description", + "auth_type": "Manual", + "client_id": "", + "client_secret": "", + "address_space_size": "small" } } + }) diff --git a/api_app/models/schemas/workspace_service.py b/api_app/models/schemas/workspace_service.py index 069fa3a4f7..a639a97ee7 100644 --- a/api_app/models/schemas/workspace_service.py +++ b/api_app/models/schemas/workspace_service.py @@ -1,6 +1,6 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field from models.domain.resource import ResourceType from models.domain.workspace_service import WorkspaceService @@ -22,40 +22,34 @@ def get_sample_workspace_service(workspace_id: str, workspace_service_id: str) - class WorkspaceServiceInResponse(BaseModel): workspaceService: WorkspaceService - - class Config: - schema_extra = { - "example": { - "workspace_service": get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "2fdc9fba-726e-4db6-a1b8-9018a2165748") - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "workspaceService": get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "2fdc9fba-726e-4db6-a1b8-9018a2165748") } + }) class WorkspaceServicesInList(BaseModel): - workspaceServices: List[WorkspaceService] = Field([], title="Workspace services") - - class Config: - schema_extra = { - "example": { - "workspaceServices": [ - get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "2fdc9fba-726e-4db6-a1b8-9018a2165748"), - get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "abcc9fba-726e-4db6-a1b8-9018a2165748") - ] - } + workspaceServices: List[WorkspaceService] = Field(default_factory=list, title="Workspace services") + model_config = ConfigDict(json_schema_extra={ + "example": { + "workspaceServices": [ + get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "2fdc9fba-726e-4db6-a1b8-9018a2165748"), + get_sample_workspace_service("933ad738-7265-4b5f-9eae-a1a62928772e", "abcc9fba-726e-4db6-a1b8-9018a2165748") + ] } + }) class WorkspaceServiceInCreate(BaseModel): templateName: str = Field(title="Workspace service type", description="Bundle name") - properties: dict = Field({}, title="Workspace service parameters", description="Values for the parameters required by the workspace service resource specification") - - class Config: - schema_extra = { - "example": { - "templateName": "tre-service-guacamole", - "properties": { - "display_name": "my workspace service", - "description": "some description", - } + properties: dict = Field(default_factory=dict, title="Workspace service parameters", description="Values for the parameters required by the workspace service resource specification") + model_config = ConfigDict(json_schema_extra={ + "example": { + "templateName": "tre-service-guacamole", + "properties": { + "display_name": "my workspace service", + "description": "some description", } } + }) diff --git a/api_app/models/schemas/workspace_service_template.py b/api_app/models/schemas/workspace_service_template.py index c3f493169e..cf3eca69f9 100644 --- a/api_app/models/schemas/workspace_service_template.py +++ b/api_app/models/schemas/workspace_service_template.py @@ -1,6 +1,7 @@ from models.domain.resource import ResourceType from models.domain.resource_template import ResourceTemplate, Property, CustomAction from models.schemas.resource_template import ResourceTemplateInCreate, ResourceTemplateInResponse +from pydantic import ConfigDict def get_sample_workspace_service_template_object(template_name: str = "tre-workspace-service") -> ResourceTemplate: @@ -23,50 +24,46 @@ def get_sample_workspace_service_template_object(template_name: str = "tre-works def get_sample_workspace_service_template() -> dict: - return get_sample_workspace_service_template_object().dict() + return get_sample_workspace_service_template_object().model_dump() def get_sample_workspace_service_template_in_response() -> dict: workspace_template = get_sample_workspace_service_template() workspace_template["system_properties"] = { - "tre_id": Property(type="string"), - "workspace_id": Property(type="string"), - "azure_location": Property(type="string"), + "tre_id": Property(type="string").model_dump(), + "workspace_id": Property(type="string").model_dump(), + "azure_location": Property(type="string").model_dump(), } return workspace_template class WorkspaceServiceTemplateInCreate(ResourceTemplateInCreate): - - class Config: - schema_extra = { - "example": { - "name": "my-tre-workspace-service", - "version": "0.0.1", - "current": "true", - "json_schema": { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/workspace_service.json", - "type": "object", - "title": "My Workspace Service Template", - "description": "These is a test workspace service resource template schema", - "required": [], - "authorizedRoles": [], - "properties": {} - }, - "customActions": [ - { - "name": "disable", - "description": "Deallocates resources" - } - ] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "name": "my-tre-workspace-service", + "version": "0.0.1", + "current": True, + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/workspace_service.json", + "type": "object", + "title": "My Workspace Service Template", + "description": "These is a test workspace service resource template schema", + "required": [], + "authorizedRoles": [], + "properties": {} + }, + "customActions": [ + { + "name": "disable", + "description": "Deallocates resources" + } + ] } + }) class WorkspaceServiceTemplateInResponse(ResourceTemplateInResponse): - - class Config: - schema_extra = { - "example": get_sample_workspace_service_template_in_response() - } + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_workspace_service_template_in_response() + }) diff --git a/api_app/models/schemas/workspace_template.py b/api_app/models/schemas/workspace_template.py index bc20955217..c6d84a4586 100644 --- a/api_app/models/schemas/workspace_template.py +++ b/api_app/models/schemas/workspace_template.py @@ -1,6 +1,7 @@ from models.domain.resource import ResourceType from models.domain.resource_template import CustomAction, ResourceTemplate, Property from models.schemas.resource_template import ResourceTemplateInCreate, ResourceTemplateInResponse +from pydantic import ConfigDict def get_sample_workspace_template_object(template_name: str = "tre-workspace-base") -> ResourceTemplate: @@ -31,70 +32,66 @@ def get_sample_workspace_template_object(template_name: str = "tre-workspace-bas def get_sample_workspace_template_in_response() -> dict: - workspace_template = get_sample_workspace_template_object().dict() + workspace_template = get_sample_workspace_template_object().model_dump() workspace_template["system_properties"] = { - "tre_id": Property(type="string"), - "workspace_id": Property(type="string"), - "azure_location": Property(type="string"), + "tre_id": Property(type="string").model_dump(), + "workspace_id": Property(type="string").model_dump(), + "azure_location": Property(type="string").model_dump(), } return workspace_template class WorkspaceTemplateInCreate(ResourceTemplateInCreate): - - class Config: - schema_extra = { - "example": { - "name": "my-tre-workspace", - "version": "0.0.1", - "current": "true", - "json_schema": { - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/workspace.json", - "type": "object", - "title": "My Workspace Template", - "description": "This is a test workspace template schema", - "required": [ - "vm_size", - "no_of_vms" - ], - "authorizedRoles": [], - "properties": { - "display_name": { - "type": "string", - "title": "Name for the workspace", - "description": "The name of the workspace to be displayed to users" - }, - "description": { - "type": "string", - "title": "Description of the workspace", - "description": "Description of the workspace" - }, - "address_space_size": { - "type": "string", - "title": "Address space size", - "description": "Network address size (small, medium, large or custom) to be used by the workspace" - }, - "address_space": { - "type": "string", - "title": "Address space", - "description": "Network address space to be used by the workspace if address_space_size is custom" - } - } - }, - "customActions": [ - { - "name": "disable", - "description": "Deallocates resources" + model_config = ConfigDict(json_schema_extra={ + "example": { + "name": "my-tre-workspace", + "version": "0.0.1", + "current": True, + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/workspaces/myworkspace/workspace.json", + "type": "object", + "title": "My Workspace Template", + "description": "This is a test workspace template schema", + "required": [ + "vm_size", + "no_of_vms" + ], + "authorizedRoles": [], + "properties": { + "display_name": { + "type": "string", + "title": "Name for the workspace", + "description": "The name of the workspace to be displayed to users" + }, + "description": { + "type": "string", + "title": "Description of the workspace", + "description": "Description of the workspace" + }, + "address_space_size": { + "type": "string", + "title": "Address space size", + "description": "Network address size (small, medium, large or custom) to be used by the workspace" + }, + "address_space": { + "type": "string", + "title": "Address space", + "description": "Network address space to be used by the workspace if address_space_size is custom" } - ] - } + } + }, + "customActions": [ + { + "name": "disable", + "description": "Deallocates resources" + } + ] } + }) class WorkspaceTemplateInResponse(ResourceTemplateInResponse): - - class Config: - schema_extra = { - "example": get_sample_workspace_template_in_response() - } + model_config = ConfigDict(json_schema_extra={ + "example": get_sample_workspace_template_in_response() + }) diff --git a/api_app/models/schemas/workspace_users.py b/api_app/models/schemas/workspace_users.py index b1b61eed10..e696713d01 100644 --- a/api_app/models/schemas/workspace_users.py +++ b/api_app/models/schemas/workspace_users.py @@ -1,15 +1,13 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import ConfigDict, BaseModel, Field class UserRoleAssignmentRequest(BaseModel): role_id: str = Field(title="Role Id", description="Role to assign users to") user_ids: List[str] = Field(default_factory=list, title="List of User Ids", description="List of User Ids to assign the role to") - - class Config: - schema_extra = { - "example": { - "role_id": "1234", - "user_ids": ["1", "2"] - } + model_config = ConfigDict(json_schema_extra={ + "example": { + "role_id": "1234", + "user_ids": ["1", "2"] } + }) diff --git a/api_app/requirements.txt b/api_app/requirements.txt index be68fe5bf5..cf77538325 100644 --- a/api_app/requirements.txt +++ b/api_app/requirements.txt @@ -22,4 +22,4 @@ pytz==2025.2 python-dateutil==2.9.0.post0 semantic-version==2.10.0 uvicorn[standard]==0.40.0 -pydantic==1.10.26 +pydantic==2.13.4 diff --git a/api_app/service_bus/airlock_request_status_update.py b/api_app/service_bus/airlock_request_status_update.py index a643404a86..c4d50b80d0 100644 --- a/api_app/service_bus/airlock_request_status_update.py +++ b/api_app/service_bus/airlock_request_status_update.py @@ -5,7 +5,7 @@ from azure.servicebus.aio import ServiceBusClient, AutoLockRenewer from azure.servicebus.exceptions import OperationTimeoutError, ServiceBusConnectionError from fastapi import HTTPException -from pydantic import ValidationError, parse_obj_as +from pydantic import ValidationError, TypeAdapter from api.dependencies.airlock import get_airlock_request_by_id_from_path from services.airlock import update_and_publish_event_airlock_request @@ -43,20 +43,20 @@ async def receive_messages(self): polling_count = 0 async with credentials.get_credential_async_context() as credential: - service_bus_client = ServiceBusClient(config.SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE, credential) - receiver = service_bus_client.get_queue_receiver(queue_name=config.SERVICE_BUS_STEP_RESULT_QUEUE) - logger.debug(f"Looking for new messages on {config.SERVICE_BUS_STEP_RESULT_QUEUE} queue...") - async with receiver: - received_msgs = await receiver.receive_messages(max_message_count=10, max_wait_time=1) - for msg in received_msgs: - async with AutoLockRenewer() as renewer: - renewer.register(receiver, msg, max_lock_renewal_duration=60) - complete_message = await self.process_message(msg) - if complete_message: - await receiver.complete_message(msg) - else: - # could have been any kind of transient issue, we'll abandon back to the queue, and retry - await receiver.abandon_message(msg) + async with ServiceBusClient(config.SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE, credential) as service_bus_client: + receiver = service_bus_client.get_queue_receiver(queue_name=config.SERVICE_BUS_STEP_RESULT_QUEUE) + logger.debug(f"Looking for new messages on {config.SERVICE_BUS_STEP_RESULT_QUEUE} queue...") + async with receiver: + received_msgs = await receiver.receive_messages(max_message_count=10, max_wait_time=1) + for msg in received_msgs: + async with AutoLockRenewer() as renewer: + renewer.register(receiver, msg, max_lock_renewal_duration=60) + complete_message = await self.process_message(msg) + if complete_message: + await receiver.complete_message(msg) + else: + # could have been any kind of transient issue, we'll abandon back to the queue, and retry + await receiver.abandon_message(msg) await asyncio.sleep(10) @@ -77,7 +77,7 @@ async def process_message(self, msg): complete_message = False try: - message = parse_obj_as(StepResultStatusUpdateMessage, json.loads(str(msg))) + message = TypeAdapter(StepResultStatusUpdateMessage).validate_python(json.loads(str(msg))) current_span.set_attribute("step_id", message.id) current_span.set_attribute("event_type", message.eventType) diff --git a/api_app/service_bus/deployment_status_updater.py b/api_app/service_bus/deployment_status_updater.py index 41670464c7..28189ba1cf 100644 --- a/api_app/service_bus/deployment_status_updater.py +++ b/api_app/service_bus/deployment_status_updater.py @@ -3,7 +3,7 @@ import uuid import time -from pydantic import ValidationError, parse_obj_as +from pydantic import ValidationError, TypeAdapter from api.routes.resource_helpers import get_timestamp from models.domain.resource import Output @@ -52,22 +52,21 @@ async def receive_messages(self): polling_count = 0 async with credentials.get_credential_async_context() as credential: - service_bus_client = ServiceBusClient(config.SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE, credential) - - logger.debug(f"Looking for new messages on {config.SERVICE_BUS_DEPLOYMENT_STATUS_UPDATE_QUEUE} queue...") - # max_wait_time=1 -> don't hold the session open after processing of the message has finished - async with service_bus_client.get_queue_receiver(queue_name=config.SERVICE_BUS_DEPLOYMENT_STATUS_UPDATE_QUEUE, max_wait_time=1, session_id=NEXT_AVAILABLE_SESSION) as receiver: - logger.info(f"Got a session containing messages: {receiver.session.session_id}") - async with AutoLockRenewer() as renewer: - renewer.register(receiver, receiver.session, max_lock_renewal_duration=60) - async for msg in receiver: - complete_message = await self.process_message(msg) - if complete_message: - await receiver.complete_message(msg) - else: - # could have been any kind of transient issue, we'll abandon back to the queue, and retry - await receiver.abandon_message(msg) - logger.info(f"Closing session: {receiver.session.session_id}") + async with ServiceBusClient(config.SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE, credential) as service_bus_client: + logger.debug(f"Looking for new messages on {config.SERVICE_BUS_DEPLOYMENT_STATUS_UPDATE_QUEUE} queue...") + # max_wait_time=1 -> don't hold the session open after processing of the message has finished + async with service_bus_client.get_queue_receiver(queue_name=config.SERVICE_BUS_DEPLOYMENT_STATUS_UPDATE_QUEUE, max_wait_time=1, session_id=NEXT_AVAILABLE_SESSION) as receiver: + logger.info(f"Got a session containing messages: {receiver.session.session_id}") + async with AutoLockRenewer() as renewer: + renewer.register(receiver, receiver.session, max_lock_renewal_duration=60) + async for msg in receiver: + complete_message = await self.process_message(msg) + if complete_message: + await receiver.complete_message(msg) + else: + # could have been any kind of transient issue, we'll abandon back to the queue, and retry + await receiver.abandon_message(msg) + logger.info(f"Closing session: {receiver.session.session_id}") except OperationTimeoutError: # Timeout occurred whilst connecting to a session - this is expected and indicates no non-empty sessions are available @@ -87,7 +86,7 @@ async def process_message(self, msg): with tracer.start_as_current_span("process_message") as current_span: try: - message = parse_obj_as(DeploymentStatusUpdateMessage, json.loads(str(msg))) + message = TypeAdapter(DeploymentStatusUpdateMessage).validate_python(json.loads(str(msg))) current_span.set_attribute("step_id", message.stepId) current_span.set_attribute("operation_id", message.operationId) diff --git a/api_app/service_bus/helpers.py b/api_app/service_bus/helpers.py index 56ff47a724..e1b2daf74d 100644 --- a/api_app/service_bus/helpers.py +++ b/api_app/service_bus/helpers.py @@ -1,6 +1,6 @@ from azure.servicebus import ServiceBusMessage from azure.servicebus.aio import ServiceBusClient -from pydantic import parse_obj_as +from pydantic import TypeAdapter from resources import strings from db.repositories.resources_history import ResourceHistoryRepository from service_bus.substitutions import substitute_properties @@ -68,7 +68,7 @@ async def update_resource_for_step(operation_step: OperationStep, resource_repo: if not parent_template.pipeline: return step_resource - parent_template_pipeline_dict = parent_template.pipeline.dict() + parent_template_pipeline_dict = parent_template.pipeline.model_dump() # if action not defined as a pipeline, custom action, no need to continue with substitutions. if primary_action not in parent_template_pipeline_dict: @@ -89,7 +89,7 @@ async def update_resource_for_step(operation_step: OperationStep, resource_repo: template_step = None for step in parent_template_pipeline_dict[primary_action]: if step["stepId"] == operation_step.templateStepId: - template_step = parse_obj_as(PipelineStep, step) + template_step = TypeAdapter(PipelineStep).validate_python(step) if (template_step.resourceAction is None and primary_action == strings.RESOURCE_ACTION_INSTALL): template_step.resourceAction = strings.RESOURCE_ACTION_INSTALL break diff --git a/api_app/service_bus/substitutions.py b/api_app/service_bus/substitutions.py index a0d6668b4e..72e2abb7b3 100644 --- a/api_app/service_bus/substitutions.py +++ b/api_app/service_bus/substitutions.py @@ -8,11 +8,11 @@ def substitute_properties(template_step: PipelineStep, primary_resource: Resourc properties = {} parent_ws_dict = {} parent_ws_svc_dict = {} - primary_resource_dict = primary_resource.dict() + primary_resource_dict = primary_resource.model_dump() if primary_parent_workspace is not None: - parent_ws_dict = primary_parent_workspace.dict() + parent_ws_dict = primary_parent_workspace.model_dump() if primary_parent_workspace_svc is not None: - parent_ws_svc_dict = primary_parent_workspace_svc.dict() + parent_ws_svc_dict = primary_parent_workspace_svc.model_dump() if template_step is None or template_step.properties is None: return properties diff --git a/api_app/services/airlock.py b/api_app/services/airlock.py index 36d7a158e1..3c84f56192 100644 --- a/api_app/services/airlock.py +++ b/api_app/services/airlock.py @@ -14,7 +14,7 @@ from models.schemas.airlock_request import AirlockReviewInCreate from models.schemas.airlock_request import AirlockRequestWithAllowedUserActions from models.schemas.resource import ResourcePatch -from typing import Tuple, List, Optional +from typing import Tuple, List, Optional, Union from models.schemas.user_resource import UserResourceInCreate from services.azure_resource_status import get_azure_resource_status from services.authentication import get_aad_service @@ -284,7 +284,7 @@ async def save_and_publish_event_airlock_request(airlock_request: AirlockRequest try: logger.debug(f"Saving airlock request item: {airlock_request.id}") - airlock_request.updatedBy = user + airlock_request.updatedBy = user.model_dump() airlock_request.updatedWhen = get_timestamp() await airlock_request_repo.save_item(airlock_request) except Exception: @@ -302,15 +302,16 @@ async def save_and_publish_event_airlock_request(airlock_request: AirlockRequest async def update_and_publish_event_airlock_request( - airlock_request: AirlockRequest, - airlock_request_repo: AirlockRequestRepository, - updated_by: User, - workspace: Workspace, - new_status: Optional[AirlockRequestStatus] = None, - request_files: Optional[List[AirlockFile]] = None, - status_message: Optional[str] = None, - airlock_review: Optional[AirlockReview] = None, - review_user_resource: Optional[AirlockReviewUserResource] = None) -> AirlockRequest: + airlock_request: AirlockRequest, + airlock_request_repo: AirlockRequestRepository, + updated_by: Union[User, dict], + workspace: Workspace, + new_status: Optional[AirlockRequestStatus] = None, + request_files: Optional[List[AirlockFile]] = None, + status_message: Optional[str] = None, + airlock_review: Optional[AirlockReview] = None, + review_user_resource: Optional[AirlockReviewUserResource] = None, +) -> AirlockRequest: try: logger.debug(f"Updating airlock request item: {airlock_request.id}") updated_airlock_request = await airlock_request_repo.update_airlock_request( diff --git a/api_app/services/schema_service.py b/api_app/services/schema_service.py index 65e98012aa..5483c73f4e 100644 --- a/api_app/services/schema_service.py +++ b/api_app/services/schema_service.py @@ -38,7 +38,7 @@ def read_schema(schema_file: str) -> Tuple[List[str], Dict]: def enrich_template(original_template, extra_properties, is_update: bool = False, is_workspace_scope: bool = True) -> dict: - template = original_template.dict(exclude_none=True) + template = original_template.model_dump(exclude_none=True) all_required = [definition[0] for definition in extra_properties] + [template["required"]] all_properties = [definition[1] for definition in extra_properties] + [template["properties"]] @@ -61,10 +61,6 @@ def enrich_template(original_template, extra_properties, is_update: bool = False if not prop.get("updateable", False): prop["readOnly"] = True - # if there is an 'allOf' property which is empty, the validator fails - so remove the key - if "allOf" in template and template["allOf"] is None: - template.pop("allOf") - if is_workspace_scope: id_field = "workspace_id" else: diff --git a/api_app/tests_ma/auth/test_rbac.py b/api_app/tests_ma/auth/test_rbac.py index 1b69b2b1a0..6a6a7143ea 100644 --- a/api_app/tests_ma/auth/test_rbac.py +++ b/api_app/tests_ma/auth/test_rbac.py @@ -1,6 +1,7 @@ """Tests for auth.rbac role-checking dependencies.""" import pytest from unittest.mock import MagicMock, patch +from pydantic import ValidationError from auth.models import AuthenticatedUser, TRERole, WorkspaceAccessRole from auth.rbac import require_roles, require_workspace_roles @@ -278,7 +279,7 @@ def test_is_tre_admin_false_for_regular_user(self): def test_model_is_frozen(self): user = _make_user(roles=["TREAdmin"]) - with pytest.raises(TypeError): + with pytest.raises(ValidationError): user.roles = [] # type: ignore[misc] def test_roles_cannot_be_mutated_in_place(self): diff --git a/api_app/tests_ma/auth/test_token_validator.py b/api_app/tests_ma/auth/test_token_validator.py index f0833772e4..2d96f079c1 100644 --- a/api_app/tests_ma/auth/test_token_validator.py +++ b/api_app/tests_ma/auth/test_token_validator.py @@ -1,6 +1,7 @@ """Tests for auth.token_validator.""" import pytest from unittest.mock import MagicMock, patch +from pydantic import ValidationError from auth.exceptions import TokenExpired, TokenInvalid, TokenSignatureInvalid from auth.models import AuthenticatedUser @@ -58,7 +59,7 @@ def test_frozen_user_cannot_be_mutated(self): with patch("auth.token_validator.jwt.decode", return_value=SAMPLE_CLAIMS): user = validator.validate("valid.jwt.token") - with pytest.raises(TypeError): + with pytest.raises(ValidationError): user.roles = [] # type: ignore[misc] # roles is a tuple, so in-place escalation is impossible too diff --git a/api_app/tests_ma/conftest.py b/api_app/tests_ma/conftest.py index 6245ec23ec..2331ab8b44 100644 --- a/api_app/tests_ma/conftest.py +++ b/api_app/tests_ma/conftest.py @@ -524,6 +524,11 @@ def resource_to_update() -> Resource: @pytest.fixture def pipeline_step() -> PipelineStep: return PipelineStep( + stepId="test-step-id", + stepTitle="Test Pipeline Step", + resourceTemplateName="test-template", + resourceType=ResourceType.Workspace, + resourceAction="install", properties=[ PipelineStepProperty( name="rule_collections", @@ -557,6 +562,11 @@ def pipeline_step() -> PipelineStep: @pytest.fixture def simple_pipeline_step() -> PipelineStep: return PipelineStep( + stepId="simple-step-id", + stepTitle="Simple Pipeline Step", + resourceTemplateName="simple-template", + resourceType=ResourceType.Workspace, + resourceAction="install", properties=[ PipelineStepProperty( name="just_text", type="string", value="Updated by {{resource.id}}" diff --git a/api_app/tests_ma/test_api/test_openapi.py b/api_app/tests_ma/test_api/test_openapi.py new file mode 100644 index 0000000000..362f234c10 --- /dev/null +++ b/api_app/tests_ma/test_api/test_openapi.py @@ -0,0 +1,21 @@ +import pytest +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +pytestmark = pytest.mark.asyncio + + +# Regression test for pydantic v2 OpenAPI generation. +# Response model examples embedded raw Property model instances in +# json_schema_extra, which broke schema generation with +# "TypeError: unhashable type: 'Property'". +@pytest.mark.filterwarnings("ignore::UserWarning") +async def test_openapi_schema_generates(app: FastAPI): + schema = get_openapi( + title=app.title, + version=app.version, + routes=app.routes, + ) + + assert schema["paths"], "OpenAPI schema should contain paths" + assert schema["components"]["schemas"], "OpenAPI schema should contain component schemas" diff --git a/api_app/tests_ma/test_api/test_routes/test_airlock.py b/api_app/tests_ma/test_api/test_routes/test_airlock.py index ee4a1c4254..7d8f295768 100644 --- a/api_app/tests_ma/test_api/test_routes/test_airlock.py +++ b/api_app/tests_ma/test_api/test_routes/test_airlock.py @@ -33,14 +33,16 @@ def sample_airlock_request_input_data(): return { "type": "import", - "businessJustification": "some business justification" + "title": "a request title", + "businessJustification": "some business justification", + "properties": {} } @pytest.fixture def sample_airlock_review_input_data(): return { - "reviewDecision": "approved", + "approval": True, "decisionExplanation": "the reason why this request was approved/rejected" } diff --git a/api_app/tests_ma/test_api/test_routes/test_requests.py b/api_app/tests_ma/test_api/test_routes/test_requests.py index b1f393d198..ba216ff715 100644 --- a/api_app/tests_ma/test_api/test_routes/test_requests.py +++ b/api_app/tests_ma/test_api/test_routes/test_requests.py @@ -41,7 +41,7 @@ async def test_get_airlock_manager_requests_returns_500(self, _, app, client): response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True}) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - @patch("api.routes.requests.AirlockRequestRepository.get_airlock_requests", return_value=[{"id": "1", "status": AirlockRequestStatus.InReview}]) + @patch("api.routes.requests.AirlockRequestRepository.get_airlock_requests", return_value=[{"id": "1", "type": AirlockRequestType.Import, "status": AirlockRequestStatus.InReview}]) async def test_get_requests_with_status_filter_returns_correct_results(self, mock_get_airlock_requests, app, client): response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"status": AirlockRequestStatus.InReview}) @@ -52,7 +52,7 @@ async def test_get_requests_with_status_filter_returns_correct_results(self, moc assert len(response.json()) == 1 assert response.json()[0]["status"] == AirlockRequestStatus.InReview - @patch("api.routes.requests.AirlockRequestRepository.get_airlock_requests_for_airlock_manager", return_value=[{"id": "2", "status": AirlockRequestStatus.InReview}]) + @patch("api.routes.requests.AirlockRequestRepository.get_airlock_requests_for_airlock_manager", return_value=[{"id": "2", "type": AirlockRequestType.Import, "status": AirlockRequestStatus.InReview}]) async def test_get_requests_with_airlock_manager_filter_returns_correct_results(self, mock_get_airlock_requests_for_airlock_manager, app, client): response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True}) diff --git a/api_app/tests_ma/test_api/test_routes/test_shared_service_templates.py b/api_app/tests_ma/test_api/test_routes/test_shared_service_templates.py index bd370a415c..98c6e0f43c 100644 --- a/api_app/tests_ma/test_api/test_routes/test_shared_service_templates.py +++ b/api_app/tests_ma/test_api/test_routes/test_shared_service_templates.py @@ -2,7 +2,7 @@ import pytest from mock import patch -from pydantic import parse_obj_as +from pydantic import TypeAdapter from starlette import status from db.errors import EntityDoesNotExist, EntityVersionExist, InvalidInput, UnableToAccessDatabase @@ -47,8 +47,8 @@ def _prepare(self, app, admin_user): @patch("api.routes.shared_service_templates.ResourceTemplateRepository.get_templates_information") async def test_get_shared_service_templates_returns_template_names_and_description(self, get_templates_info_mock, app, client): expected_template_infos = [ - ResourceTemplateInformation(name="template1", title="template 1", description="description1"), - ResourceTemplateInformation(name="template2", title="template 2", description="description2") + ResourceTemplateInformation(name="template1", title="template 1", description="description1").model_dump(), + ResourceTemplateInformation(name="template2", title="template 2", description="description2").model_dump() ] get_templates_info_mock.return_value = expected_template_infos @@ -94,21 +94,21 @@ async def test_when_creating_service_template_sets_additional_properties(self, g get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_shared_service_template - response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.model_dump()) - expected_template = parse_obj_as(SharedServiceTemplateInResponse, enrich_shared_service_template(basic_shared_service_template)) - assert json.loads(response.text)["required"] == expected_template.dict(exclude_unset=True)["required"] - assert json.loads(response.text)["properties"] == expected_template.dict(exclude_unset=True)["properties"] + expected_template = TypeAdapter(SharedServiceTemplateInResponse).validate_python(enrich_shared_service_template(basic_shared_service_template)) + assert json.loads(response.text)["required"] == expected_template.model_dump(exclude_unset=True)["required"] + assert json.loads(response.text)["properties"] == expected_template.model_dump(exclude_unset=True)["properties"] # POST /shared_services-templates @patch("api.routes.shared_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=EntityVersionExist) async def test_version_exists_not_allowed(self, _, app, client, input_shared_service_template): - response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.model_dump()) assert response.status_code == status.HTTP_409_CONFLICT @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=InvalidInput) async def test_creating_a_shared_service_template_raises_http_422_if_step_ids_are_duplicated(self, _, client, app, input_shared_service_template): - response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_SHARED_SERVICE_TEMPLATES), json=input_shared_service_template.model_dump()) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/api_app/tests_ma/test_api/test_routes/test_shared_services.py b/api_app/tests_ma/test_api/test_routes/test_shared_services.py index 8cddcb8d25..13b71878e2 100644 --- a/api_app/tests_ma/test_api/test_routes/test_shared_services.py +++ b/api_app/tests_ma/test_api/test_routes/test_shared_services.py @@ -48,7 +48,7 @@ def sample_shared_service(shared_service_id=SHARED_SERVICE_ID): }, resourcePath=f'/shared-services/{shared_service_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_admin_user() + user=create_admin_user().model_dump() ) @@ -209,7 +209,7 @@ async def test_patch_shared_service_patches_shared_service(self, _, update_item_ modified_shared_service.isEnabled = False modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_shared_service.user = create_admin_user() + modified_shared_service.user = create_admin_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": ETAG}) update_item_mock.assert_called_once_with(modified_shared_service, ETAG) @@ -230,7 +230,7 @@ async def test_patch_shared_service_with_upgrade_minor_version_patches_shared_se modified_shared_service.isEnabled = True modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_shared_service.user = create_admin_user() + modified_shared_service.user = create_admin_user().model_dump() modified_shared_service.templateVersion = "0.2.0" response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": ETAG}) @@ -252,7 +252,7 @@ async def test_patch_shared_service_with_upgrade_major_version_and_force_update_ modified_shared_service.isEnabled = True modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_shared_service.user = create_admin_user() + modified_shared_service.user = create_admin_user().model_dump() modified_shared_service.templateVersion = "2.0.0" response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID) + "?force_version_update=True", json=shared_service_patch, headers={"etag": ETAG}) @@ -274,7 +274,7 @@ async def test_patch_shared_service_with_upgrade_major_version_returns_bad_reque modified_shared_service.isEnabled = True modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_shared_service.user = create_admin_user() + modified_shared_service.user = create_admin_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": ETAG}) @@ -295,7 +295,7 @@ async def test_patch_shared_service_with_downgrade_version_returns_bad_request(s modified_shared_service.isEnabled = True modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_shared_service.user = create_admin_user() + modified_shared_service.user = create_admin_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": ETAG}) @@ -345,4 +345,4 @@ async def test_patch_shared_service_with_invalid_field_returns_422(self, _, app, assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT # Check that the error message contains the key information about the validation error assert "fakeField" in response.text - assert "extra fields not permitted" in response.text + assert "extra inputs are not permitted" in response.text.lower() diff --git a/api_app/tests_ma/test_api/test_routes/test_user_resource_templates.py b/api_app/tests_ma/test_api/test_routes/test_user_resource_templates.py index 75ad673a2d..8a7df666ef 100644 --- a/api_app/tests_ma/test_api/test_routes/test_user_resource_templates.py +++ b/api_app/tests_ma/test_api/test_routes/test_user_resource_templates.py @@ -46,7 +46,7 @@ def _prepare(self, app, admin_user): async def test_creating_user_resource_template_raises_404_if_service_template_does_not_exist(self, _, input_user_resource_template, app, client): parent_workspace_service_name = "some_template_name" - response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.model_dump()) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -60,7 +60,7 @@ async def test_when_creating_user_resource_template_it_is_returned_as_expected(s user_resource_template_in_response.parentWorkspaceService = parent_workspace_service_name create_template_mock.return_value = user_resource_template_in_response - response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.model_dump()) assert json.loads(response.text)["resourceType"] == ResourceType.UserResource assert json.loads(response.text)["parentWorkspaceService"] == parent_workspace_service_name @@ -77,7 +77,7 @@ async def test_when_creating_user_resource_template_enriched_service_template_is create_template_mock.return_value = user_resource_template_in_response expected_template = user_resource_template_in_response - response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.model_dump()) assert json.loads(response.text)["properties"] == expected_template.properties assert json.loads(response.text)["required"] == expected_template.required @@ -91,14 +91,14 @@ async def test_when_creating_user_resource_template_returns_409_if_version_exist parent_workspace_service_name = "guacamole" create_user_resource_template_mock.side_effect = EntityVersionExist - response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name=parent_workspace_service_name), json=input_user_resource_template.model_dump()) assert response.status_code == status.HTTP_409_CONFLICT @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=InvalidInput) @patch("api.dependencies.workspace_service_templates.ResourceTemplateRepository.get_current_template") async def test_creating_a_user_resource_template_raises_http_422_if_step_ids_are_duplicated(self, _, __, client, app, input_user_resource_template): - response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name="guacamole"), json=input_user_resource_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_USER_RESOURCE_TEMPLATES, service_template_name="guacamole"), json=input_user_resource_template.model_dump()) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -124,7 +124,7 @@ async def test_get_user_resource_templates_returns_template_names_and_descriptio actual_templates = response.json()["templates"] assert len(actual_templates) == len(expected_templates) for template in expected_templates: - assert template in actual_templates + assert template.model_dump() in actual_templates # GET /workspace-service-templates/{service_template_name}/user-resource-templates/{user_resource_template_name} @patch("api.routes.workspace_templates.ResourceTemplateRepository.get_current_template") diff --git a/api_app/tests_ma/test_api/test_routes/test_workspace_service_templates.py b/api_app/tests_ma/test_api/test_routes/test_workspace_service_templates.py index a9ba696ab7..ac9c86287e 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspace_service_templates.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspace_service_templates.py @@ -2,7 +2,7 @@ import pytest from mock import patch -from pydantic import parse_obj_as +from pydantic import TypeAdapter from starlette import status from auth.rbac import require_tre_admin, require_tre_user_or_admin @@ -78,7 +78,7 @@ async def test_get_workspace_service_templates_returns_template_names_and_descri actual_template_infos = response.json()["templates"] assert len(actual_template_infos) == len(expected_template_infos) for template_info in expected_template_infos: - assert template_info in actual_template_infos + assert template_info.model_dump() in actual_template_infos # POST /workspace-service-templates/ @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_template") @@ -89,7 +89,7 @@ async def test_when_updating_current_and_service_template_not_found_create_one(s get_current_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_workspace_service_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.model_dump()) assert response.status_code == status.HTTP_201_CREATED @@ -104,11 +104,11 @@ async def test_when_updating_current_and_service_template_found_update_and_add(s get_current_template_mock.return_value = basic_workspace_service_template create_template_mock.return_value = basic_workspace_service_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.model_dump()) updated_current_workspace_template = basic_workspace_service_template updated_current_workspace_template.current = False - update_item_mock.assert_called_once_with(updated_current_workspace_template.dict()) + update_item_mock.assert_called_once_with(updated_current_workspace_template) assert response.status_code == status.HTTP_201_CREATED # POST /workspace-service-templates/ @@ -120,11 +120,11 @@ async def test_when_creating_service_template_enriched_service_template_is_retur get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_workspace_service_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.model_dump()) - expected_template = parse_obj_as(WorkspaceTemplateInResponse, enrich_workspace_service_template(basic_workspace_service_template)) - assert json.loads(response.text)["required"] == expected_template.dict(exclude_unset=True)["required"] - assert json.loads(response.text)["properties"] == expected_template.dict(exclude_unset=True)["properties"] + expected_template = TypeAdapter(WorkspaceTemplateInResponse).validate_python(enrich_workspace_service_template(basic_workspace_service_template)) + assert json.loads(response.text)["required"] == expected_template.model_dump(exclude_unset=True)["required"] + assert json.loads(response.text)["properties"] == expected_template.model_dump(exclude_unset=True)["properties"] # POST /workspace-service-templates/ @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_template") @@ -135,20 +135,20 @@ async def test_when_creating_workspace_service_template_service_resource_type_is get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_workspace_service_template - await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.dict()) + await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.model_dump()) create_template_mock.assert_called_once_with(input_workspace_service_template, ResourceType.WorkspaceService, '') # POST /workspace-service-templates/ @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=EntityVersionExist) async def test_creating_a_template_raises_409_conflict_if_template_version_exists(self, _, client, app, input_workspace_service_template): - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.model_dump()) assert response.status_code == status.HTTP_409_CONFLICT @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=InvalidInput) async def test_creating_a_workspace_service_template_raises_http_422_if_step_ids_are_duplicated(self, _, client, app, input_workspace_service_template): - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_service_template.model_dump()) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/api_app/tests_ma/test_api/test_routes/test_workspace_templates.py b/api_app/tests_ma/test_api/test_routes/test_workspace_templates.py index 61a4da4ae3..640ed82958 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspace_templates.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspace_templates.py @@ -2,7 +2,7 @@ import pytest from mock import patch -from pydantic import parse_obj_as +from pydantic import TypeAdapter from starlette import status from auth.rbac import require_tre_admin, require_tre_user_or_admin @@ -11,6 +11,7 @@ from db.errors import DuplicateEntity, EntityDoesNotExist, InvalidInput, UnableToAccessDatabase from models.domain.resource_template import ResourceTemplate, CustomAction from models.schemas.resource_template import ResourceTemplateInformation +from models.schemas.workspace_service_template import WorkspaceServiceTemplateInCreate from models.schemas.workspace_template import WorkspaceTemplateInResponse from services.schema_service import enrich_workspace_template @@ -60,7 +61,7 @@ async def test_workspace_templates_returns_template_names_and_descriptions(self, actual_template_infos = response.json()["templates"] assert len(actual_template_infos) == len(expected_template_infos) for name in expected_template_infos: - assert name in actual_template_infos + assert name.model_dump() in actual_template_infos # POST /workspace-templates async def test_post_does_not_create_a_template_with_bad_payload(self, app, client): @@ -79,7 +80,7 @@ async def test_when_updating_current_and_template_not_found_create_one(self, get get_current_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_resource_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) assert response.status_code == status.HTTP_201_CREATED @@ -93,11 +94,11 @@ async def test_when_updating_current_and_template_found_update_and_add(self, get get_current_template_mock.return_value = basic_resource_template create_template_mock.return_value = basic_resource_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) updated_current_workspace_template = basic_resource_template updated_current_workspace_template.current = False - update_item_mock.assert_called_once_with(updated_current_workspace_template.dict()) + update_item_mock.assert_called_once_with(updated_current_workspace_template) assert response.status_code == status.HTTP_201_CREATED # POST /workspace-templates @@ -105,13 +106,13 @@ async def test_when_updating_current_and_template_found_update_and_add(self, get async def test_same_name_and_version_template_not_allowed(self, get_template_by_name_and_version_mock, app, client, input_workspace_template): get_template_by_name_and_version_mock.return_value = ["exists"] - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) assert response.status_code == status.HTTP_409_CONFLICT @patch("api.routes.workspace_service_templates.ResourceTemplateRepository.create_and_validate_template", side_effect=InvalidInput) async def test_creating_a_workspace_template_raises_http_422_if_step_ids_are_duplicated(self, _, client, app, input_workspace_template): - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -149,7 +150,7 @@ async def test_when_not_updating_current_and_new_registration_current_is_enforce get_current_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_resource_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) assert response.status_code == status.HTTP_201_CREATED assert json.loads(response.text)["current"] @@ -162,12 +163,12 @@ async def test_when_creating_template_enriched_template_is_returned(self, get_te get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_resource_template - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) - expected_template = parse_obj_as(WorkspaceTemplateInResponse, enrich_workspace_template(basic_resource_template)) + expected_template = TypeAdapter(WorkspaceTemplateInResponse).validate_python(enrich_workspace_template(basic_resource_template)) - assert json.loads(response.text)["required"] == expected_template.dict(exclude_unset=True)["required"] - assert json.loads(response.text)["properties"] == expected_template.dict(exclude_unset=True)["properties"] + assert json.loads(response.text)["required"] == expected_template.model_dump(exclude_unset=True)["required"] + assert json.loads(response.text)["properties"] == expected_template.model_dump(exclude_unset=True)["properties"] @patch("api.routes.workspace_templates.ResourceTemplateRepository.create_template") @patch("api.routes.workspace_templates.ResourceTemplateRepository.get_current_template") @@ -178,11 +179,11 @@ async def test_when_creating_workspace_service_template_custom_actions_is_set(se basic_resource_template.customActions = [CustomAction(name='my-custom-action', description='This is a test custom action')] create_template_mock.return_value = basic_resource_template - expected_template = parse_obj_as(WorkspaceTemplateInResponse, enrich_workspace_template(basic_resource_template)) + expected_template = TypeAdapter(WorkspaceTemplateInResponse).validate_python(enrich_workspace_template(basic_resource_template)) - response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) - assert json.loads(response.text)["customActions"] == expected_template.dict(exclude_unset=True)["customActions"] + assert json.loads(response.text)["customActions"] == expected_template.model_dump(exclude_unset=True)["customActions"] @patch("api.routes.workspace_templates.ResourceTemplateRepository.create_template") @patch("api.routes.workspace_templates.ResourceTemplateRepository.get_current_template") @@ -192,7 +193,7 @@ async def test_when_creating_workspace_service_template_custom_actions_is_not_se get_current_template_mock.side_effect = EntityDoesNotExist basic_resource_template.customActions = [] create_template_mock.return_value = basic_resource_template - input_workspace_template_dict = input_workspace_template.dict() + input_workspace_template_dict = input_workspace_template.model_dump() input_workspace_template_dict.pop("customActions") response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template_dict) @@ -207,7 +208,7 @@ async def test_when_creating_workspace_template_workspace_resource_type_is_set(s get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_resource_template - await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.dict()) + await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_TEMPLATES), json=input_workspace_template.model_dump()) create_template_mock.assert_called_once_with(input_workspace_template, ResourceType.Workspace, '') @@ -219,6 +220,6 @@ async def test_when_creating_workspace_service_template_service_resource_type_is get_current_template_mock.side_effect = EntityDoesNotExist create_template_mock.return_value = basic_workspace_service_template - await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.dict()) + await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE_TEMPLATES), json=input_workspace_template.model_dump()) - create_template_mock.assert_called_once_with(input_workspace_template, ResourceType.WorkspaceService, '') + create_template_mock.assert_called_once_with(WorkspaceServiceTemplateInCreate.model_validate(input_workspace_template.model_dump()), ResourceType.WorkspaceService, "") diff --git a/api_app/tests_ma/test_api/test_routes/test_workspace_users.py b/api_app/tests_ma/test_api/test_routes/test_workspace_users.py index bcad94e401..72666b8069 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspace_users.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspace_users.py @@ -36,7 +36,7 @@ def sample_workspace(workspace_id=WORKSPACE_ID, auth_info: dict = {}) -> Workspa }, resourcePath=f'/workspaces/{workspace_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_admin_user() + user=create_admin_user().model_dump() ) if auth_info: workspace.properties = {**auth_info} diff --git a/api_app/tests_ma/test_api/test_routes/test_workspaces.py b/api_app/tests_ma/test_api/test_routes/test_workspaces.py index 8eb8fd8a53..986bb8d450 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspaces.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspaces.py @@ -95,7 +95,7 @@ def sample_workspace(workspace_id=WORKSPACE_ID, auth_info: dict = {}) -> Workspa }, resourcePath=f'/workspaces/{workspace_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_admin_user() + user=create_admin_user().model_dump() ) if auth_info: workspace.properties = {**auth_info} @@ -141,7 +141,9 @@ def sample_resource_operation(resource_id: str, operation_id: str): OperationStep( id="random-uuid", templateStepId="main", + stepTitle="Main installation step", resourceId=resource_id, + resourceType=ResourceType.Workspace, resourceAction="install", updatedWhen=FAKE_UPDATE_TIMESTAMP, sourceTemplateResourceId=resource_id @@ -181,7 +183,7 @@ def sample_workspace_service(workspace_service_id=SERVICE_ID, workspace_id=WORKS properties={}, resourcePath=f'/workspaces/{workspace_id}/workspace-services/{workspace_service_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_workspace_owner_user() + user=create_workspace_owner_user().model_dump() ) @@ -196,7 +198,7 @@ def sample_user_resource_object(user_resource_id=USER_RESOURCE_ID, workspace_id= properties={}, resourcePath=f'/workspaces/{workspace_id}/workspace-services/{parent_workspace_service_id}/user-resources/{user_resource_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_workspace_researcher_user() + user=create_workspace_researcher_user().model_dump() ) return user_resource @@ -340,7 +342,7 @@ async def test_get_workspaces_scope_id_returns_empty_if_no_scope_id(self, worksp }, resourcePath=f'/workspaces/{WORKSPACE_ID}', updatedWhen=FAKE_CREATE_TIMESTAMP, - user=create_admin_user() + user=create_admin_user().model_dump() ) workspace_mock.return_value = no_scope_id_workspace @@ -535,7 +537,7 @@ async def test_patch_workspaces_422_when_etag_not_present(self, patch_workspace_ response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT - assert ("('header', 'etag')" in response.text and "field required" in response.text) + assert ("('header', 'etag')" in response.text and "field required" in response.text.lower()) # [PATCH] /workspaces/{workspace_id} @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", side_effect=EntityDoesNotExist) @@ -558,7 +560,7 @@ async def test_patch_workspaces_patches_workspace(self, _, __, update_item_mock, modified_workspace = sample_workspace() modified_workspace.isEnabled = False modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) @@ -581,7 +583,7 @@ async def test_patch_workspaces_with_upgrade_major_version_returns_bad_request(s modified_workspace = sample_workspace() modified_workspace.isEnabled = True modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) @@ -604,7 +606,7 @@ async def test_patch_workspaces_with_upgrade_major_version_and_force_update_retu modified_workspace = sample_workspace() modified_workspace.isEnabled = True modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_workspace.templateVersion = "2.0.0" @@ -628,7 +630,7 @@ async def test_patch_workspaces_with_downgrade_version_returns_bad_request(self, modified_workspace = sample_workspace() modified_workspace.isEnabled = True modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) @@ -651,7 +653,7 @@ async def test_patch_workspaces_with_upgrade_minor_version_patches_workspace(sel modified_workspace = sample_workspace() modified_workspace.isEnabled = True modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_workspace.templateVersion = "0.2.0" @@ -673,7 +675,7 @@ async def test_patch_workspace_returns_409_if_bad_etag(self, _, __, update_item_ modified_workspace = sample_workspace() modified_workspace.isEnabled = False modified_workspace.resourceVersion = 1 - modified_workspace.user = create_admin_user() + modified_workspace.user = create_admin_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) @@ -789,7 +791,7 @@ async def test_post_workspace_services_creates_workspace_service_with_address_sp modified_workspace = sample_workspace() modified_workspace.isEnabled = True modified_workspace.resourceVersion = 1 - modified_workspace.user = create_workspace_owner_user() + modified_workspace.user = create_workspace_owner_user().model_dump() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_workspace.properties["address_spaces"] = ["192.168.0.1/24", "10.1.4.0/24"] modified_workspace.etag = etag @@ -1052,7 +1054,7 @@ async def test_patch_user_resource_patches_user_resource(self, _, update_item_mo modified_user_resource.isEnabled = False modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.user = create_workspace_owner_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) @@ -1078,7 +1080,7 @@ async def test_patch_user_resource_with_upgrade_major_version_returns_bad_reques modified_user_resource.isEnabled = True modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.user = create_workspace_owner_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) @@ -1105,7 +1107,7 @@ async def test_patch_user_resource_with_upgrade_major_version_and_force_update_r modified_user_resource.isEnabled = True modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.user = create_workspace_owner_user().model_dump() modified_user_resource.templateVersion = "2.0.0" response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID) + "?force_version_update=True", json=user_resource_service_patch, headers={"etag": etag}) @@ -1133,7 +1135,7 @@ async def test_patch_user_resource_with_downgrade_version_returns_bad_request(se modified_user_resource.isEnabled = True modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.user = create_workspace_owner_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) @@ -1160,7 +1162,7 @@ async def test_patch_user_resource_with_upgrade_minor_version_patches_user_resou modified_user_resource.isEnabled = True modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.user = create_workspace_owner_user().model_dump() modified_user_resource.templateVersion = "0.2.0" response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) @@ -1188,7 +1190,7 @@ async def test_patch_user_resource_validates_against_template(self, _, __, ___, modified_resource.resourceVersion = 1 modified_resource.properties["vm_size"] = "large" modified_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_resource.user = create_workspace_owner_user() + modified_resource.user = create_workspace_owner_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) @@ -1266,7 +1268,7 @@ async def test_patch_workspace_service_patches_workspace_service(self, _, update modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = False modified_workspace_service.resourceVersion = 1 - modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.user = create_workspace_owner_user().model_dump() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) @@ -1293,7 +1295,7 @@ async def test_patch_workspace_service_with_upgrade_major_version_returns_bad_re modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = True modified_workspace_service.resourceVersion = 1 - modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.user = create_workspace_owner_user().model_dump() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) @@ -1319,7 +1321,7 @@ async def test_patch_workspace_service_with_upgrade_major_version_and_force_upda modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = True modified_workspace_service.resourceVersion = 1 - modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.user = create_workspace_owner_user().model_dump() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_workspace_service.templateVersion = "2.0.0" @@ -1347,7 +1349,7 @@ async def test_patch_workspace_service_with_downgrade_version_returns_bad_reques modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = True modified_workspace_service.resourceVersion = 1 - modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.user = create_workspace_owner_user().model_dump() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) @@ -1374,7 +1376,7 @@ async def test_patch_workspace_service_with_upgrade_minor_version_patches_worksp modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = True modified_workspace_service.resourceVersion = 1 - modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.user = create_workspace_owner_user().model_dump() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_workspace_service.templateVersion = "0.2.0" @@ -1680,7 +1682,7 @@ async def test_patch_user_resources_patches_user_resource(self, _, update_item_m modified_user_resource.isEnabled = False modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP - modified_user_resource.user = create_workspace_researcher_user() + modified_user_resource.user = create_workspace_researcher_user().model_dump() response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) diff --git a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py index 18a75e8d1e..fc17efebc9 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py @@ -114,6 +114,16 @@ async def test_get_airlock_request_by_id(airlock_request_repo): assert actual_service == airlock_request +async def test_get_airlock_request_by_id_accepts_legacy_request_without_type(airlock_request_repo): + airlock_request = airlock_request_mock().model_dump() + airlock_request.pop("type") + airlock_request_repo.read_item_by_id = AsyncMock(return_value=airlock_request) + + actual_service = await airlock_request_repo.get_airlock_request_by_id(AIRLOCK_REQUEST_ID) + + assert actual_service.type is None + + async def test_get_airlock_request_by_id_raises_entity_does_not_exist_if_no_such_request_id(airlock_request_repo): airlock_request_repo.read_item_by_id = AsyncMock() airlock_request_repo.read_item_by_id.side_effect = CosmosResourceNotFoundError @@ -122,13 +132,37 @@ async def test_get_airlock_request_by_id_raises_entity_does_not_exist_if_no_such await airlock_request_repo.get_airlock_request_by_id(AIRLOCK_REQUEST_ID) +async def test_update_airlock_request_item_accepts_dict_updated_by(airlock_request_repo): + original_request = airlock_request_mock(status=SUBMITTED) + new_request = airlock_request_mock(status=IN_REVIEW) + updated_by = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Test User", + "email": "test@example.com", + "roles": ["WorkspaceOwner"], + "roleAssignments": [] + } + + airlock_request_repo.upsert_item_with_etag = AsyncMock() + + updated_request = await airlock_request_repo.update_airlock_request_item( + original_request=original_request, + new_request=new_request, + updated_by=updated_by, + request_properties={"previousStatus": SUBMITTED} + ) + + assert updated_request.updatedBy == updated_by + airlock_request_repo.upsert_item_with_etag.assert_called_once() + + async def test_create_airlock_request_item_creates_an_airlock_request_with_the_right_values(sample_airlock_request_input, airlock_request_repo): airlock_request_item_to_create = sample_airlock_request_input - created_by_user = {'id': 'test_user_id'} + created_by_user = create_test_user() # Use proper User object instead of dict airlock_request = airlock_request_repo.create_airlock_request_item(airlock_request_item_to_create, WORKSPACE_ID, created_by_user) assert airlock_request.workspaceId == WORKSPACE_ID - assert airlock_request.createdBy['id'] == 'test_user_id' + assert airlock_request.createdBy["id"] == created_by_user.id @pytest.mark.parametrize("current_status, new_status", get_allowed_status_changes()) diff --git a/api_app/tests_ma/test_db/test_repositories/test_operation_repository.py b/api_app/tests_ma/test_db/test_repositories/test_operation_repository.py index 91e51fba0a..c0c5f9b09c 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_operation_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_operation_repository.py @@ -64,4 +64,4 @@ async def test_create_operation_steps_from_multi_step_template(_, __, ___, resou ) - assert operation.dict() == expected_op.dict() + assert operation.model_dump() == expected_op.model_dump() diff --git a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py index c3ffceb1c0..3aac1bebbe 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py @@ -99,7 +99,7 @@ def sample_resource_template() -> ResourceTemplate: 'updateable': True } }, - actions=[]).dict(exclude_none=True) + actions=[]).model_dump(exclude_none=True) def sample_nested_template() -> ResourceTemplate: @@ -138,7 +138,7 @@ def sample_nested_template() -> ResourceTemplate: } }, customActions=[] - ).dict(exclude_none=True) + ).model_dump(exclude_none=True) @pytest.mark.asyncio @@ -153,7 +153,7 @@ async def test_validate_input_against_template_returns_template_version_if_templ current=True, required=[], properties={}, - customActions=[]).dict() + customActions=[]).model_dump() template = await resource_repo.validate_input_against_template("template1", workspace_input, ResourceType.Workspace, []) @@ -190,10 +190,7 @@ async def test_validate_input_against_template_raises_value_error_if_payload_is_ current=True, required=["display_name"], properties={}, - customActions=[]).dict() - - # the enrich template method does this - template_dict.pop("allOf") + customActions=[]).model_dump() enriched_template_mock.return_value = template_dict @@ -216,7 +213,7 @@ async def test_validate_input_against_template_raises_if_user_does_not_have_requ required=[], authorizedRoles=["missing_role"], properties={}, - customActions=[]).dict() + customActions=[]).model_dump() with pytest.raises(UserNotAuthorizedToUseTemplate): _ = await resource_repo.validate_input_against_template("template1", workspace_input, ResourceType.Workspace, ["test_role", "another_role"]) @@ -235,7 +232,7 @@ async def test_validate_input_against_template_valid_if_user_has_only_one_role(_ required=[], authorizedRoles=["test_role", "missing_role"], properties={}, - customActions=[]).dict() + customActions=[]).model_dump() template = await resource_repo.validate_input_against_template("template1", workspace_input, ResourceType.Workspace, ["test_role", "another_role"]) @@ -255,7 +252,7 @@ async def test_validate_input_against_template_valid_if_required_roles_set_is_em current=True, required=[], properties={}, - customActions=[]).dict() + customActions=[]).model_dump() template = await resource_repo.validate_input_against_template("template1", workspace_input, ResourceType.Workspace, ["test_user_role"]) @@ -353,7 +350,7 @@ async def test_patch_resource_preserves_property_history(_, __, ___, resource_re expected_resource = sample_resource() expected_resource.properties['display_name'] = 'updated name' expected_resource.resourceVersion = 1 - expected_resource.user = user + expected_resource.user = user.model_dump() expected_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP await resource_repo.patch_resource(resource, resource_patch, None, etag, None, resource_history_repo, user, strings.RESOURCE_ACTION_UPDATE) @@ -365,7 +362,7 @@ async def test_patch_resource_preserves_property_history(_, __, ___, resource_re expected_resource.resourceVersion = 2 expected_resource.properties['display_name'] = "updated name 2" expected_resource.isEnabled = False - expected_resource.user = user + expected_resource.user = user.model_dump() await resource_repo.patch_resource(new_resource, new_patch, None, etag, None, resource_history_repo, user, strings.RESOURCE_ACTION_UPDATE) resource_repo.update_item_with_etag.assert_called_with(expected_resource, etag) @@ -408,3 +405,37 @@ def test_validate_patch_with_bad_fields_fails(template_repo, resource_repo): patch = ResourcePatch(isEnabled=True, properties={'vm_size': 'large', 'os_image': 'linux'}) with pytest.raises(ValidationError): resource_repo.validate_patch(patch, template_repo, template, strings.RESOURCE_ACTION_INSTALL) + + +@pytest.mark.parametrize("nested_schema_id", [ + "#/properties/guac_disable_paste", + "#properties/network_rule_collections", + "https://example.com/template_schema.json#properties/network_rule_collections" +]) +def test_validate_resource_parameters_ignores_legacy_nested_schema_ids(resource_repo, nested_schema_id): + template = { + "$id": "https://example.com/template_schema.json", + "type": "object", + "required": ["network_rule_collections"], + "properties": { + "network_rule_collections": { + "$id": nested_schema_id, + "type": "array" + } + } + } + + resource_input = { + "properties": { + "network_rule_collections": [] + } + } + + # Should not raise SchemaError from jsonschema's metaschema checks. + resource_repo._validate_resource_parameters(resource_input, template) + + # Normalization must not mutate stored templates or remove root metadata. + normalized_template = resource_repo._normalize_template_schema(template) + assert normalized_template["$id"] == template["$id"] + assert "$id" not in normalized_template["properties"]["network_rule_collections"] + assert template["properties"]["network_rule_collections"]["$id"] == nested_schema_id diff --git a/api_app/tests_ma/test_db/test_repositories/test_resource_templates_repository.py b/api_app/tests_ma/test_db/test_repositories/test_resource_templates_repository.py index 4109b7cec9..970b4721e7 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_resource_templates_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_resource_templates_repository.py @@ -31,7 +31,7 @@ def sample_resource_template_as_dict(name: str, version: str = "1.0", resource_t properties={}, customActions=[], required=[] - ).dict() + ).model_dump() @patch('db.repositories.resource_templates.ResourceTemplateRepository.save_item') diff --git a/api_app/tests_ma/test_db/test_repositories/test_user_resource_repository.py b/api_app/tests_ma/test_db/test_repositories/test_user_resource_repository.py index 6ff58f33fe..8690024cca 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_user_resource_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_user_resource_repository.py @@ -85,7 +85,7 @@ async def test_get_user_resources_for_workspace_queries_db(query_mock, user_reso @patch('db.repositories.user_resources.UserResourceRepository.query') async def test_get_user_resource_returns_resource_if_found(query_mock, user_resource_repo, user_resource): - query_mock.return_value = [user_resource.dict()] + query_mock.return_value = [user_resource.model_dump()] actual_resource = await user_resource_repo.get_user_resource_by_id(WORKSPACE_ID, SERVICE_ID, RESOURCE_ID) @@ -94,7 +94,7 @@ async def test_get_user_resource_returns_resource_if_found(query_mock, user_reso @patch('db.repositories.user_resources.UserResourceRepository.query') async def test_get_user_resource_by_id_queries_db(query_mock, user_resource_repo, user_resource): - query_mock.return_value = [user_resource.dict()] + query_mock.return_value = [user_resource.model_dump()] expected_query = 'SELECT * FROM c WHERE c.resourceType = @resourceType AND c.parentWorkspaceServiceId = @serviceId AND c.workspaceId = @workspaceId AND c.id = @resourceId AND c.deploymentStatus != @deletedStatus' expected_parameters = [ {'name': '@resourceType', 'value': ResourceType.UserResource}, diff --git a/api_app/tests_ma/test_db/test_repositories/test_user_resource_templates_repository.py b/api_app/tests_ma/test_db/test_repositories/test_user_resource_templates_repository.py index fd965801b0..8ab518ca57 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_user_resource_templates_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_user_resource_templates_repository.py @@ -30,7 +30,7 @@ def sample_user_resource_template_as_dict(name: str, version: str = "1.0") -> di properties={}, customActions=[], parentWorkspaceService="parent_service") - return template.dict() + return template.model_dump() @patch('db.repositories.resource_templates.ResourceTemplateRepository.query') diff --git a/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py b/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py index ac3848d5fb..f7d637599c 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py @@ -100,7 +100,7 @@ async def test_get_workspace_by_id_raises_entity_does_not_exist_if_workspace_is_ @pytest.mark.asyncio async def test_get_workspace_by_id_queries_db(workspace_repo, workspace): workspace_query_item_result = AsyncMock() - workspace_query_item_result.__aiter__.return_value = [workspace.dict()] + workspace_query_item_result.__aiter__.return_value = [workspace.model_dump()] workspace_repo.container.query_items = MagicMock(return_value=workspace_query_item_result) expected_query = 'SELECT * FROM c WHERE c.resourceType = @resourceType AND c.id = @workspaceId AND c.deploymentStatus != @deletedStatus' expected_parameters = [ diff --git a/api_app/tests_ma/test_models/test_airlock_request_schema.py b/api_app/tests_ma/test_models/test_airlock_request_schema.py index 78ea429cb6..bf7808621b 100644 --- a/api_app/tests_ma/test_models/test_airlock_request_schema.py +++ b/api_app/tests_ma/test_models/test_airlock_request_schema.py @@ -1,4 +1,8 @@ -from models.schemas.airlock_request import AirlockRequestAndOperationInResponse, get_sample_airlock_request +import pytest +from pydantic import ValidationError + +from models.domain.airlock_request import AirlockReview, AirlockReviewDecision +from models.schemas.airlock_request import AirlockRequestAndOperationInResponse, AirlockRequestInCreate, AirlockReviewInCreate, get_sample_airlock_request from models.schemas.operation import get_sample_operation @@ -16,3 +20,26 @@ def test_airlock_request_and_operation_in_response_schema_is_valid(): response = AirlockRequestAndOperationInResponse(**sample_data) assert response.airlockRequest.id == airlock_request_id assert response.operation.id == operation_id + + +def test_airlock_request_in_create_requires_type(): + with pytest.raises(ValidationError): + AirlockRequestInCreate(title="a request title", businessJustification="some business justification") + + +def test_airlock_review_in_create_requires_approval(): + with pytest.raises(ValidationError): + AirlockReviewInCreate(decisionExplanation="the reason why this request was approved/rejected") + + +def test_airlock_review_in_create_openapi_example_uses_boolean_approval(): + example = AirlockReviewInCreate.model_config["json_schema_extra"]["example"] + + assert isinstance(example["approval"], bool) + assert example["approval"] is True + + +def test_airlock_review_omitted_date_uses_float_default(): + review = AirlockReview(id="review-id", reviewDecision=AirlockReviewDecision.Approved) + + assert isinstance(review.dateCreated, float) diff --git a/api_app/tests_ma/test_models/test_operation_schema.py b/api_app/tests_ma/test_models/test_operation_schema.py index 9a79d9acad..22cba78aa3 100644 --- a/api_app/tests_ma/test_models/test_operation_schema.py +++ b/api_app/tests_ma/test_models/test_operation_schema.py @@ -1,4 +1,5 @@ -from models.domain.operation import Operation +from models.domain.operation import Operation, Status +from models.domain.resource_template import PipelineStepProperty from models.schemas.operation import get_sample_operation, OperationInResponse, OperationInList @@ -33,3 +34,36 @@ def test_operation_in_list_schema_is_valid(): op_list = OperationInList(**sample_data) assert len(op_list.operations) == 1 assert op_list.operations[0].id == operation_id + + +def test_operation_omitted_status_uses_enum_default(): + operation = Operation( + id="operation-id", + resourceId="resource-id", + resourcePath="/workspaces/resource-id", + action="install", + user={}, + ) + + assert isinstance(operation.status, Status) + assert operation.status == Status.AwaitingDeployment + + +def test_operation_omitted_timestamps_use_float_defaults(): + operation = Operation( + id="operation-id", + resourceId="resource-id", + resourcePath="/workspaces/resource-id", + status=Status.AwaitingDeployment, + action="install", + user={}, + ) + + assert isinstance(operation.createdWhen, float) + assert isinstance(operation.updatedWhen, float) + + +def test_pipeline_step_property_value_defaults_to_none_when_omitted(): + step_property = PipelineStepProperty(name="target_property", type="string") + + assert step_property.value is None diff --git a/api_app/tests_ma/test_models/test_resource.py b/api_app/tests_ma/test_models/test_resource.py index cde5b7f764..61963d3256 100644 --- a/api_app/tests_ma/test_models/test_resource.py +++ b/api_app/tests_ma/test_models/test_resource.py @@ -1,9 +1,19 @@ import pytest +from pydantic import ValidationError from models.domain.request_action import RequestAction -from models.domain.resource import Resource, ResourceType +from models.domain.airlock_request import AirlockRequest, AirlockRequestType +from models.domain.operation import Operation, Status +from models.domain.restricted_resource import RestrictedProperties, RestrictedResource +from models.domain.resource import Output, Resource, ResourceHistoryItem, ResourceType +from models.domain.resource_template import Property from models.domain.user_resource import UserResource from models.domain.workspace_service import WorkspaceService +from models.schemas.resource import ResourceHistoryInList +from models.schemas.shared_service_template import SharedServiceTemplateInCreate +from models.schemas.user_resource_template import UserResourceTemplateInCreate +from models.schemas.workspace_service_template import WorkspaceServiceTemplateInCreate +from models.schemas.workspace_template import WorkspaceTemplateInCreate OPERATION_ID = "0000c8e7-5c42-4fcb-a7fd-294cfc27aa76" @@ -43,3 +53,103 @@ def test_workspace_service_get_resource_request_message_payload_augments_payload message_payload = workspace_service.get_resource_request_message_payload(OPERATION_ID, STEP_ID, RequestAction.Install) assert message_payload["workspaceId"] == workspace_id + + +def test_legacy_actor_dicts_validate_without_user_required_fields(): + resource = Resource.model_validate({ + "id": "resource-id", + "templateName": "workspace", + "templateVersion": "1.0", + "properties": {}, + "resourceType": ResourceType.Workspace, + "_etag": "etag", + "user": {"id": "legacy-user"}, + }) + operation = Operation.model_validate({ + "id": "operation-id", + "resourceId": "resource-id", + "resourcePath": "/workspaces/resource-id", + "status": Status.AwaitingDeployment, + "action": "install", + "user": {}, + }) + airlock_request = AirlockRequest.model_validate({ + "id": "airlock-id", + "workspaceId": "workspace-id", + "type": AirlockRequestType.Import, + "createdBy": {}, + "updatedBy": {"name": "Legacy User"}, + }) + + assert resource.user == {"id": "legacy-user"} + assert operation.user == {} + assert airlock_request.createdBy == {} + assert airlock_request.updatedBy == {"name": "Legacy User"} + assert airlock_request.createdWhen is None + assert isinstance(airlock_request.updatedWhen, float) + + +def test_restricted_resource_optional_fields_default_to_none(): + restricted_resource = RestrictedResource( + id="resource-id", + templateName="workspace", + templateVersion="1.0", + resourceType=ResourceType.Workspace, + _etag="etag", + ) + + assert isinstance(restricted_resource.properties, RestrictedProperties) + assert isinstance(restricted_resource.updatedWhen, float) + assert restricted_resource.availableUpgrades is None + assert restricted_resource.deploymentStatus is None + + +def test_resource_omitted_timestamps_use_float_defaults(): + resource_history = ResourceHistoryItem(id="history-id", resourceId="resource-id") + resource = Resource( + id="resource-id", + templateName="workspace", + templateVersion="1.0", + resourceType=ResourceType.Workspace, + _etag="etag", + ) + + assert isinstance(resource_history.updatedWhen, float) + assert isinstance(resource.updatedWhen, float) + + +def test_output_requires_value(): + with pytest.raises(ValidationError): + Output(name="output-name", type="string") + + +def test_resource_template_property_preserves_heterogeneous_enum_values(): + enum_values = [1, "two", True, None] + + prop = Property(enum=enum_values) + + assert prop.enum == enum_values + + +def test_resource_history_example_uses_declared_field_types(): + example = ResourceHistoryInList.model_config["json_schema_extra"]["example"] + resource_history = ResourceHistoryInList.model_validate(example).resource_history[0] + + assert isinstance(resource_history.isEnabled, bool) + assert isinstance(resource_history.resourceVersion, int) + assert isinstance(resource_history.updatedWhen, float) + assert isinstance(resource_history.user, dict) + + +@pytest.mark.parametrize("model", [ + SharedServiceTemplateInCreate, + UserResourceTemplateInCreate, + WorkspaceServiceTemplateInCreate, + WorkspaceTemplateInCreate, +]) +def test_resource_template_create_examples_use_boolean_current(model): + example = model.model_config["json_schema_extra"]["example"] + template = model.model_validate(example) + + assert isinstance(example["current"], bool) + assert isinstance(template.current, bool) diff --git a/api_app/tests_ma/test_service_bus/test_deployment_status_update.py b/api_app/tests_ma/test_service_bus/test_deployment_status_update.py index db80c5b1f7..16a63937aa 100644 --- a/api_app/tests_ma/test_service_bus/test_deployment_status_update.py +++ b/api_app/tests_ma/test_service_bus/test_deployment_status_update.py @@ -1,7 +1,7 @@ import copy import json from unittest.mock import MagicMock, ANY -from pydantic import parse_obj_as +from pydantic import TypeAdapter import pytest import uuid @@ -139,7 +139,7 @@ async def test_receiving_bad_json_logs_error(logging_mock, payload): @patch('services.logging.logger.exception') async def test_receiving_good_message(logging_mock, resource_repo, operation_repo, _, __): expected_workspace = create_sample_workspace_object(test_sb_message["id"]) - resource_repo.return_value.get_resource_dict_by_id.return_value = expected_workspace.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = expected_workspace.model_dump() operation = create_sample_operation(test_sb_message["id"], RequestAction.Install) operation_repo.return_value.get_operation_by_id.return_value = operation @@ -150,7 +150,7 @@ async def test_receiving_good_message(logging_mock, resource_repo, operation_rep assert complete_message is True resource_repo.return_value.get_resource_dict_by_id.assert_called_once_with(uuid.UUID(test_sb_message["id"])) - resource_repo.return_value.update_item_dict.assert_called_once_with(expected_workspace.dict()) + resource_repo.return_value.update_item_dict.assert_called_once_with(expected_workspace.model_dump()) logging_mock.assert_not_called() @@ -205,7 +205,7 @@ async def test_state_transitions_from_deployed_to_deleted(resource_repo, operati service_bus_received_message_mock = ServiceBusReceivedMessageMock(updated_message) workspace = create_sample_workspace_object(test_sb_message["id"]) - resource_repo.return_value.get_resource_dict_by_id.return_value = workspace.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = workspace.model_dump() operation = create_sample_operation(workspace.id, RequestAction.UnInstall) operation.steps[0].status = Status.Deployed @@ -236,7 +236,7 @@ async def test_outputs_are_added_to_resource_item(resource_repo, operations_repo resource = create_sample_workspace_object(received_message["id"]) resource.properties = {"exitingName": "exitingValue"} - resource_repo.return_value.get_resource_dict_by_id.return_value = resource.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = resource.model_dump() new_params = { "string1": "value1", @@ -259,7 +259,7 @@ async def test_outputs_are_added_to_resource_item(resource_repo, operations_repo complete_message = await status_updater.process_message(service_bus_received_message_mock) assert complete_message is True - resource_repo.return_value.update_item_dict.assert_called_once_with(expected_resource) + resource_repo.return_value.update_item_dict.assert_called_once_with(expected_resource.model_dump()) @patch('service_bus.deployment_status_updater.ResourceHistoryRepository.create') @@ -273,7 +273,7 @@ async def test_properties_dont_change_with_no_outputs(resource_repo, operations_ resource = create_sample_workspace_object(received_message["id"]) resource.properties = {"exitingName": "exitingValue"} - resource_repo.return_value.get_resource_dict_by_id.return_value = resource.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = resource.model_dump() operation = create_sample_operation(resource.id, RequestAction.UnInstall) operations_repo.return_value.get_operation_by_id.return_value = operation @@ -285,7 +285,7 @@ async def test_properties_dont_change_with_no_outputs(resource_repo, operations_ complete_message = await status_updater.process_message(service_bus_received_message_mock) assert complete_message is True - resource_repo.return_value.update_item_dict.assert_called_once_with(expected_resource.dict()) + resource_repo.return_value.update_item_dict.assert_called_once_with(expected_resource.model_dump()) @patch('service_bus.deployment_status_updater.ResourceHistoryRepository.create') @@ -301,7 +301,7 @@ async def test_multi_step_operation_sends_next_step(sb_sender_client, resource_r sb_sender_client().get_queue_sender().send_messages = AsyncMock() # step 1 resource - resource_repo.return_value.get_resource_dict_by_id.return_value = basic_shared_service.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = basic_shared_service.model_dump() # step 2 resource resource_repo.return_value.get_resource_by_id.return_value = user_resource_multi @@ -355,7 +355,7 @@ async def test_multi_step_operation_ends_at_last_step(sb_sender_client, resource sb_sender_client().get_queue_sender().send_messages = AsyncMock() # step 2 resource - resource_repo.return_value.get_resource_dict_by_id.return_value = user_resource_multi.dict() + resource_repo.return_value.get_resource_dict_by_id.return_value = user_resource_multi.model_dump() # step 3 resource resource_repo.return_value.get_resource_by_id.return_value = basic_shared_service @@ -401,7 +401,7 @@ async def test_convert_outputs_to_dict(): assert status_updater.convert_outputs_to_dict(outputs_list) == expected_result # Test case 2: List of outputs with mixed types - deployment_status_update_message = parse_obj_as(DeploymentStatusUpdateMessage, test_sb_message_with_outputs) + deployment_status_update_message = TypeAdapter(DeploymentStatusUpdateMessage).validate_python(test_sb_message_with_outputs) expected_result = { 'string1': 'value1', diff --git a/api_app/tests_ma/test_service_bus/test_substitutions.py b/api_app/tests_ma/test_service_bus/test_substitutions.py index f31eb21587..79b564a0e1 100644 --- a/api_app/tests_ma/test_service_bus/test_substitutions.py +++ b/api_app/tests_ma/test_service_bus/test_substitutions.py @@ -2,11 +2,12 @@ import pytest from models.domain.resource_template import PipelineStep, PipelineStepProperty +from models.domain.resource import ResourceType from service_bus.substitutions import substitute_properties, substitute_value def test_substitution_for_primary_resource_no_parents(primary_resource): - resource_dict = primary_resource.dict() + resource_dict = primary_resource.model_dump() # Verify mandatory param val_to_sub = "{{ resource.properties.address_prefix }}" @@ -39,9 +40,9 @@ def test_substitution_for_primary_resource_no_parents(primary_resource): def test_substitution_for_user_resource_primary_resource_with_parents( primary_user_resource, resource_ws_parent, resource_ws_svc_parent ): - primary_user_resource_dict = primary_user_resource.dict() - parent_ws_resource_dict = resource_ws_parent.dict() - parent_ws_svc_resource_dict = resource_ws_svc_parent.dict() + primary_user_resource_dict = primary_user_resource.model_dump() + parent_ws_resource_dict = resource_ws_parent.model_dump() + parent_ws_svc_resource_dict = resource_ws_svc_parent.model_dump() # ws parent (2 levels up) # single array val @@ -145,8 +146,8 @@ def test_substitution_for_user_resource_primary_resource_with_parents( def test_substitution_for_workspace_service_primary_resource__with_parents( primary_workspace_service_resource, resource_ws_parent ): - primary_workspace_service_resource_dict = primary_workspace_service_resource.dict() - parent_ws_resource_dict = resource_ws_parent.dict() + primary_workspace_service_resource_dict = primary_workspace_service_resource.model_dump() + parent_ws_resource_dict = resource_ws_parent.model_dump() # ws parent # single array val @@ -180,7 +181,7 @@ def test_substitution_for_workspace_service_primary_resource__with_parents( def test_substitution_for_workspace_primary_resource_parents(primary_resource): - primary_resource_dict = primary_resource.dict() + primary_resource_dict = primary_resource.model_dump() # single array val val_to_sub = "I am a ws WITHOUT any parents, my name is '{{ resource.properties.display_name }}'" @@ -198,7 +199,7 @@ def test_substitution_for_workspace_primary_resource_parents(primary_resource): def test_substitution_for_shared_service_primary_resource_parents(basic_shared_service): - primary_resource_dict = basic_shared_service.dict() + primary_resource_dict = basic_shared_service.model_dump() # single array val val_to_sub = "I am a shared service WITHOUT any parents, my name is '{{ resource.properties.display_name }}'" @@ -232,6 +233,11 @@ def test_simple_substitution( def test_substitution_list_strings(primary_resource, resource_to_update): pipeline_step_with_list_strings = PipelineStep( + stepId="test-list-strings-step", + stepTitle="Test List Strings Step", + resourceTemplateName="test-template", + resourceType=ResourceType.Workspace, + resourceAction="install", properties=[ PipelineStepProperty( name="obj_list_strings", @@ -419,7 +425,7 @@ def test_substitution_array_replace_not_found( def test_substitution_boolean_preservation(primary_resource): - resource_dict = primary_resource.dict() + resource_dict = primary_resource.model_dump() # Mock a boolean property in the resource dict resource_dict["properties"]["isEnabled"] = True resource_dict["properties"]["count"] = 42 diff --git a/api_app/tests_ma/test_services/test_aad_access_service.py b/api_app/tests_ma/test_services/test_aad_access_service.py index bf18fde2b4..73f4744943 100644 --- a/api_app/tests_ma/test_services/test_aad_access_service.py +++ b/api_app/tests_ma/test_services/test_aad_access_service.py @@ -815,9 +815,9 @@ def test_get_workspace_roles_returns_roles(_, ms_graph_query_mock, mock_headers, # Mock the response of the get request request_get_mock_response = { "value": [ - Role(id=1, displayName="Airlock Manager", type=AssignmentType.APP_ROLE).dict(), - Role(id=2, displayName="Workspace Researcher", type=AssignmentType.APP_ROLE).dict(), - Role(id=3, displayName="Workspace Owner", type=AssignmentType.APP_ROLE).dict(), + Role(id=1, displayName="Airlock Manager", type=AssignmentType.APP_ROLE).model_dump(), + Role(id=2, displayName="Workspace Researcher", type=AssignmentType.APP_ROLE).model_dump(), + Role(id=3, displayName="Workspace Owner", type=AssignmentType.APP_ROLE).model_dump(), ] } ms_graph_query_mock.return_value = request_get_mock_response diff --git a/api_app/tests_ma/test_services/test_airlock.py b/api_app/tests_ma/test_services/test_airlock.py index caa0182f65..65c61e6d62 100644 --- a/api_app/tests_ma/test_services/test_airlock.py +++ b/api_app/tests_ma/test_services/test_airlock.py @@ -85,7 +85,7 @@ def sample_airlock_user_resource_object(): def sample_status_changed_event(new_status="draft", previous_status=None): status_changed_event = EventGridEvent( event_type="statusChanged", - data=StatusChangedData(request_id=AIRLOCK_REQUEST_ID, new_status=new_status, previous_status=previous_status, type=AirlockRequestType.Import, workspace_id=WORKSPACE_ID[-4:]).__dict__, + data=StatusChangedData(request_id=AIRLOCK_REQUEST_ID, new_status=new_status, previous_status=previous_status, type=AirlockRequestType.Import, workspace_id=WORKSPACE_ID[-4:]).model_dump(mode="json"), subject=f"{AIRLOCK_REQUEST_ID}/statusChanged", data_version="2.0" ) @@ -122,7 +122,7 @@ def sample_airlock_notification_event(status="draft"): id=WORKSPACE_ID, display_name="my research workspace", description="for science!" - )), + )).model_dump(mode="json"), subject=f"{AIRLOCK_REQUEST_ID}/airlockNotification", data_version="4.0" ) diff --git a/api_app/tests_ma/test_services/test_schema_service.py b/api_app/tests_ma/test_services/test_schema_service.py index 76cae2ea1a..08dbbe0f64 100644 --- a/api_app/tests_ma/test_services/test_schema_service.py +++ b/api_app/tests_ma/test_services/test_schema_service.py @@ -1,4 +1,6 @@ import pytest +from jsonschema import validate +from jsonschema.exceptions import ValidationError from mock import patch, call import services.schema_service @@ -126,6 +128,105 @@ def test_enrich_template_adds_system_properties(basic_resource_template): assert 'tre_id' in template['system_properties'] +def test_enrich_template_removes_invalid_legacy_null_property_fields(basic_resource_template): + basic_resource_template.properties = { + "os_image": { + "type": "string", + "items": None, + "properties": None, + "enum": None, + "pattern": None, + "default": None, + "const": None, + } + } + + template = services.schema_service.enrich_template(basic_resource_template, []) + + assert "items" not in template["properties"]["os_image"] + assert "properties" not in template["properties"]["os_image"] + assert "enum" not in template["properties"]["os_image"] + assert "pattern" not in template["properties"]["os_image"] + assert "default" in template["properties"]["os_image"] + assert template["properties"]["os_image"]["default"] is None + assert "const" in template["properties"]["os_image"] + assert template["properties"]["os_image"]["const"] is None + validate(instance={}, schema=template) + + +def test_enrich_template_removes_invalid_legacy_null_property_fields_recursively(basic_resource_template): + basic_resource_template.properties = { + "vm_config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vm_size": { + "type": "string", + "items": None, + "enum": ["Standard_D2_v3"] + } + } + } + } + } + basic_resource_template.allOf = [{ + "if": { + "properties": { + "assign_to_another_user": { + "const": True + } + } + }, + "then": { + "properties": { + "owner_id": { + "type": "string", + "pattern": None, + "minLength": 1 + } + } + } + }] + + template = services.schema_service.enrich_template(basic_resource_template, []) + + assert "items" not in template["properties"]["vm_config"]["items"]["properties"]["vm_size"] + assert "pattern" not in template["allOf"][0]["then"]["properties"]["owner_id"] + validate(instance={"vm_config": [{"vm_size": "Standard_D2_v3"}]}, schema=template) + + +def test_enrich_template_preserves_const_null_and_rejects_non_null_values(basic_resource_template): + basic_resource_template.required = ["nullable_const"] + basic_resource_template.properties = { + "nullable_const": { + "const": None + } + } + + template = services.schema_service.enrich_template(basic_resource_template, []) + + assert "const" in template["properties"]["nullable_const"] + assert template["properties"]["nullable_const"]["const"] is None + validate(instance={"nullable_const": None}, schema=template) + with pytest.raises(ValidationError): + validate(instance={"nullable_const": "not-null"}, schema=template) + + +def test_enrich_template_preserves_default_null_after_enrichment(basic_resource_template): + basic_resource_template.properties = { + "nullable_default": { + "type": "string", + "default": None + } + } + + template = services.schema_service.enrich_template(basic_resource_template, []) + + assert "default" in template["properties"]["nullable_default"] + assert template["properties"]["nullable_default"]["default"] is None + + def test_enrich_template_adds_read_only_on_update(basic_resource_template): original_template = basic_resource_template diff --git a/templates/shared_services/admin-vm/porter.yaml b/templates/shared_services/admin-vm/porter.yaml index 9cbdf3eaa0..63353d5bc5 100644 --- a/templates/shared_services/admin-vm/porter.yaml +++ b/templates/shared_services/admin-vm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-admin-vm -version: 0.5.4 +version: 0.5.5 description: "An admin vm shared service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/shared_services/admin-vm/template_schema.json b/templates/shared_services/admin-vm/template_schema.json index e0f08f17fd..1705857286 100644 --- a/templates/shared_services/admin-vm/template_schema.json +++ b/templates/shared_services/admin-vm/template_schema.json @@ -7,7 +7,6 @@ "required": [], "properties": { "os_image": { - "$id": "#/properties/os_image", "type": "string", "title": "Windows image", "description": "Select Windows image to use for VM", @@ -17,7 +16,6 @@ "default": "Windows 11" }, "admin_jumpbox_vm_sku": { - "$id": "#/properties/admin_jumpbox_vm_sku", "type": "string", "enum": [ "Standard_B2s", diff --git a/templates/shared_services/airlock_notifier/porter.yaml b/templates/shared_services/airlock_notifier/porter.yaml index 700821b6cd..9a60dba981 100644 --- a/templates/shared_services/airlock_notifier/porter.yaml +++ b/templates/shared_services/airlock_notifier/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-airlock-notifier -version: 1.0.10 +version: 1.0.11 description: "A shared service notifying on Airlock Operations" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/shared_services/airlock_notifier/template_schema.json b/templates/shared_services/airlock_notifier/template_schema.json index fb22366da2..192dfc95c7 100644 --- a/templates/shared_services/airlock_notifier/template_schema.json +++ b/templates/shared_services/airlock_notifier/template_schema.json @@ -21,21 +21,18 @@ "updateable": true }, "smtp_server_address": { - "$id": "#/properties/smtp_server_address", "type": "string", "title": "SMTP Server Address", "description": "SMTP Server Address", "updateable": false }, "smtp_username": { - "$id": "#/properties/smtp_username", "type": "string", "title": "SMTP Username", "description": "SMTP Username", "updateable": false }, "smtpPassword": { - "$id": "#/properties/smtpPassword", "type": "string", "title": "SMTP Password", "description": "SMTP Password", @@ -43,14 +40,12 @@ "sensitive": true }, "smtp_from_email": { - "$id": "#/properties/smtp_from_email", "type": "string", "title": "SMTP From Email", "description": "The notification emails will be sent from this address", "updateable": false }, "tre_url": { - "$id": "#/properties/tre_url", "type": "string", "title": "TRE URL", "description": "If your TRE URL is different from ${TRE_ID}.${LOCATION}.cloudapp.azure.com, please enter it here", @@ -61,14 +56,12 @@ ] }, "smtp_server_enable_ssl": { - "$id": "#/properties/smtp_server_enable_ssl", "type": "boolean", "title": "SMTP SSL Enabled", "updateable": false, "default": true }, "smtp_server_port": { - "$id": "#/properties/smtp_server_port", "type": "integer", "title": "SMTP Server Port", "updateable": false, diff --git a/templates/shared_services/certs/template_schema.json b/templates/shared_services/certs/template_schema.json index 4b3f69a5b8..9a99ddb02c 100644 --- a/templates/shared_services/certs/template_schema.json +++ b/templates/shared_services/certs/template_schema.json @@ -24,13 +24,11 @@ "updateable": true }, "domain_prefix": { - "$id": "#/properties/domain_prefix", "type": "string", "title": "Domain prefix", "description": "The FQDN prefix (which will be prepended to {TRE_ID}.{LOCATION}.cloudapp.azure.com) to generate a certificate for" }, "cert_name": { - "$id": "#/properties/cert_name", "type": "string", "title": "Cert name", "description": "What to call the certificate that's exported to KeyVault (alphanumeric and '-' only)" diff --git a/templates/shared_services/gitea/porter.yaml b/templates/shared_services/gitea/porter.yaml index f9a76a7565..700be2a7f6 100644 --- a/templates/shared_services/gitea/porter.yaml +++ b/templates/shared_services/gitea/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-gitea -version: 1.2.2 +version: 1.2.3 description: "A Gitea shared service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/shared_services/gitea/template_schema.json b/templates/shared_services/gitea/template_schema.json index 463a6715f0..9d1ab8f488 100644 --- a/templates/shared_services/gitea/template_schema.json +++ b/templates/shared_services/gitea/template_schema.json @@ -28,7 +28,6 @@ "updateable": true }, "sql_sku": { - "$id": "#/properties/sql_sku", "type": "string", "title": "MySQL server SKU", "description": "MySQL server SKU", @@ -41,7 +40,6 @@ "default": "B | 4GB 2vCores" }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is the Gitea accessible from outside of the TRE network.", diff --git a/templates/shared_services/sonatype-nexus-vm/porter.yaml b/templates/shared_services/sonatype-nexus-vm/porter.yaml index 8aea05c7c8..79f7009b9c 100644 --- a/templates/shared_services/sonatype-nexus-vm/porter.yaml +++ b/templates/shared_services/sonatype-nexus-vm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-sonatype-nexus -version: 3.10.0 +version: 3.10.1 description: "A Sonatype Nexus shared service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/shared_services/sonatype-nexus-vm/template_schema.json b/templates/shared_services/sonatype-nexus-vm/template_schema.json index 8f490def9c..8b48383ac1 100644 --- a/templates/shared_services/sonatype-nexus-vm/template_schema.json +++ b/templates/shared_services/sonatype-nexus-vm/template_schema.json @@ -43,7 +43,6 @@ "default": "nexus-ssl" }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is the Sonatype Nexus accessible from outside of the TRE network.", diff --git a/templates/workspace_services/azureml/porter.yaml b/templates/workspace_services/azureml/porter.yaml index d6d4593ad4..723713cf35 100644 --- a/templates/workspace_services/azureml/porter.yaml +++ b/templates/workspace_services/azureml/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-azureml -version: 1.1.4 +version: 1.1.5 description: "An Azure TRE service for Azure Machine Learning" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/azureml/template_schema.json b/templates/workspace_services/azureml/template_schema.json index c09cfc592b..1dfa2fdde8 100644 --- a/templates/workspace_services/azureml/template_schema.json +++ b/templates/workspace_services/azureml/template_schema.json @@ -28,31 +28,26 @@ "updateable": true }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is the Azure ML workspace accessible from outside of the workspace network. Also opens firewall rules to allow compute instances with public IP addresses.", "default": false }, "address_space": { - "$id": "#/properties/address_space", "type": "string", "title": "Address space", "description": "The address space for use by AML subnets" }, "log_analytics_workspace_name": { - "$id": "#/properties/log_analytics_workspace_name", "type": "string", "title": "Log Analytics Workspace Name" }, "workspace_owners_group_id": { - "$id": "#/properties/workspace_owners_group_id", "type": "string", "title": "Workspace Owners Group ID", "description": "Object ID of the workspace owners AAD group" }, "workspace_researchers_group_id": { - "$id": "#/properties/workspace_researchers_group_id", "type": "string", "title": "Workspace Researchers Group ID", "description": "Object ID of the workspace researchers AAD group" diff --git a/templates/workspace_services/azuresql/porter.yaml b/templates/workspace_services/azuresql/porter.yaml index 7e72065bba..48e969cb9b 100644 --- a/templates/workspace_services/azuresql/porter.yaml +++ b/templates/workspace_services/azuresql/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-azuresql -version: 1.0.17 +version: 1.0.18 description: "An Azure SQL workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/azuresql/template_schema.json b/templates/workspace_services/azuresql/template_schema.json index 2e80406307..64a048004a 100644 --- a/templates/workspace_services/azuresql/template_schema.json +++ b/templates/workspace_services/azuresql/template_schema.json @@ -7,7 +7,6 @@ "required": [], "properties": { "sql_sku": { - "$id": "#/properties/sql_sku", "type": "string", "title": "Azure SQL SKU", "description": "Azure SQL SKU", @@ -22,14 +21,12 @@ "default": "S2 | 50 DTUs" }, "storage_gb": { - "$id": "#/properties/storage_gb", "type": "number", "title": "Max storage allowed for a database (GB)", "description": "Max storage allowed for a database (GB)", "default": 5 }, "db_name": { - "$id": "#/properties/db_name", "type": "string", "title": "Database name", "description": "Database name", diff --git a/templates/workspace_services/databricks/porter.yaml b/templates/workspace_services/databricks/porter.yaml index abfdd18a83..4e913a4e09 100644 --- a/templates/workspace_services/databricks/porter.yaml +++ b/templates/workspace_services/databricks/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-databricks -version: 1.0.16 +version: 1.0.17 description: "An Azure TRE service for Azure Databricks." registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/databricks/template_schema.json b/templates/workspace_services/databricks/template_schema.json index 1e43ba0395..83e9007c58 100644 --- a/templates/workspace_services/databricks/template_schema.json +++ b/templates/workspace_services/databricks/template_schema.json @@ -28,14 +28,12 @@ "updateable": true }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is the Databricks workspace accessible from outside of the workspace network", "default": false }, "address_space": { - "$id": "#/properties/address_space", "type": "string", "title": "Address space", "description": "The address space of the databricks subnets" diff --git a/templates/workspace_services/gitea/porter.yaml b/templates/workspace_services/gitea/porter.yaml index f9044eb34e..39621ab355 100644 --- a/templates/workspace_services/gitea/porter.yaml +++ b/templates/workspace_services/gitea/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-gitea -version: 1.3.3 +version: 1.3.4 description: "A Gitea workspace service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/gitea/template_schema.json b/templates/workspace_services/gitea/template_schema.json index ef53192e76..12c85dc7f2 100644 --- a/templates/workspace_services/gitea/template_schema.json +++ b/templates/workspace_services/gitea/template_schema.json @@ -29,7 +29,6 @@ "updateable": true }, "sql_sku": { - "$id": "#/properties/sql_sku", "type": "string", "title": "MySQL server SKU", "description": "MySQL server SKU", @@ -42,7 +41,6 @@ "default": "B | 4GB 2vCores" }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is Gitea accessible from outside of the TRE network.", diff --git a/templates/workspace_services/guacamole/porter.yaml b/templates/workspace_services/guacamole/porter.yaml index b2ce9174b4..8f172fe986 100644 --- a/templates/workspace_services/guacamole/porter.yaml +++ b/templates/workspace_services/guacamole/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole -version: 0.14.2 +version: 0.14.3 description: "An Azure TRE service for Guacamole" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/template_schema.json b/templates/workspace_services/guacamole/template_schema.json index 4bf0f7b2d1..51cf1f3cbd 100644 --- a/templates/workspace_services/guacamole/template_schema.json +++ b/templates/workspace_services/guacamole/template_schema.json @@ -28,21 +28,18 @@ "updateable": true }, "guac_disable_copy": { - "$id": "#/properties/guac_disable_copy", "type": "boolean", "title": "Disable 'Copy'", "description": "Disable Copy functionality", "updateable": true }, "guac_disable_paste": { - "$id": "#/properties/guac_disable_paste", "type": "boolean", "title": "Disable 'Paste'", "description": "Disable Paste functionality", "updateable": true }, "guac_enable_drive": { - "$id": "#/properties/guac_enable_drive", "type": "boolean", "title": "Enable Drive", "description": "Enable mounted drive", @@ -50,7 +47,6 @@ "default": false }, "guac_disable_download": { - "$id": "#/properties/guac_disable_download", "type": "boolean", "title": "Disable files download", "description": "Disable files download", @@ -58,7 +54,6 @@ "default": true }, "guac_disable_upload": { - "$id": "#/properties/guac_disable_upload", "type": "boolean", "title": "Disable files upload", "description": "Disable files upload", @@ -66,7 +61,6 @@ "default": true }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Is the Guacamole service exposed outside of the vnet", diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/porter.yaml b/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/porter.yaml index ef129291f7..2907e7ec5f 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/porter.yaml +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole-export-reviewvm -version: 2.0.0 +version: 2.0.1 description: "An Azure TRE User Resource Template for reviewing Airlock export requests" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/template_schema.json b/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/template_schema.json index ed7d04f6dc..081edb8005 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/template_schema.json +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-export-reviewvm/template_schema.json @@ -10,7 +10,6 @@ ], "properties": { "os_image": { - "$id": "#/properties/os_image", "type": "string", "title": "Windows image", "description": "Select Windows image to use for VM", @@ -19,7 +18,6 @@ ] }, "vm_size": { - "$id": "#/properties/vm_size", "type": "string", "title": "VM Size", "description": "Select size of VM", @@ -29,7 +27,6 @@ "updateable": true }, "airlock_request_sas_url": { - "$id": "#/properties/airlock_request_sas_url", "type": "string", "title": "Airlock request SAS Token", "description": "SAS Token for airlock request", diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/porter.yaml b/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/porter.yaml index 28e5167a4b..dcfff73003 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/porter.yaml +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole-import-reviewvm -version: 2.0.0 +version: 2.0.1 description: "An Azure TRE User Resource Template for reviewing Airlock import requests" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/template_schema.json b/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/template_schema.json index efa1b79718..46d12c6c5d 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/template_schema.json +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-import-reviewvm/template_schema.json @@ -10,7 +10,6 @@ ], "properties": { "os_image": { - "$id": "#/properties/os_image", "type": "string", "title": "Windows image", "description": "Select Windows image to use for VM", @@ -19,7 +18,6 @@ ] }, "vm_size": { - "$id": "#/properties/vm_size", "type": "string", "title": "VM Size", "description": "Select size of VM", @@ -29,7 +27,6 @@ "updateable": true }, "airlock_request_sas_url": { - "$id": "#/properties/airlock_request_sas_url", "type": "string", "title": "Airlock request SAS Token", "description": "SAS Token for airlock request", diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/porter.yaml b/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/porter.yaml index 186da0d571..f95113432c 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/porter.yaml +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole-linuxvm -version: 1.4.3 +version: 1.4.4 description: "An Azure TRE User Resource Template for Guacamole (Linux)" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/template_schema.json b/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/template_schema.json index c2c2ffc668..1e1b7130fc 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/template_schema.json +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-linuxvm/template_schema.json @@ -28,7 +28,6 @@ "updateable": true }, "os_image": { - "$id": "#/properties/os_image", "type": "string", "title": "Linux image", "description": "Select Linux image to use for VM", @@ -44,7 +43,6 @@ "default": "" }, "vm_size": { - "$id": "#/properties/vm_size", "type": "string", "title": "VM Size", "description": "Select size of VM", @@ -58,14 +56,12 @@ "updateable": true }, "shared_storage_access": { - "$id": "#/properties/shared_storage_access", "type": "boolean", "title": "Shared storage", "default": true, "description": "Enable access to shared storage" }, "enable_shutdown_schedule": { - "$id": "#/properties/enable_shutdown_schedule", "type": "boolean", "title": "Enable Shutdown Schedule", "default": false, diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/porter.yaml b/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/porter.yaml index f994d0e021..e7ebb8242e 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/porter.yaml +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole-windowsvm -version: 3.0.0 +version: 3.0.1 description: "An Azure TRE User Resource Template for Guacamole (Windows 11 or Windows Server 2025)" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/template_schema.json b/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/template_schema.json index 7ac0d9ee8e..ce7ce841b6 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/template_schema.json +++ b/templates/workspace_services/guacamole/user_resources/guacamole-azure-windowsvm/template_schema.json @@ -25,7 +25,6 @@ "updateable": true }, "os_image": { - "$id": "#/properties/os_image", "type": "string", "title": "Windows image", "description": "Select Windows image to use for VM", @@ -42,7 +41,6 @@ "default": "" }, "vm_size": { - "$id": "#/properties/vm_size", "type": "string", "title": "VM Size", "description": "Select size of VM", @@ -56,7 +54,6 @@ "updateable": true }, "shared_storage_access": { - "$id": "#/properties/shared_storage_access", "type": "boolean", "title": "Shared storage", "default": true, @@ -99,7 +96,6 @@ "default": true }, "enable_shutdown_schedule": { - "$id": "#/properties/enable_shutdown_schedule", "type": "boolean", "title": "Enable Shutdown Schedule", "default": false, diff --git a/templates/workspace_services/health-services/porter.yaml b/templates/workspace_services/health-services/porter.yaml index d7dd92c469..c59b50f62d 100644 --- a/templates/workspace_services/health-services/porter.yaml +++ b/templates/workspace_services/health-services/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-health -version: 0.3.5 +version: 0.3.6 description: "An Azure Data Health Services workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/health-services/template_schema.json b/templates/workspace_services/health-services/template_schema.json index 98e5061de0..3c4dd1d190 100644 --- a/templates/workspace_services/health-services/template_schema.json +++ b/templates/workspace_services/health-services/template_schema.json @@ -21,7 +21,6 @@ "updateable": true }, "deploy_dicom": { - "$id": "#/properties/deploy_dicom", "type": "boolean", "title": "Deploy DICOM", "description": "Deploy DICOM instance", @@ -29,7 +28,6 @@ "default": false }, "deploy_fhir": { - "$id": "#/properties/deploy_fhir", "type": "boolean", "title": "Deploy FHIR", "description": "Deploy FHIR instance", @@ -37,13 +35,11 @@ "default": false }, "workspace_owners_group_id": { - "$id": "#/properties/workspace_owners_group_id", "type": "string", "title": "Workspace Owners Group ID", "description": "Object ID of the workspace owners AAD group" }, "workspace_researchers_group_id": { - "$id": "#/properties/workspace_researchers_group_id", "type": "string", "title": "Workspace Researchers Group ID", "description": "Object ID of the workspace researchers AAD group" diff --git a/templates/workspace_services/mysql/porter.yaml b/templates/workspace_services/mysql/porter.yaml index 80ce54d0a8..fb18c7bf97 100644 --- a/templates/workspace_services/mysql/porter.yaml +++ b/templates/workspace_services/mysql/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-mysql -version: 1.0.12 +version: 1.0.13 description: "A MySQL workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/mysql/template_schema.json b/templates/workspace_services/mysql/template_schema.json index fd2b690395..40c98cbbbe 100644 --- a/templates/workspace_services/mysql/template_schema.json +++ b/templates/workspace_services/mysql/template_schema.json @@ -7,7 +7,6 @@ "required": [], "properties": { "sql_sku": { - "$id": "#/properties/sql_sku", "type": "string", "title": "MySQL server SKU", "description": "MySQL server SKU", @@ -20,7 +19,6 @@ "default": "B | 4GB 2vCores" }, "storage_mb": { - "$id": "#/properties/storage_mb", "type": "number", "title": "Max storage allowed for a server", "description": "Max storage allowed for a server", @@ -29,7 +27,6 @@ "maximum": 16777216 }, "db_name": { - "$id": "#/properties/db_name", "type": "string", "title": "Database name", "description": "Database name", diff --git a/templates/workspace_services/ohdsi/porter.yaml b/templates/workspace_services/ohdsi/porter.yaml index ca5e7ef556..7c9aa6caae 100644 --- a/templates/workspace_services/ohdsi/porter.yaml +++ b/templates/workspace_services/ohdsi/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-ohdsi -version: 0.3.7 +version: 0.3.8 description: "An OHDSI workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/ohdsi/template_schema.json b/templates/workspace_services/ohdsi/template_schema.json index 1fa56948ba..fddd1cd6d7 100644 --- a/templates/workspace_services/ohdsi/template_schema.json +++ b/templates/workspace_services/ohdsi/template_schema.json @@ -28,7 +28,6 @@ "updateable": true }, "address_space": { - "$id": "#/properties/address_space", "type": "string", "title": "Address space", "description": "Address space for PostgreSQL's subnet" diff --git a/templates/workspace_services/openai/porter.yaml b/templates/workspace_services/openai/porter.yaml index 4ad4efc6b9..f5d2a8383d 100644 --- a/templates/workspace_services/openai/porter.yaml +++ b/templates/workspace_services/openai/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-openai -version: 1.0.8 +version: 1.0.9 description: "An OpenAI workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/openai/template_schema.json b/templates/workspace_services/openai/template_schema.json index da2cf39f92..ed8a99783f 100644 --- a/templates/workspace_services/openai/template_schema.json +++ b/templates/workspace_services/openai/template_schema.json @@ -28,7 +28,6 @@ "updateable": true }, "is_exposed_externally": { - "$id": "#/properties/is_exposed_externally", "type": "boolean", "title": "Expose externally", "description": "Should the OpenAI instance be publicly accessible?", @@ -36,7 +35,6 @@ "updateable": true }, "openai_model": { - "$id": "#/properties/openai_model", "type": "string", "title": "OpenAI Model", "description": "Which OpenAI Model should be used? (be mindful of subscription limits)",