2024-12-05 18:19:11 +01:00
|
|
|
"""Organization related plugins."""
|
|
|
|
|
|
|
|
|
|
import logging
|
2025-02-06 11:50:27 +01:00
|
|
|
import re
|
2024-12-05 18:19:11 +01:00
|
|
|
|
2025-01-22 15:24:13 +01:00
|
|
|
from django.conf import settings
|
2025-02-06 11:50:27 +01:00
|
|
|
from django.utils.text import slugify
|
2025-01-22 15:24:13 +01:00
|
|
|
|
2024-12-05 18:19:11 +01:00
|
|
|
import requests
|
2024-12-05 15:44:30 +01:00
|
|
|
from requests.adapters import HTTPAdapter, Retry
|
2024-12-05 18:19:11 +01:00
|
|
|
|
2025-01-22 15:32:15 +01:00
|
|
|
from mailbox_manager.enums import MailDomainRoleChoices
|
|
|
|
|
from mailbox_manager.models import MailDomain, MailDomainAccess
|
|
|
|
|
|
2024-12-05 18:19:11 +01:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2025-01-21 11:32:08 +01:00
|
|
|
class ApiCall:
|
|
|
|
|
"""Encapsulates a call to an external API"""
|
|
|
|
|
|
2025-01-22 15:24:13 +01:00
|
|
|
inputs: dict = {}
|
2025-01-21 11:32:08 +01:00
|
|
|
method: str = "GET"
|
|
|
|
|
base: str = ""
|
|
|
|
|
url: str = ""
|
|
|
|
|
params: dict = {}
|
|
|
|
|
headers: dict = {}
|
2025-01-22 15:32:15 +01:00
|
|
|
response_data = None
|
2025-01-21 11:32:08 +01:00
|
|
|
|
|
|
|
|
def execute(self):
|
|
|
|
|
"""Call the specified API endpoint with supplied parameters and record response"""
|
2025-01-22 15:32:15 +01:00
|
|
|
if self.method in ("POST", "PATCH"):
|
|
|
|
|
response = requests.request(
|
2025-01-21 11:32:08 +01:00
|
|
|
method=self.method,
|
|
|
|
|
url=f"{self.base}/{self.url}",
|
|
|
|
|
json=self.params,
|
|
|
|
|
headers=self.headers,
|
2025-02-19 20:30:39 +01:00
|
|
|
timeout=20,
|
2025-01-21 11:32:08 +01:00
|
|
|
)
|
2025-01-22 15:32:15 +01:00
|
|
|
else:
|
|
|
|
|
response = requests.request(
|
|
|
|
|
method=self.method,
|
|
|
|
|
url=f"{self.base}/{self.url}",
|
|
|
|
|
params=self.params,
|
|
|
|
|
headers=self.headers,
|
2025-02-19 20:30:39 +01:00
|
|
|
timeout=20,
|
2025-01-22 15:32:15 +01:00
|
|
|
)
|
|
|
|
|
self.response_data = response.json()
|
|
|
|
|
logger.info(
|
|
|
|
|
"API call: %s %s %s %s",
|
|
|
|
|
self.method,
|
|
|
|
|
self.url,
|
|
|
|
|
self.params,
|
|
|
|
|
self.response_data,
|
|
|
|
|
)
|
2025-01-21 11:32:08 +01:00
|
|
|
|
|
|
|
|
|
2025-03-26 11:22:47 +01:00
|
|
|
class CommuneCreation:
|
2025-01-21 11:32:08 +01:00
|
|
|
"""
|
|
|
|
|
This plugin handles setup tasks for French communes.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
|
|
|
|
|
|
|
|
|
def get_organization_name_from_results(self, data, siret):
|
|
|
|
|
"""Return the organization name from the results of a SIRET search."""
|
|
|
|
|
for result in data["results"]:
|
|
|
|
|
nature = "nature_juridique"
|
|
|
|
|
commune = nature in result and result[nature] == "7210"
|
2025-01-22 15:32:15 +01:00
|
|
|
if commune:
|
|
|
|
|
return result["siege"]["libelle_commune"].title()
|
2025-01-21 11:32:08 +01:00
|
|
|
|
2025-01-22 15:32:15 +01:00
|
|
|
logger.warning("Not a commune: SIRET %s", siret)
|
2025-01-21 11:32:08 +01:00
|
|
|
return None
|
|
|
|
|
|
2025-01-22 15:24:13 +01:00
|
|
|
def dns_call(self, spec):
|
|
|
|
|
"""Call to add a DNS record"""
|
2025-02-06 11:50:27 +01:00
|
|
|
zone_name = self.zone_name(spec.inputs["name"])
|
|
|
|
|
|
2025-01-22 15:24:13 +01:00
|
|
|
records = [
|
2025-01-22 15:32:15 +01:00
|
|
|
{
|
|
|
|
|
"name": item["target"],
|
|
|
|
|
"type": item["type"].upper(),
|
|
|
|
|
"data": item["value"],
|
|
|
|
|
"ttl": 3600,
|
|
|
|
|
}
|
|
|
|
|
for item in spec.response_data
|
2025-01-22 15:24:13 +01:00
|
|
|
]
|
|
|
|
|
result = ApiCall()
|
|
|
|
|
result.method = "PATCH"
|
|
|
|
|
result.base = "https://api.scaleway.com"
|
2025-02-06 11:50:27 +01:00
|
|
|
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
|
2025-01-22 15:24:13 +01:00
|
|
|
result.params = {"changes": [{"add": {"records": records}}]}
|
2025-01-22 15:32:15 +01:00
|
|
|
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
|
2025-01-22 15:24:13 +01:00
|
|
|
return result
|
|
|
|
|
|
2025-02-06 11:50:27 +01:00
|
|
|
def normalize_name(self, name: str) -> str:
|
|
|
|
|
"""Map the name to a standard form"""
|
|
|
|
|
name = re.sub("'", "-", name)
|
|
|
|
|
return slugify(name)
|
|
|
|
|
|
|
|
|
|
def zone_name(self, name: str) -> str:
|
|
|
|
|
"""Derive the zone name from the commune name"""
|
|
|
|
|
normalized = self.normalize_name(name)
|
2025-03-05 10:17:07 +01:00
|
|
|
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
|
2025-02-06 11:50:27 +01:00
|
|
|
|
2025-01-21 11:32:08 +01:00
|
|
|
def complete_commune_creation(self, name: str) -> ApiCall:
|
|
|
|
|
"""Specify the tasks to be completed after a commune is created."""
|
2025-02-06 11:50:27 +01:00
|
|
|
inputs = {"name": self.normalize_name(name)}
|
2025-01-21 11:32:08 +01:00
|
|
|
|
|
|
|
|
create_zone = ApiCall()
|
|
|
|
|
create_zone.method = "POST"
|
|
|
|
|
create_zone.base = "https://api.scaleway.com"
|
|
|
|
|
create_zone.url = "/domain/v2beta1/dns-zones"
|
|
|
|
|
create_zone.params = {
|
|
|
|
|
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
|
2025-03-05 10:17:07 +01:00
|
|
|
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
|
2025-01-21 11:32:08 +01:00
|
|
|
"subdomain": inputs["name"],
|
|
|
|
|
}
|
|
|
|
|
create_zone.headers = {
|
|
|
|
|
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-06 11:50:27 +01:00
|
|
|
zone_name = self.zone_name(inputs["name"])
|
|
|
|
|
|
2025-01-21 11:32:08 +01:00
|
|
|
create_domain = ApiCall()
|
|
|
|
|
create_domain.method = "POST"
|
|
|
|
|
create_domain.base = settings.MAIL_PROVISIONING_API_URL
|
2025-02-15 15:09:36 +01:00
|
|
|
create_domain.url = "/domains/"
|
2025-01-21 11:32:08 +01:00
|
|
|
create_domain.params = {
|
2025-02-06 11:50:27 +01:00
|
|
|
"name": zone_name,
|
2025-01-21 11:32:08 +01:00
|
|
|
"delivery": "virtual",
|
2025-01-22 15:24:13 +01:00
|
|
|
"features": ["webmail", "mailbox"],
|
2025-02-06 11:50:27 +01:00
|
|
|
"context_name": zone_name,
|
2025-01-21 11:32:08 +01:00
|
|
|
}
|
|
|
|
|
create_domain.headers = {
|
2025-01-22 15:32:15 +01:00
|
|
|
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
2025-01-21 11:32:08 +01:00
|
|
|
}
|
|
|
|
|
|
2025-01-21 15:45:21 +01:00
|
|
|
spec_domain = ApiCall()
|
2025-01-22 15:24:13 +01:00
|
|
|
spec_domain.inputs = inputs
|
2025-01-21 15:45:21 +01:00
|
|
|
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
|
2025-02-06 11:50:27 +01:00
|
|
|
spec_domain.url = f"/domains/{zone_name}/spec"
|
2025-01-21 15:45:21 +01:00
|
|
|
spec_domain.headers = {
|
2025-01-22 15:32:15 +01:00
|
|
|
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
2025-01-21 15:45:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [create_zone, create_domain, spec_domain]
|
2025-01-21 11:32:08 +01:00
|
|
|
|
2025-01-22 15:24:13 +01:00
|
|
|
def complete_zone_creation(self, spec_call):
|
|
|
|
|
"""Specify the tasks to be performed to set up the zone."""
|
|
|
|
|
return self.dns_call(spec_call)
|
|
|
|
|
|
2025-01-21 11:32:08 +01:00
|
|
|
def run_after_create(self, organization):
|
|
|
|
|
"""After creating an organization, update the organization name."""
|
2025-01-22 15:32:15 +01:00
|
|
|
logger.info("In CommuneCreation")
|
2025-01-21 11:32:08 +01:00
|
|
|
if not organization.registration_id_list:
|
|
|
|
|
# No registration ID to convert...
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# In the nominal case, there is only one registration ID because
|
2025-01-22 15:32:15 +01:00
|
|
|
# the organization has been created from it.
|
2024-12-05 15:44:30 +01:00
|
|
|
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()
|
|
|
|
|
name = self.get_organization_name_from_results(data, siret)
|
2025-01-22 15:32:15 +01:00
|
|
|
# Not a commune ?
|
2024-12-05 15:44:30 +01:00
|
|
|
if not name:
|
|
|
|
|
return
|
|
|
|
|
except requests.RequestException as exc:
|
|
|
|
|
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
|
2024-12-05 18:19:11 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
organization.name = name
|
|
|
|
|
organization.save(update_fields=["name", "updated_at"])
|
|
|
|
|
logger.info("Organization %s name updated to %s", organization, name)
|
2025-01-22 15:24:13 +01:00
|
|
|
|
2025-02-06 11:50:27 +01:00
|
|
|
zone_name = self.zone_name(name)
|
2025-02-11 10:58:40 +01:00
|
|
|
support = "support-regie@numerique.gouv.fr"
|
2025-02-06 11:50:27 +01:00
|
|
|
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
|
2025-01-22 15:32:15 +01:00
|
|
|
|
|
|
|
|
# Compute and execute the rest of the process
|
|
|
|
|
tasks = self.complete_commune_creation(name)
|
|
|
|
|
for task in tasks:
|
|
|
|
|
task.execute()
|
|
|
|
|
last_task = self.complete_zone_creation(tasks[-1])
|
|
|
|
|
last_task.execute()
|
|
|
|
|
|
2025-02-15 15:09:36 +01:00
|
|
|
def complete_grant_access(self, sub, zone_name):
|
|
|
|
|
"""Specify the tasks to be completed after making a user admin"""
|
|
|
|
|
create_user = ApiCall()
|
|
|
|
|
create_user.method = "POST"
|
|
|
|
|
create_user.base = settings.MAIL_PROVISIONING_API_URL
|
|
|
|
|
create_user.url = "/users/"
|
|
|
|
|
create_user.params = {
|
|
|
|
|
"name": sub,
|
|
|
|
|
"password": "no",
|
|
|
|
|
"is_admin": False,
|
|
|
|
|
"perms": [],
|
|
|
|
|
}
|
|
|
|
|
create_user.headers = {
|
|
|
|
|
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
grant_access = ApiCall()
|
|
|
|
|
grant_access.method = "POST"
|
|
|
|
|
grant_access.base = settings.MAIL_PROVISIONING_API_URL
|
|
|
|
|
grant_access.url = "/allows/"
|
|
|
|
|
grant_access.params = {
|
|
|
|
|
"user": sub,
|
|
|
|
|
"domain": zone_name,
|
|
|
|
|
}
|
|
|
|
|
grant_access.headers = {
|
|
|
|
|
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [create_user, grant_access]
|
|
|
|
|
|
2025-01-22 15:32:15 +01:00
|
|
|
def run_after_grant_access(self, organization_access):
|
|
|
|
|
"""After granting an organization access, check for needed domain access grant."""
|
|
|
|
|
orga = organization_access.organization
|
|
|
|
|
user = organization_access.user
|
2025-02-06 11:50:27 +01:00
|
|
|
zone_name = self.zone_name(orga.name)
|
2025-01-22 15:32:15 +01:00
|
|
|
|
|
|
|
|
try:
|
2025-01-27 15:23:35 +01:00
|
|
|
domain = MailDomain.objects.get(name=zone_name)
|
2025-01-22 15:32:15 +01:00
|
|
|
except MailDomain.DoesNotExist:
|
|
|
|
|
domain = None
|
|
|
|
|
|
|
|
|
|
if domain:
|
|
|
|
|
MailDomainAccess.objects.create(
|
|
|
|
|
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
|
|
|
|
|
)
|
2025-02-15 15:09:36 +01:00
|
|
|
|
|
|
|
|
tasks = self.complete_grant_access(user.sub, zone_name)
|
|
|
|
|
for task in tasks:
|
|
|
|
|
task.execute()
|