microservices/learning_object_service/src/routes/content_serving.py (486 lines of code) (raw):

""" Content Serving endpoints """ import pathlib from zipfile import BadZipFile from typing import Optional from typing_extensions import Literal import traceback from fastapi import APIRouter, UploadFile, File from fastapi.responses import RedirectResponse from schemas.error_schema import NotFoundErrorResponseModel from schemas.content_serving_schema import ( GetSignedUrlModelResponse, ContentLinkInputModel, ContentLinkResponse, ContentPublishResponse, BatchJobModel, ListFilesAndFolderResponse, GetContentVersionsResponse) from services.batch_job import initiate_batch_job from services.content_version_handler import (link_content_and_create_version, get_content_versions, handle_publish_event, update_lr_resource_path) from services.hierarchy_content_mapping import (get_file_and_folder_list, is_missing_linked_files, link_content_to_lr, link_srl_to_all_le) from common.models import LearningResource, FAQContent, LearningExperience from common.utils.content_processing import (ContentValidator, FileUtils, ALLOWED_CONTENT_TYPES) from common.utils.errors import (ResourceNotFoundException, ValidationError, InternalServerError, PayloadTooLargeError) from common.utils.http_exceptions import (BadRequest, ResourceNotFound, InternalServerError as InternalServerException, PayloadTooLarge) from common.utils.gcs_adapter import GcsCrudService, is_valid_path, upload_file_to_bucket, upload_folder from common.utils.logging_handler import Logger from config import (SIGNURL_SA_KEY_PATH, RESOURCE_BASE_PATH, CONTENT_SERVING_BUCKET, ERROR_RESPONSES, DATABASE_PREFIX, VALIDATE_AND_UPLOAD_ZIP, CONTENT_FILE_SIZE, ZIP_EXTRACTION_FOLDER, FAQ_BASE_PATH) # pylint: disable = line-too-long # pylint: disable = broad-except,invalid-name router = APIRouter(tags=["Content Serving"], responses=ERROR_RESPONSES) ALLOWED_CONTENT_VERSION_STATUS = Literal["published", "unpublished", "draft"] contentValidator = ContentValidator() fileHandler = FileUtils() # pylint: disable = unspecified-encoding,consider-using-with @router.get( "/content-serving/list-contents", name="List files and Folders at a given prefix", response_model=ListFilesAndFolderResponse, responses={404: { "model": NotFoundErrorResponseModel }}) def list_all_files(prefix: Optional[str] = None, list_madcap_contents: Optional[bool] = False): """Function to list all files given a prefix""" try: prefix, folders_list, files_list = get_file_and_folder_list( prefix, list_madcap_contents) return { "success": True, "message": "Successfully listed all files and folder at given prefix", "data": { "prefix": prefix, "folders": folders_list, "files": files_list } } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.get( "/content-serving/{uuid}", response_model=GetSignedUrlModelResponse, name="Get Signed URL for video/html5", responses={404: { "model": NotFoundErrorResponseModel }}) def get_signed_url(uuid: str, is_faq: bool = False, redirect: bool = False): """ Generate Signed URL for content based on resource_path of a learning_resource or a faq ### Args: uuid: `str` UUID of FAQ/Learning Resource. is_faq: `bool` This flag determins which collection to be used to fetch the resource data redirect: `bool` Response type for the signed url. Defaults to False. If set False, returns a Json response with 200 status code. Else returns a redirect with signed url with 307 status code. ### Raises: ResourceNotFoundException: Raised when the requested resource does not exists. <br/> Exception 500: Internal Server Error. Raised if something went wrong. ### Returns: Signed URL JSON response: \ `GetSignedURLModelResponse` """ try: BASE_PATH = "" resource_type = "" resource_path = "" if is_faq is True: faq_resource = FAQContent.find_by_uuid(uuid) faq_resource = faq_resource.get_fields(reformat_datetime=True) BASE_PATH = FAQ_BASE_PATH if faq_resource["resource_path"] in ["", None]: raise ValidationError( "Cannot create signed URL for a FAQ without resource_path") resource_type = "faq_html" resource_path = faq_resource["resource_path"] else: learning_resource = LearningResource.find_by_uuid(uuid) learning_resource = learning_resource.get_fields(reformat_datetime=True) BASE_PATH = RESOURCE_BASE_PATH resource_type = learning_resource["type"] if learning_resource["type"] == "": raise ResourceNotFoundException( f"No resource type found for resource with uuid {uuid}") resource_path = learning_resource["resource_path"] if resource_path == "": raise ResourceNotFoundException( f"No resource path found for resource with uuid {uuid}") gcs_service = GcsCrudService(CONTENT_SERVING_BUCKET, SIGNURL_SA_KEY_PATH) actual_path = "" if BASE_PATH in resource_path: actual_path = resource_path else: actual_path = f"{BASE_PATH}/{resource_path}" path_exists = is_valid_path(f"gs://{CONTENT_SERVING_BUCKET}/{actual_path}") if path_exists is True: signed_url = gcs_service.generate_url( actual_path, 60) if redirect is False: return { "success": True, "message": "Successfully fetched the signed url", "data": { "signed_url": signed_url, "resource_type": resource_type, "resource_uuid": uuid } } return RedirectResponse(signed_url) else: raise ResourceNotFoundException( "Provided resource path does not exist on GCS bucket") except ResourceNotFoundException as e: Logger.error(e) Logger.error(traceback.print_exc()) raise ResourceNotFound(str(e)) from e except ValidationError as e: Logger.error(e) Logger.error(traceback.print_exc()) raise BadRequest(str(e)) from e @router.post( "/content-serving/upload/sync", response_model=ListFilesAndFolderResponse, name="Synchronous Content Upload API", responses={404: { "model": NotFoundErrorResponseModel }}) async def upload_content_sync(content_file: UploadFile = File(...), is_faq: bool=False): """ Upload the learning content and recieve a singed url for preview ### Args: content_file: `Binary File` Binary File object to be uploaded. Always Required. is_faq: bool If True, the content will be uploaded to faq content folder If False, the content will be uploaded to learning resource content folder Note: This API is capable of uploading Zips as well as standalone content files. But this API does not handle SRL upload. ### Raises: ResourceNotFoundException: Raised when the requested resource does not exists. <br/> Exception 500: Internal Server Error. Raised if something went wrong. ### Returns: Signed URL JSON response: \ `ContentUploadResponse` """ try: # check file size if len(await content_file.read()) > CONTENT_FILE_SIZE: raise PayloadTooLargeError( f"File size is too large: {content_file.filename}") # check if the valid content type header is set if content_file.content_type not in ALLOWED_CONTENT_TYPES: raise ValidationError("content_type not allowed") file_name = content_file.filename file_extension = file_name.split(".")[-1] file_name_without_ext = file_name[:-(len(file_extension) + 1)] if contentValidator.checkExtensionAndContentHeader( content_file.content_type, file_extension) is False: msg = "Content Type header and file extension does not match. " msg += f"Received header contentType: {content_file.content_type}, " msg += f"received file extension .{file_extension}" raise ValidationError(msg) UPLOAD_BASE_PATH = RESOURCE_BASE_PATH if is_faq is True: UPLOAD_BASE_PATH = FAQ_BASE_PATH content_upload_folder = f"{UPLOAD_BASE_PATH}/{file_name_without_ext}" if file_extension == "zip": # save zip file locally await content_file.seek(0) content = await content_file.read() zipfile_path = pathlib.Path.cwd( ) / f"{ZIP_EXTRACTION_FOLDER}/{content_file.filename}" zipfile_path.write_bytes(content) zipfile_path = f"{ZIP_EXTRACTION_FOLDER}/{content_file.filename}" # unzip files to zip_extraction folder filename_without_ext = file_name.split(".")[0] local_dest_path = f"{ZIP_EXTRACTION_FOLDER}/extracted/{filename_without_ext}" fileHandler.unzipPackage(zipfile_path, local_dest_path) # recreate zip structure on GCS upload_folder(CONTENT_SERVING_BUCKET, local_dest_path, content_upload_folder) # Cleanup Temporary Files fileHandler.deleteFile(zipfile_path) fileHandler.deleteFolder(local_dest_path) else: # Go to the start of stream by file.seek(0) await content_file.seek(0) # Upload to GCS upload_file_to_bucket(CONTENT_SERVING_BUCKET, content_upload_folder, content_file.filename, content_file.file) prefix, folders_list, files_list = get_file_and_folder_list( content_upload_folder) msg = "Successfully uploaded the learning content" if is_faq is True: msg = "Successfully uploaded the faq content" return { "success": True, "message": msg, "data": { "prefix": prefix, "folders": folders_list, "files": files_list } } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except PayloadTooLargeError as e: raise PayloadTooLarge(str(e)) from e except BadZipFile as e: print(traceback.print_exc()) raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.post( "/content-serving/upload/async", response_model=BatchJobModel, name="Asynchronous Content Upload API", include_in_schema=False, responses={404: { "model": NotFoundErrorResponseModel }}) async def upload_content_async(content_file: UploadFile = File(...)): """ Upload the learning content and recieve a singed url for preview ### Args: content_file: `File` Binary File object to be uploaded. Always Required. ### Raises: ResourceNotFoundException: Raised when the requested resource does not exists. <br/> Exception 500: Internal Server Error. Raised if something went wrong. ### Returns: Signed URL JSON response: \ `ContentUploadResponse` """ try: # check if the valid content type header is set if content_file.content_type != "application/zip": raise ValidationError("Content Type as application/zip is only supported") file_name = content_file.filename file_extension = file_name.split(".")[-1] file_name_without_ext = file_name[:-(len(file_extension) + 1)] if contentValidator.checkExtensionAndContentHeader( content_file.content_type, file_extension) is False: msg = "Content Type header and file extension does not match. " msg += f"Received header contentType: {content_file.content_type}, " msg += f"received file extension .{file_extension}" raise ValidationError(msg) # Go to the start of stream by file.seek(0) await content_file.seek(0) # upload zip file to gcs bucket gsutil_uri = upload_file_to_bucket( CONTENT_SERVING_BUCKET, f"{RESOURCE_BASE_PATH}/zip/{file_name_without_ext}", content_file.filename, content_file.file) # create a batch job env_vars = { "DATABASE_PREFIX": DATABASE_PREFIX, "CONTENT_SERVING_BUCKET": CONTENT_SERVING_BUCKET, "RESOURCE_BASE_PATH": RESOURCE_BASE_PATH } input_data = {"gsutil_uri": gsutil_uri, "filename": content_file.filename} response = initiate_batch_job(input_data, VALIDATE_AND_UPLOAD_ZIP, env_vars) response["data"]["meta_data"] = { "message": f"""File will be uploaded at {f"{RESOURCE_BASE_PATH}/{file_name_without_ext}"}""" } return response except ResourceNotFoundException as e: Logger.error(e) Logger.error(traceback.print_exc()) raise ResourceNotFound(str(e)) from e except ValidationError as e: Logger.error(e) Logger.error(traceback.print_exc()) raise BadRequest(str(e)) from e except BadZipFile as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.put( "/content-serving/link/{uuid}", name="Link Content to Learning Resource", include_in_schema=False, response_model=ContentLinkResponse, responses={404: { "model": NotFoundErrorResponseModel }}) def link_content_to_resource(uuid: str, input_data: ContentLinkInputModel): """ This endpoint Links a resource_path with a learning resource. The response will contain a content version and a signed url for preview. ------------------------------------------------ Input: uuid: `str` UUID of the learnig resource input_data: `dict` { "resource_path: `str`, "type": `str` } """ try: input_dict = input_data.dict() new_content = link_content_and_create_version(uuid, input_dict["resource_path"], input_dict["type"]) gcs_service = GcsCrudService(CONTENT_SERVING_BUCKET, SIGNURL_SA_KEY_PATH) signed_url = gcs_service.generate_url( f"""{RESOURCE_BASE_PATH}/{new_content["resource_path"]}""", 60) return { "success": True, "message": "Successfully linked learning resource with content", "data": { "signed_url": signed_url, "resource_type": new_content["type"], "resource_uuid": new_content["uuid"] } } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.put( "/content-serving/publish/{uuid}", response_model=ContentPublishResponse, include_in_schema=False, name="Publish content", responses={404: { "model": NotFoundErrorResponseModel }}) async def publish_content_handler(uuid: str, target_version_uuid: str = None): """ Upload the learning content and recieve a singed url for preview ### Args: uuid: `str` UUID of the learning resource that is connected to the learning hierarchy target_version_uuid: `str` UUID of the target version of the content to be published """ try: _ = LearningResource.find_by_uuid(uuid) published_doc_dict = handle_publish_event(uuid, target_version_uuid) gcs_service = GcsCrudService(CONTENT_SERVING_BUCKET, SIGNURL_SA_KEY_PATH) signed_url = gcs_service.generate_url( f"""{RESOURCE_BASE_PATH}/{published_doc_dict["resource_path"]}""", 60) return { "success": True, "message": "Successfully published content", "data": { "signed_url": signed_url, "resource_type": published_doc_dict["type"], "resource_uuid": published_doc_dict["uuid"] } } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.get( "/content-serving/content-versions/{uuid}", name="List all content versions for a resource", response_model=GetContentVersionsResponse, include_in_schema=False, responses={404: { "model": NotFoundErrorResponseModel }}) def list_content_versions(uuid: str, skip: int = 0, limit: int = 5, status: ALLOWED_CONTENT_VERSION_STATUS = None): """ This endpoint returns a list of content versions available for a give Learning Resource UUID. ------------------------------------------------ Input: uuid: `str` UUID of the learnig resource """ try: content_versions_list = get_content_versions(uuid, status, skip, limit) count = 10000 response = {"records": content_versions_list, "total_count": count} return { "success": True, "message": "Successfully fetched content version for learning resource", "data": response } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.post( "/content-serving/upload/madcap/{le_uuid}", response_model=ListFilesAndFolderResponse, name="Synchronous Content Upload API for Madcap", responses={404: { "model": NotFoundErrorResponseModel }}) async def upload_madcap_export(le_uuid: str, is_srl: bool = False, content_file: UploadFile = File(...)): """Function to upload madcap exports Args: le_uuid(str): ID of Learning Experience is_srl(bool): Flag to determine if the LE is of type SRL content_file: JSON file that needs to be uploaded """ try: # check file size if len(await content_file.read()) > CONTENT_FILE_SIZE: raise PayloadTooLargeError( f"File size is too large: {content_file.filename}") learning_experience = LearningExperience.find_by_uuid(le_uuid) # check if the valid content type header is set if content_file.content_type not in ["application/zip","application/x-zip-compressed"]: raise ValidationError( f"Only content_type: application/zip or application/x-zip-compressed is allowed. Received content_type: {content_file.content_type}" ) file_name = content_file.filename file_extension = file_name.split(".")[-1] file_name_without_ext = file_name[:-(len(file_extension) + 1)] if is_srl is True and file_name_without_ext[0:3] != "SRL": raise ValidationError( """File name should start with the prefix "SRL". eg: "SRL_file_1.zip" """ ) if contentValidator.checkExtensionAndContentHeader( content_file.content_type, file_extension) is False: msg = "Content Type header and file extension does not match. " msg += f"Received header contentType: {content_file.content_type}, " msg += f"received file extension .{file_extension}" raise ValidationError(msg) content_upload_folder = f"{RESOURCE_BASE_PATH}/{file_name_without_ext}" # save zip file locally await content_file.seek(0) content = await content_file.read() zipfile_path = pathlib.Path.cwd( ) / f"{ZIP_EXTRACTION_FOLDER}/{content_file.filename}" zipfile_path.write_bytes(content) zipfile_path = f"{ZIP_EXTRACTION_FOLDER}/{content_file.filename}" # unzip files to zip_extraction folder filename_without_ext = file_name.split(".")[0] local_dest_path = f"{ZIP_EXTRACTION_FOLDER}/extracted/{filename_without_ext}" fileHandler.unzipPackage(zipfile_path, local_dest_path) flag_1, err_msg_1 = contentValidator.isValidMadcapExport( folder_path=local_dest_path, folder_name=filename_without_ext, check_srl=is_srl) if flag_1 is False: raise ValidationError(err_msg_1) is_update_lr_required = False if is_srl is False: if learning_experience.resource_path != "": is_update_lr_required = True # override only if all the file names map 1:1 flag_2, err_msg_2 = is_missing_linked_files( local_dest_path, learning_experience.resource_path) print("IF") print(flag_2, err_msg_2) if flag_2 is False: raise ValidationError(err_msg_2) else: if learning_experience.srl_resource_path != "": is_update_lr_required = True # override only if all the file names map 1:1 flag_2, err_msg_2 = is_missing_linked_files(local_dest_path, learning_experience.srl_resource_path) print("ELSE") print(flag_2, err_msg_2) if flag_2 is False: raise ValidationError(err_msg_2) print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") # recreate zip structure on GCS upload_folder(CONTENT_SERVING_BUCKET, local_dest_path, content_upload_folder) # Cleanup Temporary Files fileHandler.deleteFile(zipfile_path) fileHandler.deleteFolder(local_dest_path) prefix, folders_list, files_list = get_file_and_folder_list( content_upload_folder, True) if is_srl is False: # Add resource_path to the Learning Experience learning_experience.resource_path = prefix learning_experience.update() else: learning_experience.srl_resource_path = prefix learning_experience.update() # Update links of child Learning Resources if is_update_lr_required is True: if is_srl is False: _, _, new_file_paths = get_file_and_folder_list( learning_experience.resource_path, True) update_lr_resource_path(learning_experience.uuid, new_file_paths) if is_srl is False: return { "success": True, "message": f"Successfully uploaded the content for learning experience with uuid {le_uuid}", "data": { "prefix": prefix, "folders": folders_list, "files": files_list } } # Provide SRL access to all Sibling LE le_siblings = link_srl_to_all_le(le_uuid, prefix) for le_dict in le_siblings: if is_update_lr_required is True: _, _, new_file_paths = get_file_and_folder_list( prefix, True) # Update resource paths for all LRs update_lr_resource_path(le_dict["uuid"], new_file_paths) return { "success": True, "message": f"Successfully uploaded the SRL content for learning experience with uuid {le_uuid}", "data": { "prefix": prefix, "folders": folders_list, "files": files_list } } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except PayloadTooLargeError as e: raise PayloadTooLarge(str(e)) from e except BadZipFile as e: print(traceback.print_exc()) raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e @router.post( "/content-serving/link/madcap/{le_uuid}/{lr_uuid}", name="Link Madcap Content to Learning Resource", responses={404: { "model": NotFoundErrorResponseModel }}) async def link_madcap_to_lr( le_uuid: str, lr_uuid: str, input_json: ContentLinkInputModel, is_srl: bool = False, ): """Function to link madcap exports to a Learning Resource Args: le_uuid(str): ID of Learning Experience lr_uuid(str): ID of Learning Resource input_json: JSON file that needs to be linked is_srl(bool): Flag to determine if the LE is of type SRL""" try: input_dict = input_json.dict() resource_path = input_dict["resource_path"] resource_type = input_dict["type"] link_content_to_lr(le_uuid, lr_uuid, resource_path, resource_type, is_srl) return { "success": True, "message": f"Successfully linked content to Learning Resource with uuid {lr_uuid}" } except ResourceNotFoundException as e: raise ResourceNotFound(str(e)) from e except ValidationError as e: raise BadRequest(str(e)) from e except InternalServerError as e: raise InternalServerException(str(e)) from e