Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Airlock - API - approve/reject a request #2044

Merged
merged 12 commits into from
Jun 16, 2022
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.13"
__version__ = "0.3.14"
24 changes: 20 additions & 4 deletions api_app/api/routes/airlock.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
from api.dependencies.database import get_repository
from api.dependencies.workspaces import get_workspace_by_id_from_path, get_deployed_workspace_by_id_from_path
from api.dependencies.airlock import get_airlock_request_by_id_from_path
from models.domain.airlock_resource import AirlockRequestStatus
from models.domain.airlock_request import AirlockRequestStatus
from db.repositories.airlock_reviews import AirlockReviewRepository
from models.schemas.airlock_review import AirlockReviewInCreate, AirlockReviewInResponse

from db.repositories.airlock_requests import AirlockRequestRepository
from models.schemas.airlock_request import AirlockRequestInCreate, AirlockRequestInResponse
from resources import strings
from services.authentication import get_current_workspace_owner_or_researcher_user
from services.authentication import get_current_workspace_owner_or_researcher_user, get_current_workspace_owner_user

from .airlock_resource_helpers import save_and_publish_event_airlock_request, update_status_and_publish_event_airlock_request
from .airlock_resource_helpers import save_airlock_review, save_and_publish_event_airlock_request, update_status_and_publish_event_airlock_request

airlock_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user)])

Expand All @@ -25,7 +27,7 @@ async def create_draft_request(airlock_request_input: AirlockRequestInCreate, us
try:
airlock_request = airlock_request_repo.create_airlock_request_item(airlock_request_input, workspace.id)
except (ValidationError, ValueError) as e:
logging.error(f"Failed create air lock request model instance: {e}")
logging.error(f"Failed creating airlock request model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
await save_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user)
return AirlockRequestInResponse(airlock_request=airlock_request)
Expand All @@ -35,3 +37,17 @@ async def create_draft_request(airlock_request_input: AirlockRequestInCreate, us
async def create_submit_request(airlock_request=Depends(get_airlock_request_by_id_from_path), user=Depends(get_current_workspace_owner_or_researcher_user), airlock_request_repo=Depends(get_repository(AirlockRequestRepository))) -> AirlockRequestInResponse:
updated_resource = await update_status_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, AirlockRequestStatus.Submitted)
return AirlockRequestInResponse(airlock_request=updated_resource)


@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/reviews", status_code=status.HTTP_200_OK, response_model=AirlockReviewInResponse, name=strings.API_REVIEW_AIRLOCK_REQUEST, dependencies=[Depends(get_workspace_by_id_from_path)])
async def create_airlock_review(airlock_review_input: AirlockReviewInCreate, airlock_request=Depends(get_airlock_request_by_id_from_path), user=Depends(get_current_workspace_owner_user), airlock_request_repo=Depends(get_repository(AirlockRequestRepository)), airlock_review_repo=Depends(get_repository(AirlockReviewRepository)), workspace=Depends(get_deployed_workspace_by_id_from_path)) -> AirlockReviewInResponse:
# Create the review model and save in cosmos
try:
airlock_review = airlock_review_repo.create_airlock_review_item(airlock_review_input, workspace.id, airlock_request.id)
except (ValidationError, ValueError) as e:
logging.error(f"Failed creating airlock review model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# Update the airlock request in cosmos, send a status_changed event
await save_airlock_review(airlock_review, airlock_review_repo, user)
eladiw marked this conversation as resolved.
Show resolved Hide resolved
await update_status_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, airlock_review.reviewDecision)
return AirlockReviewInResponse(airlock_review=airlock_review)
19 changes: 17 additions & 2 deletions api_app/api/routes/airlock_resource_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

from fastapi import HTTPException
from starlette import status
from models.domain.airlock_resource import AirlockRequestStatus
from db.repositories.airlock_reviews import AirlockReviewRepository
from models.domain.airlock_review import AirlockReview
from db.repositories.airlock_requests import AirlockRequestRepository
from models.domain.airlock_request import AirlockRequest
from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus
from event_grid.helpers import send_status_changed_event
from models.domain.authentication import User

Expand Down Expand Up @@ -37,6 +38,9 @@ async def update_status_and_publish_event_airlock_request(airlock_request: Airlo
updated_airlock_request = airlock_request_repo.update_airlock_request_status(airlock_request, new_status, user)
except Exception as e:
logging.error(f'Failed updating airlock_request item {airlock_request}: {e}')
# If the validation failed, the error was not related to the saving itself
if e.status_code == 400:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.AIRLOCK_REQUEST_ILLEGAL_STATUS_CHANGE)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=strings.STATE_STORE_ENDPOINT_NOT_RESPONDING)

try:
Expand All @@ -48,5 +52,16 @@ async def update_status_and_publish_event_airlock_request(airlock_request: Airlo
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=strings.EVENT_GRID_GENERAL_ERROR_MESSAGE)


