(organizations) add siret to name conversion

This adds the plugin system to easily manage
Organization related customizations. This first
plugin tries (best effort) to get a proper name
for the Organization, using its SIRET. This
is French specificities but another plugin can
be defined for other cases.
This commit is contained in:
Quentin BEY
2024-12-05 18:19:11 +01:00
committed by BEY Quentin
parent 6e14c2e61f
commit 38a5f158b5
11 changed files with 277 additions and 0 deletions

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -0,0 +1 @@
"""Core plugins package."""

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Concrete implementation of plugins which can be used or not in the application."""

View File

@@ -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)

View File

@@ -0,0 +1 @@
"""Tests for the plugins module."""

View File

@@ -0,0 +1 @@
"""Test for the Organization plugins module."""

View File

@@ -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"