Skip to content

Commit 545fd9b

Browse files
26420 - Affiliation Pagination via Entity Mapping (bcgov#3399) (bcgov#3418)
Co-authored-by: Rajandeep Kaur <144159721+Rajandeep98@users.noreply.github.com>
1 parent 2e969a4 commit 545fd9b

29 files changed

+715
-99
lines changed

auth-api/flags.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"string-flag": "a string value",
44
"bool-flag": true,
55
"integer-flag": 10,
6-
"enable-publish-account-events": true
6+
"enable-publish-account-events": true,
7+
"enable-entity-mapping": true
78
}
89
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Add in entity mapping table.
2+
3+
Revision ID: 6f2c09061fd3
4+
Revises: bddf9fe7468c
5+
Create Date: 2025-05-22 17:21:47.908470
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '6f2c09061fd3'
14+
down_revision = 'bddf9fe7468c'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
op.add_column('entities', sa.Column('is_loaded_lear', sa.Boolean(), nullable=False, server_default=sa.true()))
21+
22+
op.create_table('entity_mapping',
23+
sa.Column('id', sa.Integer(), nullable=False),
24+
sa.Column('business_identifier', sa.String(length=75), nullable=True),
25+
sa.Column('bootstrap_identifier', sa.String(length=75), nullable=True),
26+
sa.Column('nr_identifier', sa.String(length=75), nullable=True),
27+
sa.PrimaryKeyConstraint('id')
28+
)
29+
with op.batch_alter_table('entity_mapping', schema=None) as batch_op:
30+
batch_op.create_index(batch_op.f('ix_entity_mapping_bootstrap_identifier'), ['bootstrap_identifier'], unique=False)
31+
batch_op.create_index(batch_op.f('ix_entity_mapping_business_identifier'), ['business_identifier'], unique=False)
32+
batch_op.create_index(batch_op.f('ix_entity_mapping_nr_identifier'), ['nr_identifier'], unique=False)
33+
34+
35+
def downgrade():
36+
with op.batch_alter_table('entity_mapping', schema=None) as batch_op:
37+
batch_op.drop_index(batch_op.f('ix_entity_mapping_nr_identifier'))
38+
batch_op.drop_index(batch_op.f('ix_entity_mapping_business_identifier'))
39+
batch_op.drop_index(batch_op.f('ix_entity_mapping_bootstrap_identifier'))
40+
41+
op.drop_table('entity_mapping')
42+
op.drop_column('entities', 'is_loaded_lear')

auth-api/src/auth_api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def create_app(run_mode=os.getenv("DEPLOYMENT_ENV", "production")):
5353
with app.app_context():
5454
execute_migrations(app)
5555
logger.info("Finished migration upgrade.")
56+
logger.info("Note: endpoints will 404 until the DEPLOYMENT_ENV is switched off of migration.")
5657
else:
5758
flags.init_app(app)
5859
ma.init_app(app)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .db import db, ma
3434
from .documents import Documents
3535
from .entity import Entity
36+
from .entity_mapping import EntityMapping
3637
from .invitation import Invitation
3738
from .invitation_membership import InvitationMembership
3839
from .invitation_type import InvitationType

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""This module holds data classes."""
1515

1616
from dataclasses import dataclass, field
17+
from datetime import datetime
1718
from typing import List, Optional, Self
1819

1920
from requests import Request
@@ -53,12 +54,12 @@ class AffiliationInvitationSearch: # pylint: disable=too-many-instance-attribut
5354
class AffiliationSearchDetails: # pylint: disable=too-many-instance-attributes
5455
"""Used for filtering Affiliations based on filters passed."""
5556

56-
identifier: Optional[str]
57-
status: Optional[str]
58-
name: Optional[str]
59-
type: Optional[str]
6057
page: int
6158
limit: int
59+
identifier: Optional[str] = None
60+
status: Optional[str] = None
61+
name: Optional[str] = None
62+
type: Optional[str] = None
6263

6364
@classmethod
6465
def from_request_args(cls, req: Request) -> Self:
@@ -201,3 +202,11 @@ class ProductReviewTask:
201202
product_subscription_id: int
202203
user_id: str
203204
external_source_id: Optional[str] = None
205+
206+
207+
@dataclass
208+
class AffiliationBase:
209+
"""Small class for searching in Names and LEAR."""
210+
211+
identifier: str
212+
created: datetime

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Entity(BaseModel): # pylint: disable=too-few-public-methods, too-many-ins
4343
status = Column(String(), nullable=True)
4444
last_modified_by = Column(String(), nullable=True)
4545
last_modified = Column(DateTime, default=None, nullable=True)
46+
is_loaded_lear = Column(Boolean(), default=True, nullable=False)
4647

4748
contacts = relationship("ContactLink", back_populates="entity")
4849
corp_type = relationship("CorpType", foreign_keys=[corp_type_code], lazy="joined", innerjoin=True)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Entity Mapping model which enables us to be able to do pagination on affiliations."""
2+
3+
from sqlalchemy import Column, Integer, String
4+
5+
from .db import db
6+
7+
8+
class EntityMapping(db.Model): # pylint: disable=too-few-public-methods, too-many-instance-attributes
9+
"""This is the Entity model for the Auth service."""
10+
11+
__tablename__ = "entity_mapping"
12+
13+
id = Column(Integer, primary_key=True)
14+
business_identifier = Column("business_identifier", String(75), index=True, unique=False, nullable=True)
15+
bootstrap_identifier = Column("bootstrap_identifier", String(75), index=True, unique=False, nullable=True)
16+
nr_identifier = Column("nr_identifier", String(75), index=True, unique=False, nullable=True)
17+
18+
def flush(self):
19+
"""Save and flush."""
20+
db.session.add(self)
21+
db.session.flush()
22+
return self
23+
24+
def save(self):
25+
"""Save and commit."""
26+
db.session.add(self)
27+
db.session.flush()
28+
db.session.commit()
29+
return self

