Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fetch and display commune name #583

Merged
merged 1 commit into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to

### Added

- ✨(anct) fetch and display organization names of communes #583
- ✨(frontend) display email if no username #562
- 🧑‍💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577

Expand Down
2 changes: 1 addition & 1 deletion docker/auth/realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"username": "e2e.marie",
"email": "[email protected]",
"firstName": "Marie",
"lastName": "Devarzy",
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21580304000017"
Expand Down
6 changes: 6 additions & 0 deletions src/backend/people/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,8 @@ class Development(Base):

OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"

ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]

Comment on lines +644 to +645
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be enforced here because this is not generic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General reply to your comments: thanks for the feedbacks, I've already hit "merge" but can address these in a later PR (might pick #622 which is in some ways a continuation, or a new one altogether depending on urgency).

Specific reply to the above: OK, I understand the concern here is for users and contributors outside of France to be able to start from a vanilla configuration instead of having to figure out what is French-specific (or even French territorial administration specific) stuff that needs swapping out. And I don't think this case has arisen before so it's a good occasion to start documenting that.

On the other hand I'd like to keep these active for local development and documented in the repo for ease of onboarding. Can we put these in, say, env.d/development/france.dist and pick them up in docker-compose.yml with a comment suggesting creating a country-specific set of env vars, as needed for other countries ?

def __init__(self):
"""In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name
Expand Down Expand Up @@ -686,6 +688,10 @@ class Test(Base):
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="

OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"

ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
Comment on lines +691 to +693
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this should not be enforced here, only in specific tests.


ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
Expand Down
39 changes: 24 additions & 15 deletions src/backend/plugins/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging

import requests
from requests.adapters import HTTPAdapter, Retry

from core.plugins.base import BaseOrganizationPlugin

Expand Down Expand Up @@ -33,20 +34,17 @@ def _extract_name_from_organization_data(organization_data):
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

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"
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
return self._extract_name_from_organization_data(organization)
if commune:
return organization["libelle_commune"].title()

return self._extract_name_from_organization_data(organization)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning, the indentation has changed here, this might get the wrong "siret".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I see how to construct a case where we'd get the wrong result here (i.e. a missing unit test). The indent is by intention: I did mean to return directly with the libelle_commune in the commune case (because I'm pretty sure the key is always present) and fall through to the invocation of the private method otherwise. (So we end up with a bit of asymmetry in the code with some error handling in one case and not in the other.)


logger.warning("No organization name found for SIRET %s", siret)
return None
Expand All @@ -63,10 +61,21 @@ def run_after_create(self, organization):

# 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:
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)
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return

organization.name = name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from core.models import Organization
from core.plugins.loader import get_organization_plugins

from plugins.organizations import NameFromSiretOrganizationPlugin

pytestmark = pytest.mark.django_db


Expand Down Expand Up @@ -135,3 +137,34 @@ def test_organization_plugins_run_after_create_name_already_set(
name="Magic WOW", registration_id_list=["12345678901234"]
)
assert organization.name == "Magic WOW"


def test_extract_name_from_org_data_when_commune(
organization_plugins_settings,
):
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}

plugin = NameFromSiretOrganizationPlugin()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
7 changes: 7 additions & 0 deletions src/frontend/apps/desk/src/core/auth/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ export interface User {
id: string;
email: string;
name?: string;
organization?: Organization;
abilities?: {
mailboxes: UserAbilities;
contacts: UserAbilities;
teams: UserAbilities;
};
}

export interface Organization {
id: string;
name: string;
registration_id_list: [string];
}

export type UserAbilities = {
can_view?: boolean;
can_create?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ export const AccountDropdown = () => {
<DropButton
button={
<Box $flex $direction="row" $align="center">
<Text $theme="primary">{userName}</Text>
<Box $flex $direction="column" $align="left">
<Text $theme="primary">{userName}</Text>
{userData?.organization?.registration_id_list?.at(0) && (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the condition, why should it be only when there is a registration ID?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does seem indirect now you mention it, testing for the name that we're going to display would work just as well. (I think I was biased by the testing perspective, the distinguishing feature of the organization that satisfies that test is that it's the only one in our test data that has a SIRET.)

<Text $theme="primary">{userData?.organization?.name}</Text>
)}
</Box>
<Text className="material-icons" $theme="primary" aria-hidden="true">
arrow_drop_down
</Text>
Expand Down
16 changes: 13 additions & 3 deletions src/frontend/apps/e2e/__tests__/app-desk/siret.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ test.beforeEach(async ({ page, browserName }) => {

test.describe('OIDC interop with SIRET', () => {
test('it checks the SIRET is displayed in /me endpoint', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();

const response = await page.request.get(
'http://localhost:8071/api/v1.0/users/me/',
);
Expand All @@ -21,3 +18,16 @@ test.describe('OIDC interop with SIRET', () => {
});
});
});

test.describe('When a commune, display commune name below user name', () => {
test('it checks the name is added below the user name', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();
Morendil marked this conversation as resolved.
Show resolved Hide resolved

const logout = page.getByRole('button', {
name: 'Marie Delamairie',
});

await expect(logout.getByText('Varzy')).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion src/helm/env.d/preprod/values.desk.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ backend:
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk-preprod.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin"
ORGANIZATION_PLUGINS: ["plugins.organizations.NameFromSiretOrganizationPlugin"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will break, django-configurations does not expect a list here: https://github.com/jazzband/django-configurations/blob/master/configurations/values.py#L214

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That one is confusing to me, originally I picked up the string value from this file and used it without change in settings.py, but that broke precisely because it was expecting a list of plugin names and not a singular name. (And it seemed reasonable to me that we'd want potentially more than one plugin…) Do we have different semantics between Helm files and settings.py ?

ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://desk-preprod.beta.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://desk-preprod.beta.numerique.gouv.fr
Expand Down
2 changes: 1 addition & 1 deletion src/helm/env.d/production/values.desk.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ backend:
OIDC_OP_TOKEN_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/token
OIDC_OP_USER_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/session/end
ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin"
ORGANIZATION_PLUGINS: ["plugins.organizations.NameFromSiretOrganizationPlugin"]
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD: "siret"
OIDC_RP_CLIENT_ID:
secretKeyRef:
Expand Down
Loading