♻️(plugins) rewrite plugin system as django app

This allow more flexibility around the installed plugins, this will
allow to add models in plugins if needed.
This commit is contained in:
Quentin BEY
2025-03-26 11:22:47 +01:00
committed by Sabrina Demagny
parent 4ced342062
commit 28fdee868d
20 changed files with 343 additions and 184 deletions

View File

@@ -0,0 +1 @@
"""Plugin module for La Suite numérique."""

View File

@@ -0,0 +1,10 @@
"""La Suite plugin application configuration."""
from core.plugins.base import BasePluginAppConfig
class LaSuitePluginConfig(BasePluginAppConfig):
"""Configuration for the La Suite plugin application."""
name = "plugins.la_suite"
verbose_name = "La Suite Plugin"

View File

@@ -0,0 +1,32 @@
"""
Hooks registration for the la_suite plugin.
This module is automagically loaded by the plugin system.
Putting hooks registration here allows to test the "utils"
function without registering the hook unwillingly.
"""
from core.plugins.registry import register_hook
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
from plugins.la_suite.hooks_utils.communes import CommuneCreation
@register_hook("organization_created")
def get_organization_name_and_metadata_from_siret_hook(organization):
"""After creating an organization, update the organization name & metadata."""
get_organization_name_and_metadata_from_siret(organization)
@register_hook("organization_created")
def commune_organization_created(organization):
"""After creating an organization, update the organization name."""
CommuneCreation().run_after_create(organization)
@register_hook("organization_access_granted")
def commune_organization_access_granted(organization_access):
"""After granting an organization access, check for needed domain access grant."""
CommuneCreation().run_after_grant_access(organization_access)

View File

@@ -0,0 +1 @@
"""Hook modules for La Suite"""

View File

@@ -0,0 +1,79 @@
"""
This hook 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.
"""
import logging
import requests
from requests.adapters import HTTPAdapter, Retry
logger = logging.getLogger(__name__)
API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def _get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get("complements", {}).get(
"est_service_public", False
)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, org_metadata
def get_organization_name_and_metadata_from_siret(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.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(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
name, metadata = _get_organization_name_and_metadata_from_results(data, siret)
if not name: # don't consider metadata either
return
organization.name = name
organization.metadata = (organization.metadata or {}) | metadata
organization.save(update_fields=["name", "metadata", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)

View File

@@ -9,92 +9,12 @@ from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from core.plugins.base import BaseOrganizationPlugin
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
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 get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get(
"complements", {}
).get("est_service_public", False)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, org_metadata
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.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.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
name, metadata = self.get_organization_name_and_metadata_from_results(
data, siret
)
if not name: # don't consider metadata either
return
organization.name = name
organization.metadata = (organization.metadata or {}) | metadata
organization.save(update_fields=["name", "metadata", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
def run_after_grant_access(self, organization_access):
"""After granting an organization access, we don't need to do anything."""
class ApiCall:
"""Encapsulates a call to an external API"""
@@ -134,7 +54,7 @@ class ApiCall:
)
class CommuneCreation(BaseOrganizationPlugin):
class CommuneCreation:
"""
This plugin handles setup tasks for French communes.
"""

View File

@@ -6,7 +6,7 @@ from django.test.utils import override_settings
import pytest
import responses
from plugins.organizations import ApiCall, CommuneCreation
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db

View File

@@ -4,7 +4,11 @@ import pytest
import responses
from core.models import Organization, get_organization_metadata_schema
from core.plugins.loader import get_organization_plugins
from core.plugins.registry import registry
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
pytestmark = pytest.mark.django_db
@@ -14,21 +18,16 @@ pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
@pytest.fixture(name="organization_plugins_settings")
def organization_plugins_settings_fixture(settings):
@pytest.fixture(name="hook_settings")
def hook_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
_original_hooks = dict(registry._hooks.items()) # pylint: disable=protected-access
registry.register_hook(
"organization_created", get_organization_name_and_metadata_from_siret
)
settings.ORGANIZATION_METADATA_SCHEMA = "fr/organization_metadata.json"
@@ -38,10 +37,8 @@ def organization_plugins_settings_fixture(settings):
yield
# reset get_organization_plugins cache
settings.ORGANIZATION_PLUGINS = _original_plugins
get_organization_plugins.cache_clear()
get_organization_plugins() # call to populate the cache
# reset the hooks
registry._hooks = _original_hooks # pylint: disable=protected-access
settings.ORGANIZATION_METADATA_SCHEMA = None
@@ -61,7 +58,7 @@ def organization_plugins_settings_fixture(settings):
],
)
def test_organization_plugins_run_after_create(
organization_plugins_settings, nature_juridique, is_commune, is_public_service
hook_settings, nature_juridique, is_commune, is_public_service
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
@@ -107,7 +104,7 @@ def test_organization_plugins_run_after_create(
@responses.activate
def test_organization_plugins_run_after_create_api_fail(organization_plugins_settings):
def test_organization_plugins_run_after_create_api_fail(hook_settings):
"""Test the plugin when the API call fails."""
responses.add(
responses.GET,
@@ -140,9 +137,7 @@ def test_organization_plugins_run_after_create_api_fail(organization_plugins_set
},
],
)
def test_organization_plugins_run_after_create_missing_data(
organization_plugins_settings, results
):
def test_organization_plugins_run_after_create_missing_data(hook_settings, results):
"""Test the plugin when the API call returns missing data."""
responses.add(
responses.GET,
@@ -159,7 +154,7 @@ def test_organization_plugins_run_after_create_missing_data(
@responses.activate
def test_organization_plugins_run_after_create_name_already_set(
organization_plugins_settings,
hook_settings,
):
"""Test the plugin does nothing when the name already differs from the registration ID."""
organization = Organization.objects.create(
@@ -170,7 +165,7 @@ def test_organization_plugins_run_after_create_name_already_set(
@responses.activate
def test_organization_plugins_run_after_create_no_list_enseignes(
organization_plugins_settings,
hook_settings,
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(

View File

@@ -0,0 +1,42 @@
"""Test module to check all application hooks are loaded."""
from core.plugins.registry import registry
from plugins.la_suite.apps import LaSuitePluginConfig
def test_hooks_loaded():
"""Test to check all application hooks are loaded."""
_original_hooks = dict(registry._hooks.items()) # pylint: disable=protected-access
_original_registered_apps = set(registry._registered_apps) # pylint: disable=protected-access
registry.reset()
assert registry.get_callbacks("organization_created") == []
assert registry.get_callbacks("organization_access_granted") == []
# Force the application to run "ready" method
LaSuitePluginConfig(
app_name="plugins.la_suite", app_module=__import__("plugins.la_suite")
).ready()
# Check that the hooks are loaded
organization_created_hook_names = [
callback.__name__ for callback in registry.get_callbacks("organization_created")
]
assert organization_created_hook_names == [
"get_organization_name_and_metadata_from_siret_hook",
"commune_organization_created",
]
organization_access_granted_hook_names = [
callback.__name__
for callback in registry.get_callbacks("organization_access_granted")
]
assert organization_access_granted_hook_names == [
"commune_organization_access_granted"
]
# cleanup the hooks
registry._hooks = _original_hooks # pylint: disable=protected-access
registry._registered_apps = _original_registered_apps # pylint: disable=protected-access