auth-api/src/auth_api/resources/v1/org.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
from auth_api.services import SimpleOrg as SimpleOrgService
3838
from auth_api.services import User as UserService
3939
from auth_api.services.authorization import Authorization as AuthorizationService
40+
from auth_api.services.entity_mapping import EntityMappingService
41+
from auth_api.services.flags import flags
4042
from auth_api.utils.auth import jwt as _jwt
4143
from auth_api.utils.endpoints_enums import EndpointEnum
4244
from auth_api.utils.enums import AccessType, NotificationType, OrgStatus, OrgType, PatchActions, Status
@@ -351,7 +353,7 @@ def delete_organzization_contact(org_id):
351353
def get_organization_affiliations_search(org_id):
352354
"""Get all affiliated entities for the given org, this works with pagination."""
353355
try:
354-
response, status = new_affiliation_search(org_id)
356+
response, status = affiliation_search(org_id, use_entity_mapping=True)
355357
except BusinessException as exception:
356358
response, status = {"code": exception.code, "message": exception.message}, exception.status_code
357359
except ServiceUnavailableException as exception:
@@ -371,7 +373,7 @@ def get_organization_affiliations(org_id):
371373
HTTPStatus.OK,
372374
)
373375
# Remove below after UI is pointing at new route.
374-
response, status = new_affiliation_search(org_id)
376+
response, status = affiliation_search(org_id)
375377
except BusinessException as exception:
376378
response, status = {"code": exception.code, "message": exception.message}, exception.status_code
377379
except ServiceUnavailableException as exception:
@@ -380,16 +382,19 @@ def get_organization_affiliations(org_id):
380382
return response, status
381383

382384

