diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf2413..b9a22af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(organizations) add siret to name conversion #584 - 💄(frontend) redirect home according to abilities #588 - ✨(maildomain_access) add API endpoint to search users #508 diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 0e81ac8..a1217c2 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -29,6 +29,7 @@ import jsonschema from timezone_field import TimeZoneField from core.enums import WebhookStatusChoices +from core.plugins.loader import organization_plugins_run_after_create from core.utils.webhooks import scim_synchronizer from core.validators import get_field_validators_from_setting @@ -286,6 +287,16 @@ class OrganizationManager(models.Manager): raise ValueError("Should never reach this point.") + def create(self, **kwargs): + """ + Create an organization with the given kwargs. + + This method is overridden to call the Organization plugins. + """ + instance = super().create(**kwargs) + organization_plugins_run_after_create(instance) + return instance + class Organization(BaseModel): """ diff --git a/src/backend/core/plugins/__init__.py b/src/backend/core/plugins/__init__.py new file mode 100644 index 0000000..f2c35a9 --- /dev/null +++ b/src/backend/core/plugins/__init__.py @@ -0,0 +1 @@ +"""Core plugins package.""" diff --git a/src/backend/core/plugins/base.py b/src/backend/core/plugins/base.py new file mode 100644 index 0000000..7737eec --- /dev/null +++ b/src/backend/core/plugins/base.py @@ -0,0 +1,13 @@ +"""Base plugin class for organization plugins.""" + + +class BaseOrganizationPlugin: + """ + Base class for organization plugins. + + Plugins must implement all methods of this class even if it is only to "pass". + """ + + def run_after_create(self, organization) -> None: + """Method called after creating an organization.""" + raise NotImplementedError("Plugins must implement the run_after_create method") diff --git a/src/backend/core/plugins/loader.py b/src/backend/core/plugins/loader.py new file mode 100644 index 0000000..9cc274f --- /dev/null +++ b/src/backend/core/plugins/loader.py @@ -0,0 +1,32 @@ +"""Helper functions to load and run organization plugins.""" + +from functools import lru_cache +from typing import List + +from django.conf import settings +from django.utils.module_loading import import_string + +from core.plugins.base import BaseOrganizationPlugin + + +@lru_cache(maxsize=None) +def get_organization_plugins() -> List[BaseOrganizationPlugin]: + """ + Return a list of all organization plugins. + While the plugins initialization does not depend on the request, we can cache the result. + """ + return [ + import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS + ] + + +def organization_plugins_run_after_create(organization): + """ + Run the after create method for all organization plugins. + + Each plugin will be called in the order they are listed in the settings. + Each plugin is responsible to save changes if needed, this is not optimized + but this could be easily improved later if needed. + """ + for plugin_instance in get_organization_plugins(): + plugin_instance.run_after_create(organization) diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index a7e96ba..6c1bb41 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -473,6 +473,11 @@ class Base(Configuration): environ_prefix=None, ) ) + ORGANIZATION_PLUGINS = values.ListValue( + default=[], + environ_name="ORGANIZATION_PLUGINS", + environ_prefix=None, + ) # pylint: disable=invalid-name @property diff --git a/src/backend/plugins/__init__.py b/src/backend/plugins/__init__.py new file mode 100644 index 0000000..9679fe2 --- /dev/null +++ b/src/backend/plugins/__init__.py @@ -0,0 +1 @@ +"""Concrete implementation of plugins which can be used or not in the application.""" diff --git a/src/backend/plugins/organizations.py b/src/backend/plugins/organizations.py new file mode 100644 index 0000000..0108542 --- /dev/null +++ b/src/backend/plugins/organizations.py @@ -0,0 +1,74 @@ +"""Organization related plugins.""" + +import logging + +import requests + +from core.plugins.base import BaseOrganizationPlugin + +logger = logging.getLogger(__name__) + + +class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin): + """ + This plugin is used to convert the organization registration ID + to the proper name. For French organization the registration ID + is the SIRET. + + This is a very specific plugin for French organizations and this + first implementation is very basic. It surely needs to be improved + later. + """ + + _api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}" + + @staticmethod + def _extract_name_from_organization_data(organization_data): + """Extract the name from the organization data.""" + try: + return organization_data["liste_enseignes"][0].title() + except KeyError: + logger.warning("Missing key 'liste_enseignes' in %s", organization_data) + except IndexError: + logger.warning("Empty list 'liste_enseignes' in %s", organization_data) + return None + + def _get_organization_name_from_siret(self, siret): + """Return the organization name from the SIRET.""" + try: + response = requests.get(self._api_url.format(siret=siret), timeout=10) + response.raise_for_status() + data = response.json() + except requests.RequestException as exc: + logger.exception("%s: Unable to fetch organization name from SIRET", exc) + return None + + for result in data["results"]: + for organization in result["matching_etablissements"]: + if organization.get("siret") == siret: + return self._extract_name_from_organization_data(organization) + + logger.warning("No organization name found for SIRET %s", siret) + return None + + def run_after_create(self, organization): + """After creating an organization, update the organization name.""" + if not organization.registration_id_list: + # No registration ID to convert... + return + + if organization.name not in organization.registration_id_list: + # The name has probably already been customized + return + + # In the nominal case, there is only one registration ID because + # the organization as been created from it. + name = self._get_organization_name_from_siret( + organization.registration_id_list[0] + ) + if not name: + return + + organization.name = name + organization.save(update_fields=["name", "updated_at"]) + logger.info("Organization %s name updated to %s", organization, name) diff --git a/src/backend/plugins/tests/__init__.py b/src/backend/plugins/tests/__init__.py new file mode 100644 index 0000000..6b72241 --- /dev/null +++ b/src/backend/plugins/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the plugins module.""" diff --git a/src/backend/plugins/tests/organizations/__init__.py b/src/backend/plugins/tests/organizations/__init__.py new file mode 100644 index 0000000..6587cfc --- /dev/null +++ b/src/backend/plugins/tests/organizations/__init__.py @@ -0,0 +1 @@ +"""Test for the Organization plugins module.""" diff --git a/src/backend/plugins/tests/organizations/test_name_from_siret_organization_plugin.py b/src/backend/plugins/tests/organizations/test_name_from_siret_organization_plugin.py new file mode 100644 index 0000000..b7b33ee --- /dev/null +++ b/src/backend/plugins/tests/organizations/test_name_from_siret_organization_plugin.py @@ -0,0 +1,137 @@ +"""Tests for the NameFromSiretOrganizationPlugin plugin.""" + +import pytest +import responses + +from core.models import Organization +from core.plugins.loader import get_organization_plugins + +pytestmark = pytest.mark.django_db + + +# disable unused-argument for because organization_plugins_settings +# is used to set the settings not to be used in the test +# pylint: disable=unused-argument + + +@pytest.fixture(name="organization_plugins_settings") +def organization_plugins_settings_fixture(settings): + """ + Fixture to set the organization plugins settings and + leave the initial state after the test. + """ + _original_plugins = settings.ORGANIZATION_PLUGINS + + settings.ORGANIZATION_PLUGINS = [ + "plugins.organizations.NameFromSiretOrganizationPlugin" + ] + + # reset get_organization_plugins cache + get_organization_plugins.cache_clear() + get_organization_plugins() # call to populate the cache + + yield + + # reset get_organization_plugins cache + settings.ORGANIZATION_PLUGINS = _original_plugins + get_organization_plugins.cache_clear() + get_organization_plugins() # call to populate the cache + + +@responses.activate +def test_organization_plugins_run_after_create(organization_plugins_settings): + """Test the run_after_create method of the organization plugins for nominal case.""" + responses.add( + responses.GET, + "https://recherche-entreprises.api.gouv.fr/search?q=12345678901234", + json={ + "results": [ + { + # skipping some fields + "matching_etablissements": [ + # skipping some fields + { + "liste_enseignes": ["AMAZING ORGANIZATION"], + "siret": "12345678901234", + } + ] + } + ], + "total_results": 1, + "page": 1, + "per_page": 10, + "total_pages": 1, + }, + status=200, + ) + + organization = Organization.objects.create( + name="12345678901234", registration_id_list=["12345678901234"] + ) + assert organization.name == "Amazing Organization" + + # Check that the organization has been updated in the database also + organization.refresh_from_db() + assert organization.name == "Amazing Organization" + + +@responses.activate +def test_organization_plugins_run_after_create_api_fail(organization_plugins_settings): + """Test the plugin when the API call fails.""" + responses.add( + responses.GET, + "https://recherche-entreprises.api.gouv.fr/search?q=12345678901234", + json={"error": "Internal Server Error"}, + status=500, + ) + + organization = Organization.objects.create( + name="12345678901234", registration_id_list=["12345678901234"] + ) + assert organization.name == "12345678901234" + + +@responses.activate +@pytest.mark.parametrize( + "results", + [ + {"results": []}, + {"results": [{"matching_etablissements": []}]}, + {"results": [{"matching_etablissements": [{"siret": "12345678901234"}]}]}, + { + "results": [ + { + "matching_etablissements": [ + {"siret": "12345678901234", "liste_enseignes": []} + ] + } + ] + }, + ], +) +def test_organization_plugins_run_after_create_missing_data( + organization_plugins_settings, results +): + """Test the plugin when the API call returns missing data.""" + responses.add( + responses.GET, + "https://recherche-entreprises.api.gouv.fr/search?q=12345678901234", + json=results, + status=200, + ) + + organization = Organization.objects.create( + name="12345678901234", registration_id_list=["12345678901234"] + ) + assert organization.name == "12345678901234" + + +@responses.activate +def test_organization_plugins_run_after_create_name_already_set( + organization_plugins_settings, +): + """Test the plugin does nothing when the name already differs from the registration ID.""" + organization = Organization.objects.create( + name="Magic WOW", registration_id_list=["12345678901234"] + ) + assert organization.name == "Magic WOW"