diff --git a/auth-api/migrations/versions/2024_05_15_b3a741249edc_duplicate_queue_messages.py b/auth-api/migrations/versions/2024_05_15_b3a741249edc_duplicate_queue_messages.py new file mode 100644 index 0000000000..2cb3a8a858 --- /dev/null +++ b/auth-api/migrations/versions/2024_05_15_b3a741249edc_duplicate_queue_messages.py @@ -0,0 +1,33 @@ +"""Add in new table for account mailer for pubsub message processing. + +Revision ID: b3a741249edc +Revises: e2d1d6417607 +Create Date: 2024-05-15 14:52:45.780399 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b3a741249edc' +down_revision = 'e2d1d6417607' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('pubsub_message_processing', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('cloud_event_id', sa.String(length=250), nullable=False), + sa.Column('created', sa.DateTime(), nullable=True), + sa.Column('message_type', sa.String(length=250), nullable=False), + sa.Column('processed', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_pubsub_message_processing_id'), 'pubsub_message_processing', ['id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_pubsub_message_processing_id'), table_name='pubsub_message_processing') + op.drop_table('pubsub_message_processing') diff --git a/auth-api/requirements.txt b/auth-api/requirements.txt index 8a79a0d5b0..d699de029a 100644 --- a/auth-api/requirements.txt +++ b/auth-api/requirements.txt @@ -73,7 +73,7 @@ python-jose==3.3.0 python-memcached==1.62 pytz==2024.1 redis==5.0.4 -requests==2.31.0 +requests==2.32.0 rsa==4.9 semver==3.0.2 sentry-sdk==2.0.1 diff --git a/auth-api/src/auth_api/models/__init__.py b/auth-api/src/auth_api/models/__init__.py index fdb30738e2..7710f712dc 100644 --- a/auth-api/src/auth_api/models/__init__.py +++ b/auth-api/src/auth_api/models/__init__.py @@ -50,6 +50,7 @@ from .product_subscription import ProductSubscription from .product_subscriptions_status import ProductSubscriptionsStatus from .product_type_code import ProductTypeCode +from .pubsub_message_processing import PubSubMessageProcessing from .suspension_reason_code import SuspensionReasonCode from .task import Task from .user import User diff --git a/auth-api/src/auth_api/models/membership.py b/auth-api/src/auth_api/models/membership.py index a4bacd7406..ce049b0aaa 100644 --- a/auth-api/src/auth_api/models/membership.py +++ b/auth-api/src/auth_api/models/membership.py @@ -51,7 +51,7 @@ class Membership(VersionedModel): # pylint: disable=too-few-public-methods # Te user = relationship('User', foreign_keys=[user_id], lazy='select') org = relationship('Org', foreign_keys=[org_id], lazy='select') - def __init__(self, **kwargs): + def __init__(self, **kwargs): # pylint: disable=super-init-not-called """Initialize a new membership.""" self.org_id = kwargs.get('org_id') self.user_id = kwargs.get('user_id') diff --git a/auth-api/src/auth_api/models/pubsub_message_processing.py b/auth-api/src/auth_api/models/pubsub_message_processing.py new file mode 100644 index 0000000000..2e3dc2234f --- /dev/null +++ b/auth-api/src/auth_api/models/pubsub_message_processing.py @@ -0,0 +1,32 @@ +"""This model manages pubsub message processing. + +NOTE: Only use this when it's not possible to use other indicators to track message processing. + Currently used by the account-mailer / auth-queue. This prevents duplicates. +""" +import datetime as dt +import pytz + +from sqlalchemy import Column, DateTime, Integer, String +from .db import db + + +class PubSubMessageProcessing(db.Model): + """PubSub Message Processing for cloud event messages.""" + + __tablename__ = 'pubsub_message_processing' + + id = Column(Integer, index=True, primary_key=True) + cloud_event_id = Column(String(250), nullable=False) + created = Column(DateTime, default=dt.datetime.now(pytz.utc)) + message_type = Column(String(250), nullable=False) + processed = Column(DateTime, nullable=True) + + @classmethod + def find_by_id(cls, identifier): + """Find a pubsub message processing by id.""" + return cls.query.filter_by(id=identifier).one_or_none() + + @classmethod + def find_by_cloud_event_id_and_type(cls, cloud_event_id, message_type): + """Find a pubsub message processing for cloud event id and type.""" + return cls.query.filter_by(cloud_event_id=cloud_event_id, message_type=message_type).one_or_none() diff --git a/auth-api/src/auth_api/services/authorization.py b/auth-api/src/auth_api/services/authorization.py index 991a25adb5..e7f39e51ea 100644 --- a/auth-api/src/auth_api/services/authorization.py +++ b/auth-api/src/auth_api/services/authorization.py @@ -226,6 +226,7 @@ def check_auth(**kwargs): if product_code_in_jwt == 'ALL': # Product code for super admin service account (sbc-auth-admin) return + auth = None if business_identifier: auth = Authorization.get_user_authorizations_for_entity(business_identifier) elif org_identifier: diff --git a/auth-api/src/auth_api/services/gcp_queue/gcp_queue.py b/auth-api/src/auth_api/services/gcp_queue/gcp_queue.py index df51d20c7d..7d27cf7c15 100644 --- a/auth-api/src/auth_api/services/gcp_queue/gcp_queue.py +++ b/auth-api/src/auth_api/services/gcp_queue/gcp_queue.py @@ -89,7 +89,7 @@ def publisher(self): """Returns the publisher.""" if not self._publisher and self.credentials_pub: self._publisher = pubsub_v1.PublisherClient(credentials=self.credentials_pub) - else: + if not self._publisher: self._publisher = pubsub_v1.PublisherClient() return self._publisher diff --git a/auth-api/src/auth_api/services/membership.py b/auth-api/src/auth_api/services/membership.py index bd76951b6e..7df0b1b737 100644 --- a/auth-api/src/auth_api/services/membership.py +++ b/auth-api/src/auth_api/services/membership.py @@ -168,7 +168,7 @@ def send_notification_to_member(self, origin_url, notification_type): notification_type_for_mailer = '' data = {} if notification_type == NotificationType.ROLE_CHANGED.value: - notification_type_for_mailer = 'roleChangedNotification' + notification_type_for_mailer = QueueMessageTypes.ROLE_CHANGED_NOTIFICATION.value data = { 'accountId': org_id, 'emailAddresses': recipient, @@ -181,9 +181,9 @@ def send_notification_to_member(self, origin_url, notification_type): # TODO how to check properly if user is bceid user is_bceid_user = self._model.user.username.find('@bceid') > 0 if is_bceid_user: - notification_type_for_mailer = 'membershipApprovedNotificationForBceid' + notification_type_for_mailer = QueueMessageTypes.MEMBERSHIP_APPROVED_NOTIFICATION_FOR_BCEID.value else: - notification_type_for_mailer = 'membershipApprovedNotification' + notification_type_for_mailer = QueueMessageTypes.MEMBERSHIP_APPROVED_NOTIFICATION.value data = { 'accountId': org_id, diff --git a/auth-api/src/auth_api/services/org.py b/auth-api/src/auth_api/services/org.py index f92b499a75..5d83fa20ad 100644 --- a/auth-api/src/auth_api/services/org.py +++ b/auth-api/src/auth_api/services/org.py @@ -878,9 +878,9 @@ def send_approved_rejected_notification(receipt_admin_emails, org_name, org_id, current_app.logger.debug(' ValidatorResponse: OrgType.SBC_STAFF: non_ejv_payment_methods, OrgType.STAFF: non_ejv_payment_methods, } + payment_type = None if access_type == AccessType.GOVM.value: payment_type = PaymentMethod.EJV.value elif selected_payment_method: diff --git a/auth-api/src/auth_api/utils/custom_query.py b/auth-api/src/auth_api/utils/custom_query.py index fc6ed4f938..e4356e2b71 100644 --- a/auth-api/src/auth_api/utils/custom_query.py +++ b/auth-api/src/auth_api/utils/custom_query.py @@ -18,7 +18,7 @@ from sqlalchemy import String, func -class CustomQuery(BaseQuery): +class CustomQuery(BaseQuery): # pylint: disable=too-few-public-methods """Custom Query class to extend the base query class for helper functionality.""" def filter_conditionally(self, search_criteria, model_attribute, is_like: bool = False): diff --git a/auth-api/tests/unit/services/test_authorization.py b/auth-api/tests/unit/services/test_authorization.py index 4952359fc2..5d1e6f57fe 100644 --- a/auth-api/tests/unit/services/test_authorization.py +++ b/auth-api/tests/unit/services/test_authorization.py @@ -284,8 +284,8 @@ def test_check_auth_staff_path(session, monkeypatch, test_desc, test_expect, add 'test_desc,test_expect,additional_kwargs,is_org_member,is_entity_affiliated,product_code_in_jwt', [ ( - 'Test UnboundLocalError when no role checks provided in kwargs, and no org_id or business_identifier.', - pytest.raises(UnboundLocalError), {}, False, False, ProductCode.BUSINESS.value + 'Test 403 when no role checks provided in kwargs, and no org_id or business_identifier.', + pytest.raises(Forbidden), {}, False, False, ProductCode.BUSINESS.value ), ( 'Test OK when no role checks provided in kwargs, but has ALL product in jwt. (bypass all checks).', @@ -359,7 +359,7 @@ def test_check_auth_system_path(session, monkeypatch, test_desc, test_expect, ad 'test_desc,test_expect,additional_kwargs,is_org_member,is_entity_affiliated', [ ( - 'Test UnboundLocalError when no role checks provided in kwargs.', + 'Test UnboundLocalError (403) when no role checks provided in kwargs.', pytest.raises(UnboundLocalError), {}, False, False ), ( diff --git a/auth-web/src/components/auth/staff/DissolutionSchedule.vue b/auth-web/src/components/auth/staff/DissolutionSchedule.vue index 8fb5823cd6..38a7a0f949 100644 --- a/auth-web/src/components/auth/staff/DissolutionSchedule.vue +++ b/auth-web/src/components/auth/staff/DissolutionSchedule.vue @@ -121,8 +121,8 @@ export default defineComponent({ setup () { const state = reactive({ menu: false, - numberOfBusinessesEdit: 0, - numberOfBusinessesNonEdit: 0, + numberOfBusinessesEdit: -1, + numberOfBusinessesNonEdit: -1, numberOfBusinessesRef: null, isEdit: false, isSaving: false @@ -135,7 +135,7 @@ export default defineComponent({ await staffStore.getDissolutionConfigurations() // Get the batch size current value (number of businesses to be dissolved per job run) - const numDissolutions = staffStore.involuntaryDissolutionConfigurations.configurations.find( + const numDissolutions = staffStore.involuntaryDissolutionConfigurations?.configurations?.find( config => config.name === 'NUM_DISSOLUTIONS_ALLOWED').value state.numberOfBusinessesNonEdit = parseInt(numDissolutions) }) diff --git a/auth-web/src/models/Staff.ts b/auth-web/src/models/Staff.ts index 4404c7818e..864718a102 100644 --- a/auth-web/src/models/Staff.ts +++ b/auth-web/src/models/Staff.ts @@ -71,3 +71,11 @@ export interface InvoluntaryDissolutionConfigurationIF { export interface Configurations { configurations: InvoluntaryDissolutionConfigurationIF[] } + +export interface DissolutionStatistics { + data: { eligibleCount: number } +} + +export interface SafeListEmailsRequestBody { + email: string[] +} diff --git a/auth-web/src/services/staff.services.ts b/auth-web/src/services/staff.services.ts index e1fbe421c8..e922a9d5cc 100644 --- a/auth-web/src/services/staff.services.ts +++ b/auth-web/src/services/staff.services.ts @@ -1,4 +1,12 @@ -import { AccountType, Configurations, ProductCode, Products, ProductsRequestBody } from '@/models/Staff' +import { + AccountType, + Configurations, + DissolutionStatistics, + ProductCode, + Products, + ProductsRequestBody, + SafeListEmailsRequestBody +} from '@/models/Staff' import { OrgFilterParams, OrgList, Organizations } from '@/models/Organization' import { AxiosResponse } from 'axios' import ConfigHelper from '@/util/config-helper' @@ -53,7 +61,19 @@ export default class StaffService { return axios.get(`${ConfigHelper.getLegalAPIV2Url()}/admin/configurations`) } + static async getDissolutionStatistics (): Promise> { + return axios.get(`${ConfigHelper.getLegalAPIV2Url()}/admin/dissolutions/statistics`) + } + static async updateInvoluntaryDissolutionConfigurations (configurations: Configurations): Promise> { return axios.put(`${ConfigHelper.getLegalAPIV2Url()}/admin/configurations`, configurations) } + + static async deleteSafeEmail (email: string): Promise> { + return axios.delete(`${ConfigHelper.getNotifiyAPIUrl()}/safe_list/${email}`) + } + + static async addSafeEmail (safeListEmailsRequestBody: SafeListEmailsRequestBody): Promise> { + return axios.post(`${ConfigHelper.getNotifiyAPIUrl()}/safe_list`, safeListEmailsRequestBody) + } } diff --git a/auth-web/src/stores/staff.ts b/auth-web/src/stores/staff.ts index d7666dd8a9..94e1d38c63 100644 --- a/auth-web/src/stores/staff.ts +++ b/auth-web/src/stores/staff.ts @@ -1,5 +1,5 @@ import { AccountStatus, AffidavitStatus, TaskAction, TaskRelationshipStatus, TaskRelationshipType, TaskType } from '@/util/constants' -import { AccountType, Configurations, GLCode, ProductCode } from '@/models/Staff' +import { AccountType, Configurations, DissolutionStatistics, GLCode, ProductCode } from '@/models/Staff' import { MembershipType, OrgFilterParams, Organization } from '@/models/Organization' import { SyncAccountPayload, Task } from '@/models/Task' import { computed, reactive, toRefs } from '@vue/composition-api' @@ -25,6 +25,7 @@ export const useStaffStore = defineStore('staff', () => { accountUnderReviewAdminContact: {} as Contact, accountUnderReviewAffidavitInfo: {} as AffidavitInformation, activeStaffOrgs: [] as Organization[], + dissolutionStatistics: {} as DissolutionStatistics, involuntaryDissolutionConfigurations: {} as Configurations, pendingInvitationOrgs: [] as Organization[], pendingStaffOrgs: [] as Organization[], @@ -42,6 +43,7 @@ export const useStaffStore = defineStore('staff', () => { state.accountUnderReviewAdminContact = {} as Contact state.accountUnderReviewAffidavitInfo = {} as AffidavitInformation state.activeStaffOrgs = [] as Organization[] + state.dissolutionStatistics = {} as DissolutionStatistics state.involuntaryDissolutionConfigurations = {} as Configurations state.pendingInvitationOrgs = [] as Organization[] state.pendingStaffOrgs = [] as Organization[] @@ -81,12 +83,15 @@ export const useStaffStore = defineStore('staff', () => { } /** Get the involuntary dissolution configurations array from Legal API. */ - async function getDissolutionConfigurations (): Promise { + async function getDissolutionConfigurations (): Promise { const response = await StaffService.getInvoluntaryDissolutionConfigurations() - if (response?.data && response.status === 200) { - state.involuntaryDissolutionConfigurations = response.data - return response.data - } + if (response?.data && response.status === 200) state.involuntaryDissolutionConfigurations = response.data + } + + /** Get the businesses dissolution statistics (businesses ready for D1 dissolution). */ + async function getDissolutionStatistics (): Promise { + const response = await StaffService.getDissolutionStatistics() + if (response?.data && response.status === 200) state.dissolutionStatistics = response.data } async function getAccountTypes (): Promise { @@ -307,6 +312,7 @@ export const useStaffStore = defineStore('staff', () => { deleteOrg, getAccountTypes, getDissolutionConfigurations, + getDissolutionStatistics, getGLCode, getGLCodeList, getGLCodeFiling, diff --git a/auth-web/src/util/constants.ts b/auth-web/src/util/constants.ts index 233235f8ac..6d8054667e 100644 --- a/auth-web/src/util/constants.ts +++ b/auth-web/src/util/constants.ts @@ -660,9 +660,12 @@ export enum CfsAccountStatus { export enum InvoluntaryDissolutionConfigNames { DISSOLUTIONS_ON_HOLD = 'DISSOLUTIONS_ON_HOLD', + DISSOLUTIONS_STAGE_1_SCHEDULE='DISSOLUTIONS_STAGE_1_SCHEDULE', + DISSOLUTIONS_STAGE_2_SCHEDULE='DISSOLUTIONS_STAGE_2_SCHEDULE', + DISSOLUTIONS_STAGE_3_SCHEDULE='DISSOLUTIONS_STAGE_3_SCHEDULE', + DISSOLUTIONS_SUMMARY_EMAIL = 'DISSOLUTIONS_SUMMARY_EMAIL', MAX_DISSOLUTIONS_ALLOWED = 'MAX_DISSOLUTIONS_ALLOWED', - NUM_DISSOLUTIONS_ALLOWED = 'NUM_DISSOLUTIONS_ALLOWED', - NEW_DISSOLUTIONS_SCHEDULE = 'NEW_DISSOLUTIONS_SCHEDULE' + NUM_DISSOLUTIONS_ALLOWED = 'NUM_DISSOLUTIONS_ALLOWED' } export enum AccountType { diff --git a/auth-web/src/views/auth/staff/InvoluntaryDissolution.vue b/auth-web/src/views/auth/staff/InvoluntaryDissolution.vue index e9ee7f59ae..d13c0d0cfb 100644 --- a/auth-web/src/views/auth/staff/InvoluntaryDissolution.vue +++ b/auth-web/src/views/auth/staff/InvoluntaryDissolution.vue @@ -3,6 +3,20 @@ id="involuntary-dissolution" class="view-container" > + + +
+ +
+

Staff Involuntary Dissolution Batch @@ -55,9 +69,10 @@