diff --git a/document-service/doc-api/pyproject.toml b/document-service/doc-api/pyproject.toml index 5ca65159..614742ea 100644 --- a/document-service/doc-api/pyproject.toml +++ b/document-service/doc-api/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "doc-api" -version = "0.3.10" +version = "0.3.11" description = "" authors = ["dlovett "] license = "BSD 3" diff --git a/document-service/doc-api/src/doc_api/models/utils.py b/document-service/doc-api/src/doc_api/models/utils.py index 76a9b596..a1bb60fc 100755 --- a/document-service/doc-api/src/doc_api/models/utils.py +++ b/document-service/doc-api/src/doc_api/models/utils.py @@ -69,6 +69,9 @@ REPORT_TYPE_FILING = "FILING" REPORT_TYPE_NOA = "NOA" REPORT_TYPE_RECEIPT = "RECEIPT" +REPORT_TYPE_FILING_2 = "FILING-2" +REPORT_TYPE_FILING_3 = "FILING-3" +REPORT_TYPE_FILING_4 = "FILING-4" def now_ts(): diff --git a/document-service/doc-api/src/doc_api/resources/v1/application_reports.py b/document-service/doc-api/src/doc_api/resources/v1/application_reports.py index a8149c3c..cf9f2065 100644 --- a/document-service/doc-api/src/doc_api/resources/v1/application_reports.py +++ b/document-service/doc-api/src/doc_api/resources/v1/application_reports.py @@ -115,6 +115,34 @@ def update_product_report_info(prod_code: str, doc_service_id: str): return resource_utils.default_exception_response(default_exception) +@bp.route("//", methods=["PUT", "OPTIONS"]) +@jwt.requires_auth +def replace_product_report(prod_code: str, doc_service_id: str): + """Replace product report for application report record that is associated with the document service ID.""" + try: + account_id = resource_utils.get_account_id(request) + if not is_report_authorized(jwt): + logger.error("User unuauthorized for this endpoint: not staff or service account.") + return resource_utils.unauthorized_error_response(account_id) + req_path: str = CHANGE_REQUEST_PATH_PRODUCT.format(product_code=prod_code, doc_service_id=doc_service_id) + logger.info(f"Starting replace product report record request {req_path}, account={account_id}") + if not request.get_data(): + logger.error(f"Replace product report request id={doc_service_id} no payload.") + return resource_utils.bad_request_response("Replace product report request invalid: no payload.") + app_report: ApplicationReport = ApplicationReport.find_by_doc_service_id(doc_service_id, prod_code) + if not app_report: + logger.warning(f"No product report record found for {prod_code} document service id={doc_service_id}.") + return resource_utils.not_found_error_response(f"PATCH {prod_code} report information", doc_service_id) + response_json = save_replace(app_report, request.get_data()) + return jsonify(response_json), HTTPStatus.CREATED, CONTENT_JSON + except DatabaseException as db_exception: + return resource_utils.db_exception_response(db_exception, account_id, "PATCH product report information") + except BusinessException as exception: + return resource_utils.business_exception_response(exception) + except Exception as default_exception: # noqa: B902; return nicer default error + return resource_utils.default_exception_response(default_exception) + + @bp.route("//", methods=["GET", "OPTIONS"]) @jwt.requires_auth def get_individual_product_report(prod_code: str, doc_service_id: str): @@ -424,6 +452,17 @@ def save_create(request_json: dict, raw_data) -> dict: return report_json +def save_replace(app_report: ApplicationReport, raw_data) -> dict: + """Replace app report record document with request binary data. Return a download link""" + logger.info(f"save_replace starting raw data size={len(raw_data)}, saving to doc storage...") + doc_link: str = save_to_doc_storage(app_report, raw_data) + app_report.save() + report_json = app_report.json + report_json["url"] = doc_link + logger.info("save_replace completed...") + return report_json + + def get_report_link(app_report: ApplicationReport) -> dict: """Generate the report download URL link for the app report.""" report_json: dict = app_report.json @@ -464,9 +503,11 @@ def get_report_links(reports_json: list, product_code: str) -> list: for report_json in reports_json: storage_name: str = report_json.get("url") report_type: str = report_json.get("reportType") - if storage_name and report_type in (model_utils.REPORT_TYPE_FILING, model_utils.REPORT_TYPE_NOA): + if storage_name and ( + report_type == model_utils.REPORT_TYPE_NOA or report_type.startswith(model_utils.REPORT_TYPE_FILING) + ): report_json["url"] = "" - elif storage_name and report_type not in (model_utils.REPORT_TYPE_FILING, model_utils.REPORT_TYPE_NOA): + elif storage_name: logger.debug(f"getting link for type={storage_type} name={storage_name}...") doc_link = GoogleStorageService.get_document_link(storage_name, storage_type, 2) report_json["url"] = doc_link @@ -475,10 +516,15 @@ def get_report_links(reports_json: list, product_code: str) -> list: def is_certified_copy_request(report_data: ApplicationReport, certified_copy) -> bool: """Verify a report request is for a certified copy of the report.""" - if not report_data.report_type or report_data.report_type not in ( + # Allow certified copies of any application filing report where the report type starts with "FILING". + if not report_data.report_type: + return False + if report_data.report_type not in ( model_utils.REPORT_TYPE_NOA, model_utils.REPORT_TYPE_FILING, - ): + ) and not str( + report_data.report_type + ).startswith(model_utils.REPORT_TYPE_FILING): return False return certified_copy and bool(certified_copy) and bool(certified_copy) is True diff --git a/document-service/doc-api/tests/unit/resources/test_application_reports.py b/document-service/doc-api/tests/unit/resources/test_application_reports.py index e38da5e2..d6f2cebd 100644 --- a/document-service/doc-api/tests/unit/resources/test_application_reports.py +++ b/document-service/doc-api/tests/unit/resources/test_application_reports.py @@ -24,8 +24,9 @@ from doc_api.models import ApplicationReport, Document from doc_api.models import utils as model_utils -from doc_api.utils.logging import logger +from doc_api.resources.v1.application_reports import is_certified_copy_request from doc_api.services.authz import BC_REGISTRY, COLIN_ROLE, STAFF_ROLE, SYSTEM_ROLE +from doc_api.utils.logging import logger from tests.unit.services.utils import ( create_header_account, create_header_account_report, @@ -49,13 +50,14 @@ "consumerReferenceId": "3333001" } TEST_DATAFILE = "tests/unit/services/unit_test.pdf" +TEST_DATAFILE_FILING = "tests/unit/reports/data/filing.pdf" +TEST_DATAFILE_FILING_2 = "tests/unit/reports/data/legacy-filing.pdf" TEST_FILENAME = "updated_name.pdf" PARAM_TEST_FILENAME = "?consumerFilename=updated_name.pdf" MOCK_AUTH_URL = "https://test.api.connect.gov.bc.ca/mockTarget/auth/api/v1/" MEDIA_PDF = model_utils.CONTENT_TYPE_PDF -PARAMS1 = ( - "?consumerFilename=test.pdf&consumerFilingDate=2005-10-31" -) +PARAMS1 = "?consumerFilename=test.pdf&consumerFilingDate=2005-10-31" +PARAMS2 = "?consumerFilename=test2.pdf&consumerFilingDate=2005-10-31" PATH: str = "/api/v1/application-reports/{entity_id}/{event_id}/{report_type}" CHANGE_PATH = "/api/v1/application-reports/{doc_service_id}" EVENT_PATH = "/api/v1/application-reports/events/{event_id}" @@ -116,6 +118,12 @@ ("Invalid role", "UT-123456", "123456", "FILING", HTTPStatus.UNAUTHORIZED, "BUSINESS", PRODUCT_ROLES_COLIN), ("Invalid product code", "UT-123456", "123456", "FILING", HTTPStatus.BAD_REQUEST, "XXX", PRODUCT_ROLES_SYSTEM), ] +# testdata pattern is ({description}, {entity_id}, {event_id}, {rtype}, {rtype2}, {status}, {prod_code}, {roles}) +TEST_CREATE_DATA_PRODUCT_ADDITIONAL = [ + ("Valid minimal SA", "UT-123456", "123456", model_utils.REPORT_TYPE_FILING, model_utils.REPORT_TYPE_FILING_2, + HTTPStatus.CREATED, "BUSINESS", PRODUCT_ROLES_SYSTEM), + ] + # testdata pattern is ({description}, {entity_id}, {event_id}, {rtype}, {status}, {payload}) TEST_PATCH_DATA = [ ("Invalid name", "UT-123456", "123456", "FILING", HTTPStatus.BAD_REQUEST, PATCH_PAYLOAD1), @@ -188,6 +196,33 @@ ("Conversion AR legacy", "UT9900003", 99900003, "FILING", CC_FILING_AR_LEGACY_INFILE, CC_FILING_AR_LEGACY_OUTFILE, "UT9900003-CONVL-FILING.pdf"), ] +# testdata pattern is ({description}, {report_type}, {certified_requested}, {certified_allowed}) +TEST_CERTIFIED_COPY_REQUEST_DATA = [ + ("NOA not certified", model_utils.REPORT_TYPE_NOA, False, False), + ("NOA certified", model_utils.REPORT_TYPE_NOA, True, True), + ("FILING not certified", model_utils.REPORT_TYPE_FILING, False, False), + ("FILING certified", model_utils.REPORT_TYPE_FILING, True, True), + ("RECEIPT not certified", model_utils.REPORT_TYPE_RECEIPT, False, False), + ("RECEIPT certified", model_utils.REPORT_TYPE_RECEIPT, True, False), + ("CERT not certified", model_utils.REPORT_TYPE_CERT, False, False), + ("CERT certified", model_utils.REPORT_TYPE_CERT, True, False), + ("FILING additional 2 not certified", model_utils.REPORT_TYPE_FILING_2, False, False), + ("FILING additional 2 certified", model_utils.REPORT_TYPE_FILING_2, True, True), + ("FILING additional 3 not certified", model_utils.REPORT_TYPE_FILING_3, False, False), + ("FILING additional 3 certified", model_utils.REPORT_TYPE_FILING_3, True, True), + ("FILING additional 4 not certified", model_utils.REPORT_TYPE_FILING_4, False, False), + ("FILING additional 4 certified", model_utils.REPORT_TYPE_FILING_4, True, True), +] +# testdata pattern is ({description}, {entity_id}, {event_id}, {rtype}, {status}, {prod_code}, {roles}) +TEST_PUT_DATA_PRODUCT = [ + ("Invalid entity ID", "1", "12345", "FILING", HTTPStatus.BAD_REQUEST, "BUSINESS", PRODUCT_ROLES_SYSTEM), + ("Invalid event ID", "UT-123456", "JUNK", "FILING", HTTPStatus.BAD_REQUEST, "BUSINESS", PRODUCT_ROLES_SYSTEM), + ("Invalid report type", "UT-123456", "123456", "F", HTTPStatus.BAD_REQUEST, "BUSINESS", PRODUCT_ROLES_SYSTEM), + ("Invalid no payload", "UT-123456", "123456", "FILING", HTTPStatus.BAD_REQUEST, "BUSINESS", PRODUCT_ROLES_SYSTEM), + ("Invalid role", "UT-123456", "123456", "FILING", HTTPStatus.UNAUTHORIZED, "BUSINESS", PRODUCT_ROLES_COLIN), + ("Invalid product code", "UT-123456", "123456", "FILING", HTTPStatus.BAD_REQUEST, "XXX", PRODUCT_ROLES_SYSTEM), + ("Valid SA", "UT-123456", "123456", "FILING", HTTPStatus.CREATED, "BUSINESS", PRODUCT_ROLES_SYSTEM), +] @pytest.mark.parametrize("desc,entity_id,event_id,report_type,status,prod_code,roles", TEST_CREATE_DATA_PRODUCT) @@ -223,6 +258,106 @@ def test_product_create(session, client, jwt, desc, entity_id, event_id, report_ assert app_report +@pytest.mark.parametrize("desc,entity_id,event_id,report_type,report_type2,status,prod_code,roles", TEST_CREATE_DATA_PRODUCT_ADDITIONAL) +def test_product_create_additional(session, client, jwt, desc, entity_id, event_id, report_type, report_type2, status, prod_code, roles): + """Assert that a post save new product filing and additional filing reports works as expected.""" + if is_ci_testing(): + return + + # setup + current_app.config.update(AUTH_SVC_URL=MOCK_AUTH_URL) + headers = create_header_account_upload(jwt, roles, "UT-TEST", "PS12345", MEDIA_PDF) + req_path = PATH_PRODUCT.format(prod_code=prod_code, entity_id=entity_id, event_id=event_id, report_type=report_type) + # test + if status != HTTPStatus.CREATED: + response = client.post(req_path, data=None, headers=headers, content_type=MEDIA_PDF) + else: + raw_data = None + req_path += PARAMS1 + with open(TEST_DATAFILE_FILING, "rb") as data_file: + raw_data = data_file.read() + data_file.close() + response = client.post(req_path, data=raw_data, headers=headers, content_type=MEDIA_PDF) + + # check + assert response.status_code == status + if response.status_code == HTTPStatus.CREATED: + report_json = response.json + assert report_json + assert report_json.get("identifier") + assert report_json.get("url") + assert report_json.get("productCode") == prod_code + assert report_json.get("entityIdentifier") == entity_id + assert report_json.get("eventIdentifier") == int(event_id) + assert report_json.get("reportType") == report_type + app_report: ApplicationReport = ApplicationReport.find_by_doc_service_id(report_json.get("identifier")) + assert app_report + if status == HTTPStatus.CREATED and report_type2: + req_path = PATH_PRODUCT.format(prod_code=prod_code, entity_id=entity_id, event_id=event_id, report_type=report_type2) + req_path += PARAMS2 + with open(TEST_DATAFILE_FILING_2, "rb") as data_file: + raw_data = data_file.read() + data_file.close() + response = client.post(req_path, data=raw_data, headers=headers, content_type=MEDIA_PDF) + assert response.status_code == status + report_json = response.json + assert report_json + assert report_json.get("identifier") + assert report_json.get("url") + assert report_json.get("productCode") == prod_code + assert report_json.get("entityIdentifier") == entity_id + assert report_json.get("eventIdentifier") == int(event_id) + assert report_json.get("reportType") == report_type2 + app_report: ApplicationReport = ApplicationReport.find_by_doc_service_id(report_json.get("identifier")) + assert app_report + + +@pytest.mark.parametrize("desc,entity_id,event_id,report_type,status,prod_code,roles", TEST_PUT_DATA_PRODUCT) +def test_product_replace(session, client, jwt, desc, entity_id, event_id, report_type, status, prod_code, roles): + """Assert that a put replace product report works as expected.""" + if is_ci_testing(): + return + # setup + current_app.config.update(AUTH_SVC_URL=MOCK_AUTH_URL) + headers = create_header_account_upload(jwt, roles, "UT-TEST", "PS12345", MEDIA_PDF) + req_path = PATH_PRODUCT.format(prod_code=prod_code, entity_id=entity_id, event_id=event_id, report_type=report_type) + # test + if status != HTTPStatus.CREATED: + response = client.post(req_path, data=None, headers=headers, content_type=MEDIA_PDF) + else: + req_path += PARAMS1 + raw_data = None + with open(TEST_DATAFILE, "rb") as data_file: + raw_data = data_file.read() + data_file.close() + response = client.post(req_path, data=raw_data, headers=headers, content_type=MEDIA_PDF) + # logger.info(response.json) + + # check + assert response.status_code == status + if response.status_code == HTTPStatus.CREATED: + report_json = response.json + assert report_json + assert report_json.get("identifier") + drs_id: str = report_json.get("identifier") + req_path = CHANGE_PATH_PRODUCT.format(prod_code=prod_code, doc_service_id=drs_id) + raw_data = None + with open(TEST_DATAFILE_FILING, "rb") as data_file: + raw_data = data_file.read() + data_file.close() + response2 = client.put(req_path, data=raw_data, headers=headers, content_type=MEDIA_PDF) + # logger.info(response2.json) + assert response2.status_code == status + report2_json = response.json + assert report2_json + assert report2_json.get("identifier") == drs_id + assert report2_json.get("url") + assert report2_json.get("productCode") == prod_code + assert report2_json.get("entityIdentifier") == entity_id + assert report2_json.get("eventIdentifier") == int(event_id) + assert report2_json.get("reportType") == report_type + + @pytest.mark.parametrize("desc,entity_id,event_id,report_type,status,payload,prod_code,roles", TEST_PATCH_DATA_PRODUCT) def test_product_update(session, client, jwt, desc, entity_id, event_id, report_type, status, payload, prod_code, roles): """Assert that a request to update report information (not the report itself) works as expected.""" @@ -575,6 +710,14 @@ def test_get_certified_copy(session, client, jwt, desc, entity_id, event_id, rep pdf_file.close() +@pytest.mark.parametrize("desc,report_type,certified_requested,certified_allowed", TEST_CERTIFIED_COPY_REQUEST_DATA) +def test_certified_copy(session, client, jwt, desc, report_type, certified_requested, certified_allowed): + """Assert that the certified copy check for a report type works as expected.""" + app_report: ApplicationReport = ApplicationReport(report_type=report_type) + result: bool = is_certified_copy_request(app_report, certified_requested) + assert result == certified_allowed + + def is_ci_testing() -> bool: """Check unit test environment: exclude pub/sub for CI testing.""" return current_app.config.get("DEPLOYMENT_ENV", "testing") == "testing" diff --git a/document-service/docs/doc-api.yaml b/document-service/docs/doc-api.yaml index aa9eec2a..5578a6da 100644 --- a/document-service/docs/doc-api.yaml +++ b/document-service/docs/doc-api.yaml @@ -214,7 +214,7 @@ paths: summary: Upload a report response. value: identifier: DSR0100001003 - entityIdentifier: BC794361 + entityIdentifier: BC0794361 eventIdentifier: 4194022 reportType: RECEIPT dateCreated: '2025-03-03T22:58:45+00:00' @@ -274,7 +274,7 @@ paths: summary: Update a receipt report response. value: identifier: DSR0100001003 - entityIdentifier: BC794361 + entityIdentifier: BC0794361 eventIdentifier: 4194022 reportType: RECEIPT dateCreated: '2025-03-03T22:58:45+00:00' @@ -289,6 +289,58 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + + put: + tags: + - app-reports + summary: Replace an existing application report. + description:

Replace the existing application report record specified by the identifer with the payload document. The identifier is the DRS unique identifier for the report returned in a previous POST or GET response.

+ operationId: put-product-report + requestBody: + content: + application/pdf: + schema: + type: string + format: binary + responses: + '201': + description: Created + headers: + Access-Control-Allow-Origin: + $ref: '#/components/headers/AccessControlAllowOrigin' + Access-Control-Allow-Methods: + $ref: '#/components/headers/AccessControlAllowMethods' + Access-Control-Allow-Headers: + $ref: '#/components/headers/AccessControlAllowHeaders' + Access-Control-Max-Age: + $ref: '#/components/headers/AccessControlMaxAge' + content: + application/json: + schema: + $ref: '#/components/schemas/applicationReport' + examples: + /api/v1/application-reports/BUSINESS/DSR0100001003/put: + summary: Replace a filing report response. + value: + identifier: DSR0100001003 + entityIdentifier: BC0794361 + eventIdentifier: 4194022 + reportType: FILING + dateCreated: '2025-03-03T22:58:45+00:00' + datePublished: '2023-12-22T16:22:32+00:00' + name: AR-Filing-4194022.pdf + url: 'storage-url-here' + productCode: BUSINESS + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + get: parameters: - name: certifiedCopy