Skip to content

Commit bde3d71

Browse files
authored
13248 - Auth ORG changes (#2102)
* Auth ORG changes - Revert service level blocking - Implement model level blocking (thanks Kial) - Allow Staff and SBC Staff to add products * Use roles, add in payment methods for SBC STAFF / STAFF. * Fix statements and transactions. * Remove submodule entry * Lint error * More lint errors, need to fix IDE. * Use PREMIUM_ORG_TYPES to default to BCOL * Another fix, need to use Thor's enums. * Parametize unit tests
1 parent eb5cf0a commit bde3d71

File tree

9 files changed

+106
-30
lines changed

9 files changed

+106
-30
lines changed

auth-api/src/auth_api/models/org.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
Basic users will have an internal Org that is not created explicitly, but implicitly upon User account creation.
1717
"""
1818
from flask import current_app
19-
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, and_, cast, func
19+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, and_, cast, event, func
2020
from sqlalchemy.orm import contains_eager, relationship
21+
22+
from auth_api.exceptions import BusinessException
23+
from auth_api.exceptions.errors import Error
2124
from auth_api.models.affiliation import Affiliation
2225
from auth_api.models.dataclass import OrgSearch
2326
from auth_api.utils.enums import AccessType, InvitationStatus, InvitationType
2427
from auth_api.utils.enums import OrgStatus as OrgStatusEnum
28+
from auth_api.utils.enums import OrgType as OrgTypeEnum
2529
from auth_api.utils.roles import EXCLUDED_FIELDS, VALID_STATUSES
2630

2731
from .base_model import VersionedModel
@@ -240,3 +244,25 @@ def reset(self):
240244
self.save()
241245
else:
242246
super().reset()
247+
248+
249+
@event.listens_for(Org, 'before_insert')
250+
def receive_before_insert(mapper, connection, target): # pylint: disable=unused-argument; SQLAlchemy callback signature
251+
"""Rejects invalid type_codes on insert."""
252+
org = target
253+
if org.type_code in (OrgTypeEnum.SBC_STAFF.value, OrgTypeEnum.STAFF.value):
254+
raise BusinessException(
255+
Error.INVALID_INPUT,
256+
None
257+
)
258+
259+
260+
@event.listens_for(Org, 'before_update', raw=True)
261+
def receive_before_update(mapper, connection, state): # pylint: disable=unused-argument; SQLAlchemy callback signature
262+
"""Rejects invalid type_codes on update."""
263+
if Org.type_code.key in state.unmodified:
264+
return
265+
raise BusinessException(
266+
Error.INVALID_INPUT,
267+
None
268+
)

auth-api/src/auth_api/services/org.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,7 @@ def create_org(org_info: dict, user_id):
105105
if access_type == AccessType.GOVM.value:
106106
org_info.update({'typeCode': OrgType.PREMIUM.value})
107107

108-
org_snake = camelback2snake(org_info)
109-
if org_snake.get('type_code') in (OrgType.STAFF.value, OrgType.SBC_STAFF.value):
110-
raise BusinessException(Error.INVALID_INPUT, None)
111-
org = OrgModel.create_from_dict(org_snake)
108+
org = OrgModel.create_from_dict(camelback2snake(org_info))
112109
org.access_type = access_type
113110

114111
# Set the status based on access type

auth-api/src/auth_api/services/products.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@
3030
from auth_api.schemas import ProductCodeSchema
3131
from auth_api.utils.constants import BCOL_PROFILE_PRODUCT_MAP
3232
from auth_api.utils.enums import (
33-
AccessType, ActivityAction, OrgType, ProductSubscriptionStatus, TaskAction, TaskRelationshipStatus,
34-
TaskRelationshipType, TaskStatus)
33+
AccessType, ActivityAction, ProductSubscriptionStatus, TaskAction, TaskRelationshipStatus, TaskRelationshipType,
34+
TaskStatus)
3535
from auth_api.utils.user_context import UserContext, user_context
3636

3737
from ..utils.account_mailer import publish_to_mailer
3838
from ..utils.cache import cache
39-
from ..utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, STAFF
39+
from ..utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, PREMIUM_ORG_TYPES, STAFF
4040
from .activity_log_publisher import ActivityLogPublisher
4141
from .authorization import check_auth
4242
from .task import Task as TaskService
@@ -91,7 +91,7 @@ def create_product_subscription(org_id, subscription_data: Dict[str, Any], # py
9191
product_model: ProductCodeModel = ProductCodeModel.find_by_code(product_code)
9292
if product_model:
9393
# Check if product needs premium account, if yes skip and continue.
94-
if product_model.premium_only and org.type_code != OrgType.PREMIUM.value:
94+
if product_model.premium_only and org.type_code not in PREMIUM_ORG_TYPES:
9595
continue
9696

9797
subscription_status = Product.find_subscription_status(org, product_model)

auth-api/src/auth_api/services/validators/payment_type.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@ def validate(is_fatal=False, **kwargs) -> ValidatorResponse:
2929
default_cc_method = PaymentMethod.DIRECT_PAY.value if current_app.config.get(
3030
'DIRECT_PAY_ENABLED') else PaymentMethod.CREDIT_CARD.value
3131
validator_response = ValidatorResponse()
32+
non_ejv_payment_methods = (
33+
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value,
34+
PaymentMethod.PAD.value, PaymentMethod.BCOL.value)
3235
org_payment_method_mapping = {
3336
OrgType.BASIC: (
3437
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value, PaymentMethod.ONLINE_BANKING.value),
35-
OrgType.PREMIUM: (
36-
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value,
37-
PaymentMethod.PAD.value, PaymentMethod.BCOL.value)
38+
OrgType.PREMIUM: non_ejv_payment_methods,
39+
OrgType.SBC_STAFF: non_ejv_payment_methods,
40+
OrgType.STAFF: non_ejv_payment_methods,
3841
}
3942
if access_type == AccessType.GOVM.value:
4043
payment_type = PaymentMethod.EJV.value
@@ -48,7 +51,8 @@ def validate(is_fatal=False, **kwargs) -> ValidatorResponse:
4851
if is_fatal:
4952
raise BusinessException(Error.INVALID_INPUT, None)
5053
else:
54+
premium_org_types = (OrgType.PREMIUM, OrgType.SBC_STAFF, OrgType.STAFF)
5155
payment_type = PaymentMethod.BCOL.value if \
52-
org_type == OrgType.PREMIUM else default_cc_method
56+
org_type in premium_org_types else default_cc_method
5357
validator_response.add_info({'payment_type': payment_type})
5458
return validator_response

auth-api/src/auth_api/utils/roles.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""Role definitions."""
1515
from enum import Enum
1616

17-
from .enums import OrgStatus, ProductSubscriptionStatus, Status
17+
from .enums import OrgStatus, OrgType, ProductSubscriptionStatus, Status
1818

1919

2020
class Role(Enum):
@@ -57,3 +57,5 @@ class Role(Enum):
5757
CLIENT_AUTH_ROLES = (*CLIENT_ADMIN_ROLES, USER)
5858
ALL_ALLOWED_ROLES = (*CLIENT_AUTH_ROLES, STAFF)
5959
EXCLUDED_FIELDS = ('status_code', 'type_code')
60+
61+
PREMIUM_ORG_TYPES = (OrgType.PREMIUM.value, OrgType.SBC_STAFF.value, OrgType.STAFF.value)

auth-api/tests/unit/services/test_org.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919

2020
import pytest
2121
from requests import Response
22+
from sqlalchemy import event
2223

2324
from auth_api.models.dataclass import Activity
2425
from auth_api.exceptions import BusinessException
2526
from auth_api.exceptions.errors import Error
2627
from auth_api.models import ContactLink as ContactLinkModel
2728
from auth_api.models import Org as OrgModel
29+
from auth_api.models.org import receive_before_insert, receive_before_update
2830
from auth_api.models import Task as TaskModel
2931
from auth_api.services import ActivityLogPublisher
3032
from auth_api.services import Affidavit as AffidavitService
@@ -49,7 +51,7 @@
4951
factory_contact_model, factory_entity_model, factory_entity_service, factory_invitation, factory_membership_model,
5052
factory_org_model, factory_org_service, factory_user_model, factory_user_model_with_contact,
5153
patch_pay_account_delete, patch_pay_account_post, patch_pay_account_put, patch_token_info)
52-
54+
from tests.utilities.sqlalchemy import clear_event_listeners
5355

