diff --git a/api/.env.sample b/api/.env.sample index 8e69ad308..f3efbbf76 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -81,3 +81,6 @@ MRAS_SVC_API_KEY= # Local development only DISABLE_NAMEREQUEST_SOLR_UPDATES=1 + +# launchdarkly +NAMEX_LD_SDK_ID=sdk-075200eb-4ff7-4e0e-872a-848585d3d460 diff --git a/api/flags.json b/api/flags.json new file mode 100644 index 000000000..fe9e799c1 --- /dev/null +++ b/api/flags.json @@ -0,0 +1,8 @@ +{ + "flagValues": { + "string-flag": "a string value", + "bool-flag": true, + "integer-flag": 10, + "enable-won-emails": false + } +} diff --git a/api/namex/VERSION.py b/api/namex/VERSION.py index 20c121245..a6bf9a6e7 100644 --- a/api/namex/VERSION.py +++ b/api/namex/VERSION.py @@ -1 +1 @@ -__version__ = '1.2.21' +__version__ = '1.2.22' diff --git a/api/namex/__init__.py b/api/namex/__init__.py index a5ac2db58..89e6a6615 100644 --- a/api/namex/__init__.py +++ b/api/namex/__init__.py @@ -32,6 +32,7 @@ from namex.models import db, ma from namex.resources import api from namex.utils.run_version import get_run_version +from namex.services import flags # noqa: I003; dont know what flake8 wants here @@ -52,6 +53,7 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): integrations=[FlaskIntegration()] ) + flags.init_app(app) queue.init_app(app) db.init_app(app) diff --git a/api/namex/resources/name_requests/report_resource.py b/api/namex/resources/name_requests/report_resource.py index 31e8e3cf9..497b606f4 100644 --- a/api/namex/resources/name_requests/report_resource.py +++ b/api/namex/resources/name_requests/report_resource.py @@ -174,12 +174,6 @@ def _get_template_data(nr_model): nr_report_json['applicants']['countryName'] = \ pycountry.countries.search_fuzzy(nr_report_json['applicants']['countryTypeCd'])[0].name actions_obj = ReportResource._get_next_action_text(nr_model['entity_type_cd']) - structured_log(request, "DEBUG", f"NR_notification - NameX API: {actions_obj}") - current_app.logger.debug(f"NR_notification - NameX API: {actions_obj}") - structured_log(request, "DEBUG", f"NR_notification - NameX API: {nr_report_json['request_action_cd']}") - current_app.logger.debug(f"NR_notification - NameX API: {nr_report_json['request_action_cd']}") - structured_log(request, "DEBUG", f"NR_notification - NameX API: {nr_model['entity_type_cd']}") - current_app.logger.debug(f"NR_notification - NameX API: {nr_model['entity_type_cd']}") if actions_obj: action_text = actions_obj.get(nr_report_json['request_action_cd']) if not action_text: @@ -298,7 +292,7 @@ def _is_potential_colin(legal_type): @staticmethod def _get_instruction_group(legal_type, request_action, corpNum): - if request_action == RequestAction.CHG.value or RequestAction.CNV.value: + if request_action in {RequestAction.CHG.value, RequestAction.CNV.value}: # For the 'Name Change' or 'Alteration', return 'modernized' if the company is in LEAR, and 'colin' if not return 'modernized' if ReportResource._is_lear_entity(corpNum) else 'colin' if ReportResource._is_modernized(legal_type): diff --git a/api/namex/services/__init__.py b/api/namex/services/__init__.py index d8304be4d..53d1d61c7 100644 --- a/api/namex/services/__init__.py +++ b/api/namex/services/__init__.py @@ -7,3 +7,6 @@ from .messages import MessageServices from .audit_trail import EventRecorder from .name_request.name_request_state import is_reapplication_eligible +from .flags import Flags + +flags = Flags() diff --git a/api/namex/services/flags.py b/api/namex/services/flags.py new file mode 100644 index 000000000..02e43c1c7 --- /dev/null +++ b/api/namex/services/flags.py @@ -0,0 +1,145 @@ +# Copyright © 2025 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. +"""Manage the Feature Flags initialization, setup and service.""" +from flask import current_app +from ldclient import get as ldclient_get, set_config as ldclient_set_config # noqa: I001 +from ldclient.config import Config # noqa: I005 +from ldclient.impl.integrations.files.file_data_source import _FileDataSource +from ldclient.interfaces import UpdateProcessor + +from namex.models import User + + +class FileDataSource(UpdateProcessor): + """FileDataStore has been removed, so this provides similar functionality.""" + + @classmethod + def factory(cls, **kwargs): + """Provide a way to use local files as a source of feature flag state. + + .. deprecated:: 6.8.0 + This module and this implementation class are deprecated and may be changed or removed in the future. + Please use :func:`ldclient.integrations.Files.new_data_source()`. + + The keyword arguments are the same as the arguments to :func:`ldclient.integrations.Files.new_data_source()`. + """ + return lambda config, store, ready: _FileDataSource(store, ready, + paths=kwargs.get('paths'), + auto_update=kwargs.get('auto_update', False), + poll_interval=kwargs.get('poll_interval', 1), + force_polling=kwargs.get('force_polling', False)) + + +class Flags(): + """Wrapper around the feature flag system. + + calls FAIL to FALSE + + If the feature flag service is unavailable + AND + there is no local config file + Calls -> False + + """ + + def __init__(self, app=None): + """Initialize this object.""" + self.sdk_key = None + self.app = None + + if app: + self.init_app(app) + + def init_app(self, app): + """Initialize the Feature Flag environment.""" + self.app = app + self.sdk_key = app.config.get('NAMEX_LD_SDK_ID') + + if self.sdk_key or app.env != 'production': + + if app.env == 'production': + config = Config(sdk_key=self.sdk_key) + else: + factory = FileDataSource.factory(paths=['flags.json'], + auto_update=True) + config = Config(sdk_key=self.sdk_key, + update_processor_class=factory, + send_events=False) + + ldclient_set_config(config) + client = ldclient_get() + + app.extensions['featureflags'] = client + + def teardown(self, exception): # pylint: disable=unused-argument; flask method signature + """Destroy all objects created by this extension.""" + client = current_app.extensions['featureflags'] + client.close() + + def _get_client(self): + try: + client = current_app.extensions['featureflags'] + except KeyError: + try: + self.init_app(current_app) + client = current_app.extensions['featureflags'] + except KeyError: + client = None + + return client + + @staticmethod + def _get_anonymous_user(): + return { + 'key': 'anonymous' + } + + @staticmethod + def _user_as_key(user: User): + user_json = { + 'key': user.sub, + 'firstName': user.firstname, + 'lastName': user.lastname + } + return user_json + + def is_on(self, flag: str, user: User = None) -> bool: + """Assert that the flag is set for this user.""" + client = self._get_client() + + if user: + flag_user = self._user_as_key(user) + else: + flag_user = self._get_anonymous_user() + + try: + return bool(client.variation(flag, flag_user, None)) + except Exception as err: + current_app.logger.error('Unable to read flags: %s' % repr(err), exc_info=True) + return False + + def value(self, flag: str, user: User = None) -> bool: + """Retrieve the value of the (flag, user) tuple.""" + client = self._get_client() + + if user: + flag_user = self._user_as_key(user) + else: + flag_user = self._get_anonymous_user() + + try: + return client.variation(flag, flag_user, None) + except Exception as err: + current_app.logger.error('Unable to read flags: %s' % repr(err), exc_info=True) + return False diff --git a/api/tests/python/services/test_flags.py b/api/tests/python/services/test_flags.py new file mode 100644 index 000000000..a75c9ba68 --- /dev/null +++ b/api/tests/python/services/test_flags.py @@ -0,0 +1,183 @@ +# 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 Flag Services. + +Test-Suite to ensure that the Flag Service is working as expected. +""" +import pytest +from flask import Flask + +from namex.models import User +from namex.services.flags import Flags + + +def test_flags_init(): + """Ensure that extension can be initialized.""" + app = Flask(__name__) + app.env = 'development' + + with app.app_context(): + flags = Flags(app) + + assert flags + assert app.extensions['featureflags'] + + +def test_flags_init_app(): + """Ensure that extension can be initialized.""" + app = Flask(__name__) + app.env = 'development' + app.config['NAMEX_LD_SDK_ID'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert app.extensions['featureflags'] + + +def test_flags_init_app_production(): + """Ensure that extension can be initialized.""" + app = Flask(__name__) + app.env = 'production' + app.config['NAMEX_LD_SDK_ID'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert app.extensions['featureflags'] + + +def test_flags_init_app_no_key_dev(): + """Assert that the extension is setup with a KEY, but in non-production mode.""" + app = Flask(__name__) + app.config['NAMEX_LD_SDK_ID'] = None + app.env = 'development' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + assert app.extensions['featureflags'] + + +def test_flags_init_app_no_key_prod(): + """Assert that prod with no key initializes, but does not setup the extension.""" + app = Flask(__name__) + app.config['NAMEX_LD_SDK_ID'] = None + app.env = 'production' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + with pytest.raises(KeyError): + client = app.extensions['featureflags'] + assert not client + + +def test_flags_bool_no_key_prod(): + """Assert that prod with no key initializes, but does not setup the extension.""" + app = Flask(__name__) + app.config['NAMEX_LD_SDK_ID'] = None + app.env = 'production' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + on = flags.is_on('bool-flag') + + assert not on + + +def test_flags_bool(): + """Assert that a boolean (True) is returned, when using the local Flag.json file.""" + app = Flask(__name__) + app.env = 'development' + app.config['NAMEX_LD_SDK_ID'] = 'https://no.flag/avail' + + with app.app_context(): + flags = Flags() + flags.init_app(app) + flag_on = flags.is_on('bool-flag') + + assert flag_on + + +def test_flags_bool_missing_flag(app): + """Assert that a boolean (False) is returned when flag doesn't exist, when using the local Flag.json file.""" + from namex import flags + app_env = app.env + try: + with app.app_context(): + flag_on = flags.is_on('no flag here') + + assert not flag_on + except: # pylint: disable=bare-except; # noqa: B901, E722 + # for tests we don't care + assert False + finally: + app.env = app_env + + +def test_flags_bool_using_current_app(): + """Assert that a boolean (True) is returned, when using the local Flag.json file.""" + from namex import flags + app = Flask(__name__) + app.env = 'development' + + with app.app_context(): + flag_on = flags.is_on('bool-flag') + + assert flag_on + + +@pytest.mark.parametrize('test_name,flag_name,expected', [ + ('boolean flag', 'bool-flag', True), + ('string flag', 'string-flag', 'a string value'), + ('integer flag', 'integer-flag', 10), +]) +def test_flags_bool_value(test_name, flag_name, expected): + """Assert that a boolean (True) is returned, when using the local Flag.json file.""" + from namex import flags + app = Flask(__name__) + app.env = 'development' + + with app.app_context(): + val = flags.value(flag_name) + + assert val == expected + + +def test_flag_bool_unique_user(): + """Assert that a unique user can retrieve a flag, when using the local Flag.json file.""" + app = Flask(__name__) + app.env = 'development' + app.config['NAMEX_LD_SDK_ID'] = 'https://no.flag/avail' + + user = User(username='username', firstname='firstname', lastname='lastname', sub='sub', iss='iss', idp_userid='123', login_source='IDIR') + + app_env = app.env + try: + with app.app_context(): + flags = Flags() + flags.init_app(app) + app.env = 'development' + val = flags.value('bool-flag', user) + flag_on = flags.is_on('bool-flag', user) + + assert val + assert flag_on + except: # pylint: disable=bare-except; # noqa: B901, E722 + # for tests we don't care + assert False + finally: + app.env = app_env diff --git a/services/emailer/.env.sample b/services/emailer/.env.sample index 4b42328a6..32359d165 100644 --- a/services/emailer/.env.sample +++ b/services/emailer/.env.sample @@ -26,4 +26,5 @@ DASHBOARD_URL= AUTH_WEB_URL= BUSINESS_REGISTRY_URL= - \ No newline at end of file + + NAMEX_LD_SDK_ID=sdk-075200eb-4ff7-4e0e-872a-848585d3d460 diff --git a/services/emailer/config.py b/services/emailer/config.py index 3d39cf39b..73d630ca6 100644 --- a/services/emailer/config.py +++ b/services/emailer/config.py @@ -73,7 +73,7 @@ class Config: # pylint: disable=too-few-public-methods ENVIRONMENT = os.getenv("APP_ENV", "prod") - LD_SDK_KEY = os.getenv("LD_SDK_KEY", None) + NAMEX_LD_SDK_ID = os.getenv("NAMEX_LD_SDK_ID", None) SENTRY_DSN = os.getenv("SENTRY_DSN", None) @@ -90,8 +90,8 @@ class Config: # pylint: disable=too-few-public-methods NOTIFY_API_VERSION = os.getenv("NOTIFY_API_VERSION", "") NAMEX_API_URL = os.getenv("NAMEX_API_URL", "") NAMEX_API_VERSION = os.getenv("NAMEX_API_VERSION", "") - LEGAL_API_URL = os.getenv("NOTIFY_API_URL", "https://legal-api-dev.apps.silver.devops.gov.bc.ca") - LEGAL_API_VERSION = os.getenv("NOTIFY_API_VERSION", "/api/v2") + LEGAL_API_URL = os.getenv("LEGAL_API_URL", "https://legal-api-dev.apps.silver.devops.gov.bc.ca") + LEGAL_API_VERSION = os.getenv("LEGAL_API_VERSION", "/api/v1") NOTIFY_API_URL = f"{NOTIFY_API_URL + NOTIFY_API_VERSION}/notify" NAMEX_SVC_URL = f"{NAMEX_API_URL + NAMEX_API_VERSION}" diff --git a/services/emailer/devops/vault.json b/services/emailer/devops/vault.json index 688ddd9d3..bf0dc7377 100644 --- a/services/emailer/devops/vault.json +++ b/services/emailer/devops/vault.json @@ -40,5 +40,11 @@ "auth-web", "business-registry-ui" ] + }, + { + "vault": "launchdarkly", + "application": [ + "namex" + ] } ] \ No newline at end of file diff --git a/services/emailer/devops/vaults.gcp.env b/services/emailer/devops/vaults.gcp.env index 572ad5327..731b7baeb 100644 --- a/services/emailer/devops/vaults.gcp.env +++ b/services/emailer/devops/vaults.gcp.env @@ -22,4 +22,6 @@ BUSINESS_REGISTRY_URL="op://web-url/$APP_ENV/business-registry-ui/BUSINESS_REGIS REPORT_API_URL="op://API/$APP_ENV/report-api/REPORT_API_URL" REPORT_API_VERSION="op://API/$APP_ENV/report-api/REPORT_API_VERSION" LEGAL_API_URL="op://API/$APP_ENV/legal-api/LEGAL_API_URL" +LEGAL_API_VERSION="op://API/$APP_ENV/legal-api/LEGAL_API_VERSION" +NAMEX_LD_SDK_ID="op://launchdarkly/$APP_ENV/namex/NAMEX_LD_SDK_ID" SENTRY_DSN="" diff --git a/services/emailer/flags.json b/services/emailer/flags.json new file mode 100644 index 000000000..fe9e799c1 --- /dev/null +++ b/services/emailer/flags.json @@ -0,0 +1,8 @@ +{ + "flagValues": { + "string-flag": "a string value", + "bool-flag": true, + "integer-flag": 10, + "enable-won-emails": false + } +} diff --git a/services/emailer/poetry.lock b/services/emailer/poetry.lock index 2092713ea..321367911 100644 --- a/services/emailer/poetry.lock +++ b/services/emailer/poetry.lock @@ -1531,7 +1531,7 @@ zipp = "^3.8.1" type = "git" url = "https://github.com/bcgov/namex.git" reference = "HEAD" -resolved_reference = "63b06cfb82228002e89e7f43343c117cde4a0ae5" +resolved_reference = "de7aa89a3aa5e3bb583142718e2486ad9f08727e" subdirectory = "api" [[package]] diff --git a/services/emailer/src/namex_emailer/__init__.py b/services/emailer/src/namex_emailer/__init__.py index c21b6588c..1f4e0ea9f 100644 --- a/services/emailer/src/namex_emailer/__init__.py +++ b/services/emailer/src/namex_emailer/__init__.py @@ -47,6 +47,8 @@ from .resources import register_endpoints from .services import queue +from namex.services import flags + def create_app(environment: Config = Production, **kwargs) -> Flask: """Return a configured Flask App using the Factory method.""" @@ -62,6 +64,7 @@ def create_app(environment: Config = Production, **kwargs) -> Flask: send_default_pii=False, ) + flags.init_app(app) queue.init_app(app) register_endpoints(app) diff --git a/services/emailer/src/namex_emailer/email_processors/nr_notification.py b/services/emailer/src/namex_emailer/email_processors/nr_notification.py index e2650013d..f8fd807b5 100644 --- a/services/emailer/src/namex_emailer/email_processors/nr_notification.py +++ b/services/emailer/src/namex_emailer/email_processors/nr_notification.py @@ -89,8 +89,6 @@ def process(email_info: SimpleCloudEvent, option) -> dict: # pylint: disable-ms legal_type = nr_data["entity_type_cd"] request_action = nr_data["request_action_cd"] corpNum = nr_data["corpNum"] - # This function will be restred after the emailer service and NameX API are sync well. - # group = ReportResource._get_instruction_group(legal_type, request_action, corpNum) group = get_instruction_group(legal_type, request_action, corpNum) if group: instruction_group = "-" + group diff --git a/services/emailer/src/namex_emailer/email_processors/nr_result.py b/services/emailer/src/namex_emailer/email_processors/nr_result.py index 11fdd85ce..7f4dbc34e 100644 --- a/services/emailer/src/namex_emailer/email_processors/nr_result.py +++ b/services/emailer/src/namex_emailer/email_processors/nr_result.py @@ -52,8 +52,6 @@ def email_consent_letter(email_info: SimpleCloudEvent): legal_type = nr_model['entity_type_cd'] request_action = nr_model["request_action_cd"] corpNum = nr_model["corpNum"] - # This function will be restred after the emailer service and NameX API are sync well. - # instruction_group = ReportResource._get_instruction_group(legal_type, request_action, corpNum) instruction_group = get_instruction_group(legal_type, request_action, corpNum) if instruction_group: file_name = f"{file_name}-{instruction_group}" @@ -104,10 +102,7 @@ def email_report(email_info: SimpleCloudEvent): legal_type = nr_model['entity_type_cd'] request_action = nr_model["request_action_cd"] corpNum = nr_model["corpNum"] - # This function will be restred after the emailer service and NameX API are sync well. - # instruction_group = ReportResource._get_instruction_group(legal_type, request_action, corpNum) instruction_group = get_instruction_group(legal_type, request_action, corpNum) - structured_log(request, "DEBUG", f"NR_notification: {instruction_group}") file_name='' if nr_model['consentFlag'] in ['Y', 'R']: file_name = 'conditional' @@ -120,7 +115,6 @@ def email_report(email_info: SimpleCloudEvent): email_template = Path(f'{template_path}/{file_name}.md').read_text() - structured_log(request, "DEBUG", f"NR_notification: {nr_model}") email_body = _build_email_body(email_template, nr_model, recipient_emails[0], recipient_phones[0]) email = { @@ -163,4 +157,4 @@ def _build_email_body(template: str, nr_model, email, phone): if isinstance(val, datetime): val = val.strftime(DATE_FORMAT) template = template.replace(template_string, val) - return template \ No newline at end of file + return template diff --git a/services/emailer/src/namex_emailer/services/helpers.py b/services/emailer/src/namex_emailer/services/helpers.py index d064e087b..c2105ee20 100644 --- a/services/emailer/src/namex_emailer/services/helpers.py +++ b/services/emailer/src/namex_emailer/services/helpers.py @@ -2,10 +2,12 @@ import pytz import requests -from flask import current_app +from http import HTTPStatus +from flask import current_app, request +from gcp_queue.logging import structured_log from cachetools import cached, TTLCache from urllib.parse import urlencode -from namex.constants import RequestAction +from namex.resources.name_requests import ReportResource @staticmethod @cached(cache=TTLCache(maxsize=1, ttl=180)) @@ -72,39 +74,25 @@ def get_magic_link(nr_number, email, phone): return f'{BUSINESS_REGISTRY_URL}incorporateNow/?{encoded_params}' -@staticmethod -def _is_lear_entity(corpNum): - if not corpNum: - return False - entity_url = f'{current_app.config.get("ENTITY_SVC_URL")}/businesses/{corpNum}' - token = get_bearer_token() - response = requests.get(entity_url, headers={ - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + token - }) - - return response - - -# This function will be removed if the emailer service and NameX API are in sync well @staticmethod def get_instruction_group(legal_type, request_action, corpNum): + from namex.services import flags # pylint: disable=import-outside-toplevel + enable_won_emails = flags.value('enable-won-emails') + structured_log(request, "DEBUG", f"enable way of navigation emails feature flags value: {enable_won_emails}") + if enable_won_emails: + structured_log(request, "DEBUG", f"enable way of navigation emails") + return ReportResource._get_instruction_group(legal_type, request_action, corpNum) + legal_type_groups = { 'modernized': ['GP', 'DBA', 'FR', 'CP', 'BC'], - 'colin': ['XCR', 'XUL', 'RLC'], - 'society': ['SO', 'XSO'], - 'potential_colin': ['CR', 'UL', 'CC'] + 'colin': ['CR', 'UL', 'CC', 'XCR', 'XUL', 'RLC'], + 'society': ['SO', 'XSO'] } - if request_action in {RequestAction.CHG.value, RequestAction.CNV.value}: - return 'modernized' if _is_lear_entity(corpNum) else 'colin' if legal_type in legal_type_groups['modernized']: return 'modernized' if legal_type in legal_type_groups['colin']: return 'colin' if legal_type in legal_type_groups['society']: return 'so' - if legal_type in legal_type_groups['potential_colin']: - return 'new' if request_action == RequestAction.NEW.value else 'colin' - return ''