async def save_airlock_review(airlock_review: AirlockReview, airlock_review_repo: AirlockReviewRepository, user: User):
try:
logging.debug(f"Saving airlock review item: {airlock_review.id}")
airlock_review.user = user
airlock_review.updatedWhen = get_timestamp()
airlock_review_repo.save_item(airlock_review)
except Exception as e:
logging.error(f'Failed saving airlock request {airlock_review}: {e}')
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=strings.STATE_STORE_ENDPOINT_NOT_RESPONDING)


def get_timestamp() -> float:
return datetime.utcnow().timestamp()
5 changes: 2 additions & 3 deletions api_app/db/repositories/airlock_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
from pydantic import parse_obj_as
from models.domain.authentication import User
from db.errors import EntityDoesNotExist
from models.domain.airlock_resource import AirlockRequestStatus
from db.repositories.airlock_resources import AirlockResourceRepository
from models.domain.airlock_request import AirlockRequest
from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus
from models.schemas.airlock_request import AirlockRequestInCreate
from resources import strings

Expand Down Expand Up @@ -61,7 +60,7 @@ def update_airlock_request_status(self, airlock_request: AirlockRequest, new_sta
if self._validate_status_update(current_status, new_status):
updated_request = copy.deepcopy(airlock_request)
updated_request.status = new_status
return self.update_airlock_resource_item(airlock_request, updated_request, user)
return self.update_airlock_resource_item(airlock_request, updated_request, user, {"previousStatus": current_status})
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.AIRLOCK_REQUEST_ILLEGAL_STATUS_CHANGE)

Expand Down
4 changes: 2 additions & 2 deletions api_app/db/repositories/airlock_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ def get_resource_base_spec_params():
def get_timestamp(self) -> float:
return datetime.utcnow().timestamp()

def update_airlock_resource_item(self, original_resource: AirlockResource, new_resource: AirlockResource, user: User) -> AirlockResource:
def update_airlock_resource_item(self, original_resource: AirlockResource, new_resource: AirlockResource, user: User, resource_properties: dict) -> AirlockResource:
history_item = AirlockResourceHistoryItem(
resourceVersion=original_resource.resourceVersion,
updatedWhen=original_resource.updatedWhen,
user=original_resource.user,
previousStatus=original_resource.status
properties=resource_properties
)
new_resource.history.append(history_item)

Expand Down
24 changes: 24 additions & 0 deletions api_app/db/repositories/airlock_reviews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import uuid
from azure.cosmos import CosmosClient
from models.domain.airlock_review import AirlockReview
from models.schemas.airlock_review import AirlockReviewInCreate
from db.repositories.airlock_resources import AirlockResourceRepository


class AirlockReviewRepository(AirlockResourceRepository):
def __init__(self, client: CosmosClient):
super().__init__(client)

def create_airlock_review_item(self, airlock_review_input: AirlockReviewInCreate, workspace_id: str, request_id: str) -> AirlockReview:
full_airlock_review_id = str(uuid.uuid4())

# TODO - validate the review https://github.com/microsoft/AzureTRE/issues/2016
airlock_review = AirlockReview(
id=full_airlock_review_id,
workspaceId=workspace_id,
requestId=request_id,
reviewDecision=airlock_review_input.reviewDecision,
decisionExplanation=airlock_review_input.decisionExplanation
)

return airlock_review
4 changes: 2 additions & 2 deletions api_app/event_grid/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ async def send_status_changed_event(airlock_request: AirlockRequest):
request_id = airlock_request.id
status = airlock_request.status
request_type = airlock_request.requestType
workspace_id = airlock_request.workspaceId
short_workspace_id = airlock_request.workspaceId[-4:]

