From fd438a773372665f15a0814e7e00ec7238581f94 Mon Sep 17 00:00:00 2001 From: PaulG <144158015+PaulGarewal@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:36:14 -0700 Subject: [PATCH 01/60] added review endpoint testing (#2838) --- .../tests/unit/resources/v2/test_reviews.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/legal-api/tests/unit/resources/v2/test_reviews.py b/legal-api/tests/unit/resources/v2/test_reviews.py index d6b79f413e..237cc620e1 100644 --- a/legal-api/tests/unit/resources/v2/test_reviews.py +++ b/legal-api/tests/unit/resources/v2/test_reviews.py @@ -22,6 +22,8 @@ ) from http import HTTPStatus +from flask import current_app + from legal_api.services.authz import BASIC_USER, STAFF_ROLE from tests.unit.services.utils import create_header @@ -83,3 +85,44 @@ def test_get_reviews_with_valid_user(app, session, client, jwt): assert 10 == rv.json.get('limit') assert 3 == rv.json.get('total') +def test_get_specific_review_with_valid_user(app, session, client, jwt, mocker): + """Assert specific review object returned for STAFF role.""" + review = basic_test_review() + review.save() + + base_url = current_app.config.get('LEGAL_API_BASE_URL') + + mock_filing = mocker.Mock() + mock_filing.temp_reg = 'BC1234567' + mock_filing.id = 1 + mocker.patch('legal_api.models.Filing.find_by_id', return_value=mock_filing) + + mocker.patch('legal_api.resources.v2.admin.reviews.current_app.config.get', return_value=base_url) + + rv = client.get(f'/api/v2/admin/reviews/{review.id}', + headers=create_header(jwt, [STAFF_ROLE], 'user')) + + + assert rv.status_code == HTTPStatus.OK + assert rv.json['id'] == review.id + assert 'filingLink' in rv.json + assert rv.json['filingLink'] == f'{base_url}/{mock_filing.temp_reg}/filings/{mock_filing.id}' + +def test_get_specific_review_with_invalid_user(app, session, client, jwt): + """Assert unauthorized for BASIC_USER role when getting a specific review.""" + review = basic_test_review() + review.save() + + rv = client.get(f'/api/v2/admin/reviews/{review.id}', + headers=create_header(jwt, [BASIC_USER], 'user')) + + assert rv.status_code == HTTPStatus.UNAUTHORIZED + +def test_get_nonexistent_review(app, session, client, jwt): + """Assert not found for non-existent review ID.""" + rv = client.get('/api/v2/admin/reviews/99999', + headers=create_header(jwt, [STAFF_ROLE], 'user')) + + assert rv.status_code == HTTPStatus.NOT_FOUND + assert 'message' in rv.json + assert rv.json['message'] == 'Review not found.' From 313c5a4d6e18cbae4c29484ceb2d2200c08a0709 Mon Sep 17 00:00:00 2001 From: Argus Chiu Date: Wed, 17 Jul 2024 11:43:37 -0700 Subject: [PATCH 02/60] 21953 Fix issue when auth returns empty contacts list for GET /entities/{identifier} endpoint (#2839) --- jobs/furnishings/src/furnishings/stage_processors/stage_one.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py index b606e57fa9..f7705224fc 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py @@ -255,7 +255,7 @@ def _get_email_address_from_auth(identifier: str): f'{current_app.config.get("AUTH_URL")}/entities/{identifier}', headers=headers ) - contacts = contact_info.json()['contacts'] + contacts = contact_info.json().get('contacts', []) if not contacts or not contacts[0]['email']: return None return contacts[0]['email'] From e69cf78eb1b3984a624046cae4f067646007d4cf Mon Sep 17 00:00:00 2001 From: Argus Chiu Date: Wed, 17 Jul 2024 13:38:49 -0700 Subject: [PATCH 03/60] 21953 Fix issue when auth returns 404 for GET /entities/{identifier} endpoint (#2840) * 21953 Fix issue when auth returns 404 for GET /entities/{identifier} endpoint * lint fixes * lint fixes --- jobs/furnishings/Makefile | 4 ++-- jobs/furnishings/requirements.txt | 4 ++-- .../furnishings/stage_processors/stage_one.py | 20 ++++++++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/jobs/furnishings/Makefile b/jobs/furnishings/Makefile index 85dd661960..be6123ad58 100644 --- a/jobs/furnishings/Makefile +++ b/jobs/furnishings/Makefile @@ -39,7 +39,7 @@ clean-test: ## clean test files build-req: clean ## Upgrade requirements test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements/prod.txt ;\ pip freeze | sort > requirements.txt ;\ cat requirements/bcregistry-libraries.txt >> requirements.txt ;\ @@ -48,7 +48,7 @@ build-req: clean ## Upgrade requirements install: clean ## Install python virtual environment test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements.txt install-dev: ## Install local application diff --git a/jobs/furnishings/requirements.txt b/jobs/furnishings/requirements.txt index 86a3780b5f..6744c4a180 100644 --- a/jobs/furnishings/requirements.txt +++ b/jobs/furnishings/requirements.txt @@ -22,12 +22,12 @@ pyasn1==0.4.8 pyrsistent==0.17.3 python-dotenv==0.17.1 python-jose==3.2.0 -pytz==2021.1 +pytz==2024.1 rsa==4.7.2 sentry-sdk==1.20.0 six==1.15.0 urllib3==1.26.11 requests==2.25.1 -cachelib==0.1.1 +cachelib==0.9.0 git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/business-schemas.git@2.15.38#egg=registry_schemas diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py index f7705224fc..0732cb94f8 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py @@ -251,12 +251,22 @@ def _get_email_address_from_auth(identifier: str): 'Authorization': f'Bearer {token}' } - contact_info = requests.get( - f'{current_app.config.get("AUTH_URL")}/entities/{identifier}', - headers=headers - ) + url = f'{current_app.config.get("AUTH_URL")}/entities/{identifier}' + try: + contact_info = requests.get(url, headers=headers) + contact_info.raise_for_status() + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + current_app.logger.info(f'No entity found for identifier: {identifier}') + else: + current_app.logger.error(f'HTTP error occurred: {e}, URL: {url}, Status code: {e.response.status_code}') + return None + except requests.exceptions.RequestException as e: + current_app.logger.error(f'Request failed: {e}, URL: {url}') + return None + contacts = contact_info.json().get('contacts', []) - if not contacts or not contacts[0]['email']: + if not contacts or not contacts[0].get('email'): return None return contacts[0]['email'] From 8d2758d1e4028222f3a6c4ff485dbc477f3b829f Mon Sep 17 00:00:00 2001 From: leodube-aot <122323255+leodube-aot@users.noreply.github.com> Date: Wed, 17 Jul 2024 14:49:20 -0600 Subject: [PATCH 04/60] Bump version numbers up for release 22.2a (#2841) --- colin-api/src/colin_api/version.py | 2 +- legal-api/src/legal_api/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/colin-api/src/colin_api/version.py b/colin-api/src/colin_api/version.py index 29244caa4c..bc40d474a7 100644 --- a/colin-api/src/colin_api/version.py +++ b/colin-api/src/colin_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.117.0' # pylint: disable=invalid-name +__version__ = '2.118.0' # pylint: disable=invalid-name diff --git a/legal-api/src/legal_api/version.py b/legal-api/src/legal_api/version.py index 181be0505c..6749145c5e 100644 --- a/legal-api/src/legal_api/version.py +++ b/legal-api/src/legal_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.117.0' # pylint: disable=invalid-name +__version__ = '2.118.0' # pylint: disable=invalid-name From 4830d336d3b9510cc7a688336107965dd005fcf8 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 17 Jul 2024 14:09:26 -0700 Subject: [PATCH 05/60] 21985 creationDate in isoformat (#2842) --- .../src/legal_api/models/review_result.py | 2 +- .../legal_api/resources/v2/admin/reviews.py | 2 +- .../tests/unit/resources/v2/test_reviews.py | 53 +++++++++---------- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/legal-api/src/legal_api/models/review_result.py b/legal-api/src/legal_api/models/review_result.py index fcbf5f1ad6..1e4096e0cb 100644 --- a/legal-api/src/legal_api/models/review_result.py +++ b/legal-api/src/legal_api/models/review_result.py @@ -78,5 +78,5 @@ def json(self) -> dict: 'comments': self.comments, 'reviewer': self.reviewer.display_name, 'submissionDate': self.submission_date.isoformat() if self.submission_date else None, - 'creationDate': self.creation_date + 'creationDate': self.creation_date.isoformat() } diff --git a/legal-api/src/legal_api/resources/v2/admin/reviews.py b/legal-api/src/legal_api/resources/v2/admin/reviews.py index 91263b3c8c..3e51a4da73 100644 --- a/legal-api/src/legal_api/resources/v2/admin/reviews.py +++ b/legal-api/src/legal_api/resources/v2/admin/reviews.py @@ -32,7 +32,7 @@ def get_reviews(): limit = int(request.args.get('limit', 10)) reviews = Review.get_paginated_reviews(page, limit) - return reviews, HTTPStatus.OK + return jsonify(reviews), HTTPStatus.OK @bp_admin.route('/reviews/', methods=['GET', 'OPTIONS']) diff --git a/legal-api/tests/unit/resources/v2/test_reviews.py b/legal-api/tests/unit/resources/v2/test_reviews.py index 237cc620e1..9cd56d25e1 100644 --- a/legal-api/tests/unit/resources/v2/test_reviews.py +++ b/legal-api/tests/unit/resources/v2/test_reviews.py @@ -32,7 +32,7 @@ from tests.unit.models import factory_filing -def basic_test_review(): +def create_test_review(no_of_reviews=1): filing_dict = { 'filing': { 'header': { @@ -44,52 +44,49 @@ def basic_test_review(): } } filing_dict['filing']['continuationIn'] = copy.deepcopy(CONTINUATION_IN) - filing = factory_filing(None, filing_dict) - review = Review() - review.filing_id = filing.id - review.nr_number = filing_dict['filing']['continuationIn']['nameRequest']['nrNumber'] - review.identifier = filing_dict['filing']['continuationIn']['foreignJurisdiction']['identifier'] - review.completing_party = 'completing party' - review.status = ReviewStatus.AWAITING_REVIEW + reviews = [] + for _ in range(no_of_reviews): + filing = factory_filing(None, filing_dict) - return review + review = Review() + review.filing_id = filing.id + review.nr_number = filing_dict['filing']['continuationIn']['nameRequest']['nrNumber'] + review.identifier = filing_dict['filing']['continuationIn']['foreignJurisdiction']['identifier'] + review.completing_party = 'completing party' + review.status = ReviewStatus.AWAITING_REVIEW + review.save() + reviews.append(review) + + return reviews def test_get_reviews_with_invalid_user(app, session, client, jwt): """Assert unauthorized for BASIC_USER role.""" - rv = client.get(f'/api/v2/admin/reviews', headers=create_header(jwt, [BASIC_USER], 'user')) - assert rv.status_code == HTTPStatus.UNAUTHORIZED + def test_get_reviews_with_valid_user(app, session, client, jwt): """Assert review object returned for STAFF role.""" - - review_one = basic_test_review() - review_one.save() - - review_two = basic_test_review() - review_two.save() - - review_three = basic_test_review() - review_three.save() + no_of_reviews = 11 + create_test_review(no_of_reviews) rv = client.get(f'/api/v2/admin/reviews', headers=create_header(jwt, [STAFF_ROLE], 'user')) assert rv.status_code == HTTPStatus.OK - assert 'reviews' in rv.json + assert len(rv.json.get('reviews')) == 10 assert 1 == rv.json.get('page') assert 10 == rv.json.get('limit') - assert 3 == rv.json.get('total') + assert no_of_reviews == rv.json.get('total') + def test_get_specific_review_with_valid_user(app, session, client, jwt, mocker): """Assert specific review object returned for STAFF role.""" - review = basic_test_review() - review.save() - + review = create_test_review(1)[0] + base_url = current_app.config.get('LEGAL_API_BASE_URL') mock_filing = mocker.Mock() @@ -102,22 +99,22 @@ def test_get_specific_review_with_valid_user(app, session, client, jwt, mocker): rv = client.get(f'/api/v2/admin/reviews/{review.id}', headers=create_header(jwt, [STAFF_ROLE], 'user')) - assert rv.status_code == HTTPStatus.OK assert rv.json['id'] == review.id assert 'filingLink' in rv.json assert rv.json['filingLink'] == f'{base_url}/{mock_filing.temp_reg}/filings/{mock_filing.id}' + def test_get_specific_review_with_invalid_user(app, session, client, jwt): """Assert unauthorized for BASIC_USER role when getting a specific review.""" - review = basic_test_review() - review.save() + review = create_test_review(1)[0] rv = client.get(f'/api/v2/admin/reviews/{review.id}', headers=create_header(jwt, [BASIC_USER], 'user')) assert rv.status_code == HTTPStatus.UNAUTHORIZED + def test_get_nonexistent_review(app, session, client, jwt): """Assert not found for non-existent review ID.""" rv = client.get('/api/v2/admin/reviews/99999', From a628d79d6e6a7ee87d91ca0764d972b813e2f26b Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 17 Jul 2024 15:23:01 -0700 Subject: [PATCH 06/60] 19794 colin sync for amalgamationApplication (#2837) --- colin-api/src/colin_api/models/__init__.py | 1 + colin-api/src/colin_api/models/business.py | 6 +- .../src/colin_api/models/corp_involved.py | 121 +++++++++++++++++ colin-api/src/colin_api/models/filing.py | 127 +++++++++++++----- colin-api/src/colin_api/models/reset.py | 2 +- colin-api/src/colin_api/resources/business.py | 3 +- colin-api/src/colin_api/resources/filing.py | 7 +- colin-api/src/colin_api/resources/reset.py | 25 ++-- jobs/update-colin-filings/requirements.txt | 4 + .../requirements/prod.txt | 3 + .../update_legal_filings.py | 60 +++++---- 11 files changed, 279 insertions(+), 80 deletions(-) create mode 100644 colin-api/src/colin_api/models/corp_involved.py diff --git a/colin-api/src/colin_api/models/__init__.py b/colin-api/src/colin_api/models/__init__.py index 4b7f34e30e..e75f7dab9e 100644 --- a/colin-api/src/colin_api/models/__init__.py +++ b/colin-api/src/colin_api/models/__init__.py @@ -1,6 +1,7 @@ """Model imports.""" from .address import Address from .business import Business +from .corp_involved import CorpInvolved from .corp_name import CorpName from .corp_party import Party from .filing_type import FilingType diff --git a/colin-api/src/colin_api/models/business.py b/colin-api/src/colin_api/models/business.py index e4ad9acde7..6f0477d608 100644 --- a/colin-api/src/colin_api/models/business.py +++ b/colin-api/src/colin_api/models/business.py @@ -63,6 +63,7 @@ class CorpStateTypes(Enum): VOLUNTARY_DISSOLUTION = 'HDV' ADMINISTRATIVE_DISSOLUTION = 'HDA' + AMALGAMATED = 'HAM' NUMBERED_CORP_NAME_SUFFIX = { TypeCodes.BCOMP.value: 'B.C. LTD.', @@ -173,7 +174,10 @@ def _get_bn_15s(cls, cursor, identifiers: List) -> Dict: for row in cursor.fetchall(): row = dict(zip([x[0].lower() for x in cursor.description], row)) if row['bn_15']: - bn_15s[f'BC{row["corp_num"]}'] = row['bn_15'] + if row['corp_num'].isdecimal(): # valid only for BC + bn_15s[f'BC{row["corp_num"]}'] = row['bn_15'] + else: + bn_15s[row['corp_num']] = row['bn_15'] return bn_15s except Exception as err: diff --git a/colin-api/src/colin_api/models/corp_involved.py b/colin-api/src/colin_api/models/corp_involved.py new file mode 100644 index 0000000000..84e468761d --- /dev/null +++ b/colin-api/src/colin_api/models/corp_involved.py @@ -0,0 +1,121 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Meta information about the service. + +Currently this only provides API versioning information +""" +from __future__ import annotations + +from typing import List + +from flask import current_app + +from colin_api.resources.db import DB + + +class CorpInvolved: + """Corp Involved object.""" + + event_id = None + corp_involve_id = None + corp_num = None + can_jur_typ_cd = None + adopted_corp_ind = None + home_juri_num = None + othr_juri_desc = None + foreign_nme = None + + def __init__(self): + """Initialize with all values None.""" + + def as_dict(self): + """Return dict camel case version of self.""" + return { + 'eventId': self.event_id, + 'corpInvolveId': self.corp_involve_id, + 'corpNum': self.corp_num, + 'canJurTypCd': self.can_jur_typ_cd, + 'adoptedCorpInd': self.adopted_corp_ind, + 'homeJuriNum': self.home_juri_num, + 'othrJuriDesc': self.othr_juri_desc, + 'foreignName': self.foreign_nme, + } + + @classmethod + def _create_corp_involved_objs(cls, cursor) -> List: + """Return a CorpInvolved obj by parsing cursor.""" + corps_involved = cursor.fetchall() + + corp_involved_objs = [] + for corp_involved in corps_involved: + corp_involved = dict(zip([x[0].lower() for x in cursor.description], corp_involved)) + corp_involved_obj = CorpInvolved() + corp_involved_obj.event_id = corp_involved['event_id'] + corp_involved_obj.corp_involve_id = corp_involved['corp_involve_id'] + corp_involved_obj.corp_num = corp_involved['corp_num'] + corp_involved_obj.can_jur_typ_cd = corp_involved['can_jur_typ_cd'] + corp_involved_obj.adopted_corp_ind = corp_involved['adopted_corp_ind'] + corp_involved_obj.home_juri_num = corp_involved['home_juri_num'] + corp_involved_obj.othr_juri_desc = corp_involved['othr_juri_desc'] + corp_involved_obj.foreign_nme = corp_involved['foreign_nme'] + corp_involved_objs.append(corp_involved_obj) + + return corp_involved_objs + + @classmethod + def create_corp_involved(cls, cursor, corp_involved_obj) -> CorpInvolved: + """Add record to the CORP INVOLVED table.""" + try: + cursor.execute( + """ + insert into CORP_INVOLVED (EVENT_ID, CORP_INVOLVE_ID, CORP_NUM, CAN_JUR_TYP_CD, ADOPTED_CORP_IND, + HOME_JURI_NUM, OTHR_JURI_DESC, FOREIGN_NME) + values (:event_id, :corp_involve_id, :corp_num, :can_jur_typ_cd, :adopted_corp_ind, + :home_juri_num, :othr_juri_desc, :foreign_nme) + """, + event_id=corp_involved_obj.event_id, + corp_involve_id=corp_involved_obj.corp_involve_id, + corp_num=corp_involved_obj.corp_num, + can_jur_typ_cd=corp_involved_obj.can_jur_typ_cd, + adopted_corp_ind=corp_involved_obj.adopted_corp_ind, + home_juri_num=corp_involved_obj.home_juri_num, + othr_juri_desc=corp_involved_obj.othr_juri_desc, + foreign_nme=corp_involved_obj.foreign_nme, + ) + + except Exception as err: + current_app.logger.error(f'Error inserting corp involved for event {corp_involved_obj.event_id}.') + raise err + + @classmethod + def get_by_event(cls, cursor, event_id: str) -> List[CorpInvolved]: + """Get the corps involved with the given event id.""" + querystring = ( + """ + select event_id, corp_involve_id, corp_num, can_jur_typ_cd, adopted_corp_ind, home_juri_num, + othr_juri_desc, foreign_nme, dd_event_id + from corp_involved + where event_id=:event_id + """ + ) + + try: + if not cursor: + cursor = DB.connection.cursor() + cursor.execute(querystring, event_id=event_id) + return cls._create_corp_involved_objs(cursor=cursor) + + except Exception as err: + current_app.logger.error(f'error getting corp involved for event {event_id}') + raise err diff --git a/colin-api/src/colin_api/models/filing.py b/colin-api/src/colin_api/models/filing.py index 3887e271ed..7504738812 100644 --- a/colin-api/src/colin_api/models/filing.py +++ b/colin-api/src/colin_api/models/filing.py @@ -33,7 +33,7 @@ PartiesNotFoundException, # noqa: I001 UnableToDetermineCorpTypeException, # noqa: I001 ) # noqa: I001 -from colin_api.models import Business, CorpName, FilingType, Office, Party, ShareObject +from colin_api.models import Business, CorpInvolved, CorpName, FilingType, Office, Party, ShareObject from colin_api.resources.db import DB from colin_api.utils import convert_to_json_date, convert_to_json_datetime, convert_to_snake @@ -111,8 +111,30 @@ class FilingSource(Enum): Business.TypeCodes.COOP.value: 'OTSPE', }, 'amalgamationApplication': { - 'type_code_list': ['OTAMA'], - Business.TypeCodes.COOP.value: 'OTAMA', + 'sub_type_property': 'type', + 'sub_type_list': ['regular', 'horizontal', 'vertical'], + 'type_code_list': ['OTAMA', 'AMALR', 'AMLRU', 'AMALH', 'AMLHU', 'AMALV', 'AMLVU'], + 'regular': { + Business.TypeCodes.COOP.value: 'OTAMA', + Business.TypeCodes.BCOMP.value: 'AMALR', + Business.TypeCodes.BC_COMP.value: 'AMALR', + Business.TypeCodes.ULC_COMP.value: 'AMLRU', + Business.TypeCodes.CCC_COMP.value: 'AMALR' + }, + 'horizontal': { + Business.TypeCodes.COOP.value: 'OTAMA', + Business.TypeCodes.BCOMP.value: 'AMALH', + Business.TypeCodes.BC_COMP.value: 'AMALH', + Business.TypeCodes.ULC_COMP.value: 'AMLHU', + Business.TypeCodes.CCC_COMP.value: 'AMALH' + }, + 'vertical': { + Business.TypeCodes.COOP.value: 'OTAMA', + Business.TypeCodes.BCOMP.value: 'AMALV', + Business.TypeCodes.BC_COMP.value: 'AMALV', + Business.TypeCodes.ULC_COMP.value: 'AMLVU', + Business.TypeCodes.CCC_COMP.value: 'AMALV' + } }, 'dissolved': { 'type_code_list': ['OTDIS'], @@ -425,35 +447,19 @@ def _insert_filing(cls, cursor, filing, ar_date: str, agm_date: str): # pylint: effective_dt=filing.effective_date, filing_date=filing.filing_date ) - elif filing_type_code in ['NOCAD', 'BEINC', 'ICORP', 'ICORU', 'ICORC', 'CRBIN', 'TRANS']: - insert_stmnt = insert_stmnt + ', arrangement_ind, ods_typ_cd) ' - values_stmnt = values_stmnt + ", 'N', 'F')" - cursor.execute( - insert_stmnt + values_stmnt, - event_id=filing.event_id, - filing_type_code=filing_type_code, - effective_dt=filing.effective_date - ) - elif filing_type_code in ['NOALA', 'NOALB', 'NOALC', 'NOALE', 'NOALR', 'NOALU']: - arragement_ind = 'N' + elif filing_type_code in ['NOCAD', 'CRBIN', 'TRANS', + 'BEINC', 'ICORP', 'ICORU', 'ICORC', + 'AMALR', 'AMALH', 'AMALV', + 'NOALA', 'NOALB', 'NOALC', 'NOALE', 'NOALR', 'NOALU', + 'REGSN', 'REGSO', 'COURT']: + arrangement_ind = 'N' court_order_num = None - if court_order := filing.body.get('courtOrder', None): - arragement_ind = 'Y' if court_order.get('effectOfOrder', None) else 'N' - court_order_num = court_order['fileNumber'] - - insert_stmnt = insert_stmnt + ', arrangement_ind, court_order_num, ods_typ_cd) ' - values_stmnt = values_stmnt + ", :arragement_ind, :court_order_num, 'F')" - cursor.execute( - insert_stmnt + values_stmnt, - event_id=filing.event_id, - filing_type_code=filing_type_code, - effective_dt=filing.effective_date, - arragement_ind=arragement_ind, - court_order_num=court_order_num - ) - elif filing_type_code in ['REGSN', 'REGSO', 'COURT']: - arrangement_ind = 'Y' if filing.body.get('effectOfOrder', '') == 'planOfArrangement' else 'N' - court_order_num = filing.body.get('fileNumber', None) + if filing_type_code in ['REGSN', 'REGSO', 'COURT']: + arrangement_ind = 'Y' if filing.body.get('effectOfOrder', '') == 'planOfArrangement' else 'N' + court_order_num = filing.body.get('fileNumber', None) + elif court_order := filing.body.get('courtOrder', None): + arrangement_ind = 'Y' if court_order.get('effectOfOrder', None) else 'N' + court_order_num = court_order.get('fileNumber', None) insert_stmnt = insert_stmnt + ', arrangement_ind, court_order_num, ods_typ_cd) ' values_stmnt = values_stmnt + ", :arrangement_ind, :court_order_num, 'F')" @@ -990,10 +996,10 @@ def add_administrative_dissolution_event(cls, con, corp_num) -> int: def add_filing(cls, con, filing: Filing) -> int: """Add new filing to COLIN tables.""" try: - if filing.filing_type not in ['annualReport', 'changeOfAddress', 'changeOfDirectors', - 'incorporationApplication', 'alteration', 'transition', 'correction', - 'registrarsNotation', 'registrarsOrder', 'courtOrder', 'dissolution', - 'specialResolution']: + if filing.filing_type not in ['alteration', 'amalgamationApplication', 'annualReport', 'changeOfAddress', + 'changeOfDirectors', 'correction', 'courtOrder', 'dissolution', + 'incorporationApplication', 'registrarsNotation', 'registrarsOrder', + 'specialResolution', 'transition']: raise InvalidFilingTypeException(filing_type=filing.filing_type) if filing.filing_sub_type \ @@ -1020,6 +1026,9 @@ def add_filing(cls, con, filing: Filing) -> int: # create new filing cls._insert_filing(cursor=cursor, filing=filing, ar_date=ar_date, agm_date=agm_date) + if filing.filing_type == 'amalgamationApplication': + cls._process_amalgamating_businesses(cursor, filing) + if filing.filing_type == 'correction': cls._process_correction(cursor, business, filing, corp_num) else: @@ -1133,6 +1142,50 @@ def is_filing_type_match(cls, filing: Filing, filing_type: str, filing_sub_type: """Return whether filing has specificed filing type and filing sub-type.""" return filing.filing_type == filing_type and filing.filing_sub_type == filing_sub_type + @classmethod + def _process_amalgamating_businesses(cls, cursor, filing): + """Process amalgamating businesses.""" + for index, amalgamating_business in enumerate(filing.body.get('amalgamatingBusinesses', [])): + corp_involved = CorpInvolved() + corp_involved.event_id = filing.event_id + corp_involved.corp_involve_id = index + + identifier = amalgamating_business.get('identifier') + + if ((foreign_jurisdiction := amalgamating_business.get('foreignJurisdiction', {})) and + not (identifier.startswith('A') and # is expro + foreign_jurisdiction.get('country') == 'CA' and + foreign_jurisdiction.get('region') == 'BC')): + corp_involved.home_juri_num = identifier + corp_involved.foreign_nme = amalgamating_business.get('legalName') + + country_code = foreign_jurisdiction.get('country').upper() + region_code = (foreign_jurisdiction.get('region') or '').upper() + if country_code == 'CA': + if region_code == 'FEDERAL': + corp_involved.can_jur_typ_cd = 'FD' + else: + corp_involved.can_jur_typ_cd = region_code + else: + corp_involved.can_jur_typ_cd = 'OT' + corp_involved.othr_juri_desc = \ + f'{country_code}, {region_code}' if region_code else country_code + else: + # strip prefix BC + if identifier.startswith('BC'): + identifier = identifier[-7:] + corp_involved.corp_num = identifier + + if amalgamating_business['role'] in ['holding', 'primary']: + corp_involved.adopted_corp_ind = 'Y' + + Business.update_corp_state(cursor, + filing.event_id, + identifier, + Business.CorpStateTypes.AMALGAMATED.value) + + CorpInvolved.create_corp_involved(cursor, corp_involved) + @classmethod # pylint: disable=too-many-arguments; def _process_ar(cls, cursor, filing: Filing, corp_num: str, ar_date: str, agm_date: str, filing_source: str) -> str: @@ -1185,7 +1238,7 @@ def _process_office(cls, cursor, filing: Filing) -> str: office_type=office_type ) # create new ledger text for address change - if filing.filing_type != 'incorporationApplication': + if filing.filing_type not in ['amalgamationApplication', 'incorporationApplication']: office_desc = (office_type.replace('O', ' O')).title() if text: text = f'{text} Change to the {office_desc}.' @@ -1249,7 +1302,7 @@ def _process_directors(cls, cursor, filing: Filing, business: Business, corp_num @classmethod def _create_corp_name(cls, cursor, filing: Filing, corp_num: str, name: str = None): """Create name.""" - if filing.filing_type == 'incorporationApplication': + if filing.filing_type in ['amalgamationApplication', 'incorporationApplication']: # create corp state Business.create_corp_state(cursor=cursor, corp_num=corp_num, event_id=filing.event_id) elif filing.filing_type == 'alteration': diff --git a/colin-api/src/colin_api/models/reset.py b/colin-api/src/colin_api/models/reset.py index 477794b4bf..48618030d0 100644 --- a/colin-api/src/colin_api/models/reset.py +++ b/colin-api/src/colin_api/models/reset.py @@ -253,7 +253,7 @@ def reset_filings(cls, start_date: str = None, end_date: str = None, identifiers raise err @classmethod - def reset_filings_by_event(cls, event_ids: list = []): + def reset_filings_by_event(cls, event_ids: list = []): # pylint: disable=dangerous-default-value """Reset changes made for given event ids.""" # initialize reset object reset_obj = Reset() diff --git a/colin-api/src/colin_api/resources/business.py b/colin-api/src/colin_api/resources/business.py index 76aa74ec5f..57f0b10056 100644 --- a/colin-api/src/colin_api/resources/business.py +++ b/colin-api/src/colin_api/resources/business.py @@ -172,7 +172,8 @@ def get(info_type, legal_type=None, identifier=None): # pylint: disable = too-m if not json_data or not json_data['identifiers']: return jsonify({'message': 'No input data provided'}), HTTPStatus.BAD_REQUEST # remove the BC prefix - identifiers = [x[-7:] for x in json_data['identifiers']] + identifiers = [x[-7:] if identifier.startswith('BC') else x + for x in json_data['identifiers']] bn_15s = Business._get_bn_15s( # pylint: disable = protected-access; internal call cursor=cursor, identifiers=identifiers diff --git a/colin-api/src/colin_api/resources/filing.py b/colin-api/src/colin_api/resources/filing.py index e5e167dc65..1dc3fa700b 100644 --- a/colin-api/src/colin_api/resources/filing.py +++ b/colin-api/src/colin_api/resources/filing.py @@ -128,6 +128,7 @@ def post(legal_type, identifier, **kwargs): 'changeOfDirectors': json_data.get('changeOfDirectors', None), 'annualReport': json_data.get('annualReport', None), 'incorporationApplication': json_data.get('incorporationApplication', None), + 'amalgamationApplication': json_data.get('amalgamationApplication', None), 'alteration': json_data.get('alteration', None), 'transition': json_data.get('transition', None), 'registrarsNotation': json_data.get('registrarsNotation', None), @@ -213,10 +214,10 @@ def _add_filings(con, json_data: dict, filing_list: list, identifier: str) -> li # get utc lear effective date and convert to pacific time for insert into oracle filing.effective_date = convert_to_pacific_time(filing.header['learEffectiveDate']) - if filing_type != 'incorporationApplication': - filing.business = Business.find_by_identifier(identifier, con=con) - else: + if filing_type in ['amalgamationApplication', 'incorporationApplication']: filing.business = Business.create_corporation(con, json_data) + else: + filing.business = Business.find_by_identifier(identifier, con=con) # add the new filing event_id = Filing.add_filing(con, filing) filings_added.append({'event_id': event_id, diff --git a/colin-api/src/colin_api/resources/reset.py b/colin-api/src/colin_api/resources/reset.py index 03ef2d96b7..0f1fb6eda3 100644 --- a/colin-api/src/colin_api/resources/reset.py +++ b/colin-api/src/colin_api/resources/reset.py @@ -16,8 +16,9 @@ Currently this only resets changes made to COOP data made with user COOPER """ import json + from flask import current_app, jsonify, request -from flask_restx import Namespace, Resource, cors, fields +from flask_restx import Namespace, Resource, cors from colin_api.models.reset import Reset from colin_api.utils.auth import COLIN_SVC_ROLE, jwt @@ -62,10 +63,12 @@ def post(): @API.route('/by_event_id') class ResetByEventId(Resource): """Reset filing(s) based on the provided event_id, or array of event_ids. - This is only tested to work on Annual Reports, ymmv""" - eventResetParser = API.parser() - eventResetParser.add_argument( + This is only tested to work on Annual Reports, ymmv + """ + + event_reset_parser = API.parser() + event_reset_parser.add_argument( 'event_ids', type=list, help='The list of event ids to reset. Can be one id', @@ -75,19 +78,21 @@ class ResetByEventId(Resource): @staticmethod @cors.crossdomain(origin='*') @jwt.requires_roles([COLIN_SVC_ROLE]) - @API.expect(eventResetParser) + @API.expect(event_reset_parser) def post(): """Reset filing(s) based on the provided event_id, or array of event_ids. - This is only tested to work on Annual Reports, ymmv""" + + This is only tested to work on Annual Reports, ymmv + """ try: - + event_ids = API.payload.get('event_ids', None) Reset.reset_filings_by_event( - event_ids=event_ids + event_ids=event_ids ) - - return jsonify({'message':"Reset for event ids "+json.dumps(event_ids)}), 200 + + return jsonify({'message': 'Reset for event ids ' + json.dumps(event_ids)}), 200 except Exception as err: # pylint: disable=broad-except; want to catch all errors # general catch-all exception diff --git a/jobs/update-colin-filings/requirements.txt b/jobs/update-colin-filings/requirements.txt index b2887f31d2..b7f4f9f3d4 100644 --- a/jobs/update-colin-filings/requirements.txt +++ b/jobs/update-colin-filings/requirements.txt @@ -27,5 +27,9 @@ rsa==4.7.2 sentry-sdk==1.20.0 six==1.15.0 urllib3==1.26.11 +asyncio-nats-client==0.11.4 +asyncio-nats-streaming==0.4.0 +nest_asyncio +protobuf==3.15.8 git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/business-schemas.git#egg=registry_schemas diff --git a/jobs/update-colin-filings/requirements/prod.txt b/jobs/update-colin-filings/requirements/prod.txt index 14d6c89ee7..4711b4e91a 100644 --- a/jobs/update-colin-filings/requirements/prod.txt +++ b/jobs/update-colin-filings/requirements/prod.txt @@ -8,3 +8,6 @@ python-dotenv requests sentry-sdk[flask] werkzeug<1.0 +asyncio-nats-client==0.11.4 +asyncio-nats-streaming==0.4.0 +nest_asyncio diff --git a/jobs/update-legal-filings/update_legal_filings.py b/jobs/update-legal-filings/update_legal_filings.py index 7915b779c3..8a301afb48 100644 --- a/jobs/update-legal-filings/update_legal_filings.py +++ b/jobs/update-legal-filings/update_legal_filings.py @@ -323,39 +323,45 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n if response.status_code != 200: application.logger.error('legal-updater failed to get identifiers from legal-api.') raise Exception # pylint: disable=broad-exception-raised - identifiers = response.json() - - if identifiers['identifiers']: - # get tax ids that exist for above entities - application.logger.debug(f'Getting tax ids for {identifiers["identifiers"]} from colin api...') - response = requests.get( - application.config['COLIN_URL'] + '/internal/tax_ids', - json=identifiers, - headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, - timeout=AccountService.timeout - ) - if response.status_code != 200: - application.logger.error('legal-updater failed to get tax_ids from colin-api.') - raise Exception # pylint: disable=broad-exception-raised - tax_ids = response.json() - if tax_ids.keys(): - # update lear with new tax ids from colin - application.logger.debug(f'Updating tax ids for {tax_ids.keys()} in lear...') - response = requests.post( - application.config['LEGAL_API_URL'] + '/internal/tax_ids', - json=tax_ids, + business_identifiers = response.json() + + if business_identifiers['identifiers']: + start = 0 + end = 100 + # make a colin-api call with 100 identifiers at a time + while identifiers := business_identifiers['identifiers'][start:end]: + start = end + end += 100 + # get tax ids that exist for above entities + application.logger.debug(f'Getting tax ids for {identifiers} from colin api...') + response = requests.get( + application.config['COLIN_URL'] + '/internal/tax_ids', + json={'identifiers': identifiers}, headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, timeout=AccountService.timeout ) - if response.status_code != 201: - application.logger.error('legal-updater failed to update tax_ids in lear.') + if response.status_code != 200: + application.logger.error('legal-updater failed to get tax_ids from colin-api.') raise Exception # pylint: disable=broad-exception-raised + tax_ids = response.json() + if tax_ids.keys(): + # update lear with new tax ids from colin + application.logger.debug(f'Updating tax ids for {tax_ids.keys()} in lear...') + response = requests.post( + application.config['LEGAL_API_URL'] + '/internal/tax_ids', + json=tax_ids, + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout + ) + if response.status_code != 201: + application.logger.error('legal-updater failed to update tax_ids in lear.') + raise Exception # pylint: disable=broad-exception-raised - await publish_queue_events(tax_ids, application) + await publish_queue_events(tax_ids, application) - application.logger.debug('Successfully updated tax ids in lear.') - else: - application.logger.debug('No tax ids in colin to update in lear.') + application.logger.debug('Successfully updated tax ids in lear.') + else: + application.logger.debug('No tax ids in colin to update in lear.') else: application.logger.debug('No businesses in lear with outstanding tax ids.') From 271c9f330dc70e439bc6a595471f8226a72a753f Mon Sep 17 00:00:00 2001 From: leodube-aot <122323255+leodube-aot@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:26:12 -0600 Subject: [PATCH 07/60] 21952 Furnishings job - Implement stage three (#2833) * Implement stage three * Add business name * Save new furnishing properly --- .../furnishings/stage_processors/stage_one.py | 2 + .../stage_processors/stage_three.py | 69 +++++++++++++ jobs/furnishings/src/furnishings/worker.py | 4 +- .../unit/stage_processors/test_stage_one.py | 6 -- .../unit/stage_processors/test_stage_three.py | 99 +++++++++++++++++++ 5 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 jobs/furnishings/src/furnishings/stage_processors/stage_three.py create mode 100644 jobs/furnishings/tests/unit/stage_processors/test_stage_three.py diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py index 0732cb94f8..d20e2021fd 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py @@ -114,6 +114,7 @@ async def _send_first_round_notification(self, batch_processing: BatchProcessing # TODO: send AR and transition pdf to BCMail+ new_furnishing.status = Furnishing.FurnishingStatus.PROCESSED new_furnishing.processed_date = datetime.utcnow() + new_furnishing.save() async def _send_second_round_notification(self, batch_processing: BatchProcessing): """Send paper letter if business is still not in good standing after 5 days of email letter sent out.""" @@ -142,6 +143,7 @@ async def _send_second_round_notification(self, batch_processing: BatchProcessin # TODO: send AR and transition pdf to BCMail+ new_furnishing.status = Furnishing.FurnishingStatus.PROCESSED new_furnishing.processed_date = datetime.utcnow() + new_furnishing.save() def _create_new_furnishing( # pylint: disable=too-many-arguments self, diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_three.py b/jobs/furnishings/src/furnishings/stage_processors/stage_three.py new file mode 100644 index 0000000000..1a88491eaa --- /dev/null +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_three.py @@ -0,0 +1,69 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Furnishings job procssing rules for stage three of involuntary dissolution.""" +from datetime import datetime + +from flask import Flask +from legal_api.models import Batch, BatchProcessing, Business, Furnishing, db +from sqlalchemy import exists, not_ + + +def process(app: Flask): + """Run process to manage and track notifications for dissolution stage three process.""" + try: + furnishing_subquery = exists().where( + Furnishing.batch_id == BatchProcessing.batch_id, + Furnishing.business_id == BatchProcessing.business_id, + Furnishing.furnishing_name.in_([ + Furnishing.FurnishingName.CORP_DISSOLVED, + Furnishing.FurnishingName.CORP_DISSOLVED_XPRO + ]) + ) + batch_processings = ( + db.session.query(BatchProcessing) + .filter(BatchProcessing.status == BatchProcessing.BatchProcessingStatus.PROCESSING) + .filter(BatchProcessing.step == BatchProcessing.BatchProcessingStep.DISSOLUTION) + .filter(Batch.id == BatchProcessing.batch_id) + .filter(Batch.batch_type == Batch.BatchType.INVOLUNTARY_DISSOLUTION) + .filter(Batch.status == Batch.BatchStatus.PROCESSING) + .filter(not_(furnishing_subquery)) + ).all() + + grouping_identifier = Furnishing.get_next_grouping_identifier() + + for batch_processing in batch_processings: + business = batch_processing.business + furnishing_name = ( + Furnishing.FurnishingName.CORP_DISSOLVED_XPRO + if business.legal_type == Business.LegalTypes.EXTRA_PRO_A.value + else Furnishing.FurnishingName.CORP_DISSOLVED + ) + new_furnishing = Furnishing( + furnishing_type=Furnishing.FurnishingType.GAZETTE, + furnishing_name=furnishing_name, + batch_id=batch_processing.batch_id, + business_id=batch_processing.business_id, + business_identifier=batch_processing.business_identifier, + created_date=datetime.utcnow(), + last_modified=datetime.utcnow(), + status=Furnishing.FurnishingStatus.QUEUED, + grouping_identifier=grouping_identifier, + business_name=business.legal_name + ) + new_furnishing.save() + # TODO: create data files and SFTPing to BC Laws + # TODO: mark furnishings entry processed + + except Exception as err: + app.logger.error(err) diff --git a/jobs/furnishings/src/furnishings/worker.py b/jobs/furnishings/src/furnishings/worker.py index 3441689e09..80cdfc4d9a 100644 --- a/jobs/furnishings/src/furnishings/worker.py +++ b/jobs/furnishings/src/furnishings/worker.py @@ -26,7 +26,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from furnishings.config import get_named_config # pylint: disable=import-error -from furnishings.stage_processors import stage_one, stage_two +from furnishings.stage_processors import stage_one, stage_three, stage_two from furnishings.utils.logging import setup_logging # pylint: disable=import-error @@ -119,4 +119,4 @@ async def run(application: Flask, qsm: QueueService): # pylint: disable=redefin if stage_2_valid: stage_two.process(application) if stage_3_valid: - pass + stage_three.process(application) diff --git a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py index 6f431ec164..a5fe966481 100644 --- a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py +++ b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py @@ -137,12 +137,6 @@ async def test_process_first_notification(app, session, test_name, entity_type, @pytest.mark.asyncio @pytest.mark.parametrize( 'test_name, has_email_furnishing, has_mail_furnishing, is_email_elapsed', [ - ( - 'NO_EMAIL_FURNISHING', - False, - False, - False - ), ( 'EMAIL_FURNISHING_NOT_ELAPSED', True, diff --git a/jobs/furnishings/tests/unit/stage_processors/test_stage_three.py b/jobs/furnishings/tests/unit/stage_processors/test_stage_three.py new file mode 100644 index 0000000000..8550a9816e --- /dev/null +++ b/jobs/furnishings/tests/unit/stage_processors/test_stage_three.py @@ -0,0 +1,99 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the Furnishings Job. + +Test suite to ensure that the Furnishings Job stage three is working as expected. +""" +from datetime import datetime + +import pytest +from datedelta import datedelta +from legal_api.models import BatchProcessing, Business, Furnishing + +from furnishings.stage_processors.stage_three import process + +from .. import factory_batch, factory_batch_processing, factory_business, factory_furnishing + + +@pytest.mark.parametrize( + 'test_name, entity_type, step, new_entry', [ + ( + 'BC_NEW_FURNISHING', + Business.LegalTypes.COMP.value, + BatchProcessing.BatchProcessingStep.DISSOLUTION, + True + ), + ( + 'XPRO_NEW_FURNISHING', + Business.LegalTypes.EXTRA_PRO_A.value, + BatchProcessing.BatchProcessingStep.DISSOLUTION, + True + ), + ( + 'STAGE_3_ALREADY_RUN', + Business.LegalTypes.COMP.value, + BatchProcessing.BatchProcessingStep.DISSOLUTION, + False + ), + ( + 'NOT_IN_STAGE_3', + Business.LegalTypes.COMP.value, + BatchProcessing.BatchProcessingStep.WARNING_LEVEL_2, + False + ) + ] +) +def test_process_create_furnishings(app, session, test_name, entity_type, step, new_entry): + """Assert that new furnishing entries are created correctly.""" + business = factory_business(identifier='BC1234567', entity_type=entity_type) + batch = factory_batch() + factory_batch_processing( + batch_id=batch.id, + business_id=business.id, + identifier=business.identifier, + step=step + ) + + if test_name == 'STAGE_3_ALREADY_RUN': + existing_furnishing = factory_furnishing( + batch_id=batch.id, + business_id=business.id, + identifier=business.identifier, + furnishing_name=Furnishing.FurnishingName.CORP_DISSOLVED, + furnishing_type=Furnishing.FurnishingType.GAZETTE, + created_date=datetime.utcnow()+datedelta(years=1), + last_modified=datetime.utcnow()+datedelta(years=1), + business_name=business.legal_name + ) + + process(app) + + furnishings = Furnishing.find_by(business_id=business.id) + if new_entry: + assert len(furnishings) == 1 + furnishing = furnishings[0] + assert furnishing.furnishing_type == Furnishing.FurnishingType.GAZETTE + assert furnishing.business_name == business.legal_name + if entity_type == Business.LegalTypes.EXTRA_PRO_A.value: + assert furnishing.furnishing_name == Furnishing.FurnishingName.CORP_DISSOLVED_XPRO + else: + assert furnishing.furnishing_name == Furnishing.FurnishingName.CORP_DISSOLVED + else: + if test_name == 'STAGE_3_ALREADY_RUN': + assert len(furnishings) == 1 + furnishing = furnishings[0] + assert furnishing == existing_furnishing + else: + assert len(furnishings) == 0 From ef01856970daca95f002d584a9b539bf2e0e8467 Mon Sep 17 00:00:00 2001 From: Kevin Zhang <54437031+kzdev420@users.noreply.github.com> Date: Fri, 19 Jul 2024 12:11:23 -0400 Subject: [PATCH 08/60] 21760 involuntary dissolution stage 1 overdue ARs notification (#2836) * 21760 create email template * Added new process for involuntary dissoultion and tracker * Updated involuntary dissolution notification part and traker. * added mras call if corp has EP in another jurisdiction * Updated email template for involuntary dissoultion notification * Fixed lint issues * update process name, add test * fix lint issue * add url in email template * update get_business_method * fix broken test --wip * refactor * fix lint issue * fix lint issue * fix broken test --wip * fix broken test * remove BC check * fix lint issue * fix lint issue * update misc * fix data type in test * update the furnishing name validate process * fix test * update misc * fix lint issue, temp fix for broken test * fix lint issue * fix broken test --wip --- .../email_processors/__init__.py | 14 +++ ...untary_dissolution_stage_1_notification.py | 96 +++++++++++++++++++ .../email_templates/INVOL-DIS-STAGE-1.html | 84 ++++++++++++++++ .../entity_emailer/message_tracker/tracker.py | 10 +- .../src/entity_emailer/worker.py | 21 +++- .../entity-emailer/tests/unit/__init__.py | 35 ++++++- ...untary_dissolution_stage_1_notification.py | 51 ++++++++++ .../entity-emailer/tests/unit/test_tracker.py | 17 ++++ .../entity-emailer/tests/unit/test_worker.py | 64 ++++++++++++- 9 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/INVOL-DIS-STAGE-1.html create mode 100644 queue_services/entity-emailer/tests/unit/email_processors/test_involuntary_dissolution_stage_1_notification.py diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/__init__.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/__init__.py index b38f672276..fb021f1f06 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/__init__.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/__init__.py @@ -194,3 +194,17 @@ def substitute_template_parts(template_code: str) -> str: template_code = template_code.replace('[[{}.html]]'.format(template_part), template_part_code) return template_code + + +def get_jurisdictions(identifier: str, token: str) -> str: + """Get jurisdictions call.""" + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {token}' + } + + response = requests.get( + f'{current_app.config.get("LEGAL_API_URL")}/mras/{identifier}', headers=headers + ) + + return response diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py new file mode 100644 index 0000000000..1e8184e6b1 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py @@ -0,0 +1,96 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Email processing rules and actions for involuntary_dissolution stage 1 overdue ARs notifications.""" +from __future__ import annotations + +from pathlib import Path + +from entity_queue_common.service_utils import logger +from flask import current_app +from jinja2 import Template +from legal_api.models import Furnishing + +from entity_emailer.email_processors import get_entity_dashboard_url, get_jurisdictions, substitute_template_parts + + +PROCESSABLE_FURNISHING_NAMES = [ + Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR.name, + Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_TR.name, + Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR_XPRO.name, + Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_TR_XPRO.name +] + + +def process(email_info: dict, token: str) -> dict: # pylint: disable=too-many-locals, , too-many-branches + """Build the email for Involuntary dissolution notification.""" + logger.debug('involuntary_dissolution_stage_1_notification: %s', email_info) + # get business + furnishing_id = email_info['data']['furnishing']['furnishingId'] + furnishing = Furnishing.find_by_id(furnishing_id) + business = furnishing.business + business_identifier = business.identifier + template = Path( + f'{current_app.config.get("TEMPLATE_PATH")}/INVOL-DIS-STAGE-1.html' + ).read_text() + filled_template = substitute_template_parts(template) + # render template with vars + jnja_template = Template(filled_template, autoescape=True) + # get response from get jurisdictions + jurisdictions_response = get_jurisdictions(business_identifier, token) + # get extra provincials array + extra_provincials = get_extra_provincials(jurisdictions_response) + html_out = jnja_template.render( + business=business.json(), + entity_dashboard_url=get_entity_dashboard_url(business_identifier, token), + extra_provincials=extra_provincials + ) + # get recipients + recipients = [] + recipients.append(furnishing.email) # furnishing email + + recipients = list(set(recipients)) + recipients = ', '.join(filter(None, recipients)).strip() + + legal_name = business.legal_name + subject = f'Attention {business_identifier} - {legal_name}' + + return { + 'recipients': recipients, + 'requestBy': 'BCRegistries@gov.bc.ca', + 'content': { + 'subject': subject, + 'body': f'{html_out}' + } + } + + +def get_extra_provincials(response: dict): + """Get extra provincials name.""" + extra_provincials = [] + if response: + jurisdictions = response.get('jurisdictions', []) + for jurisdiction in jurisdictions: + name = jurisdiction.get('name') + if name: + extra_provincials.append(name) + + return extra_provincials + + +def post_process(email_msg: dict, status: str): + """Update corresponding furnishings entry as PROCESSED or FAILED depending on notification status.""" + furnishing_id = email_msg['data']['furnishing']['furnishingId'] + furnishing = Furnishing.find_by_id(furnishing_id) + furnishing.status = status + furnishing.save() diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/INVOL-DIS-STAGE-1.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/INVOL-DIS-STAGE-1.html new file mode 100644 index 0000000000..066047d732 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/INVOL-DIS-STAGE-1.html @@ -0,0 +1,84 @@ + + + + + + + + + Attention {{ business.identifier }} {{ business.legalName }} + [[style.html]] + + + + + + + + + + diff --git a/queue_services/entity-emailer/src/entity_emailer/message_tracker/tracker.py b/queue_services/entity-emailer/src/entity_emailer/message_tracker/tracker.py index 046ce817b2..30eb9390ab 100644 --- a/queue_services/entity-emailer/src/entity_emailer/message_tracker/tracker.py +++ b/queue_services/entity-emailer/src/entity_emailer/message_tracker/tracker.py @@ -18,7 +18,7 @@ import nats from flask import current_app -from entity_emailer.email_processors import filing_notification +from entity_emailer.email_processors import filing_notification, involuntary_dissolution_stage_1_notification from tracker.models import MessageProcessing from tracker.services import MessageProcessingService @@ -61,6 +61,14 @@ def get_message_context_properties(queue_msg: nats.aio.client.Msg): identifier = email_msg.get('identifier', None) message_id = f'{etype}_{filing_id}' return create_message_context_properties(etype, message_id, None, identifier, False) + + if etype == 'bc.registry.dissolution': + furnishing_name = email_msg.get('data', {}).get('furnishing', {}).get('furnishingName', None) + if furnishing_name \ + and furnishing_name in involuntary_dissolution_stage_1_notification.PROCESSABLE_FURNISHING_NAMES: + source = email_msg.get('source', None) + identifier = email_msg.get('identifier', None) + return create_message_context_properties(etype, message_id, source, identifier, False) else: email = email_msg.get('email', None) etype = email_msg.get('email', {}).get('type', None) diff --git a/queue_services/entity-emailer/src/entity_emailer/worker.py b/queue_services/entity-emailer/src/entity_emailer/worker.py index e2f9a2c611..0a6c866cb9 100644 --- a/queue_services/entity-emailer/src/entity_emailer/worker.py +++ b/queue_services/entity-emailer/src/entity_emailer/worker.py @@ -35,7 +35,7 @@ from entity_queue_common.service_utils import EmailException, QueueException, logger from flask import Flask from legal_api import db -from legal_api.models import Filing +from legal_api.models import Filing, Furnishing from legal_api.services.bootstrap import AccountService from legal_api.services.flags import Flags from sqlalchemy.exc import OperationalError @@ -55,6 +55,7 @@ correction_notification, dissolution_notification, filing_notification, + involuntary_dissolution_stage_1_notification, mras_notification, name_request, nr_notification, @@ -142,6 +143,24 @@ def process_email(email_msg: dict, flask_app: Flask): # pylint: disable=too-man elif etype and etype == 'bc.registry.bnmove': email = bn_notification.process_bn_move(email_msg, token) send_email(email, token) + elif etype and etype == 'bc.registry.dissolution': + # Confirm the data.furnishingName + furnishing_name = email_msg.get('data', {}).get('furnishing', {}).get('furnishingName', None) + if furnishing_name \ + and furnishing_name in involuntary_dissolution_stage_1_notification.PROCESSABLE_FURNISHING_NAMES: + email = involuntary_dissolution_stage_1_notification.process(email_msg, token) + try: + send_email(email, token) + # Update corresponding furnishings entry as PROCESSED + involuntary_dissolution_stage_1_notification.post_process(email_msg, + Furnishing.FurnishingStatus.PROCESSED) + except Exception as _: # noqa B902; pylint: disable=W0703 + # Update corresponding furnishings entry as FAILED + involuntary_dissolution_stage_1_notification.post_process(email_msg, + Furnishing.FurnishingStatus.FAILED) + raise + else: + logger.debug('Furnishing name is not valid. Skipping processing of email_msg: %s', email_msg) else: etype = email_msg['email']['type'] option = email_msg['email']['option'] diff --git a/queue_services/entity-emailer/tests/unit/__init__.py b/queue_services/entity-emailer/tests/unit/__init__.py index c8ff954f49..ccb8926ad8 100644 --- a/queue_services/entity-emailer/tests/unit/__init__.py +++ b/queue_services/entity-emailer/tests/unit/__init__.py @@ -18,7 +18,7 @@ from random import randrange from unittest.mock import Mock -from legal_api.models import Business, Filing, RegistrationBootstrap, User +from legal_api.models import Batch, Business, Filing, Furnishing, RegistrationBootstrap, User from registry_schemas.example_data import ( AGM_EXTENSION, AGM_LOCATION_CHANGE, @@ -708,3 +708,36 @@ def create_mock_message(message_payload: dict): json_msg_payload = json.dumps(message_payload) mock_msg.data.decode = Mock(return_value=json_msg_payload) return mock_msg + + +def create_batch(): + """Return a test batch.""" + batch = Batch() + batch.batch_type = Batch.BatchType.INVOLUNTARY_DISSOLUTION + batch.status = Batch.BatchStatus.PROCESSING + batch.save() + return batch + + +def create_furnishing(session, business=None, batch_id=None, + email='test@test.com', furnishing_name='DISSOLUTION_COMMENCEMENT_NO_AR'): + """Return a test furnishing.""" + furnishing = Furnishing() + furnishing.furnishing_type = 'EMAIL' + furnishing.furnishing_name = furnishing_name + furnishing.status = Furnishing.FurnishingStatus.QUEUED + furnishing.email = email + if business: + furnishing.business_id = business.id + furnishing.business_identifier = business.identifier + else: + business = create_business(identifier='BC123232', legal_type='BC', legal_name='Test Business') + furnishing.business_id = business.id + furnishing.business_identifier = business.identifier + if not batch_id: + batch = create_batch() + furnishing.batch_id = batch.id + else: + furnishing.batch_id = batch_id + furnishing.save() + return furnishing diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_involuntary_dissolution_stage_1_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_involuntary_dissolution_stage_1_notification.py new file mode 100644 index 0000000000..677e54b041 --- /dev/null +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_involuntary_dissolution_stage_1_notification.py @@ -0,0 +1,51 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for the involuntary_dissolution_stage_1_notification processor.""" +from entity_emailer.email_processors import involuntary_dissolution_stage_1_notification +from tests.unit import create_business, create_furnishing # noqa: I003 + + +def test_involuntary_dissolution_stage_1_notification(app, session, mocker): + """Assert that the test_involuntary_dissolution_stage_1_notification can be processed.""" + token = 'token' + message_id = '16fd2111-8baf-433b-82eb-8c7fada84ccc' + business_identifier = 'BC1234567' + business = create_business(business_identifier, 'BC', 'Test Business') + furnishing = create_furnishing(session, business=business) + message_payload = { + 'specversion': '1.x-wip', + 'type': 'bc.registry.dissolution', + 'source': 'furnishingsJob', + 'id': message_id, + 'time': '', + 'datacontenttype': 'application/json', + 'identifier': business_identifier, + 'data': { + 'furnishing': { + 'type': 'INVOLUNTARY_DISSOLUTION', + 'furnishingId': furnishing.id, + 'furnishingName': furnishing.furnishing_name + } + } + } + + # test processor + mocker.patch( + 'entity_emailer.email_processors.involuntary_dissolution_stage_1_notification.get_jurisdictions', + return_value=[]) + email = involuntary_dissolution_stage_1_notification.process(message_payload, token) + + assert email['content']['subject'] == f'Attention {business_identifier} - Test Business' + assert email['recipients'] == 'test@test.com' + assert email['content']['body'] diff --git a/queue_services/entity-emailer/tests/unit/test_tracker.py b/queue_services/entity-emailer/tests/unit/test_tracker.py index 1e8fefd079..8e6fc5147f 100644 --- a/queue_services/entity-emailer/tests/unit/test_tracker.py +++ b/queue_services/entity-emailer/tests/unit/test_tracker.py @@ -123,6 +123,23 @@ } } }), + ('f36e3af7-90c3-4859-a6f6-2feefbdc1e30', + { + 'specversion': '1.x-wip', + 'type': 'bc.registry.dissolution', + 'source': 'furnishingsJob', + 'id': 'f36e3af7-90c3-4859-a6f6-2feefbdc1e30', + 'time': '', + 'datacontenttype': 'application/json', + 'identifier': 'BC123223', + 'data': { + 'furnishing': { + 'type': 'INVOLUNTARY_DISSOLUTION', + 'furnishingId': 1, + 'furnishingName': 'DISSOLUTION_COMMENCEMENT_NO_AR' + } + } + }), ('bc.registry.affiliation_1', { 'type': 'bc.registry.affiliation', diff --git a/queue_services/entity-emailer/tests/unit/test_worker.py b/queue_services/entity-emailer/tests/unit/test_worker.py index 348421bfa5..ad6ac9b570 100644 --- a/queue_services/entity-emailer/tests/unit/test_worker.py +++ b/queue_services/entity-emailer/tests/unit/test_worker.py @@ -16,8 +16,8 @@ from unittest.mock import patch import pytest -from entity_queue_common.service_utils import QueueException -from legal_api.models import Business +from entity_queue_common.service_utils import EmailException, QueueException +from legal_api.models import Business, Furnishing from legal_api.services import NameXService from legal_api.services.bootstrap import AccountService from legal_api.utils.legislation_datetime import LegislationDatetime @@ -33,6 +33,8 @@ ) from tests import MockResponse from tests.unit import ( + create_business, + create_furnishing, prep_cp_special_resolution_correction_filing, prep_cp_special_resolution_filing, prep_incorp_filing, @@ -466,3 +468,61 @@ def test_send_email_with_incomplete_payload(app, session, email_msg): worker.send_email(email_msg, None) assert 'Unsuccessful sending email' in str(excinfo) + + +@pytest.mark.parametrize(['test_name', 'exception', 'furnishing_name', 'expected_furnishing_status'], [ + ('Will be failed with invalid furnishing_name', None, 'INVALID_NAME', 'QUEUED'), + ('Will be processed with valid furnishing_name', None, 'DISSOLUTION_COMMENCEMENT_NO_AR', 'PROCESSED'), + ('Will be processed with valid furnishing_name', None, 'DISSOLUTION_COMMENCEMENT_NO_TR', 'PROCESSED'), + ('Will be processed with valid furnishing_name', None, 'DISSOLUTION_COMMENCEMENT_NO_AR_XPRO', 'PROCESSED'), + ('Will be processed with valid furnishing_name', None, 'DISSOLUTION_COMMENCEMENT_NO_TR_XPRO', 'PROCESSED'), + ('When email is failed', EmailException, 'DISSOLUTION_COMMENCEMENT_NO_AR', 'FAILED') +]) +def test_involuntary_dissolution_stage_1_notification(app, db, session, mocker, test_name, + exception, furnishing_name, expected_furnishing_status): + """Assert that the stage 1 overdue ARs notification can be processed.""" + business_identifier = 'BC1234567' + business = create_business(business_identifier, 'BC', 'Test Business') + furnishing = create_furnishing(session, business=business) + + mocker.patch( + 'entity_emailer.email_processors.involuntary_dissolution_stage_1_notification.get_jurisdictions', + return_value=[]) + + message_payload = { + 'specversion': '1.x-wip', + 'type': 'bc.registry.dissolution', + 'source': 'furnishingsJob', + 'id': '16fd2111-8baf-433b-82eb-8c7fada84ccc', + 'time': '', + 'datacontenttype': 'application/json', + 'identifier': business_identifier, + 'data': { + 'furnishing': { + 'type': 'INVOLUNTARY_DISSOLUTION', + 'furnishingId': furnishing.id, + 'furnishingName': furnishing_name + } + } + } + + # run worker + with patch.object(AccountService, 'get_bearer_token', return_value=1): + with patch.object(worker, 'send_email', return_value='success', side_effect=exception) as mock_send_email: + session.expunge_all() + if exception: + with pytest.raises(exception): + worker.process_email(message_payload, app) + else: + worker.process_email(message_payload, app) + + call_args = mock_send_email.call_args + if furnishing_name == 'INVALID_NAME': + assert call_args is None + else: + assert call_args[0][0]['content']['subject'] == f'Attention {business_identifier} - Test Business' + assert call_args[0][0]['recipients'] == 'test@test.com' + assert call_args[0][0]['content']['body'] + + updated_furnishing = Furnishing.find_by_id(furnishing.id) + assert updated_furnishing.status.name == expected_furnishing_status From 8b7f098aaff0744e52f448c307a6895bae08364b Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Fri, 19 Jul 2024 13:16:41 -0700 Subject: [PATCH 09/60] 19794 pagination (#2845) --- .../update_colin_filings.py | 50 +++++++++++-------- .../update_legal_filings.py | 6 +-- legal-api/src/legal_api/models/filing.py | 13 +++-- .../resources/v1/business/business_filings.py | 8 +-- .../resources/v2/business/colin_sync.py | 14 ++++-- legal-api/tests/unit/models/test_filing.py | 3 ++ .../unit/resources/v2/test_colin_sync.py | 12 +++-- 7 files changed, 66 insertions(+), 40 deletions(-) diff --git a/jobs/update-colin-filings/update_colin_filings.py b/jobs/update-colin-filings/update_colin_filings.py index 1947e3eeae..60822fe8eb 100644 --- a/jobs/update-colin-filings/update_colin_filings.py +++ b/jobs/update-colin-filings/update_colin_filings.py @@ -64,9 +64,9 @@ def shell_context(): app.shell_context_processor(shell_context) -def get_filings(app: Flask, token): +def get_filings(app: Flask, token, page, limit): """Get a filing with filing_id.""" - req = requests.get(f'{app.config["LEGAL_API_URL"]}/internal/filings', + req = requests.get(f'{app.config["LEGAL_API_URL"]}/internal/filings?page={page}&limit={limit}', headers={'Authorization': AccountService.BEARER + token}, timeout=AccountService.timeout) if not req or req.status_code != 200: @@ -133,29 +133,35 @@ def run(): # get updater-job token token = AccountService.get_bearer_token() - filings = get_filings(application, token) - if not filings: - # pylint: disable=no-member; false positive - application.logger.debug('No completed filings to send to colin.') - for filing in filings: - filing_id = filing['filingId'] - identifier = filing['filing']['business']['identifier'] - if identifier in corps_with_failed_filing: + page = 1 + total_pages = None + while ((total_pages is None or page <= total_pages) and + (results := get_filings(application, token, page, 50))): + page += 1 + total_pages = results.get('pages') + if not (filings := results.get('filings')): # pylint: disable=no-member; false positive - application.logger.debug(f'Skipping filing {filing_id} for' - f' {filing["filing"]["business"]["identifier"]}.') - else: - colin_ids = send_filing(app=application, filing=filing, filing_id=filing_id) - update = None - if colin_ids: - update = update_colin_id(app=application, filing_id=filing_id, colin_ids=colin_ids, token=token) - if update: + application.logger.debug('No completed filings to send to colin.') + for filing in filings: + filing_id = filing['filingId'] + identifier = filing['filing']['business']['identifier'] + if identifier in corps_with_failed_filing: # pylint: disable=no-member; false positive - application.logger.debug(f'Successfully updated filing {filing_id}') + application.logger.debug(f'Skipping filing {filing_id} for' + f' {filing["filing"]["business"]["identifier"]}.') else: - corps_with_failed_filing.append(filing['filing']['business']['identifier']) - # pylint: disable=no-member; false positive - application.logger.error(f'Failed to update filing {filing_id} with colin event id.') + colin_ids = send_filing(app=application, filing=filing, filing_id=filing_id) + update = None + if colin_ids: + update = update_colin_id(app=application, filing_id=filing_id, + colin_ids=colin_ids, token=token) + if update: + # pylint: disable=no-member; false positive + application.logger.debug(f'Successfully updated filing {filing_id}') + else: + corps_with_failed_filing.append(filing['filing']['business']['identifier']) + # pylint: disable=no-member; false positive + application.logger.error(f'Failed to update filing {filing_id} with colin event id.') except Exception as err: # noqa: B902 # pylint: disable=no-member; false positive diff --git a/jobs/update-legal-filings/update_legal_filings.py b/jobs/update-legal-filings/update_legal_filings.py index 8a301afb48..609e4ee858 100644 --- a/jobs/update-legal-filings/update_legal_filings.py +++ b/jobs/update-legal-filings/update_legal_filings.py @@ -327,11 +327,11 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n if business_identifiers['identifiers']: start = 0 - end = 100 - # make a colin-api call with 100 identifiers at a time + end = 20 + # make a colin-api call with 20 identifiers at a time while identifiers := business_identifiers['identifiers'][start:end]: start = end - end += 100 + end += 20 # get tax ids that exist for above entities application.logger.debug(f'Getting tax ids for {identifiers} from colin api...') response = requests.get( diff --git a/legal-api/src/legal_api/models/filing.py b/legal-api/src/legal_api/models/filing.py index f54c472a91..6ca49a53b7 100644 --- a/legal-api/src/legal_api/models/filing.py +++ b/legal-api/src/legal_api/models/filing.py @@ -974,7 +974,7 @@ def get_most_recent_legal_filing(business_id: str, filing_type: str = None): return filing.first() @staticmethod - def get_completed_filings_for_colin(): + def get_completed_filings_for_colin(page=1, limit=20): """Return the filings with statuses in the status array input.""" from .business import Business # noqa: F401; pylint: disable=import-outside-toplevel excluded_filings = ['adminFreeze', 'courtOrder', 'registrarsNotation', 'registrarsOrder'] @@ -986,8 +986,15 @@ def get_completed_filings_for_colin(): Filing.colin_event_ids == None, # pylint: disable=singleton-comparison # noqa: E711; Filing._status == Filing.Status.COMPLETED.value, Filing.effective_date != None # pylint: disable=singleton-comparison # noqa: E711; - ).order_by(Filing.filing_date).all() - return filings + ).order_by(Filing.filing_date).paginate(per_page=limit, page=page) + + return { + 'page': page, + 'limit': limit, + 'filings': filings.items, + 'pages': filings.pages, + 'total': filings.total + } @staticmethod def get_all_filings_by_status(status): diff --git a/legal-api/src/legal_api/resources/v1/business/business_filings.py b/legal-api/src/legal_api/resources/v1/business/business_filings.py index 0c10465faa..56c8ec47cc 100644 --- a/legal-api/src/legal_api/resources/v1/business/business_filings.py +++ b/legal-api/src/legal_api/resources/v1/business/business_filings.py @@ -131,7 +131,7 @@ def get(identifier, filing_id=None): # pylint: disable=too-many-return-statemen # Does it make sense to get a PDF of all filings? if str(request.accept_mimetypes) == 'application/pdf': - return jsonify({'message': _('Cannot return a single PDF of multiple filing submissions.')}),\ + return jsonify({'message': _('Cannot return a single PDF of multiple filing submissions.')}), \ HTTPStatus.NOT_ACCEPTABLE rv = [] @@ -213,7 +213,7 @@ def put(identifier, filing_id): # pylint: disable=too-many-return-statements,to filing_json = filing.json if response: filing_json['filing']['header'].update(response) - return jsonify(filing_json),\ + return jsonify(filing_json), \ (HTTPStatus.CREATED if (request.method == 'POST') else HTTPStatus.ACCEPTED) @staticmethod @@ -229,7 +229,7 @@ def delete(identifier, filing_id=None): # pylint: disable=too-many-branches # check authorization if not authorized(identifier, jwt, action=['edit']): return jsonify({'message': - _('You are not authorized to delete a filing for:') + identifier}),\ + _('You are not authorized to delete a filing for:') + identifier}), \ HTTPStatus.UNAUTHORIZED if identifier.startswith('T'): @@ -867,7 +867,7 @@ def get(status=None): if status is None: pending_filings = Filing.get_completed_filings_for_colin() - for filing in pending_filings: + for filing in pending_filings.get('filings'): filing_json = filing.filing_json business = Business.find_by_internal_id(filing.business_id) if filing_json and filing.filing_type != 'lear_epoch' and \ diff --git a/legal-api/src/legal_api/resources/v2/business/colin_sync.py b/legal-api/src/legal_api/resources/v2/business/colin_sync.py index 07eeb1c2ca..54438ee6cb 100644 --- a/legal-api/src/legal_api/resources/v2/business/colin_sync.py +++ b/legal-api/src/legal_api/resources/v2/business/colin_sync.py @@ -40,8 +40,10 @@ def get_completed_filings_for_colin(status=None): filings = [] if status is None: - pending_filings = Filing.get_completed_filings_for_colin() - for filing in pending_filings: + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + pending_filings = Filing.get_completed_filings_for_colin(page, limit) + for filing in pending_filings.get('filings'): filing_json = filing.filing_json business = Business.find_by_internal_id(filing.business_id) @@ -79,7 +81,13 @@ def get_completed_filings_for_colin(status=None): continue # do not break this function because of one filing filings.append(filing_json) - return jsonify(filings), HTTPStatus.OK + return jsonify({ + 'filings': filings, + 'page': page, + 'limit': limit, + 'pages': pending_filings.get('pages'), + 'total': pending_filings.get('total') + }), HTTPStatus.OK pending_filings = Filing.get_all_filings_by_status(status) for filing in pending_filings: diff --git a/legal-api/tests/unit/models/test_filing.py b/legal-api/tests/unit/models/test_filing.py index c6c4a3d099..e558692c4e 100644 --- a/legal-api/tests/unit/models/test_filing.py +++ b/legal-api/tests/unit/models/test_filing.py @@ -541,6 +541,7 @@ def test_get_internal_filings(session, client, jwt): filing.colin_event_ids.append(colin_event_id) filing.save() filings = Filing.get_completed_filings_for_colin() + filings = filings.get('filings') # test method # assert doesn't return completed filing with colin_event_ids set @@ -549,6 +550,7 @@ def test_get_internal_filings(session, client, jwt): filing.colin_event_ids.clear() filing.save() filings = Filing.get_completed_filings_for_colin() + filings = filings.get('filings') assert len(filings) == 1 assert filing.id == filings[0].json['filing']['header']['filingId'] assert filings[0].json['filing']['header']['colinIds'] == [] @@ -557,6 +559,7 @@ def test_get_internal_filings(session, client, jwt): filing.save() assert filing.status != Filing.Status.COMPLETED.value filings = Filing.get_completed_filings_for_colin() + filings = filings.get('filings') assert len(filings) == 0 diff --git a/legal-api/tests/unit/resources/v2/test_colin_sync.py b/legal-api/tests/unit/resources/v2/test_colin_sync.py index 91a24c1437..281976b5b8 100644 --- a/legal-api/tests/unit/resources/v2/test_colin_sync.py +++ b/legal-api/tests/unit/resources/v2/test_colin_sync.py @@ -73,8 +73,9 @@ def test_get_internal_filings(session, client, jwt): rv = client.get('/api/v2/businesses/internal/filings', headers=create_header(jwt, [COLIN_SVC_ROLE])) assert rv.status_code == HTTPStatus.OK - assert len(rv.json) == 1 - assert rv.json[0]['filingId'] == filing1.id + filings = rv.json.get('filings') + assert len(filings) == 1 + assert filings[0]['filingId'] == filing1.id @pytest.mark.parametrize('identifier, base_filing, corrected_filing, colin_id', [ @@ -96,11 +97,12 @@ def test_get_bcomp_corrections(session, client, jwt, identifier, base_filing, co rv = client.get('/api/v2/businesses/internal/filings', headers=create_header(jwt, [COLIN_SVC_ROLE])) assert rv.status_code == HTTPStatus.OK - assert len(rv.json) == 1 + filings = rv.json.get('filings') + assert len(filings) == 1 if colin_id: - assert rv.json[0]['filingId'] == filing.id + assert filings[0]['filingId'] == filing.id else: - assert rv.json[0]['filingId'] == incorp_filing.id + assert filings[0]['filingId'] == incorp_filing.id def test_patch_internal_filings(session, client, jwt): From d4e808a114639cb75d6d9c7b4a99a96012d79fa4 Mon Sep 17 00:00:00 2001 From: Kevin Zhang <54437031+kzdev420@users.noreply.github.com> Date: Fri, 19 Jul 2024 18:24:08 -0400 Subject: [PATCH 10/60] additional furnishing record updates (#2848) --- .../involuntary_dissolution_stage_1_notification.py | 5 +++++ queue_services/entity-emailer/tests/unit/test_worker.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py index 1e8184e6b1..64cfd519ee 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/involuntary_dissolution_stage_1_notification.py @@ -14,6 +14,7 @@ """Email processing rules and actions for involuntary_dissolution stage 1 overdue ARs notifications.""" from __future__ import annotations +from datetime import datetime from pathlib import Path from entity_queue_common.service_utils import logger @@ -93,4 +94,8 @@ def post_process(email_msg: dict, status: str): furnishing_id = email_msg['data']['furnishing']['furnishingId'] furnishing = Furnishing.find_by_id(furnishing_id) furnishing.status = status + furnishing.processed_date = datetime.utcnow() + furnishing.last_modified = datetime.utcnow() + if status == Furnishing.FurnishingStatus.FAILED: + furnishing.notes = 'Failure to send email' furnishing.save() diff --git a/queue_services/entity-emailer/tests/unit/test_worker.py b/queue_services/entity-emailer/tests/unit/test_worker.py index ad6ac9b570..f0a97faf51 100644 --- a/queue_services/entity-emailer/tests/unit/test_worker.py +++ b/queue_services/entity-emailer/tests/unit/test_worker.py @@ -526,3 +526,5 @@ def test_involuntary_dissolution_stage_1_notification(app, db, session, mocker, updated_furnishing = Furnishing.find_by_id(furnishing.id) assert updated_furnishing.status.name == expected_furnishing_status + if expected_furnishing_status == 'FAILED': + assert updated_furnishing.notes == 'Failure to send email' From 15373e67577af2d9e8d23f96ff20072bee3a0272 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Mon, 22 Jul 2024 09:52:37 -0700 Subject: [PATCH 11/60] 19794 fixes (#2850) --- colin-api/src/colin_api/resources/business.py | 2 +- jobs/update-colin-filings/update_colin_filings.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/colin-api/src/colin_api/resources/business.py b/colin-api/src/colin_api/resources/business.py index 57f0b10056..82d2915bc8 100644 --- a/colin-api/src/colin_api/resources/business.py +++ b/colin-api/src/colin_api/resources/business.py @@ -172,7 +172,7 @@ def get(info_type, legal_type=None, identifier=None): # pylint: disable = too-m if not json_data or not json_data['identifiers']: return jsonify({'message': 'No input data provided'}), HTTPStatus.BAD_REQUEST # remove the BC prefix - identifiers = [x[-7:] if identifier.startswith('BC') else x + identifiers = [x[-7:] if x.startswith('BC') else x for x in json_data['identifiers']] bn_15s = Business._get_bn_15s( # pylint: disable = protected-access; internal call cursor=cursor, diff --git a/jobs/update-colin-filings/update_colin_filings.py b/jobs/update-colin-filings/update_colin_filings.py index 60822fe8eb..c70d06ef8c 100644 --- a/jobs/update-colin-filings/update_colin_filings.py +++ b/jobs/update-colin-filings/update_colin_filings.py @@ -16,6 +16,7 @@ This module is the API for the Legal Entity system. """ import logging +import math import os import requests @@ -134,11 +135,12 @@ def run(): token = AccountService.get_bearer_token() page = 1 - total_pages = None - while ((total_pages is None or page <= total_pages) and - (results := get_filings(application, token, page, 50))): + limit = 50 + pending_filings = None + while ((pending_filings is None or page <= math.ceil(pending_filings/limit)) and + (results := get_filings(application, token, page, limit))): page += 1 - total_pages = results.get('pages') + pending_filings = results.get('total') if not (filings := results.get('filings')): # pylint: disable=no-member; false positive application.logger.debug('No completed filings to send to colin.') @@ -158,6 +160,7 @@ def run(): if update: # pylint: disable=no-member; false positive application.logger.debug(f'Successfully updated filing {filing_id}') + pending_filings -= 1 else: corps_with_failed_filing.append(filing['filing']['business']['identifier']) # pylint: disable=no-member; false positive From 3fe951b91db49e87ff5b05647918f81de53e0833 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Tue, 23 Jul 2024 12:47:53 -0700 Subject: [PATCH 12/60] 21931 submit staff review (#2851) --- legal-api/src/legal_api/models/filing.py | 14 ++ .../legal_api/resources/v2/admin/reviews.py | 74 ++++++++- .../tests/unit/resources/v2/test_reviews.py | 155 ++++++++++++++---- .../src/business_pay/database/filings.py | 5 +- .../src/business_pay/database/review.py | 7 +- .../src/business_pay/resources/pay_filer.py | 2 + 6 files changed, 214 insertions(+), 43 deletions(-) diff --git a/legal-api/src/legal_api/models/filing.py b/legal-api/src/legal_api/models/filing.py index 6ca49a53b7..82edc31691 100644 --- a/legal-api/src/legal_api/models/filing.py +++ b/legal-api/src/legal_api/models/filing.py @@ -1076,6 +1076,20 @@ def reset_filing_to_draft(self): self._payment_token = None self.save() + def set_review_status(self, filing_status): + """Set review status.""" + if filing_status not in [Filing.Status.CHANGE_REQUESTED.value, + Filing.Status.APPROVED.value, + Filing.Status.REJECTED.value]: + raise BusinessException( + error=f'Cannot set this filing status {filing_status}.', + status_code=HTTPStatus.FORBIDDEN + ) + setattr(self, 'skip_status_listener', True) + self._status = filing_status + self.save() + setattr(self, 'skip_status_listener', False) + def legal_filings(self) -> List: """Return a list of the filings extracted from this filing submission. diff --git a/legal-api/src/legal_api/resources/v2/admin/reviews.py b/legal-api/src/legal_api/resources/v2/admin/reviews.py index 3e51a4da73..e459e2b0b8 100644 --- a/legal-api/src/legal_api/resources/v2/admin/reviews.py +++ b/legal-api/src/legal_api/resources/v2/admin/reviews.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. """API endpoints for retrieving review data.""" +from datetime import datetime, timezone from http import HTTPStatus -from flask import current_app, jsonify, request +from flask import current_app, g, jsonify, request from flask_cors import cross_origin +from html_sanitizer import Sanitizer -from legal_api.models import Filing, Review, UserRoles +from legal_api.models import Filing, Review, ReviewResult, ReviewStatus, User, UserRoles +from legal_api.services import queue from legal_api.utils.auth import jwt from .bp import bp_admin @@ -35,7 +38,69 @@ def get_reviews(): return jsonify(reviews), HTTPStatus.OK -@bp_admin.route('/reviews/', methods=['GET', 'OPTIONS']) +@bp_admin.route('/reviews/', methods=['POST']) +@cross_origin(origin='*') +@jwt.has_one_of_roles([UserRoles.staff]) +def save_review(review_id: int): + """Save review. + + Current review status -> Allowable review status + AWAITING_REVIEW/RESUBMITTED -> CHANGE_REQUESTED/APPROVED/REJECTED + CHANGE_REQUESTED/APPROVED/REJECTED -> No changes allowed + """ + user = User.get_or_create_user_by_jwt(g.jwt_oidc_token_info) + if review := Review.find_by_id(review_id): + if review.status not in [ReviewStatus.AWAITING_REVIEW, ReviewStatus.RESUBMITTED]: + return jsonify({'message': 'No changes allowed.'}), HTTPStatus.BAD_REQUEST + else: + return jsonify({'message': 'Review not found.'}), HTTPStatus.NOT_FOUND + + json_input = request.get_json() + status = json_input.get('status') + comment = json_input.get('comment') + if not status or not comment: + return jsonify({'message': 'Status and Comment are required.'}), HTTPStatus.BAD_REQUEST + + if not ((status := ReviewStatus[status]) and + (status in [ReviewStatus.CHANGE_REQUESTED, + ReviewStatus.APPROVED, + ReviewStatus.REJECTED])): + return jsonify({'message': 'Invalid Status.'}), HTTPStatus.BAD_REQUEST + + filing = Filing.find_by_id(review.filing_id) + + review_result = ReviewResult() + review_result.reviewer_id = user.id + review_result.status = status + review_result.comments = Sanitizer().sanitize(comment) + review.review_results.append(review_result) + review.status = status + review.save() + + status_mapping = { + ReviewStatus.CHANGE_REQUESTED: Filing.Status.CHANGE_REQUESTED.value, + ReviewStatus.APPROVED: Filing.Status.APPROVED.value, + ReviewStatus.REJECTED: Filing.Status.REJECTED.value, + } + filing.set_review_status(status_mapping[status]) + + # emailer notification + queue.publish_json( + {'email': {'filingId': filing.id, 'type': filing.filing_type, 'option': filing.status}}, + current_app.config.get('NATS_EMAILER_SUBJECT') + ) + + if (filing.status == Filing.Status.APPROVED.value and + filing.effective_date <= datetime.now(timezone.utc)): + # filer notification + queue.publish_json( + {'filing': {'id': filing.id}}, + current_app.config.get('NATS_FILER_SUBJECT')) + + return jsonify({'message': 'Review saved.'}), HTTPStatus.CREATED + + +@bp_admin.route('/reviews/', methods=['GET']) @cross_origin(origin='*') @jwt.has_one_of_roles([UserRoles.staff]) def get_review(review_id: int): @@ -46,10 +111,9 @@ def get_review(review_id: int): return jsonify({'message': 'Review not found.'}), HTTPStatus.NOT_FOUND result = review.json - filing = Filing.find_by_id(review.filing_id) base_url = current_app.config.get('LEGAL_API_BASE_URL') + filing = Filing.find_by_id(review.filing_id) filing_link = f'{base_url}/{filing.temp_reg}/filings/{filing.id}' - result['filingLink'] = filing_link return jsonify(result), HTTPStatus.OK diff --git a/legal-api/tests/unit/resources/v2/test_reviews.py b/legal-api/tests/unit/resources/v2/test_reviews.py index 9cd56d25e1..b0dcf8c78b 100644 --- a/legal-api/tests/unit/resources/v2/test_reviews.py +++ b/legal-api/tests/unit/resources/v2/test_reviews.py @@ -17,22 +17,28 @@ Test-Suite to ensure that admin/reviews endpoints are working as expected. """ import copy -from registry_schemas.example_data import ( - CONTINUATION_IN, -) +import pytest from http import HTTPStatus from flask import current_app +from registry_schemas.example_data import CONTINUATION_IN +from legal_api.models import Filing, RegistrationBootstrap, Review, ReviewStatus from legal_api.services.authz import BASIC_USER, STAFF_ROLE + +from tests.unit.models import factory_filing from tests.unit.services.utils import create_header -from legal_api.models import Review, ReviewStatus -from tests.unit.models import factory_filing +def create_reviews(no_of_reviews=1): + reviews = [] + for index in range(no_of_reviews): + review = create_review(f'T1z3a56{index}') + reviews.append(review) + return reviews -def create_test_review(no_of_reviews=1): +def create_review(identifier, status=ReviewStatus.AWAITING_REVIEW): filing_dict = { 'filing': { 'header': { @@ -45,20 +51,22 @@ def create_test_review(no_of_reviews=1): } filing_dict['filing']['continuationIn'] = copy.deepcopy(CONTINUATION_IN) - reviews = [] - for _ in range(no_of_reviews): - filing = factory_filing(None, filing_dict) - - review = Review() - review.filing_id = filing.id - review.nr_number = filing_dict['filing']['continuationIn']['nameRequest']['nrNumber'] - review.identifier = filing_dict['filing']['continuationIn']['foreignJurisdiction']['identifier'] - review.completing_party = 'completing party' - review.status = ReviewStatus.AWAITING_REVIEW - review.save() - reviews.append(review) + filing = factory_filing(None, filing_dict) + temp_reg = RegistrationBootstrap() + temp_reg._identifier = identifier + temp_reg.save() + filing.temp_reg = identifier + filing.save() - return reviews + review = Review() + review.filing_id = filing.id + review.nr_number = filing_dict['filing']['continuationIn']['nameRequest']['nrNumber'] + review.identifier = filing_dict['filing']['continuationIn']['foreignJurisdiction']['identifier'] + review.completing_party = 'completing party' + review.status = status + review.save() + + return review def test_get_reviews_with_invalid_user(app, session, client, jwt): @@ -71,7 +79,7 @@ def test_get_reviews_with_invalid_user(app, session, client, jwt): def test_get_reviews_with_valid_user(app, session, client, jwt): """Assert review object returned for STAFF role.""" no_of_reviews = 11 - create_test_review(no_of_reviews) + create_reviews(no_of_reviews) rv = client.get(f'/api/v2/admin/reviews', headers=create_header(jwt, [STAFF_ROLE], 'user')) @@ -85,15 +93,10 @@ def test_get_reviews_with_valid_user(app, session, client, jwt): def test_get_specific_review_with_valid_user(app, session, client, jwt, mocker): """Assert specific review object returned for STAFF role.""" - review = create_test_review(1)[0] + review = create_review('T1z3a567') + filing = Filing.find_by_id(review.filing_id) base_url = current_app.config.get('LEGAL_API_BASE_URL') - - mock_filing = mocker.Mock() - mock_filing.temp_reg = 'BC1234567' - mock_filing.id = 1 - mocker.patch('legal_api.models.Filing.find_by_id', return_value=mock_filing) - mocker.patch('legal_api.resources.v2.admin.reviews.current_app.config.get', return_value=base_url) rv = client.get(f'/api/v2/admin/reviews/{review.id}', @@ -102,12 +105,12 @@ def test_get_specific_review_with_valid_user(app, session, client, jwt, mocker): assert rv.status_code == HTTPStatus.OK assert rv.json['id'] == review.id assert 'filingLink' in rv.json - assert rv.json['filingLink'] == f'{base_url}/{mock_filing.temp_reg}/filings/{mock_filing.id}' + assert rv.json['filingLink'] == f'{base_url}/{filing.temp_reg}/filings/{filing.id}' def test_get_specific_review_with_invalid_user(app, session, client, jwt): """Assert unauthorized for BASIC_USER role when getting a specific review.""" - review = create_test_review(1)[0] + review = create_review('T1z3a567') rv = client.get(f'/api/v2/admin/reviews/{review.id}', headers=create_header(jwt, [BASIC_USER], 'user')) @@ -123,3 +126,97 @@ def test_get_nonexistent_review(app, session, client, jwt): assert rv.status_code == HTTPStatus.NOT_FOUND assert 'message' in rv.json assert rv.json['message'] == 'Review not found.' + + +@pytest.mark.parametrize('status', [ + ReviewStatus.CHANGE_REQUESTED, + ReviewStatus.APPROVED, + ReviewStatus.REJECTED, +]) +def test_save_review(app, session, client, jwt, mocker, status): + """Assert that a review can be saved.""" + review = create_review('T1z3a567') + + data = { + 'status': status.name, + 'comment': 'Upload all documents' + } + + subjects_in_queue = {} + + def publish_json(payload, subject): + subjects_in_queue[subject] = payload + + mocker.patch('legal_api.resources.v2.admin.reviews.queue.publish_json', side_effect=publish_json) + + rv = client.post(f'/api/v2/admin/reviews/{review.id}', + json=data, + headers=create_header(jwt, [STAFF_ROLE], 'user')) + assert rv.status_code == HTTPStatus.CREATED + review = Review.find_by_id(review.id) + assert review.status == status + result = review.review_results.all()[0] + assert result + assert result.status == status + assert result.comments == data['comment'] + + status_mapping = { + ReviewStatus.CHANGE_REQUESTED: Filing.Status.CHANGE_REQUESTED.value, + ReviewStatus.APPROVED: Filing.Status.APPROVED.value, + ReviewStatus.REJECTED: Filing.Status.REJECTED.value, + } + filing = Filing.find_by_id(review.filing_id) + assert filing.status == status_mapping[status] + + assert current_app.config.get('NATS_EMAILER_SUBJECT') in subjects_in_queue + if filing.status == Filing.Status.APPROVED.value: + assert current_app.config.get('NATS_FILER_SUBJECT') in subjects_in_queue + else: + assert current_app.config.get('NATS_FILER_SUBJECT') not in subjects_in_queue + + +@pytest.mark.parametrize('data, message, response_code', [ + ({'status': ReviewStatus.APPROVED.name}, 'Status and Comment are required.', HTTPStatus.BAD_REQUEST), + ({'status': ReviewStatus.RESUBMITTED.name, 'comment': 'all docs'}, 'Invalid Status.', HTTPStatus.BAD_REQUEST), +]) +def test_save_review_validation(app, session, client, jwt, mocker, data, message, response_code): + """Assert that a save review can be validated.""" + review = create_review('T1z3a567') + + mocker.patch('legal_api.resources.v2.admin.reviews.queue.publish_json', return_value=None) + rv = client.post(f'/api/v2/admin/reviews/{review.id}', + json=data, + headers=create_header(jwt, [STAFF_ROLE], 'user')) + assert rv.status_code == response_code + assert rv.json['message'] == message + + +@pytest.mark.parametrize('status', [ + ReviewStatus.APPROVED, + ReviewStatus.REJECTED, + ReviewStatus.CHANGE_REQUESTED, +]) +def test_save_review_not_allowed(app, session, client, jwt, mocker, status): + """Assert that a save review can be validated.""" + review = create_review('T1z3a567', status) + + data = { + 'status': status.name, + 'comment': 'Upload all documents' + } + + mocker.patch('legal_api.resources.v2.admin.reviews.queue.publish_json', return_value=None) + rv = client.post(f'/api/v2/admin/reviews/{review.id}', + json=data, + headers=create_header(jwt, [STAFF_ROLE], 'user')) + assert rv.status_code == HTTPStatus.BAD_REQUEST + assert rv.json['message'] == 'No changes allowed.' + + +def test_save_no_review_validation(app, session, client, jwt): + """Assert that a save review can be validated.""" + rv = client.post(f'/api/v2/admin/reviews/{857}', + json={}, + headers=create_header(jwt, [STAFF_ROLE], 'user')) + assert rv.status_code == HTTPStatus.NOT_FOUND + assert rv.json['message'] == 'Review not found.' diff --git a/queue_services/business-pay/src/business_pay/database/filings.py b/queue_services/business-pay/src/business_pay/database/filings.py index 544ea75a77..5c9012f4fd 100644 --- a/queue_services/business-pay/src/business_pay/database/filings.py +++ b/queue_services/business-pay/src/business_pay/database/filings.py @@ -39,7 +39,6 @@ from dataclasses import dataclass from datetime import datetime -from datetime import timezone from enum import Enum from typing import Dict, Optional @@ -98,9 +97,7 @@ class Status(str, Enum): payment_account: Optional[str] id = db.Column(db.Integer, primary_key=True) - effective_date = db.Column( - "effective_date", db.DateTime(timezone=True), default=datetime.now(timezone.utc) - ) + effective_date = db.Column("effective_date", db.DateTime(timezone=True)) filing_type = db.Column("filing_type", db.String(30)) filing_sub_type = db.Column("filing_sub_type", db.String(30)) filing_json = db.Column("filing_json", JSONB) diff --git a/queue_services/business-pay/src/business_pay/database/review.py b/queue_services/business-pay/src/business_pay/database/review.py index 89e31bbbf3..f7d8c0c0df 100644 --- a/queue_services/business-pay/src/business_pay/database/review.py +++ b/queue_services/business-pay/src/business_pay/database/review.py @@ -14,7 +14,6 @@ """This module holds the data about review.""" from __future__ import annotations -from datetime import datetime, timezone from enum import auto from business_pay.utils.base import BaseEnum @@ -42,10 +41,8 @@ class Review(db.Model): # pylint: disable=too-many-instance-attributes identifier = db.Column("identifier", db.String(50)) completing_party = db.Column("completing_party", db.String(150)) status = db.Column("status", db.Enum(ReviewStatus), nullable=False) - submission_date = db.Column("submission_date", db.DateTime( - timezone=True), default=datetime.now(timezone.utc)) # last submission date - creation_date = db.Column("creation_date", db.DateTime( - timezone=True), default=datetime.now(timezone.utc)) + submission_date = db.Column("submission_date", db.DateTime(timezone=True)) + creation_date = db.Column("creation_date", db.DateTime(timezone=True)) # parent keys filing_id = db.Column("filing_id", db.Integer, diff --git a/queue_services/business-pay/src/business_pay/resources/pay_filer.py b/queue_services/business-pay/src/business_pay/resources/pay_filer.py index 416badec51..b687dcf969 100644 --- a/queue_services/business-pay/src/business_pay/resources/pay_filer.py +++ b/queue_services/business-pay/src/business_pay/resources/pay_filer.py @@ -178,6 +178,8 @@ def create_staff_review(filing: Filing): "foreignJurisdiction", {}).get("identifier") review.status = ReviewStatus.AWAITING_REVIEW review.completing_party = get_completing_party(filing_data["parties"]) + review.submission_date = datetime.now(timezone.utc) + review.creation_date = datetime.now(timezone.utc) review.save() filing.status = Filing.Status.AWAITING_REVIEW.value From 4594c296d95382a73dce1ec31ff022daabef95be Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Tue, 23 Jul 2024 15:07:52 -0700 Subject: [PATCH 13/60] 21931 update validation (#2853) --- .../legal_api/resources/v2/admin/reviews.py | 24 +++++++++++-------- .../tests/unit/resources/v2/test_reviews.py | 18 +++++++------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/legal-api/src/legal_api/resources/v2/admin/reviews.py b/legal-api/src/legal_api/resources/v2/admin/reviews.py index e459e2b0b8..c2dbd18249 100644 --- a/legal-api/src/legal_api/resources/v2/admin/reviews.py +++ b/legal-api/src/legal_api/resources/v2/admin/reviews.py @@ -56,23 +56,27 @@ def save_review(review_id: int): return jsonify({'message': 'Review not found.'}), HTTPStatus.NOT_FOUND json_input = request.get_json() - status = json_input.get('status') - comment = json_input.get('comment') - if not status or not comment: - return jsonify({'message': 'Status and Comment are required.'}), HTTPStatus.BAD_REQUEST + if status := json_input.get('status'): + if not ((status := ReviewStatus[status]) and + (status in [ReviewStatus.CHANGE_REQUESTED, + ReviewStatus.APPROVED, + ReviewStatus.REJECTED])): + return jsonify({'message': 'Invalid Status.'}), HTTPStatus.BAD_REQUEST + + else: + return jsonify({'message': 'Status is required.'}), HTTPStatus.BAD_REQUEST - if not ((status := ReviewStatus[status]) and - (status in [ReviewStatus.CHANGE_REQUESTED, - ReviewStatus.APPROVED, - ReviewStatus.REJECTED])): - return jsonify({'message': 'Invalid Status.'}), HTTPStatus.BAD_REQUEST + comment = json_input.get('comment') + if (status in [ReviewStatus.CHANGE_REQUESTED, ReviewStatus.REJECTED] + and not comment): + return jsonify({'message': 'Comment is required.'}), HTTPStatus.BAD_REQUEST filing = Filing.find_by_id(review.filing_id) review_result = ReviewResult() review_result.reviewer_id = user.id review_result.status = status - review_result.comments = Sanitizer().sanitize(comment) + review_result.comments = Sanitizer().sanitize(comment) if comment else None review.review_results.append(review_result) review.status = status review.save() diff --git a/legal-api/tests/unit/resources/v2/test_reviews.py b/legal-api/tests/unit/resources/v2/test_reviews.py index b0dcf8c78b..5be81a1346 100644 --- a/legal-api/tests/unit/resources/v2/test_reviews.py +++ b/legal-api/tests/unit/resources/v2/test_reviews.py @@ -128,18 +128,18 @@ def test_get_nonexistent_review(app, session, client, jwt): assert rv.json['message'] == 'Review not found.' -@pytest.mark.parametrize('status', [ - ReviewStatus.CHANGE_REQUESTED, - ReviewStatus.APPROVED, - ReviewStatus.REJECTED, +@pytest.mark.parametrize('status, comment', [ + (ReviewStatus.CHANGE_REQUESTED, 'Upload all documents'), + (ReviewStatus.APPROVED, None), + (ReviewStatus.REJECTED, 'Upload all documents'), ]) -def test_save_review(app, session, client, jwt, mocker, status): +def test_save_review(app, session, client, jwt, mocker, status, comment): """Assert that a review can be saved.""" review = create_review('T1z3a567') data = { 'status': status.name, - 'comment': 'Upload all documents' + 'comment': comment } subjects_in_queue = {} @@ -158,7 +158,7 @@ def publish_json(payload, subject): result = review.review_results.all()[0] assert result assert result.status == status - assert result.comments == data['comment'] + assert result.comments == comment status_mapping = { ReviewStatus.CHANGE_REQUESTED: Filing.Status.CHANGE_REQUESTED.value, @@ -176,7 +176,9 @@ def publish_json(payload, subject): @pytest.mark.parametrize('data, message, response_code', [ - ({'status': ReviewStatus.APPROVED.name}, 'Status and Comment are required.', HTTPStatus.BAD_REQUEST), + ({'comment': 'all docs'}, 'Status is required.', HTTPStatus.BAD_REQUEST), + ({'status': ReviewStatus.CHANGE_REQUESTED.name}, 'Comment is required.', HTTPStatus.BAD_REQUEST), + ({'status': ReviewStatus.REJECTED.name}, 'Comment is required.', HTTPStatus.BAD_REQUEST), ({'status': ReviewStatus.RESUBMITTED.name, 'comment': 'all docs'}, 'Invalid Status.', HTTPStatus.BAD_REQUEST), ]) def test_save_review_validation(app, session, client, jwt, mocker, data, message, response_code): From 075a7a7da47edd58b2d8346518ec9be894313352 Mon Sep 17 00:00:00 2001 From: Vysakh Menon Date: Wed, 24 Jul 2024 08:44:21 -0700 Subject: [PATCH 14/60] 22354 latestReviewComment (#2854) --- legal-api/src/legal_api/core/filing.py | 7 +++++++ .../src/legal_api/models/review_result.py | 12 ++++++------ .../business_filings/business_filings.py | 19 +++++++++++++++++-- .../tests/unit/models/test_review_result.py | 2 +- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/legal-api/src/legal_api/core/filing.py b/legal-api/src/legal_api/core/filing.py index ba0fb11969..1f563a6a26 100644 --- a/legal-api/src/legal_api/core/filing.py +++ b/legal-api/src/legal_api/core/filing.py @@ -53,6 +53,12 @@ class Status(str, Enum): PAPER_ONLY = 'PAPER_ONLY' PENDING_CORRECTION = 'PENDING_CORRECTION' + # filings with staff review + APPROVED = 'APPROVED' + AWAITING_REVIEW = 'AWAITING_REVIEW' + CHANGE_REQUESTED = 'CHANGE_REQUESTED' + REJECTED = 'REJECTED' + class FilingTypes(str, Enum): """Render an Enum of all Filing Types.""" @@ -189,6 +195,7 @@ def is_future_effective(self) -> bool: def get_json(self) -> Optional[Dict]: """Return a dict representing the filing json.""" if not self._storage or (self._storage and self._storage.status not in [Filing.Status.COMPLETED.value, + Filing.Status.APPROVED.value, Filing.Status.PAID.value, Filing.Status.PENDING.value, ]): diff --git a/legal-api/src/legal_api/models/review_result.py b/legal-api/src/legal_api/models/review_result.py index 1e4096e0cb..95d5653e26 100644 --- a/legal-api/src/legal_api/models/review_result.py +++ b/legal-api/src/legal_api/models/review_result.py @@ -21,7 +21,7 @@ from legal_api.utils.datetime import datetime from .db import db -from .review import ReviewStatus +from .review import Review, ReviewStatus class ReviewResult(db.Model): # pylint: disable=too-many-instance-attributes @@ -60,12 +60,12 @@ def get_review_results(cls, review_id) -> List[ReviewResult]: return review_results @ classmethod - def get_last_review_result(cls, review_id) -> ReviewResult: - """Return the last review result by the review id.""" + def get_last_review_result(cls, filing_id) -> ReviewResult: + """Return the last review result by the filing id.""" review_result = None - if review_id: - review_result = (db.session.query(ReviewResult). - filter(ReviewResult.review_id == review_id). + if filing_id: + review_result = (db.session.query(ReviewResult).join(Review). + filter(Review.filing_id == filing_id). order_by(ReviewResult.creation_date.desc()). first()) return review_result diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py index 7ddbad42a4..82633b7b8f 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py @@ -36,7 +36,17 @@ from legal_api.constants import BOB_DATE from legal_api.core import Filing as CoreFiling from legal_api.exceptions import BusinessException -from legal_api.models import Address, Business, Filing, OfficeType, RegistrationBootstrap, User, UserRoles, db +from legal_api.models import ( + Address, + Business, + Filing, + OfficeType, + RegistrationBootstrap, + ReviewResult, + User, + UserRoles, + db, +) from legal_api.models.colin_event_id import ColinEventId from legal_api.schemas import rsbc_schemas from legal_api.services import ( @@ -270,8 +280,13 @@ def get_single_filing(identifier: str, filing_id: int): if documents := DocumentMetaService().get_documents(filing_json): filing_json['filing']['documents'] = documents - if filing_json.get('filing', {}).get('header', {}).get('status') == Filing.Status.PENDING.value: + if rv.status == Filing.Status.PENDING.value: ListFilingResource.get_payment_update(filing_json) + elif (rv.status in [Filing.Status.CHANGE_REQUESTED.value, + Filing.Status.APPROVED.value, + Filing.Status.REJECTED.value] and + (review_result := ReviewResult.get_last_review_result(rv.id))): + filing_json['filing']['header']['latestReviewComment'] = review_result.comments filing_json = {**filing_json, **CoreFiling.common_ledger_items(identifier, rv.storage)} diff --git a/legal-api/tests/unit/models/test_review_result.py b/legal-api/tests/unit/models/test_review_result.py index 0dc98368d9..fd742c710e 100644 --- a/legal-api/tests/unit/models/test_review_result.py +++ b/legal-api/tests/unit/models/test_review_result.py @@ -87,5 +87,5 @@ def test_review_result_save(session): review_results = ReviewResult.get_review_results(review.id) assert len(review_results) == 2 - last_review_result = ReviewResult.get_last_review_result(review.id) + last_review_result = ReviewResult.get_last_review_result(review.filing_id) assert last_review_result.id == approved.id From e36f1059d23981bee66a8775967d6e1fabfcb42d Mon Sep 17 00:00:00 2001 From: Hongjing <60866283+chenhongjing@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:22:58 -0700 Subject: [PATCH 15/60] 22235 Legal API - Create endpoint for retrieving stage 1 AR letter (#2849) * 22235-legal-api-create endpoint for retrieving stage 1 AR (major updates) Signed-off-by: Hongjing Chen * misc related updates Signed-off-by: Hongjing Chen * update furnishing job stage one to populate address for both email & mail scenarios Signed-off-by: Hongjing Chen * fix linting Signed-off-by: Hongjing Chen * handle bad data Signed-off-by: Hongjing Chen * fix typo Signed-off-by: Hongjing Chen * specify user roles for endpoint Signed-off-by: Hongjing Chen * fix typo Signed-off-by: Hongjing Chen * 1password & related updates Signed-off-by: Hongjing Chen * update address position Signed-off-by: Hongjing Chen * update address type for furnishing address Signed-off-by: Hongjing Chen * remove handling for bad data Signed-off-by: Hongjing Chen * remove FURNISHING type in address model & fix broken tests in furnishings job Signed-off-by: Hongjing Chen * rename 'output_type' to 'variant' Signed-off-by: Hongjing Chen --------- Signed-off-by: Hongjing Chen --- .../furnishings/stage_processors/stage_one.py | 25 +- .../unit/stage_processors/test_stage_one.py | 8 +- legal-api/devops/vaults.json | 3 +- .../noticeOfDissolutionCommencement.html | 81 +++ .../template-parts/common/v2/footer.html | 68 ++ .../template-parts/common/v2/footerMail.html | 68 ++ .../template-parts/common/v2/header.html | 108 +++ .../template-parts/common/v2/headerMail.html | 108 +++ .../template-parts/common/v2/style.html | 603 +++++++++++++++++ .../template-parts/common/v2/styleMail.html | 635 ++++++++++++++++++ legal-api/requirements.txt | 1 + legal-api/src/legal_api/config.py | 4 + .../exceptions/error_messages/codes.py | 1 + .../exceptions/error_messages/messages.py | 1 + legal-api/src/legal_api/models/address.py | 3 +- legal-api/src/legal_api/reports/report_v2.py | 240 +++++++ .../resources/v2/business/__init__.py | 1 + .../business/business_furnishings/__init__.py | 21 + .../furnishing_documents.py | 65 ++ legal-api/tests/unit/models/__init__.py | 45 ++ .../tests/unit/reports/test_report_v2.py | 48 ++ .../v2/test_business_furnishings/__init__.py | 14 + .../test_furnishing_documents.py | 101 +++ 23 files changed, 2229 insertions(+), 23 deletions(-) create mode 100644 legal-api/report-templates/noticeOfDissolutionCommencement.html create mode 100644 legal-api/report-templates/template-parts/common/v2/footer.html create mode 100644 legal-api/report-templates/template-parts/common/v2/footerMail.html create mode 100644 legal-api/report-templates/template-parts/common/v2/header.html create mode 100644 legal-api/report-templates/template-parts/common/v2/headerMail.html create mode 100644 legal-api/report-templates/template-parts/common/v2/style.html create mode 100644 legal-api/report-templates/template-parts/common/v2/styleMail.html create mode 100644 legal-api/src/legal_api/reports/report_v2.py create mode 100644 legal-api/src/legal_api/resources/v2/business/business_furnishings/__init__.py create mode 100644 legal-api/src/legal_api/resources/v2/business/business_furnishings/furnishing_documents.py create mode 100644 legal-api/tests/unit/reports/test_report_v2.py create mode 100644 legal-api/tests/unit/resources/v2/test_business_furnishings/__init__.py create mode 100644 legal-api/tests/unit/resources/v2/test_business_furnishings/test_furnishing_documents.py diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py index d20e2021fd..9070296e1c 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py @@ -84,9 +84,7 @@ async def _send_first_round_notification(self, batch_processing: BatchProcessing # send email/letter notification for the first time email = self._get_email_address_from_auth(batch_processing.business_identifier) business = Business.find_by_identifier(batch_processing.business_identifier) - if email: - # send email letter - new_furnishing = self._create_new_furnishing( + new_furnishing = self._create_new_furnishing( batch_processing, eligible_details, Furnishing.FurnishingType.EMAIL, @@ -94,21 +92,16 @@ async def _send_first_round_notification(self, batch_processing: BatchProcessing business.legal_name, email ) - # notify emailer + mailing_address = business.mailing_address.one_or_none() + if mailing_address: + self._create_furnishing_address(mailing_address, new_furnishing.id) + if email: + # send email letter await self._send_email(new_furnishing) else: # send paper letter if business doesn't have email address - new_furnishing = self._create_new_furnishing( - batch_processing, - eligible_details, - Furnishing.FurnishingType.MAIL, - business.last_ar_date if business.last_ar_date else business.founding_date, - business.legal_name - ) - - mailing_address = business.mailing_address.one_or_none() - if mailing_address: - self._create_furnishing_address(mailing_address, new_furnishing.id) + new_furnishing.furnishing_type = Furnishing.FurnishingType.MAIL + new_furnishing.save() # TODO: create and add letter to either AR or transition pdf # TODO: send AR and transition pdf to BCMail+ @@ -192,7 +185,7 @@ def _create_new_furnishing( # pylint: disable=too-many-arguments def _create_furnishing_address(self, mailing_address: Address, furnishings_id: int) -> Address: """Clone business mailing address to be used by mail furnishings.""" furnishing_address = Address( - address_type=Address.FURNISHING, + address_type=mailing_address.address_type, street=mailing_address.street, street_additional=mailing_address.street_additional, city=mailing_address.city, diff --git a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py index a5fe966481..9a9a8ed479 100644 --- a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py +++ b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py @@ -88,7 +88,7 @@ def test_get_email_address_from_auth(session, test_name, mock_return): async def test_process_first_notification(app, session, test_name, entity_type, email, expected_furnishing_name): """Assert that the first notification furnishing entry is created correctly.""" business = factory_business(identifier='BC1234567', entity_type=entity_type) - factory_address(address_type=Address.MAILING, business_id=business.id) + mailing_address = factory_address(address_type=Address.MAILING, business_id=business.id) batch = factory_batch() factory_batch_processing( batch_id=batch.id, @@ -129,7 +129,7 @@ async def test_process_first_notification(app, session, test_name, entity_type, assert len(furnishing_addresses) == 1 furnishing_address = furnishing_addresses[0] assert furnishing_address - assert furnishing_address.address_type == Address.FURNISHING + assert furnishing_address.address_type == mailing_address.address_type assert furnishing_address.furnishings_id == furnishing.id assert furnishing_address.business_id == None assert furnishing_address.office_id == None @@ -160,7 +160,7 @@ async def test_process_first_notification(app, session, test_name, entity_type, async def test_process_second_notification(app, session, test_name, has_email_furnishing, has_mail_furnishing, is_email_elapsed): """Assert that the second notification furnishing entry is created correctly.""" business = factory_business(identifier='BC1234567') - factory_address(address_type=Address.MAILING, business_id=business.id) + mailing_address = factory_address(address_type=Address.MAILING, business_id=business.id) batch = factory_batch() factory_batch_processing( batch_id=batch.id, @@ -210,7 +210,7 @@ async def test_process_second_notification(app, session, test_name, has_email_fu assert len(furnishing_addresses) == 1 furnishing_address = furnishing_addresses[0] assert furnishing_address - assert furnishing_address.address_type == Address.FURNISHING + assert furnishing_address.address_type == mailing_address.address_type assert furnishing_address.furnishings_id == mail_furnishing.id assert furnishing_address.business_id == None assert furnishing_address.office_id == None diff --git a/legal-api/devops/vaults.json b/legal-api/devops/vaults.json index 27ef04a298..cdc7e9a3c8 100644 --- a/legal-api/devops/vaults.json +++ b/legal-api/devops/vaults.json @@ -39,7 +39,8 @@ { "vault": "api", "application": [ - "mras-api" + "mras-api", + "report-api-gotenberg" ] } ] diff --git a/legal-api/report-templates/noticeOfDissolutionCommencement.html b/legal-api/report-templates/noticeOfDissolutionCommencement.html new file mode 100644 index 0000000000..e1dbc0aa49 --- /dev/null +++ b/legal-api/report-templates/noticeOfDissolutionCommencement.html @@ -0,0 +1,81 @@ + + + + Notice of Commencement of Dissolution + + + {% if variant == 'default' %} + [[common/v2/style.html]] + {% else %} + [[common/v2/styleMail.html]] + {% endif %} + + +
+ {% if furnishing.mailingAddress %} +
+ {% if furnishing.mailingAddress.streetAddressAdditional %} +
+ {% else %} +
+ {% endif %} +
{{ furnishing.businessName }}
+
{{ furnishing.mailingAddress.streetAddress }}
+
{{ furnishing.mailingAddress.streetAddressAdditional }}
+
+ {{ furnishing.mailingAddress.addressCity }} + {{ furnishing.mailingAddress.addressRegion }} +  {{ furnishing.mailingAddress.postalCode }} +
+
+
+ {% endif %} +
+
No Annual Reports Filed Since {{ furnishing.lastARDate }} for {{ furnishing.businessIdentifier }}
+
To file an annual report online, log in to your Business Page at business.bcregistry.gov.bc.ca/{{ furnishing.businessIdentifier }} to file any outstanding annual reports listed.
+
Under section 422 of the Business Corporation Act (the Act), this letter is to notify you that your company has for two years failed to file the annual reports required under section 51 of the Act. A company must annually, within two months after each anniversary of the date on which the company was recognized, file an annual report with the Registrar.
+
If within one month after the date of this letter, the company fails to file the outstanding annual reports, a notice may be published on the BC Laws website www.bclaws.ca. This notice will state that, at any time after the expiration of one month after the date of publication of the notice, the company will be dissolved, unless cause is shown to the contrary; I am satisfied the failure has been or is being remedied; or a copy of the entered court order to the contrary has been filed.
+ + {% if furnishing.foreignRegistrations %} +
+ Our records indicate your company is registered in + {% if furnishing.foreignRegistrations|length == 1 %} + {{ furnishing.foreignRegistrations[0] }} + {% elif furnishing.foreignRegistrations|length == 2 %} + {{ furnishing.foreignRegistrations[0] }} and {{ furnishing.foreignRegistrations[1] }} + {% else %} + {{ furnishing.foreignRegistrations[0] }}, {{ furnishing.foreignRegistrations[1] }}, and {{ furnishing.foreignRegistrations[2] }} + {% endif %} + as an extraprovincial company. Therefore, if your company is dissolved, its registration as an extraprovincial company in + {% if furnishing.foreignRegistrations|length == 1 %} + {{ furnishing.foreignRegistrations[0] }} + {% elif furnishing.foreignRegistrations|length == 2 %} + {{ furnishing.foreignRegistrations[0] }} and {{ furnishing.foreignRegistrations[1] }} + {% else %} + {{ furnishing.foreignRegistrations[0] }}, {{ furnishing.foreignRegistrations[1] }}, and {{ furnishing.foreignRegistrations[2] }} + {% endif %} + will automatically be cancelled as well. +
+ {% endif %} + +
To request a delay of the dissolution, go to business.bcregistry.gov.bc.ca/{{ furnishing.businessIdentifier }} and request for a Delay of Dissolution or Cancellation under the To Do section. This must be completed prior to the dissolution of the company.
+
If your company is dissolved under section 422(1)(a) of the Act, section 347 of the Act states the liability of each director, officer, shareholder and liquidator of a company that is dissolved continues and may be enforced as if the company had not been dissolved.
+
If you have filed the outstanding annual reports, no further action is required.
+
If you need help with setting up an account or managing a business, please visit our Resources and Help page at bcreg.ca/resources
+ +

Issued on my behalf on {{ furnishing.processedDate }}

+
+
[[common/certificateRegistrarSignature.html]]
+
+
{{ registrarInfo.name }}
+
{{ registrarInfo.title }}
+
+
+
BC Registries and Online Services
+
Toll-Free Phone: 1-877-526-1526
+
+
+
+
+ + diff --git a/legal-api/report-templates/template-parts/common/v2/footer.html b/legal-api/report-templates/template-parts/common/v2/footer.html new file mode 100644 index 0000000000..327baf5792 --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/footer.html @@ -0,0 +1,68 @@ + + + + + + + + diff --git a/legal-api/report-templates/template-parts/common/v2/footerMail.html b/legal-api/report-templates/template-parts/common/v2/footerMail.html new file mode 100644 index 0000000000..10bb2c1869 --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/footerMail.html @@ -0,0 +1,68 @@ + + + + + + + + diff --git a/legal-api/report-templates/template-parts/common/v2/header.html b/legal-api/report-templates/template-parts/common/v2/header.html new file mode 100644 index 0000000000..52e8b1a6ca --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/header.html @@ -0,0 +1,108 @@ + + + + Reg Report + + + +
+ + + + + +
+ + +
{{TITLE}}
+
BC Registries and Online Services
+
+
+
+
+
+ + diff --git a/legal-api/report-templates/template-parts/common/v2/headerMail.html b/legal-api/report-templates/template-parts/common/v2/headerMail.html new file mode 100644 index 0000000000..b7a740aaee --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/headerMail.html @@ -0,0 +1,108 @@ + + + + Reg Report + + + +
+ + + + + +
+ + +
{{TITLE}}
+
BC Registries and Online Services
+
+
+
+
+
+ + diff --git a/legal-api/report-templates/template-parts/common/v2/style.html b/legal-api/report-templates/template-parts/common/v2/style.html new file mode 100644 index 0000000000..c8a13f8a97 --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/style.html @@ -0,0 +1,603 @@ + diff --git a/legal-api/report-templates/template-parts/common/v2/styleMail.html b/legal-api/report-templates/template-parts/common/v2/styleMail.html new file mode 100644 index 0000000000..260a93b039 --- /dev/null +++ b/legal-api/report-templates/template-parts/common/v2/styleMail.html @@ -0,0 +1,635 @@ + diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index 6cbd539327..9b9dbd94cc 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -30,6 +30,7 @@ ecdsa==0.14.1 expiringdict==1.1.4 flask-jwt-oidc==0.3.0 flask-restx==0.3.0 +google-auth==2.16.2 gunicorn==20.1.0 idna==2.10 itsdangerous==1.1.0 diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index efb2b61988..b673a9a560 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -171,6 +171,10 @@ class _Config(): # pylint: disable=too-few-public-methods MRAS_SVC_URL = os.getenv('MRAS_SVC_URL') MRAS_SVC_API_KEY = os.getenv('MRAS_SVC_API_KEY') + # GCP Gotenberg report service + REPORT_API_GOTENBERG_AUDIENCE = os.getenv('REPORT_API_GOTENBERG_AUDIENCE', '') + REPORT_API_GOTENBERG_URL = os.getenv('REPORT_API_GOTENBERG_URL', 'https://') + TESTING = False DEBUG = False diff --git a/legal-api/src/legal_api/exceptions/error_messages/codes.py b/legal-api/src/legal_api/exceptions/error_messages/codes.py index f62e1bccab..8ba8476c1e 100644 --- a/legal-api/src/legal_api/exceptions/error_messages/codes.py +++ b/legal-api/src/legal_api/exceptions/error_messages/codes.py @@ -32,3 +32,4 @@ class ErrorCode(AutoName): FILING_NOT_FOUND = auto() MISSING_BUSINESS = auto() NOT_AUTHORIZED = auto() + FURNISHING_NOT_FOUND = auto() diff --git a/legal-api/src/legal_api/exceptions/error_messages/messages.py b/legal-api/src/legal_api/exceptions/error_messages/messages.py index 826dc79b09..f8e7c77d40 100644 --- a/legal-api/src/legal_api/exceptions/error_messages/messages.py +++ b/legal-api/src/legal_api/exceptions/error_messages/messages.py @@ -19,4 +19,5 @@ ErrorCode.MISSING_BUSINESS: 'Business not found for identifier: {identifier}', ErrorCode.FILING_NOT_FOUND: 'Filing: {filing_id} not found for: {identifier}', ErrorCode.NOT_AUTHORIZED: 'Not authorized to access business: {identifier}', + ErrorCode.FURNISHING_NOT_FOUND: 'Furnishing: {furnishing_id} not found for identifier: {identifier}' } diff --git a/legal-api/src/legal_api/models/address.py b/legal-api/src/legal_api/models/address.py index b8ce59188d..91c78bb126 100644 --- a/legal-api/src/legal_api/models/address.py +++ b/legal-api/src/legal_api/models/address.py @@ -32,8 +32,7 @@ class Address(db.Model): # pylint: disable=too-many-instance-attributes MAILING = 'mailing' DELIVERY = 'delivery' - FURNISHING = 'furnishing' - ADDRESS_TYPES = [MAILING, DELIVERY, FURNISHING] + ADDRESS_TYPES = [MAILING, DELIVERY] JSON_MAILING = 'mailingAddress' JSON_DELIVERY = 'deliveryAddress' JSON_ADDRESS_TYPES = [JSON_MAILING, JSON_DELIVERY] diff --git a/legal-api/src/legal_api/reports/report_v2.py b/legal-api/src/legal_api/reports/report_v2.py new file mode 100644 index 0000000000..7dfe946c0b --- /dev/null +++ b/legal-api/src/legal_api/reports/report_v2.py @@ -0,0 +1,240 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +"""Produces a PDF output for Furnishing based on templates and JSON messages.""" +import copy +from enum import auto +from http import HTTPStatus +from pathlib import Path +from typing import Final + +import google.auth.transport.requests +import google.oauth2.id_token +import requests +from flask import current_app, jsonify +from jinja2 import Template + +from legal_api.models import Address +from legal_api.reports.registrar_meta import RegistrarInfo +from legal_api.services import MrasService +from legal_api.utils.base import BaseEnum +from legal_api.utils.legislation_datetime import LegislationDatetime + + +OUTPUT_DATE_FORMAT: Final = '%B %-d, %Y' +SINGLE_URI: Final = '/forms/chromium/convert/html' +HEADER_PATH: Final = '/template-parts/common/v2/header.html' +HEADER_MAIL_PATH: Final = '/template-parts/common/v2/headerMail.html' +FOOTER_PATH: Final = '/template-parts/common/v2/footer.html' +FOOTER_MAIL_PATH: Final = '/template-parts/common/v2/footerMail.html' +HEADER_TITLE_REPLACE: Final = '{{TITLE}}' +REPORT_META_DATA = { + 'marginTop': 1.93, + 'marginLeft': 0.4, + 'marginRight': 0.4, + 'marginBottom': 0.9, + 'printBackground': True +} +REPORT_FILES = { + 'index.html': '', + 'header.html': '', + 'footer.html': '' +} + + +class ReportV2: + """Service to create Gotenberg document outputs.""" + + def __init__(self, business, furnishing, document_key, variant=None): + """Create ReportV2 instance.""" + self._furnishing = furnishing + self._business = business + self._document_key = document_key + self._report_data = None + self._report_date_time = LegislationDatetime.now() + self._variant = variant + + def get_pdf(self): + """Render the furnishing document pdf response.""" + headers = {} + token = ReportV2.get_report_api_token() + if token: + headers['Authorization'] = 'Bearer {}'.format(token) + url = current_app.config.get('REPORT_API_GOTENBERG_URL') + SINGLE_URI + data = { + 'reportName': self._get_report_filename(), + 'template': self._get_template(), + 'templateVars': self._get_template_data() + } + files = self._get_report_files(data) + response = requests.post(url=url, headers=headers, data=REPORT_META_DATA, files=files, timeout=1800.0) + + if response.status_code != HTTPStatus.OK: + return jsonify(message=str(response.content)), response.status_code + + # return response.content, response.status_code + return current_app.response_class( + response=response.content, + status=response.status_code, + mimetype='application/pdf' + ) + + def _get_report_filename(self): + report_date = str(self._report_date_time)[:19] + return '{}_{}_{}.pdf'.format(self._business.identifier, report_date, + ReportMeta.reports[self._document_key]['reportName']).replace(' ', '_') + + def _get_template(self): + try: + template_path = current_app.config.get('REPORT_TEMPLATE_PATH') + template_file_name = ReportMeta.reports[self._document_key]['templateName'] + template_code = Path(f'{template_path}/{template_file_name}.html').read_text(encoding='UTF-8') + # substitute template parts + template_code = self._substitute_template_parts(template_code) + except Exception as err: + current_app.logger.error(err) + raise err + return template_code + + @staticmethod + def _substitute_template_parts(template_code): + template_path = current_app.config.get('REPORT_TEMPLATE_PATH') + template_parts = [ + 'common/v2/style', + 'common/v2/styleMail', + 'common/certificateRegistrarSignature' + ] + # substitute template parts - marked up by [[filename]] + for template_part in template_parts: + template_part_code = Path(f'{template_path}/template-parts/{template_part}.html')\ + .read_text(encoding='UTF-8') + template_code = template_code.replace('[[{}.html]]'.format(template_part), template_part_code) + return template_code + + def _get_template_data(self): + self._report_data = {} + self._format_furnishing_data() + self._set_meta_info() + self._set_address() + self._set_registrar_info() + if self._document_key == ReportTypes.DISSOLUTION: + self._set_ep_registration() + return self._report_data + + def _format_furnishing_data(self): + self._report_data['furnishing'] = { + 'businessName': self._furnishing.business_name, + 'businessIdentifier': self._furnishing.business_identifier + } + + if self._furnishing.last_ar_date: + last_ar_date = LegislationDatetime.as_legislation_timezone(self._furnishing.last_ar_date) + else: + last_ar_date = LegislationDatetime.as_legislation_timezone(self._business.founding_date) + self._report_data['furnishing']['lastARDate'] = last_ar_date.strftime(OUTPUT_DATE_FORMAT) + + if self._furnishing.processed_date: + processed_date = LegislationDatetime.as_legislation_timezone(self._furnishing.processed_date) + else: + processed_date = LegislationDatetime.as_legislation_timezone(self._report_date_time) + self._report_data['furnishing']['processedDate'] = processed_date.strftime(OUTPUT_DATE_FORMAT) + + def _set_meta_info(self): + if self._variant: + self._report_data['variant'] = self._variant + else: + self._report_data['variant'] = 'default' + self._report_data['title'] = ReportMeta.reports[self._document_key]['reportDescription'].upper() + + def _set_address(self): + if (furnishing_address := Address.find_by(furnishings_id=self._furnishing.id)): + furnishing_address = furnishing_address[0] + self._report_data['furnishing']['mailingAddress'] = furnishing_address.json + + def _set_registrar_info(self): + if self._furnishing.processed_date: + self._report_data['registrarInfo'] = {**RegistrarInfo.get_registrar_info(self._furnishing.processed_date)} + else: + self._report_data['registrarInfo'] = {**RegistrarInfo.get_registrar_info(self._report_date_time)} + + def _set_ep_registration(self): + jurisdictions = MrasService.get_jurisdictions(self._furnishing.business_identifier) + if jurisdictions: + ep_registrations = [e['name'] for e in jurisdictions if e['id'] in ['AB', 'SK', 'MB']] + ep_registrations.sort() + self._report_data['furnishing']['foreignRegistrations'] = ep_registrations + else: + self._report_data['furnishing']['foreignRegistrations'] = [] + + def _get_report_files(self, data): + """Get gotenberg report generation source file data.""" + title = self._report_data['title'] + files = copy.deepcopy(REPORT_FILES) + files['index.html'] = self._get_html_from_data(data) + if self._variant == 'default': + files['header.html'] = self._get_html_from_path(HEADER_PATH, title) + files['footer.html'] = self._get_html_from_path(FOOTER_PATH) + else: + files['header.html'] = self._get_html_from_path(HEADER_MAIL_PATH, title) + files['footer.html'] = self._get_html_from_path(FOOTER_MAIL_PATH) + + return files + + @staticmethod + def _get_html_from_data(data): + """Get html by merging the template with the report data.""" + html_output = None + try: + template = Template(data['template'], autoescape=True) + html_output = template.render(data['templateVars']) + except Exception as err: + current_app.logger.error('Error rendering HTML template: ' + str(err)) + return html_output + + @staticmethod + def _get_html_from_path(path, title=None): + html_template = None + try: + template_path = current_app.config.get('REPORT_TEMPLATE_PATH') + path + html_template = Path(template_path).read_text(encoding='UTF-8') + if title: + html_template = html_template.replace(HEADER_TITLE_REPLACE, title) + except Exception as err: + current_app.logger.error(f'Error loading HTML template from path={template_path}: ' + str(err)) + return html_template + + @staticmethod + def get_report_api_token(): + """Generate access token for Gotenberg Report API.""" + audience = current_app.config.get('REPORT_API_GOTENBERG_AUDIENCE') + if not audience: + return None + auth_req = google.auth.transport.requests.Request() + token = google.oauth2.id_token.fetch_id_token(auth_req, audience) + current_app.logger.info('Obtained token for Gotenberg Report API.') + return token + + +class ReportTypes(BaseEnum): + """Render an Enum of the Gotenberg report types.""" + + DISSOLUTION = auto() + + +class ReportMeta: + """Helper class to maintain the report meta information.""" + + reports = { + ReportTypes.DISSOLUTION: { + 'reportName': 'dissoluion', + 'templateName': 'noticeOfDissolutionCommencement', + 'reportDescription': 'Notice of Commencement of Dissolution' + } + } diff --git a/legal-api/src/legal_api/resources/v2/business/__init__.py b/legal-api/src/legal_api/resources/v2/business/__init__.py index a25986f34d..07d64d3c89 100644 --- a/legal-api/src/legal_api/resources/v2/business/__init__.py +++ b/legal-api/src/legal_api/resources/v2/business/__init__.py @@ -23,6 +23,7 @@ from .business_directors import get_directors from .business_documents import get_business_documents from .business_filings import delete_filings, get_documents, get_filings, patch_filings, saving_filings +from .business_furnishings import get_furnishing_document from .business_parties import get_parties from .business_resolutions import get_resolutions from .business_share_classes import get_share_class diff --git a/legal-api/src/legal_api/resources/v2/business/business_furnishings/__init__.py b/legal-api/src/legal_api/resources/v2/business/business_furnishings/__init__.py new file mode 100644 index 0000000000..8cf38f256f --- /dev/null +++ b/legal-api/src/legal_api/resources/v2/business/business_furnishings/__init__.py @@ -0,0 +1,21 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Business Furnishings. + +Provides all furnishing entries externalized services. +""" +from .furnishing_documents import get_furnishing_document + + +__all__ = ('get_furnishing_document',) diff --git a/legal-api/src/legal_api/resources/v2/business/business_furnishings/furnishing_documents.py b/legal-api/src/legal_api/resources/v2/business/business_furnishings/furnishing_documents.py new file mode 100644 index 0000000000..17d636dcf4 --- /dev/null +++ b/legal-api/src/legal_api/resources/v2/business/business_furnishings/furnishing_documents.py @@ -0,0 +1,65 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Retrieve the specified letter for the furnishing entry.""" +from http import HTTPStatus +from typing import Final + +from flask import jsonify, request +from flask_cors import cross_origin + +from legal_api.exceptions import ErrorCode, get_error_message +from legal_api.models import Business, Furnishing, UserRoles +from legal_api.reports.report_v2 import ReportTypes, ReportV2 +from legal_api.services import authorized +from legal_api.utils.auth import jwt + +from ..bp import bp + + +FURNISHING_DOC_BASE_ROUTE: Final = '//furnishings//document' + + +@bp.route(FURNISHING_DOC_BASE_ROUTE, methods=['GET', 'OPTIONS']) +@cross_origin(origins='*') +@jwt.has_one_of_roles([UserRoles.system, UserRoles.staff]) +def get_furnishing_document(identifier: str, furnishing_id: int): + """Return a JSON object with meta information about the Service.""" + # basic checks + if not authorized(identifier, jwt, ['view', ]): + return jsonify( + message=get_error_message(ErrorCode.NOT_AUTHORIZED, **{'identifier': identifier}) + ), HTTPStatus.UNAUTHORIZED + + if not (business := Business.find_by_identifier(identifier)): + return jsonify( + message=get_error_message(ErrorCode.MISSING_BUSINESS, + **{'identifier': identifier}) + ), HTTPStatus.NOT_FOUND + if not (furnishing := Furnishing.find_by_id(furnishing_id)) or furnishing.business_id != business.id: + return jsonify( + message=get_error_message(ErrorCode.FURNISHING_NOT_FOUND, + **{'furnishing_id': furnishing_id, 'identifier': identifier}) + ), HTTPStatus.NOT_FOUND + + variant = request.args.get('variant', 'default').lower() + if variant not in ['default', 'greyscale']: + return jsonify({'message': f'{variant} not a valid variant'}), HTTPStatus.BAD_REQUEST + + if 'application/pdf' in request.accept_mimetypes: + try: + return ReportV2(business, furnishing, ReportTypes.DISSOLUTION, variant).get_pdf() + except Exception: + return jsonify({'message': 'Unable to get furnishing document.'}), HTTPStatus.INTERNAL_SERVER_ERROR + + return {}, HTTPStatus.NOT_FOUND diff --git a/legal-api/tests/unit/models/__init__.py b/legal-api/tests/unit/models/__init__.py index 5105bc616c..e722148d74 100644 --- a/legal-api/tests/unit/models/__init__.py +++ b/legal-api/tests/unit/models/__init__.py @@ -32,6 +32,7 @@ Business, Comment, Filing, + Furnishing, Office, Party, PartyRole, @@ -421,3 +422,47 @@ def factory_batch_processing(batch_id, ) batch_processing.save() return batch_processing + + +def factory_furnishing(business_id, + business_identifier, + batch_id, + furnishing_type=Furnishing.FurnishingType.EMAIL, + furnishing_name=Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR, + status=Furnishing.FurnishingStatus.QUEUED, + created_date=datetime.utcnow(), + last_modified=datetime.utcnow(), + processed_date=datetime.utcnow(), + last_ar_date=None, + business_name=None + ): + """Create a furnishing.""" + furnishing = Furnishing( + business_id=business_id, + business_identifier=business_identifier, + batch_id=batch_id, + furnishing_type=furnishing_type, + furnishing_name=furnishing_name, + status=status, + created_date=created_date, + last_modified=last_modified, + processed_date=processed_date, + last_ar_date=last_ar_date, + business_name=business_name + ) + + furnishing.save() + return furnishing + + +def factory_business_with_stage_one_furnishing(): + """Create a business with a stage one furnishing entry.""" + business = factory_business('BC1234567') + factory_business_mailing_address(business) + batch = factory_batch() + furnishing = factory_furnishing(business_id=business.id, + business_identifier=business.identifier, + batch_id=batch.id, + last_ar_date=EPOCH_DATETIME, + business_name='TEST-BUSINESS') + return business, furnishing diff --git a/legal-api/tests/unit/reports/test_report_v2.py b/legal-api/tests/unit/reports/test_report_v2.py new file mode 100644 index 0000000000..8209ca9919 --- /dev/null +++ b/legal-api/tests/unit/reports/test_report_v2.py @@ -0,0 +1,48 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite to ensure that the ReportV2(Gotenberg Report) class is working as expected.""" +import pytest + +from unittest.mock import patch +from legal_api.services import MrasService +from legal_api.reports.report_v2 import ReportV2, ReportTypes +from tests.unit.models import factory_business_with_stage_one_furnishing + + +@pytest.mark.parametrize( + 'test_name, variant', [ + ('COMMENCEMENT_DEFAULT', 'default'), + ('COMMENCEMENT_GREYSCALE', 'greyscale'), + ] +) +def test_get_pdf(session, test_name, variant): + """Assert that furnishing can be returned as a Gotenberg PDF.""" + business, furnishing = factory_business_with_stage_one_furnishing() + with patch.object(MrasService, 'get_jurisdictions', return_value=[]): + report = ReportV2(business, furnishing, ReportTypes.DISSOLUTION, variant) + filename = report._get_report_filename() + assert filename + template = report._get_template() + assert template + template_data = report._get_template_data() + assert template_data + assert template_data['furnishing'] + assert template_data['variant'] == variant + assert template_data['registrarInfo'] + assert template_data['title'] == 'NOTICE OF COMMENCEMENT OF DISSOLUTION' + report_files = report._get_report_files(template_data) + assert report_files + assert 'header.html' in report_files + assert 'index.html' in report_files + assert 'footer.html' in report_files diff --git a/legal-api/tests/unit/resources/v2/test_business_furnishings/__init__.py b/legal-api/tests/unit/resources/v2/test_business_furnishings/__init__.py new file mode 100644 index 0000000000..30b067ad02 --- /dev/null +++ b/legal-api/tests/unit/resources/v2/test_business_furnishings/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test-Suite for the businesses/{}/furnishings API.""" \ No newline at end of file diff --git a/legal-api/tests/unit/resources/v2/test_business_furnishings/test_furnishing_documents.py b/legal-api/tests/unit/resources/v2/test_business_furnishings/test_furnishing_documents.py new file mode 100644 index 0000000000..c59a2d2181 --- /dev/null +++ b/legal-api/tests/unit/resources/v2/test_business_furnishings/test_furnishing_documents.py @@ -0,0 +1,101 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the business-furnishings end-point + +Test-Suite to ensure that the /businesses/_id_/furnishings endpoint is working as expected. +""" +import pytest +from http import HTTPStatus +from unittest.mock import patch + +from legal_api.models import UserRoles +from legal_api.reports.report_v2 import ReportV2 +from tests.unit.models import factory_business_with_stage_one_furnishing +from tests.unit.services.utils import create_header + + + +def test_get_furnishing_document(session, client, jwt): + """Assert that the endpoint is worked as expected.""" + + business, furnishing = factory_business_with_stage_one_furnishing() + with patch.object(ReportV2, 'get_pdf', return_value=('', HTTPStatus.OK)): + rv = client.get(f'/api/v2/businesses/{business.identifier}/furnishings/{furnishing.id}/document', + headers=create_header(jwt, [UserRoles.system, ], business.identifier, **{'accept': 'application/pdf'})) + + assert rv + assert rv.status_code == HTTPStatus.OK + + +def test_get_furnishing_document_invalid_role(session, client, jwt): + """Assert the call fails for invalid user role.""" + business, furnishing = factory_business_with_stage_one_furnishing() + rv = client.get(f'/api/v2/businesses/{business.identifier}/furnishings/{furnishing.id}/document', + headers=create_header(jwt, [UserRoles.basic, ], business.identifier, **{'accept': 'application/pdf'})) + + assert rv + assert rv.status_code == HTTPStatus.UNAUTHORIZED + code = rv.json.get('code') + assert code == 'missing_a_valid_role' + + +def test_get_furnishing_document_missing_business(session, client, jwt): + business, furnishing = factory_business_with_stage_one_furnishing() + invalid_identifier = 'ABC' + rv = client.get(f'/api/v2/businesses/{invalid_identifier}/furnishings/{furnishing.id}/document', + headers=create_header(jwt, [UserRoles.system, ], business.identifier, **{'accept': 'application/pdf'})) + + assert rv + assert rv.status_code == HTTPStatus.NOT_FOUND + message = rv.json.get('message') + assert message + assert invalid_identifier in message + + +def test_get_furnishing_document_missing_furnishing(session, client, jwt): + business, furnishing = factory_business_with_stage_one_furnishing() + invalid_furnishing_id = '123456789' + rv = client.get(f'/api/v2/businesses/{business.identifier}/furnishings/{invalid_furnishing_id}/document', + headers=create_header(jwt, [UserRoles.system, ], business.identifier, **{'accept': 'application/pdf'})) + + assert rv + assert rv.status_code == HTTPStatus.NOT_FOUND + message = rv.json.get('message') + assert message + assert business.identifier in message + assert invalid_furnishing_id in message + +@pytest.mark.parametrize( + 'test_name, variant, valid', [ + ('TEST_DEFAULT', 'default', True), + ('TEST_GREYSCALE', 'greyscale', True), + ('TEST_VALID_CASE_INSENSITIVE', 'dEFAULT', True), + ('TEST_INVALID', 'paper', False) + ] +) +def test_get_furnishing_document_variant(session, client, jwt, test_name, variant, valid): + business, furnishing = factory_business_with_stage_one_furnishing() + with patch.object(ReportV2, 'get_pdf', return_value=('', HTTPStatus.OK)): + rv = client.get(f'/api/v2/businesses/{business.identifier}/furnishings/{furnishing.id}/document?variant={variant}', + headers=create_header(jwt, [UserRoles.system, ], business.identifier, **{'accept': 'application/pdf'})) + + assert rv + if valid: + assert rv.status_code == HTTPStatus.OK + else: + assert rv.status_code == HTTPStatus.BAD_REQUEST + message = rv.json.get('message') + assert message + assert variant in message From 11e423d639d224f6cf6b3d44a10d3ce51cf81603 Mon Sep 17 00:00:00 2001 From: Kevin Zhang <54437031+kzdev420@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:34:56 -0400 Subject: [PATCH 16/60] 22285 db update for storing xml payloads (#2843) * 22285 db update to store xml payload * update xml_payload_id can be nullable * add index furnishings_furnishing_group_id * create postgresql XML data type * move file * fix typo --- .../versions/b14f77400786_xml_payloads.py | 46 ++++++++++++ legal-api/src/legal_api/models/__init__.py | 4 ++ .../src/legal_api/models/custom_db_types.py | 67 ++++++++++++++++++ legal-api/src/legal_api/models/furnishing.py | 9 ++- .../src/legal_api/models/furnishing_group.py | 59 ++++++++++++++++ legal-api/src/legal_api/models/xml_payload.py | 44 ++++++++++++ .../unit/models/test_furnishing_group.py | 70 +++++++++++++++++++ .../tests/unit/models/test_xml_payload.py | 42 +++++++++++ 8 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 legal-api/migrations/versions/b14f77400786_xml_payloads.py create mode 100644 legal-api/src/legal_api/models/custom_db_types.py create mode 100644 legal-api/src/legal_api/models/furnishing_group.py create mode 100644 legal-api/src/legal_api/models/xml_payload.py create mode 100644 legal-api/tests/unit/models/test_furnishing_group.py create mode 100644 legal-api/tests/unit/models/test_xml_payload.py diff --git a/legal-api/migrations/versions/b14f77400786_xml_payloads.py b/legal-api/migrations/versions/b14f77400786_xml_payloads.py new file mode 100644 index 0000000000..5e86c7beb0 --- /dev/null +++ b/legal-api/migrations/versions/b14f77400786_xml_payloads.py @@ -0,0 +1,46 @@ +"""xml_payloads + +Revision ID: b14f77400786 +Revises: feb206b2ce65 +Create Date: 2024-07-17 23:46:20.873233 + +""" +from alembic import op +import sqlalchemy as sa + +from legal_api.models.custom_db_types import PostgreSQLXML + +# revision identifiers, used by Alembic. +revision = 'b14f77400786' +down_revision = 'feb206b2ce65' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('xml_payloads', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('payload', PostgreSQLXML(), nullable=False), + sa.Column('created_date', sa.TIMESTAMP(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table('furnishing_groups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('xml_payload_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['xml_payload_id'], ['xml_payloads.id']), + sa.PrimaryKeyConstraint('id') + ) + + op.add_column('furnishings', sa.Column('furnishing_group_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'furnishings', 'furnishing_groups', ['furnishing_group_id'], ['id']) + op.create_index(op.f('ix_furnishings_furnishing_group_id'), 'furnishings', ['furnishing_group_id'], unique=False) + pass + + +def downgrade(): + op.drop_index(op.f('ix_furnishings_furnishing_group_id'), table_name='furnishings') + op.drop_column('furnishings', 'furnishing_group_id') + op.drop_table('furnishing_groups') + op.drop_table('xml_payloads') + pass diff --git a/legal-api/src/legal_api/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index dd46d34e75..3e4374923a 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -34,6 +34,7 @@ from .document import Document, DocumentType from .filing import Filing from .furnishing import Furnishing +from .furnishing_group import FurnishingGroup from .jurisdiction import Jurisdiction from .naics_element import NaicsElement from .naics_structure import NaicsStructure @@ -47,6 +48,7 @@ from .share_class import ShareClass from .share_series import ShareSeries from .user import User, UserRoles +from .xml_payload import XmlPayload __all__ = ( @@ -72,6 +74,7 @@ 'DocumentType', 'Filing', 'Furnishing', + 'FurnishingGroup', 'Jurisdiction', 'NaicsElement', 'NaicsStructure', @@ -89,4 +92,5 @@ 'ShareSeries', 'User', 'UserRoles', + 'XmlPayload' ) diff --git a/legal-api/src/legal_api/models/custom_db_types.py b/legal-api/src/legal_api/models/custom_db_types.py new file mode 100644 index 0000000000..2739c7b347 --- /dev/null +++ b/legal-api/src/legal_api/models/custom_db_types.py @@ -0,0 +1,67 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""PostgresDB data type utilities.""" +from sqlalchemy.types import UserDefinedType + + +class PostgreSQLXML(UserDefinedType): + """ + A custom SQLAlchemy type to handle PostgreSQL's XML data type. + + This class allows SQLAlchemy to store and retrieve XML data in PostgreSQL databases. + It provides methods to bind and process data when inserting into or querying from the database. + + Example usage: + class MyModel(Base): + __tablename__ = 'my_model' + id = Column(Integer, primary_key=True) + payload = Column(PostgreSQLXML(), nullable=False) + """ + + def get_col_spec(self): + """ + Return the column type specification. + + This method returns the string 'XML' to indicate that the column should store XML data. + """ + return 'XML' + + def bind_processor(self, dialect): + """ + Return a processor for binding values to the database. + + Args: + dialect: The database dialect being used. + Returns: + A function that processes the value to be stored in the database. In this case, the function + returns the value unchanged, as no special processing is needed. + """ + def process(value): + return value + return process + + def result_processor(self, dialect, coltype): + """ + Return a processor for retrieving values from the database. + + Args: + dialect: The database dialect being used. + coltype: The column type. + Returns: + A function that processes the value retrieved from the database. In this case, the function + returns the value unchanged, as no special processing is needed. + """ + def process(value): + return value + return process diff --git a/legal-api/src/legal_api/models/furnishing.py b/legal-api/src/legal_api/models/furnishing.py index f5e744fe1d..ca04c42fe9 100644 --- a/legal-api/src/legal_api/models/furnishing.py +++ b/legal-api/src/legal_api/models/furnishing.py @@ -77,9 +77,12 @@ class FurnishingStatus(BaseEnum): # parent keys batch_id = db.Column('batch_id', db.Integer, db.ForeignKey('batches.id'), index=True, nullable=False) business_id = db.Column('business_id', db.Integer, db.ForeignKey('businesses.id'), index=True, nullable=False) + furnishing_group_id = db.Column('furnishing_group_id', db.Integer, db.ForeignKey('furnishing_groups.id'), + index=True, nullable=False) # relationships business = db.relationship('Business', backref=db.backref('furnishings', lazy=True)) + furnishing_group = db.relationship('FurnishingGroup', backref=db.backref('furnishings', lazy=True)) def save(self): """Save the object to the database immediately.""" @@ -101,7 +104,8 @@ def find_by(cls, # pylint: disable=too-many-arguments furnishing_name: str = None, furnishing_type: str = None, status: str = None, - grouping_identifier: int = None + grouping_identifier: int = None, + furnishing_group_id: int = None ) -> List[Furnishing]: """Return the Furnishing entries matching the filter.""" query = db.session.query(Furnishing) @@ -124,6 +128,9 @@ def find_by(cls, # pylint: disable=too-many-arguments if grouping_identifier: query = query.filter(Furnishing.grouping_identifier == grouping_identifier) + if furnishing_group_id: + query = query.filter(Furnishing.furnishing_group_id == furnishing_group_id) + return query.all() @classmethod diff --git a/legal-api/src/legal_api/models/furnishing_group.py b/legal-api/src/legal_api/models/furnishing_group.py new file mode 100644 index 0000000000..b8d58bb0f3 --- /dev/null +++ b/legal-api/src/legal_api/models/furnishing_group.py @@ -0,0 +1,59 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds data for furnishing groups.""" +from __future__ import annotations + +from typing import List + +from .db import db + + +class FurnishingGroup(db.Model): + """This class manages the furnishing groups.""" + + __tablename__ = 'furnishing_groups' + + id = db.Column(db.Integer, primary_key=True) + + # parent keys + xml_payload_id = db.Column('xml_payload_id', db.Integer, db.ForeignKey('xml_payloads.id'), + index=True, nullable=True) + + # relationships + xml_payload = db.relationship('XmlPayload', backref=db.backref('furnishing_groups', lazy=True)) + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() + + @classmethod + def find_by_id(cls, furnishing_group_id: int): + """Return a Furnishing entry by the id.""" + furnishing_group = None + if furnishing_group_id: + furnishing_group = cls.query.filter_by(id=furnishing_group_id).one_or_none() + return furnishing_group + + @classmethod + def find_by(cls, # pylint: disable=too-many-arguments + xml_payload_id: int = None + ) -> List[FurnishingGroup]: + """Return the Furnishing entries matching the filter.""" + query = db.session.query(FurnishingGroup) + + if xml_payload_id: + query = query.filter(FurnishingGroup.xml_payload_id == xml_payload_id) + + return query.all() diff --git a/legal-api/src/legal_api/models/xml_payload.py b/legal-api/src/legal_api/models/xml_payload.py new file mode 100644 index 0000000000..4b12f8a228 --- /dev/null +++ b/legal-api/src/legal_api/models/xml_payload.py @@ -0,0 +1,44 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds data for xml payloads.""" +from __future__ import annotations + +from datetime import datetime + +from legal_api.models.custom_db_types import PostgreSQLXML + +from .db import db + + +class XmlPayload(db.Model): + """This class manages the xml_payloads.""" + + __tablename__ = 'xml_payloads' + + id = db.Column(db.Integer, primary_key=True) + payload = db.Column('payload', PostgreSQLXML(), default='', nullable=True) + created_date = db.Column('created_date', db.DateTime(timezone=True), default=datetime.utcnow) + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() + + @classmethod + def find_by_id(cls, xml_payload_id: int): + """Return a XmlPayload entry by the id.""" + xml_payload = None + if xml_payload_id: + xml_payload = cls.query.filter_by(id=xml_payload_id).one_or_none() + return xml_payload diff --git a/legal-api/tests/unit/models/test_furnishing_group.py b/legal-api/tests/unit/models/test_furnishing_group.py new file mode 100644 index 0000000000..e05bd77e72 --- /dev/null +++ b/legal-api/tests/unit/models/test_furnishing_group.py @@ -0,0 +1,70 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the Furnishing Group Model. + +Test-Suite to ensure that the Furnishing Group Model is working as expected. +""" +from legal_api.models import FurnishingGroup, XmlPayload + +def test_valid_furnishing_save(session): + """Assert that a valid furnishing_group can be saved.""" + xml_payload = XmlPayload( + payload = 'value', + + ) + xml_payload.save() + + furnishing_group = FurnishingGroup( + xml_payload_id = xml_payload.id + ) + + furnishing_group.save() + assert furnishing_group.id + + +def test_find_furnishing_group_by_id(session): + """Assert that the method returns correct value.""" + xml_payload = XmlPayload( + payload = 'value', + + ) + xml_payload.save() + + furnishing_group = FurnishingGroup( + xml_payload_id = xml_payload.id + ) + furnishing_group.save() + + res = FurnishingGroup.find_by_id(furnishing_group_id=furnishing_group.id) + assert res + + +def test_find_furnishing_group_by(session): + """Assert that the method returns correct values.""" + xml_payload = XmlPayload( + payload = 'value', + + ) + xml_payload.save() + + furnishing_group = FurnishingGroup( + xml_payload_id = xml_payload.id + ) + furnishing_group.save() + + res = FurnishingGroup.find_by(xml_payload_id=xml_payload.id) + + assert len(res) == 1 + assert res[0].id == furnishing_group.id diff --git a/legal-api/tests/unit/models/test_xml_payload.py b/legal-api/tests/unit/models/test_xml_payload.py new file mode 100644 index 0000000000..b9564940f7 --- /dev/null +++ b/legal-api/tests/unit/models/test_xml_payload.py @@ -0,0 +1,42 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the XmlPayload Model. + +Test-Suite to ensure that the XmlPayload Model is working as expected. +""" +from legal_api.models import XmlPayload + +def test_valid_xml_payload_save(session): + """Assert that a valid xml payload can be saved.""" + xml_payload = XmlPayload( + payload = 'value', + + ) + + xml_payload.save() + assert xml_payload.id + + +def test_find_xml_payload_by_id(session): + """Assert that the method returns correct value.""" + xml_payload = XmlPayload( + payload='value' + ) + + xml_payload.save() + + res = XmlPayload.find_by_id(xml_payload_id=xml_payload.id) + + assert res From 2e6ef23a816cf84caadc5508cd9d33876ad7adea Mon Sep 17 00:00:00 2001 From: Hongjing <60866283+chenhongjing@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:45:00 -0700 Subject: [PATCH 17/60] 22235 - Legal API - Small tweak on address format for dissolution AR letter (#2856) * small tweak on address format for dissolution letter Signed-off-by: Hongjing Chen * rename variable Signed-off-by: Hongjing Chen * update Signed-off-by: Hongjing Chen --------- Signed-off-by: Hongjing Chen --- .../noticeOfDissolutionCommencement.html | 2 +- legal-api/src/legal_api/reports/report_v2.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/legal-api/report-templates/noticeOfDissolutionCommencement.html b/legal-api/report-templates/noticeOfDissolutionCommencement.html index e1dbc0aa49..197cd6cc28 100644 --- a/legal-api/report-templates/noticeOfDissolutionCommencement.html +++ b/legal-api/report-templates/noticeOfDissolutionCommencement.html @@ -25,7 +25,7 @@
{{ furnishing.mailingAddress.addressCity }} {{ furnishing.mailingAddress.addressRegion }} -  {{ furnishing.mailingAddress.postalCode }} +   {{ furnishing.mailingAddress.postalCode }}
diff --git a/legal-api/src/legal_api/reports/report_v2.py b/legal-api/src/legal_api/reports/report_v2.py index 7dfe946c0b..1f13742861 100644 --- a/legal-api/src/legal_api/reports/report_v2.py +++ b/legal-api/src/legal_api/reports/report_v2.py @@ -10,6 +10,7 @@ # specific language governing permissions and limitations under the License. """Produces a PDF output for Furnishing based on templates and JSON messages.""" import copy +import re from enum import auto from http import HTTPStatus from pathlib import Path @@ -28,6 +29,7 @@ from legal_api.utils.legislation_datetime import LegislationDatetime +POSTAL_CODE_REGEX: Final = r'^[A-Z]\d[A-Z]\d[A-Z]\d$' OUTPUT_DATE_FORMAT: Final = '%B %-d, %Y' SINGLE_URI: Final = '/forms/chromium/convert/html' HEADER_PATH: Final = '/template-parts/common/v2/header.html' @@ -156,7 +158,14 @@ def _set_meta_info(self): def _set_address(self): if (furnishing_address := Address.find_by(furnishings_id=self._furnishing.id)): furnishing_address = furnishing_address[0] - self._report_data['furnishing']['mailingAddress'] = furnishing_address.json + self._report_data['furnishing']['mailingAddress'] = self._format_address(furnishing_address.json) + + @staticmethod + def _format_address(address): + postal_code = address['postalCode'] + if re.match(POSTAL_CODE_REGEX, postal_code): + address['postalCode'] = postal_code[:3] + ' ' + postal_code[3:] + return address def _set_registrar_info(self): if self._furnishing.processed_date: From 386f42ce31a79d83c97b5378d9b3aa263ee6b38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9verin=20Beauvais?= Date: Thu, 25 Jul 2024 12:02:40 -0700 Subject: [PATCH 18/60] - updated some version numbers to 2.119.0 (#2859) Co-authored-by: Severin Beauvais --- colin-api/src/colin_api/version.py | 2 +- legal-api/src/legal_api/version.py | 2 +- queue_services/entity-emailer/src/entity_emailer/version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/colin-api/src/colin_api/version.py b/colin-api/src/colin_api/version.py index bc40d474a7..1cf3c1296d 100644 --- a/colin-api/src/colin_api/version.py +++ b/colin-api/src/colin_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.118.0' # pylint: disable=invalid-name +__version__ = '2.119.0' # pylint: disable=invalid-name diff --git a/legal-api/src/legal_api/version.py b/legal-api/src/legal_api/version.py index 6749145c5e..42e978a40b 100644 --- a/legal-api/src/legal_api/version.py +++ b/legal-api/src/legal_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.118.0' # pylint: disable=invalid-name +__version__ = '2.119.0' # pylint: disable=invalid-name diff --git a/queue_services/entity-emailer/src/entity_emailer/version.py b/queue_services/entity-emailer/src/entity_emailer/version.py index cdd89b35c3..1cf3c1296d 100644 --- a/queue_services/entity-emailer/src/entity_emailer/version.py +++ b/queue_services/entity-emailer/src/entity_emailer/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.116.0' # pylint: disable=invalid-name +__version__ = '2.119.0' # pylint: disable=invalid-name From 4df530dc29a806bbfde25cb2e3c73676e0ec9436 Mon Sep 17 00:00:00 2001 From: Kevin Zhang <54437031+kzdev420@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:16:29 -0400 Subject: [PATCH 19/60] 22286 update to use new furnishing groups approach (#2858) * 22286 update to use new furnishing groups approach * update function name --- ...emove_group_identifier_from_furnishings.py | 28 +++++++++++++++++++ legal-api/src/legal_api/models/furnishing.py | 16 +++++------ legal-api/tests/unit/models/__init__.py | 8 ++++++ .../tests/unit/models/test_furnishing.py | 25 ++++++----------- 4 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 legal-api/migrations/versions/bb9f4ab856b1_remove_group_identifier_from_furnishings.py diff --git a/legal-api/migrations/versions/bb9f4ab856b1_remove_group_identifier_from_furnishings.py b/legal-api/migrations/versions/bb9f4ab856b1_remove_group_identifier_from_furnishings.py new file mode 100644 index 0000000000..7bf2f9fddc --- /dev/null +++ b/legal-api/migrations/versions/bb9f4ab856b1_remove_group_identifier_from_furnishings.py @@ -0,0 +1,28 @@ +"""remove_group_identifier_from_furnishings + +Revision ID: bb9f4ab856b1 +Revises: b14f77400786 +Create Date: 2024-07-25 15:21:31.652920 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb9f4ab856b1' +down_revision = 'b14f77400786' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column('furnishings','grouping_identifier') + op.execute("DROP SEQUENCE grouping_identifier;") + pass + + +def downgrade(): + op.add_column('furnishings', sa.Column('grouping_identifier', sa.Integer(), nullable=True)) + op.execute("CREATE SEQUENCE grouping_identifier START 1;") + pass diff --git a/legal-api/src/legal_api/models/furnishing.py b/legal-api/src/legal_api/models/furnishing.py index ca04c42fe9..2dba7b3795 100644 --- a/legal-api/src/legal_api/models/furnishing.py +++ b/legal-api/src/legal_api/models/furnishing.py @@ -23,6 +23,7 @@ from legal_api.utils.datetime import datetime from .db import db +from .furnishing_group import FurnishingGroup class Furnishing(db.Model): @@ -59,7 +60,6 @@ class FurnishingStatus(BaseEnum): id = db.Column(db.Integer, primary_key=True) furnishing_type = db.Column('furnishing_type', db.Enum(FurnishingType), nullable=False) furnishing_name = db.Column('furnishing_name', db.Enum(FurnishingName), nullable=False) - grouping_identifier = db.Column(db.Integer, nullable=True) business_identifier = db.Column('business_identifier', db.String(10), default='', nullable=False) processed_date = db.Column('processed_date', db.DateTime(timezone=True), nullable=True) status = db.Column('status', db.Enum(FurnishingStatus), nullable=False) @@ -78,7 +78,7 @@ class FurnishingStatus(BaseEnum): batch_id = db.Column('batch_id', db.Integer, db.ForeignKey('batches.id'), index=True, nullable=False) business_id = db.Column('business_id', db.Integer, db.ForeignKey('businesses.id'), index=True, nullable=False) furnishing_group_id = db.Column('furnishing_group_id', db.Integer, db.ForeignKey('furnishing_groups.id'), - index=True, nullable=False) + index=True, nullable=True) # relationships business = db.relationship('Business', backref=db.backref('furnishings', lazy=True)) @@ -104,7 +104,6 @@ def find_by(cls, # pylint: disable=too-many-arguments furnishing_name: str = None, furnishing_type: str = None, status: str = None, - grouping_identifier: int = None, furnishing_group_id: int = None ) -> List[Furnishing]: """Return the Furnishing entries matching the filter.""" @@ -125,15 +124,14 @@ def find_by(cls, # pylint: disable=too-many-arguments if status: query = query.filter(Furnishing.status == status) - if grouping_identifier: - query = query.filter(Furnishing.grouping_identifier == grouping_identifier) - if furnishing_group_id: query = query.filter(Furnishing.furnishing_group_id == furnishing_group_id) return query.all() @classmethod - def get_next_grouping_identifier(cls): - """Return the next grouping_identifier from the sequence.""" - return db.session.execute("SELECT nextval('grouping_identifier')").scalar() + def get_next_furnishing_group_id(cls): + """Return thefurnishing_group_id.""" + furnishing_group = FurnishingGroup() + furnishing_group.save() + return furnishing_group.id diff --git a/legal-api/tests/unit/models/__init__.py b/legal-api/tests/unit/models/__init__.py index e722148d74..b1e5dcffd2 100644 --- a/legal-api/tests/unit/models/__init__.py +++ b/legal-api/tests/unit/models/__init__.py @@ -33,6 +33,7 @@ Comment, Filing, Furnishing, + FurnishingGroup, Office, Party, PartyRole, @@ -466,3 +467,10 @@ def factory_business_with_stage_one_furnishing(): last_ar_date=EPOCH_DATETIME, business_name='TEST-BUSINESS') return business, furnishing + + +def factory_furnishing_group(): + """Create a furnishing group.""" + furnishing_group = FurnishingGroup() + furnishing_group.save() + return furnishing_group diff --git a/legal-api/tests/unit/models/test_furnishing.py b/legal-api/tests/unit/models/test_furnishing.py index f0103b7e81..1406adc253 100644 --- a/legal-api/tests/unit/models/test_furnishing.py +++ b/legal-api/tests/unit/models/test_furnishing.py @@ -18,7 +18,7 @@ """ import pytest from legal_api.models import Furnishing -from tests.unit.models import factory_business, factory_batch +from tests.unit.models import factory_business, factory_batch, factory_furnishing_group def test_valid_furnishing_save(session): """Assert that a valid furnishing can be saved.""" @@ -69,7 +69,7 @@ def test_find_furnishing_by_id(session): 'furnishing_name': None, 'furnishing_type': None, 'status': None, - 'grouping_identifier': None + 'furnishing_group_id': None }, { 'batch_id': None, @@ -77,7 +77,7 @@ def test_find_furnishing_by_id(session): 'furnishing_name': Furnishing.FurnishingType.EMAIL, 'furnishing_type': None, 'status': None, - 'grouping_identifier': None + 'furnishing_group_id': None }, { 'batch_id': None, @@ -85,7 +85,7 @@ def test_find_furnishing_by_id(session): 'furnishing_name': Furnishing.FurnishingType.EMAIL, 'furnishing_type': Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR, 'status': None, - 'grouping_identifier': None + 'furnishing_group_id': None }, { 'batch_id': None, @@ -93,7 +93,7 @@ def test_find_furnishing_by_id(session): 'furnishing_name': Furnishing.FurnishingType.EMAIL, 'furnishing_type': Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR, 'status': Furnishing.FurnishingStatus.QUEUED, - 'grouping_identifier': None + 'furnishing_group_id': None }, { 'batch_id': None, @@ -101,7 +101,7 @@ def test_find_furnishing_by_id(session): 'furnishing_name': Furnishing.FurnishingType.EMAIL, 'furnishing_type': Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR, 'status': Furnishing.FurnishingStatus.QUEUED, - 'grouping_identifier': 2 + 'furnishing_group_id': None }, ] ) @@ -110,6 +110,7 @@ def test_find_furnishing_by(session, params): identifier = 'BC1234567' business = factory_business(identifier) batch = factory_batch() + furnishing_group = factory_furnishing_group() furnishing = Furnishing( furnishing_type = Furnishing.FurnishingType.EMAIL, furnishing_name = Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR, @@ -117,7 +118,7 @@ def test_find_furnishing_by(session, params): business_id = business.id, business_identifier = business.identifier, status = Furnishing.FurnishingStatus.QUEUED, - grouping_identifier = 2 + furnishing_group_id = furnishing_group.id ) furnishing.save() @@ -126,13 +127,3 @@ def test_find_furnishing_by(session, params): assert len(res) == 1 assert res[0].id == furnishing.id - - -def test_get_next_grouping_identifier(session): - """Assert that the grouping_identifier value is generated successfully.""" - first_val = Furnishing.get_next_grouping_identifier() - assert first_val - - next_val = Furnishing.get_next_grouping_identifier() - assert next_val - assert next_val == first_val + 1 From 0432c868088ff522cdc6eb68742cd871bb562ce6 Mon Sep 17 00:00:00 2001 From: Kevin Zhang <54437031+kzdev420@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:03:14 -0400 Subject: [PATCH 20/60] 22286 Update job to use new furnishing groups approach (#2862) * 22286_update_job_to_use_new_furnishing_groups_approach * update method name --- .../furnishings/stage_processors/stage_one.py | 24 +++++++++---------- .../stage_processors/stage_three.py | 4 ++-- .../furnishings/stage_processors/stage_two.py | 4 ++-- .../unit/stage_processors/test_stage_one.py | 6 ++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py index 9070296e1c..7f74219870 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_one.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_one.py @@ -34,8 +34,8 @@ def __init__(self, app, qsm): self._qsm = qsm self._second_notice_delay = app.config.get('SECOND_NOTICE_DELAY') - self._email_grouping_identifier = None - self._mail_grouping_identifier = None + self._email_furnishing_group_id = None + self._mail_furnishing_group_id = None async def process(self, batch_processing: BatchProcessing): """Process batch_processing entry.""" @@ -162,7 +162,7 @@ def _create_new_furnishing( # pylint: disable=too-many-arguments else Furnishing.FurnishingName.DISSOLUTION_COMMENCEMENT_NO_AR ) - grouping_identifier = self._get_grouping_identifier(furnishing_type) + furnishing_group_id = self._get_furnishing_group_id(furnishing_type) new_furnishing = Furnishing( furnishing_type=furnishing_type, @@ -173,7 +173,7 @@ def _create_new_furnishing( # pylint: disable=too-many-arguments created_date=datetime.utcnow(), last_modified=datetime.utcnow(), status=Furnishing.FurnishingStatus.QUEUED, - grouping_identifier=grouping_identifier, + furnishing_group_id=furnishing_group_id, last_ar_date=last_ar_date, business_name=business_name, email=email @@ -224,16 +224,16 @@ async def _send_email(self, furnishing: Furnishing): except Exception as err: self._app.logger.error('Queue Error: furnishing.id=%s, %s', furnishing.id, err, exc_info=True) - def _get_grouping_identifier(self, furnishing_type: Furnishing.FurnishingType) -> int: - """Return grouping identifier based on furnishing type.""" + def _get_furnishing_group_id(self, furnishing_type: Furnishing.FurnishingType) -> int: + """Return furnishing group id based on furnishing type.""" if furnishing_type == Furnishing.FurnishingType.EMAIL: - if not self._email_grouping_identifier: - self._email_grouping_identifier = Furnishing.get_next_grouping_identifier() - return self._email_grouping_identifier + if not self._email_furnishing_group_id: + self._email_furnishing_group_id = Furnishing.get_next_furnishing_group_id() + return self._email_furnishing_group_id elif furnishing_type == Furnishing.FurnishingType.MAIL: - if not self._mail_grouping_identifier: - self._mail_grouping_identifier = Furnishing.get_next_grouping_identifier() - return self._mail_grouping_identifier + if not self._mail_furnishing_group_id: + self._mail_furnishing_group_id = Furnishing.get_next_furnishing_group_id() + return self._mail_furnishing_group_id else: return None diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_three.py b/jobs/furnishings/src/furnishings/stage_processors/stage_three.py index 1a88491eaa..7cfeb3e48a 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_three.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_three.py @@ -40,7 +40,7 @@ def process(app: Flask): .filter(not_(furnishing_subquery)) ).all() - grouping_identifier = Furnishing.get_next_grouping_identifier() + furnishing_group_id = Furnishing.get_next_furnishing_group_id() for batch_processing in batch_processings: business = batch_processing.business @@ -58,7 +58,7 @@ def process(app: Flask): created_date=datetime.utcnow(), last_modified=datetime.utcnow(), status=Furnishing.FurnishingStatus.QUEUED, - grouping_identifier=grouping_identifier, + furnishing_group_id=furnishing_group_id, business_name=business.legal_name ) new_furnishing.save() diff --git a/jobs/furnishings/src/furnishings/stage_processors/stage_two.py b/jobs/furnishings/src/furnishings/stage_processors/stage_two.py index 997cb75239..0d752d58a3 100644 --- a/jobs/furnishings/src/furnishings/stage_processors/stage_two.py +++ b/jobs/furnishings/src/furnishings/stage_processors/stage_two.py @@ -40,7 +40,7 @@ def process(app: Flask): .filter(not_(furnishing_subquery)) ).all() - grouping_identifier = Furnishing.get_next_grouping_identifier() + furnishing_group_id = Furnishing.get_next_furnishing_group_id() for batch_processing in batch_processings: business = batch_processing.business @@ -58,7 +58,7 @@ def process(app: Flask): created_date=datetime.utcnow(), last_modified=datetime.utcnow(), status=Furnishing.FurnishingStatus.QUEUED, - grouping_identifier=grouping_identifier, + furnishing_group_id=furnishing_group_id, business_name=business.legal_name ) new_furnishing.save() diff --git a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py index 9a9a8ed479..c4cab70e0f 100644 --- a/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py +++ b/jobs/furnishings/tests/unit/stage_processors/test_stage_one.py @@ -112,7 +112,7 @@ async def test_process_first_notification(app, session, test_name, entity_type, assert furnishing.email == 'test@no-reply.com' assert furnishing.furnishing_name == expected_furnishing_name assert furnishing.status == Furnishing.FurnishingStatus.QUEUED - assert furnishing.grouping_identifier is not None + assert furnishing.furnishing_group_id is not None assert furnishing.last_ar_date == business.founding_date assert furnishing.business_name == business.legal_name else: @@ -123,7 +123,7 @@ async def test_process_first_notification(app, session, test_name, entity_type, assert furnishing.furnishing_type == Furnishing.FurnishingType.MAIL assert furnishing.furnishing_name == expected_furnishing_name assert furnishing.status == Furnishing.FurnishingStatus.PROCESSED - assert furnishing.grouping_identifier is not None + assert furnishing.furnishing_group_id is not None furnishing_addresses = Address.find_by(furnishings_id=furnishing.id) assert len(furnishing_addresses) == 1 @@ -204,7 +204,7 @@ async def test_process_second_notification(app, session, test_name, has_email_fu mail_furnishing = next((f for f in furnishings if f.furnishing_type == Furnishing.FurnishingType.MAIL), None) assert mail_furnishing assert mail_furnishing.status == Furnishing.FurnishingStatus.PROCESSED - assert mail_furnishing.grouping_identifier is not None + assert mail_furnishing.furnishing_group_id is not None furnishing_addresses = Address.find_by(furnishings_id=mail_furnishing.id) assert len(furnishing_addresses) == 1 From bec061bc2b158255a42b56b501f0918c5c34f229 Mon Sep 17 00:00:00 2001 From: Hongjing <60866283+chenhongjing@users.noreply.github.com> Date: Mon, 29 Jul 2024 12:26:51 -0700 Subject: [PATCH 21/60] 22368 Emailer - Add letter attachment to stage 1 AR email (#2865) * 22368 - emailer -add letter attachment to stage 1 AR email Signed-off-by: Hongjing Chen * update q_cli.py Signed-off-by: Hongjing Chen * fix related tests Signed-off-by: Hongjing Chen --------- Signed-off-by: Hongjing Chen --- queue_services/entity-emailer/q_cli.py | 28 +++++++++-- .../email_processors/__init__.py | 12 +++-- ...untary_dissolution_stage_1_notification.py | 48 +++++++++++++++++-- ...untary_dissolution_stage_1_notification.py | 30 ++++++++---- .../entity-emailer/tests/unit/test_worker.py | 4 ++ 5 files changed, 102 insertions(+), 20 deletions(-) diff --git a/queue_services/entity-emailer/q_cli.py b/queue_services/entity-emailer/q_cli.py index 356e347624..74bf30802d 100755 --- a/queue_services/entity-emailer/q_cli.py +++ b/queue_services/entity-emailer/q_cli.py @@ -38,6 +38,7 @@ affiliation_type: Final = 'bc.registry.affiliation' +dissolution_type: Final = 'bc.registry.dissolution' async def run(loop, email_info): # pylint: disable=too-many-locals @@ -88,7 +89,7 @@ def subscription_options(): functools.partial(signal_handler, sig_loop=loop, sig_nc=nc, task=close) ) - if email_info['type'] == affiliation_type: + if email_info['type'] in [affiliation_type, dissolution_type]: payload = email_info else: payload = {'email': email_info} @@ -104,11 +105,11 @@ def subscription_options(): if __name__ == '__main__': try: - opts, args = getopt.getopt(sys.argv[1:], 'f:t:o:i:', ['fid=', 'etype=', 'option=', 'identifier=']) + opts, args = getopt.getopt(sys.argv[1:], 'f:t:o:i:n:', ['fid=', 'etype=', 'option=', 'identifier=', 'name=']) except getopt.GetoptError: - print('q_cli.py -f -t -o