383-
def new_affiliation_search(org_id):
385+
def affiliation_search(org_id, use_entity_mapping=False):
384386
"""Get all affiliated entities for the given org by calling into Names and LEAR."""
385-
# get affiliation identifiers and the urls for the source data
386387
org = OrgService.find_by_org_id(org_id, allowed_roles=ALL_ALLOWED_ROLES)
387388
if org is None:
388389
raise BusinessException(Error.DATA_NOT_FOUND, None)
389-
affiliations = AffiliationModel.find_affiliations_by_org_id(org_id)
390390
search_details = AffiliationSearchDetails.from_request_args(request)
391+
if use_entity_mapping:
392+
affiliation_bases = EntityMappingService.populate_affiliation_base(org_id, search_details)
393+
else:
394+
affiliations = AffiliationModel.find_affiliations_by_org_id(org_id)
395+
affiliation_bases = AffiliationService.affiliation_to_affiliation_base(affiliations)
391396
affiliations_details_list = asyncio.run(
392-
AffiliationService.get_affiliation_details(affiliations, search_details, org_id)
397+
AffiliationService.get_affiliation_details(affiliation_bases, search_details, org_id)
393398
)
394399
# Use orjson serializer here, it's quite a bit faster.
395400
response, status = (
@@ -428,7 +433,6 @@ def post_organization_affiliation(org_id):
428433
phone=request_json.get("phone"),
429434
certified_by_name=request_json.get("certifiedByName"),
430435
)
431-
432436
response, status = (
433437
AffiliationService.create_new_business_affiliation(affiliation_data).as_dict(),
434438
HTTPStatus.CREATED,
@@ -444,10 +448,15 @@ def post_organization_affiliation(org_id):
444448
).as_dict(),
445449
HTTPStatus.CREATED,
446450
)
447-
448451
entity_details = request_json.get("entityDetails", None)
449452
if entity_details:
453+
if flags.is_on("enable-entity-mapping", default=False) is True:
454+
EntityMappingService.from_entity_details(entity_details)
450455
AffiliationService.fix_stale_affiliations(org_id, entity_details)
456+
# Auth-queue handles the row creation for new business (NR only), this handles passcode missing info
457+
elif is_new_business is False:
458+
if flags.is_on("enable-entity-mapping", default=False) is True:
459+
EntityMappingService.populate_entity_mapping_for_identifier(business_identifier)
451460
except BusinessException as exception:
452461
response, status = {"code": exception.code, "message": exception.message}, exception.status_code
453462

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

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from auth_api.models.contact_link import ContactLink
3232
from auth_api.models.dataclass import Activity
3333
from auth_api.models.dataclass import Affiliation as AffiliationData
34-
from auth_api.models.dataclass import AffiliationSearchDetails, DeleteAffiliationRequest
34+
from auth_api.models.dataclass import AffiliationBase, AffiliationSearchDetails, DeleteAffiliationRequest
3535
from auth_api.models.entity import Entity
3636
from auth_api.models.membership import Membership as MembershipModel
3737
from auth_api.schemas import AffiliationSchema
@@ -452,23 +452,34 @@ def fix_stale_affiliations(org_id: int, entity_details: Dict, **kwargs):
452452
logger.debug(">fix_stale_affiliations")
453453

454454
@staticmethod
455-
def _affiliation_details_url(affiliation: AffiliationModel) -> str:
455+
def _affiliation_details_url(identifier: str) -> str:
456456
"""Determine url to call for affiliation details."""
457457
# only have LEAR and NAMEX affiliations
458-
if affiliation.entity.corp_type_code == CorpType.NR.value:
458+
if identifier.startswith("NR"):
459459
return current_app.config.get("NAMEX_AFFILIATION_DETAILS_URL")
460460
return current_app.config.get("LEAR_AFFILIATION_DETAILS_URL")
461461