5456
# noqa: I005
5557

@@ -403,6 +405,33 @@ def test_create_product_multiple_subscription(session, keycloak_mock, monkeypatc
403405
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][1]['productCode'])
404406

405407

408+
@pytest.mark.parametrize(
409+
'org_type', [(OrgType.STAFF.value), (OrgType.SBC_STAFF.value)]
410+
)
411+
def test_create_product_subscription_staff(session, keycloak_mock, org_type, monkeypatch):
412+
"""Assert that updating product subscription works for staff."""
413+
user = factory_user_model(TestUserInfo.user_test)
414+
patch_token_info({'sub': user.keycloak_guid}, monkeypatch)
415+
org = OrgService.create_org(TestOrgInfo.org1, user_id=user.id)
416+
417+
# Clearing the event listeners here, because we can't change the type_code.
418+
clear_event_listeners(OrgModel)
419+
org_db = OrgModel.find_by_id(org._model.id)
420+
org_db.type_code = org_type
421+
org_db.save()
422+
event.listen(OrgModel, 'before_update', receive_before_update, raw=True)
423+
event.listen(OrgModel, 'before_insert', receive_before_insert)
424+
425+
subscriptions = ProductService.create_product_subscription(org._model.id,
426+
TestOrgProductsInfo.org_products2,
427+
skip_auth=True)
428+
429+
assert next(prod for prod in subscriptions
430+
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][0]['productCode'])
431+
assert next(prod for prod in subscriptions
432+
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][1]['productCode'])
433+
434+
406435
def test_create_org_with_duplicate_name(session, monkeypatch): # pylint:disable=unused-argument
407436
"""Assert that an Org with duplicate name cannot be created."""
408437
user = factory_user_model()
@@ -639,8 +668,11 @@ def test_get_owner_count_one_owner(session, keycloak_mock, monkeypatch): # pyli
639668
assert org.get_owner_count() == 1
640669

