✨(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:
@@ -10,6 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(organizations) add siret to name conversion #584
|
||||||
- 💄(frontend) redirect home according to abilities #588
|
- 💄(frontend) redirect home according to abilities #588
|
||||||
- ✨(maildomain_access) add API endpoint to search users #508
|
- ✨(maildomain_access) add API endpoint to search users #508
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import jsonschema
|
|||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
from core.enums import WebhookStatusChoices
|
from core.enums import WebhookStatusChoices
|
||||||
|
from core.plugins.loader import organization_plugins_run_after_create
|
||||||
from core.utils.webhooks import scim_synchronizer
|
from core.utils.webhooks import scim_synchronizer
|
||||||
from core.validators import get_field_validators_from_setting
|
from core.validators import get_field_validators_from_setting
|
||||||
|
|
||||||
@@ -286,6 +287,16 @@ class OrganizationManager(models.Manager):
|
|||||||
|
|
||||||
raise ValueError("Should never reach this point.")
|
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):
|
class Organization(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|||||||
1
src/backend/core/plugins/__init__.py
Normal file
1
src/backend/core/plugins/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core plugins package."""
|
||||||
13
src/backend/core/plugins/base.py
Normal file
13
src/backend/core/plugins/base.py
Normal 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")
|
||||||
32
src/backend/core/plugins/loader.py
Normal file
32
src/backend/core/plugins/loader.py
Normal 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)
|
||||||
@@ -473,6 +473,11 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
ORGANIZATION_PLUGINS = values.ListValue(
|
||||||
|
default=[],
|
||||||
|
environ_name="ORGANIZATION_PLUGINS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
|
|||||||
1
src/backend/plugins/__init__.py
Normal file
1
src/backend/plugins/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Concrete implementation of plugins which can be used or not in the application."""
|
||||||
74
src/backend/plugins/organizations.py
Normal file
74
src/backend/plugins/organizations.py
Normal 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)
|
||||||
1
src/backend/plugins/tests/__init__.py
Normal file
1
src/backend/plugins/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the plugins module."""
|
||||||
1
src/backend/plugins/tests/organizations/__init__.py
Normal file
1
src/backend/plugins/tests/organizations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test for the Organization plugins module."""
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user