462+
@staticmethod
463+
def affiliation_to_affiliation_base(affiliations: List[AffiliationModel]) -> List[AffiliationBase]:
464+
"""Convert affiliations to a common data class."""
465+
return [
466+
AffiliationBase(identifier=affiliation.entity.business_identifier, created=affiliation.created)
467+
for affiliation in affiliations
468+
]
469+
462470
@staticmethod
463471
async def get_affiliation_details(
464-
affiliations: List[AffiliationModel], search_details: AffiliationSearchDetails, org_id
472+
affiliation_bases: List[AffiliationBase], search_details: AffiliationSearchDetails, org_id
465473
) -> List:
466474
"""Return affiliation details by calling the source api."""
467475
url_identifiers = {} # i.e. turns into { url: [identifiers...] }
476+
# Our pagination is already handled at the auth level when not doing a search.
477+
if not (search_details.status and search_details.name and search_details.type and search_details.identifier):
478+
search_details.page = 1
468479
search_dict = asdict(search_details)
469-
for affiliation in affiliations:
470-
url = Affiliation._affiliation_details_url(affiliation)
471-
url_identifiers.setdefault(url, []).append(affiliation.entity.business_identifier)
480+
for affiliation_base in affiliation_bases:
481+
url = Affiliation._affiliation_details_url(affiliation_base.identifier)
482+
url_identifiers.setdefault(url, []).append(affiliation_base.identifier)
472483
call_info = [
473484
{
474485
"url": url,
@@ -486,12 +497,8 @@ async def get_affiliation_details(
486497
try:
487498
responses = await RestService.call_posts_in_parallel(call_info, token, org_id)
488499
combined = Affiliation._combine_affiliation_details(responses)
489-
# Should provide us with ascending order
490-
affiliations_sorted = sorted(affiliations, key=lambda x: x.created, reverse=True)
491-
# Provide us with a dict with the max created date.
492-
ordered = {
493-
affiliation.entity.business_identifier: affiliation.created for affiliation in affiliations_sorted
494-
}
500+
affiliations_bases_sorted = sorted(affiliation_bases, key=lambda x: x.created, reverse=True)
501+
ordered = {affiliation.identifier: affiliation.created for affiliation in affiliations_bases_sorted}
495502

496503
def sort_key(item):
497504
identifier = item.get("identifier", item.get("nameRequest", {}).get("nrNum", ""))
@@ -502,7 +509,7 @@ def sort_key(item):
502509
return combined
503510
except ServiceUnavailableException as err:
504511
logger.debug(err)
505-
logger.debug("Failed to get affiliations details: %s", affiliations)
512+
logger.debug("Failed to get affiliations details: %s", affiliation_bases)
506513
raise ServiceUnavailableException("Failed to get affiliation details") from err
507514

508515
@staticmethod

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

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from auth_api.models.org import Org as OrgModel
3939
from auth_api.schemas import AffiliationInvitationSchema
4040
from auth_api.services.entity import Entity as EntityService
41+
from auth_api.services.entity_mapping import EntityMappingService
4142
from auth_api.services.flags import flags
4243
from auth_api.services.org import Org as OrgService
4344
from auth_api.services.user import User as UserService
@@ -503,32 +504,20 @@ def find_affiliation_invitation_by_id(invitation_id):
503504
@staticmethod
504505
def _get_token_confirm_path(org_name, token, query_params=None):
505506
"""Get the config for different email types."""
506-
if flags.is_on("enable-new-magic-link-formatting-with-query-params", default=False):
507-
# New query parameter based URL structure
508-
params = {
509-
"token": token,
510-
"orgName": escape_wam_friendly_url(org_name),
511-
}
512-
513-
# Add any additional query params
514-
if query_params:
515-
params.update(query_params)
516-
517-
# Point to new business registry project
518-
app_url = current_app.config.get("BUSINESS_REGISTRY_URL") + "/"
519-
# Build the URL with query parameters
520-
token_confirm_url = f"{app_url}/affiliationInvitation/acceptToken?{urlencode(params)}"
521-
else:
522-
# Point to auth-web
523-
app_url = current_app.config.get("WEB_APP_URL") + "/"
524-
# Original URL structure
525-
escape_url = escape_wam_friendly_url(org_name)
526-
path = f"{escape_url}/affiliationInvitation/acceptToken"
527-
token_confirm_url = f"{app_url}/{path}/{token}"
507+
# New query parameter based URL structure
508+
params = {
509+
"token": token,
510+
"orgName": escape_wam_friendly_url(org_name),
511+
}
528512

529-
if query_params:
530-
token_confirm_url += f"?{urlencode(query_params)}"
513+
# Add any additional query params
514+
if query_params:
515+
params.update(query_params)
531516

517+
# Point to new business registry project
518+
app_url = current_app.config.get("BUSINESS_REGISTRY_URL") + "/"
519+
# Build the URL with query parameters
520+
token_confirm_url = f"{app_url}/affiliationInvitation/acceptToken?{urlencode(params)}"
532521
return token_confirm_url
533522

534523
@staticmethod
@@ -697,6 +686,8 @@ def accept_affiliation_invitation(
697686
affiliation_model = AffiliationModel(org_id=org_id, entity_id=entity_id, certified_by_name=None)
698687
affiliation_model.save()
699688
entity_model = EntityModel.find_by_entity_id(entity_id)
689+
if flags.is_on("enable-entity-mapping", default=False) is True:
690+
EntityMappingService.populate_entity_mapping_for_identifier(entity_model.business_identifier)
700691
publish_affiliation_event(
701692
QueueMessageTypes.BUSINESS_AFFILIATED.value, org_id, entity_model.business_identifier
702693
)

0 commit comments

Comments
 (0)