641670

642-
def test_create_staff_org_failure(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
643-
"""Assert that count of owners is correct."""
671+
@pytest.mark.parametrize(
672+
'staff_org', [(TestOrgInfo.staff_org), (TestOrgInfo.sbc_staff_org)]
673+
)
674+
def test_create_staff_org_failure(session, keycloak_mock, staff_org, monkeypatch): # pylint:disable=unused-argument
675+
"""Assert that staff org cannot be created."""
644676
user_with_token = TestUserInfo.user_test
645677
user_with_token['keycloak_guid'] = TestJwtClaims.public_user_role['sub']
646678
user = factory_user_model(user_info=user_with_token)
@@ -650,17 +682,6 @@ def test_create_staff_org_failure(session, keycloak_mock, monkeypatch): # pylin
650682
assert exception.value.code == Error.INVALID_INPUT.name
651683

652684

653-
def test_create_sbc_staff_org_failure(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
654-
"""Assert wrong org cannot be created."""
655-
user_with_token = TestUserInfo.user_test
656-
user_with_token['keycloak_guid'] = TestJwtClaims.public_user_role['sub']
657-
user = factory_user_model(user_info=user_with_token)
658-
patch_token_info({'sub': user.keycloak_guid}, monkeypatch)
659-
with pytest.raises(BusinessException) as exception:
660-
OrgService.create_org(TestOrgInfo.sbc_staff_org, user.id)
661-
assert exception.value.code == Error.INVALID_INPUT.name
662-
663-
664685
def test_get_owner_count_two_owner_with_admins(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
665686
"""Assert wrong org cannot be created."""
666687
user_with_token = TestUserInfo.user_test
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright © 2022 Province of British Columbia
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Utility to remove event listeners for models."""
15+
import ctypes
16+
from sqlalchemy import event
17+
18+
19+
def clear_event_listeners(model):
20+
"""Remove event listeners for a model."""
21+
keys = [k for k in event.registry._key_to_collection if k[0] == id(model)]
22+
for key in keys:
23+
target = model
24+
identifier = key[1]
25+
fn = ctypes.cast(key[2], ctypes.py_object).value # get function by id
26+
event.remove(target, identifier, fn)

auth-web/src/components/auth/account-settings/statement/Statements.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export default class Statements extends Mixins(AccountChangeMixin) {
204204
}
205205
206206
private get isStatementsAllowed (): boolean {
207-
return (this.currentOrganization?.orgType === Account.PREMIUM) &&
207+
return [Account.PREMIUM, Account.STAFF, Account.SBC_STAFF].includes(this.currentOrganization?.orgType as Account) &&
208208
[MembershipType.Admin, MembershipType.Coordinator].includes(this.currentMembership.membershipTypeCode)
209209
}
210210

auth-web/src/components/auth/account-settings/transaction/Transactions.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export default class Transactions extends Mixins(AccountChangeMixin) {
167167
}
168168
169169
private get isTransactionsAllowed (): boolean {
170-
return (this.currentOrganization?.orgType === Account.PREMIUM) &&
170+
return [Account.PREMIUM, Account.STAFF, Account.SBC_STAFF].includes(this.currentOrganization?.orgType as Account) &&
171171
[MembershipType.Admin, MembershipType.Coordinator].includes(this.currentMembership.membershipTypeCode)
172172
}
173173
}

0 commit comments

Comments
 (0)