status_changed_event = EventGridEvent(
event_type="statusChanged",
data={
"request_id": request_id,
"status": status,
"type": request_type,
"workspace_id": workspace_id
"workspace_id": short_workspace_id
},
subject=f"{request_id}/statusChanged",
data_version="2.0"
Expand Down
15 changes: 14 additions & 1 deletion api_app/models/domain/airlock_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,20 @@
from enum import Enum
from pydantic import Field
from resources import strings
from models.domain.airlock_resource import AirlockRequestStatus, AirlockResource, AirlockResourceType
from models.domain.airlock_resource import AirlockResource, AirlockResourceType


class AirlockRequestStatus(str, Enum):
"""
Airlock Resource status
"""
Draft = strings.AIRLOCK_RESOURCE_STATUS_DRAFT
Submitted = strings.AIRLOCK_RESOURCE_STATUS_SUBMITTED
InReview = strings.AIRLOCK_RESOURCE_STATUS_INREVIEW
Approved = strings.AIRLOCK_RESOURCE_STATUS_APPROVED
Rejected = strings.AIRLOCK_RESOURCE_STATUS_REJECTED
Cancelled = strings.AIRLOCK_RESOURCE_STATUS_CANCELLED
Blocked = strings.AIRLOCK_RESOURCE_STATUS_BLOCKED


class AirlockRequestType(str, Enum):
Expand Down
18 changes: 2 additions & 16 deletions api_app/models/domain/airlock_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,12 @@
from resources import strings


class AirlockRequestStatus(str, Enum):
"""
Airlock Request status
"""
Draft = strings.AIRLOCK_RESOURCE_STATUS_DRAFT
Submitted = strings.AIRLOCK_RESOURCE_STATUS_SUBMITTED
InReview = strings.AIRLOCK_RESOURCE_STATUS_INREVIEW
Approved = strings.AIRLOCK_RESOURCE_STATUS_APPROVED
Rejected = strings.AIRLOCK_RESOURCE_STATUS_REJECTED
Cancelled = strings.AIRLOCK_RESOURCE_STATUS_CANCELLED
Blocked = strings.AIRLOCK_RESOURCE_STATUS_BLOCKED


class AirlockResourceType(str, Enum):
"""
Type of resource to create
"""
AirlockRequest = strings.AIRLOCK_RESOURCE_TYPE_REQUEST
# TODO Airlock review type - https://github.com/microsoft/AzureTRE/issues/1840
AirlockReview = strings.AIRLOCK_RESOURCE_TYPE_REVIEW


class AirlockResourceHistoryItem(AzureTREModel):
Expand All @@ -33,7 +20,7 @@ class AirlockResourceHistoryItem(AzureTREModel):
resourceVersion: int
updatedWhen: float
user: dict = {}
previousStatus: AirlockRequestStatus
properties: dict = {}


class AirlockResource(AzureTREModel):
Expand All @@ -46,4 +33,3 @@ class AirlockResource(AzureTREModel):
user: dict = {}
updatedWhen: float = 0
history: List[AirlockResourceHistoryItem] = []
status: AirlockRequestStatus
19 changes: 19 additions & 0 deletions api_app/models/domain/airlock_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import Field
from models.domain.airlock_request import AirlockRequestStatus
from models.domain.airlock_resource import AirlockResource, AirlockResourceType


class AirlockReviewDecision(str):
Approved = AirlockRequestStatus.Approved
Rejected = AirlockRequestStatus.Rejected


class AirlockReview(AirlockResource):
"""
Airlock review
"""
workspaceId: str = Field("", title="Workspace ID", description="Service target Workspace id")
requestId: str = Field("", title="Airlock Request ID", description="Service target Airlock id")
resourceType = AirlockResourceType.AirlockReview
reviewDecision: AirlockReviewDecision = Field("", title="Airlock review decision")
decisionExplanation: str = Field(False, title="Explanation why the request was approved/rejected")
38 changes: 38 additions & 0 deletions api_app/models/schemas/airlock_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field
from models.domain.airlock_review import AirlockReview, AirlockReviewDecision
from models.domain.airlock_resource import AirlockResourceType


def get_sample_airlock_review(workspace_id: str, airlock_request_id: str, airlock_review_id: str) -> dict:
return {
"reviewId": airlock_review_id,
"requestId": airlock_request_id,
"workspaceId": workspace_id,
"reviewDecision": "Describe why the request was approved/rejected",
"decisionExplanation": "Describe why the request was approved/rejected",
"resourceType": AirlockResourceType.AirlockReview
}


class AirlockReviewInResponse(BaseModel):
airlock_review: AirlockReview

class Config:
schema_extra = {
"example": {
"airlock_review": get_sample_airlock_review("933ad738-7265-4b5f-9eae-a1a62928772e", "121e921f-a4aa-44b3-90a9-e8da030495ef", "5c8c3430-b362-4e38-8270-441ca4381739")
}
}


class AirlockReviewInCreate(BaseModel):
reviewDecision: AirlockReviewDecision = 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": {
"reviewDecision": "approved",
"decisionExplanation": "the reason why this request was approved/rejected"
}
}
4 changes: 3 additions & 1 deletion api_app/resources/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

API_CREATE_AIRLOCK_REQUEST = "Create an airlock request"
API_SUBMIT_AIRLOCK_REQUEST = "Submit an airlock request"
API_REVIEW_AIRLOCK_REQUEST = "Review an airlock request"

API_CREATE_WORKSPACE_TEMPLATES = "Register workspace template"
API_GET_WORKSPACE_TEMPLATES = "Get workspace templates"
Expand Down Expand Up @@ -147,6 +148,7 @@

# Airlock Resource Type
AIRLOCK_RESOURCE_TYPE_REQUEST = "airlock-request"
AIRLOCK_RESOURCE_TYPE_REVIEW = "airlock-review"

# Airlock Resource Status
AIRLOCK_RESOURCE_STATUS_DRAFT = "draft"
Expand All @@ -163,7 +165,7 @@

# Airlock Messages
AIRLOCK_REQUEST_DOES_NOT_EXIST = "Airlock request does not exist"
AIRLOCK_REQUEST_ILLEGAL_STATUS_CHANGE = "Airlock request status changes was illegal"
AIRLOCK_REQUEST_ILLEGAL_STATUS_CHANGE = "Airlock request status change was illegal"

# Deployments
RESOURCE_STATUS_NOT_DEPLOYED_MESSAGE = "This resource has not yet been deployed"
Expand Down