feat(service): prefill contact information from annuaire service public

This commit is contained in:
Colin Darie 2024-10-07 18:07:37 +02:00
parent a4054053f7
commit 8dc47c1b93
No known key found for this signature in database
GPG key ID: 4FB865FDBCA4BCC4
8 changed files with 462 additions and 0 deletions

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
module PrefillableFromServicePublicConcern
extend ActiveSupport::Concern
included do
def prefill_from_siret
result = AnnuaireServicePublicService.new.(siret:)
# TODO: get organisme, … from API Entreprise
case result
in Dry::Monads::Success(data)
self.nom = data[:nom] if nom.blank?
self.email = data[:adresse_courriel] if email.blank?
self.telephone = data[:telephone]&.first&.dig("valeur") if telephone.blank?
self.horaires = denormalize_plage_ouverture(data[:plage_ouverture]) if horaires.blank?
self.adresse = APIGeoService.inline_service_public_address(data[:adresse]&.first) if adresse.blank?
else
# NOOP
end
result
end
private
def denormalize_plage_ouverture(data)
return if data.blank?
data.map do |range|
day_range = range.values_at('nom_jour_debut', 'nom_jour_fin').uniq.join(' au ')
hours_range = (1..2).each_with_object([]) do |i, hours|
start_hour = range["valeur_heure_debut_#{i}"]
end_hour = range["valeur_heure_fin_#{i}"]
if start_hour.present? && end_hour.present?
hours << "de #{format_time(start_hour)} à #{format_time(end_hour)}"
end
end
result = day_range
result += " : #{hours_range.join(' et ')}" if hours_range.present?
result += " (#{range['commentaire']})" if range['commentaire'].present?
result
end.join("\n")
end
def format_time(str_time)
Time.zone
.parse(str_time)
.strftime("%-H:%M")
end
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Service < ApplicationRecord
include PrefillableFromServicePublicConcern
has_many :procedures
belongs_to :administrateur, optional: false

View file

@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://demarches-simplifiees.fr/service-public.schema.json",
"title": "Service Public",
"type": "object",
"properties": {
"total_count": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"plage_ouverture": { "type": ["string", "null"] },
"site_internet": { "type": ["string", "null"] },
"copyright": { "type": ["string", "null"] },
"siren": { "type": ["string", "null"] },
"ancien_code_pivot": { "type": ["string", "null"] },
"reseau_social": { "type": ["string", "null"] },
"texte_reference": { "type": ["string", "null"] },
"partenaire": { "type": ["string", "null"] },
"telecopie": { "type": ["string", "null"] },
"nom": { "type": "string" },
"siret": { "type": ["string", "null"] },
"itm_identifiant": { "type": ["string", "null"] },
"sigle": { "type": ["string", "null"] },
"affectation_personne": { "type": ["string", "null"] },
"date_modification": { "type": "string" },
"adresse_courriel": { "type": ["string", "null"] },
"service_disponible": { "type": ["string", "null"] },
"organigramme": { "type": ["string", "null"] },
"pivot": { "type": ["string", "null"] },
"partenaire_identifiant": { "type": ["string", "null"] },
"ancien_identifiant": { "type": ["string", "null"] },
"id": { "type": "string" },
"ancien_nom": { "type": ["string", "null"] },
"commentaire_plage_ouverture": { "type": ["string", "null"] },
"annuaire": { "type": ["string", "null"] },
"tchat": { "type": ["string", "null"] },
"hierarchie": { "type": ["string", "null"] },
"categorie": { "type": "string" },
"sve": { "type": ["string", "null"] },
"telephone_accessible": { "type": ["string", "null"] },
"application_mobile": { "type": ["string", "null"] },
"version_type": { "type": "string" },
"type_repertoire": { "type": ["string", "null"] },
"telephone": { "type": ["string", "null"] },
"version_etat_modification": { "type": ["string", "null"] },
"date_creation": { "type": "string" },
"partenaire_date_modification": { "type": ["string", "null"] },
"mission": { "type": ["string", "null"] },
"formulaire_contact": { "type": ["string", "null"] },
"version_source": { "type": ["string", "null"] },
"type_organisme": { "type": ["string", "null"] },
"code_insee_commune": { "type": ["string", "null"] },
"statut_de_diffusion": { "type": ["string", "null"] },
"adresse": { "type": ["string", "null"] },
"url_service_public": { "type": ["string", "null"] },
"information_complementaire": { "type": ["string", "null"] },
"date_diffusion": { "type": ["string", "null"] }
},
"required": [
"id",
"nom",
"categorie",
"adresse",
"adresse_courriel",
"telephone",
"plage_ouverture"
]
}
}
},
"required": ["total_count", "results"]
}

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
class AnnuaireServicePublicService
include Dry::Monads[:result]
def call(siret:)
result = API::Client.new.call(url: url(siret), schema:, timeout: 1.second)
case result
in Success(body:)
result = body[:results].first
if result.present?
Success(
result.slice(:nom, :adresse, :adresse_courriel).merge(
telephone: maybe_json_parse(result[:telephone]),
plage_ouverture: maybe_json_parse(result[:plage_ouverture]),
adresse: maybe_json_parse(result[:adresse])
)
)
else
Failure(API::Client::Error[:not_found, 404, false, "No result found for this SIRET."])
end
in Failure(code:, reason:) if code.in?(401..403)
Sentry.capture_message("#{self.class.name}: #{reason} code: #{code}", extra: { siret: })
Failure(API::Client::Error[:unauthorized, code, false, reason])
in Failure(type: :schema, code:, reason:)
reason.errors[0].first
Sentry.capture_exception(reason, extra: { siret:, code: })
Failure(API::Client::Error[:schema, code, false, reason])
else
result
end
end
private
def schema
JSONSchemer.schema(Rails.root.join('app/schemas/service-public.json'))
end
def url(siret)
"https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:#{siret}"
end
def maybe_json_parse(value)
return nil if value.blank?
JSON.parse(value)
end
end

View file

@ -263,6 +263,21 @@ class APIGeoService
end
end
def inline_service_public_address(address_data)
return nil if address_data.blank?
components = [
address_data['numero_voie'],
address_data['complement1'],
address_data['complement2'],
address_data['service_distribution'],
address_data['code_postal'],
address_data['nom_commune']
].compact_blank
components.join(' ')
end
private
def code_metropole?(result)

View file

@ -0,0 +1,70 @@
---
http_interactions:
- request:
method: get
uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:20004021000000
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- demarches.gouv.fr
Expect:
- ''
response:
status:
code: 200
message: ''
headers:
Server:
- openresty
Date:
- Mon, 07 Oct 2024 14:41:59 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '33'
X-Ratelimit-Remaining:
- '999978'
X-Ratelimit-Limit:
- '1000000'
X-Ratelimit-Reset:
- '2024-10-08 00:00:00+00:00'
Cache-Control:
- no-cache, no-store, max-age=0, must-revalidate
Vary:
- Accept-Language, Cookie, Host
Content-Language:
- fr-fr
Access-Control-Allow-Origin:
- "*"
Access-Control-Allow-Methods:
- POST, GET, OPTIONS
Access-Control-Max-Age:
- '1000'
Access-Control-Allow-Headers:
- Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type,
ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept
Access-Control-Expose-Headers:
- ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit,
X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit,
X-RateLimit-dataset-Reset
Strict-Transport-Security:
- max-age=31536000
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
Referrer-Policy:
- strict-origin-when-cross-origin
Permissions-Policy:
- midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()
Content-Security-Policy:
- upgrade-insecure-requests;
X-Ua-Compatible:
- IE=edge
body:
encoding: ASCII-8BIT
string: '{"total_count": 0, "results": []}'
recorded_at: Mon, 07 Oct 2024 14:41:59 GMT
recorded_with: VCR 6.2.0

View file

@ -0,0 +1,100 @@
---
http_interactions:
- request:
method: get
uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:20004021000060
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- demarches.gouv.fr
Expect:
- ''
response:
status:
code: 200
message: ''
headers:
Server:
- openresty
Date:
- Mon, 07 Oct 2024 14:41:57 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '2573'
X-Ratelimit-Remaining:
- '999979'
X-Ratelimit-Limit:
- '1000000'
X-Ratelimit-Reset:
- '2024-10-08 00:00:00+00:00'
Cache-Control:
- no-cache, no-store, max-age=0, must-revalidate
Vary:
- Accept-Language, Cookie, Host
Content-Language:
- fr-fr
Access-Control-Allow-Origin:
- "*"
Access-Control-Allow-Methods:
- POST, GET, OPTIONS
Access-Control-Max-Age:
- '1000'
Access-Control-Allow-Headers:
- Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type,
ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept
Access-Control-Expose-Headers:
- ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit,
X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit,
X-RateLimit-dataset-Reset
Strict-Transport-Security:
- max-age=31536000
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
Referrer-Policy:
- strict-origin-when-cross-origin
Permissions-Policy:
- midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()
Content-Security-Policy:
- upgrade-insecure-requests;
X-Ua-Compatible:
- IE=edge
body:
encoding: ASCII-8BIT
string: '{"total_count": 1, "results": [{"plage_ouverture": "[{\"nom_jour_debut\":
\"Lundi\", \"nom_jour_fin\": \"Jeudi\", \"valeur_heure_debut_1\": \"08:00:00\",
\"valeur_heure_fin_1\": \"12:00:00\", \"valeur_heure_debut_2\": \"13:30:00\",
\"valeur_heure_fin_2\": \"17:30:00\", \"commentaire\": \"\"}, {\"nom_jour_debut\":
\"Vendredi\", \"nom_jour_fin\": \"Vendredi\", \"valeur_heure_debut_1\": \"08:00:00\",
\"valeur_heure_fin_1\": \"12:00:00\", \"valeur_heure_debut_2\": \"\", \"valeur_heure_fin_2\":
\"\", \"commentaire\": \"\"}]", "site_internet": "[{\"libelle\": \"\", \"valeur\":
\"https://www.cc-lacsgorgesverdon.fr/\"}]", "copyright": "Direction de l''information
l\u00e9gale et administrative (Premier ministre)", "siren": "200040210", "ancien_code_pivot":
"epci-83007-01", "reseau_social": null, "texte_reference": null, "partenaire":
null, "telecopie": null, "nom": "Communaut\u00e9 de communes - Lacs et Gorges
du Verdon", "siret": "20004021000060", "itm_identifiant": "999974", "sigle":
null, "affectation_personne": null, "date_modification": "31/01/2024 14:25:10",
"adresse_courriel": "redacted@email.fr", "service_disponible": null, "organigramme":
null, "pivot": "[{\"type_service_local\": \"epci\", \"code_insee_commune\":
[\"83007\"]}]", "partenaire_identifiant": null, "ancien_identifiant": null,
"id": "3b9ce22d-f7bd-46d9-82d1-8b44c8d08e39", "ancien_nom": null, "commentaire_plage_ouverture":
null, "annuaire": null, "tchat": null, "hierarchie": null, "categorie": "SL",
"sve": null, "telephone_accessible": null, "application_mobile": null, "version_type":
"Publiable", "type_repertoire": null, "telephone": "[{\"valeur\": \"04 94
70 00 00\", \"description\": \"\"}]", "version_etat_modification": null, "date_creation":
"11/05/2017 11:28:41", "partenaire_date_modification": null, "mission": null,
"formulaire_contact": "https://www.cc-lacsgorgesverdon.fr/contacts-comcom",
"version_source": null, "type_organisme": null, "code_insee_commune": "83007",
"statut_de_diffusion": "true", "adresse": "[{\"type_adresse\": \"Adresse\",
\"complement1\": \"\", \"complement2\": \"\", \"numero_voie\": \"242 avenue
Albert-1er\", \"service_distribution\": \"\", \"code_postal\": \"83630\",
\"nom_commune\": \"Aups\", \"pays\": \"\", \"continent\": \"\", \"longitude\":
\"6.22516\", \"latitude\": \"43.627448\", \"accessibilite\": \"ACC\", \"note_accessibilite\":
\"ascenseur\"}]", "url_service_public": "https://lannuaire.service-public.fr/provence-alpes-cote-d-azur/var/3b9ce22d-f7bd-46d9-82d1-8b44c8d08e39",
"information_complementaire": null, "date_diffusion": null}]}'
recorded_at: Mon, 07 Oct 2024 14:41:57 GMT
recorded_with: VCR 6.2.0

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PrefillableFromServicePublicConcern, type: :model do
let(:siret) { '20004021000060' }
let(:service) { build(:service, siret:) }
describe '#prefill_from_siret' do
let(:service) { Service.new(siret:) }
subject { service.prefill_from_siret }
context 'when API call is successful' do
it 'prefills service attributes' do
VCR.use_cassette('annuaire_service_public_success_20004021000060') do
expect(subject).to be_success
expect(service.nom).to eq("Communauté de communes - Lacs et Gorges du Verdon")
expect(service.email).to eq("redacted@email.fr")
expect(service.telephone).to eq("04 94 70 00 00")
expect(service.horaires).to eq("Lundi au Jeudi : de 8:00 à 12:00 et de 13:30 à 17:30\nVendredi : de 8:00 à 12:00")
expect(service.adresse).to eq("242 avenue Albert-1er 83630 Aups")
end
end
it 'does not overwrite existing attributes' do
service.nom = "Existing Name"
service.email = "existing@email.com"
VCR.use_cassette('annuaire_service_public_success_20004021000060') do
service.prefill_from_siret
expect(service.nom).to eq("Existing Name")
expect(service.email).to eq("existing@email.com")
end
end
end
context 'when API call do not find siret' do
let(:siret) { '20004021000000' }
it 'returns a failure result' do
VCR.use_cassette('annuaire_service_public_failure_20004021000000') do
expect(subject).to be_failure
end
end
end
end
describe '#denormalize_plage_ouverture' do
it 'correctly formats opening hours with one time range' do
data = [
{
"nom_jour_debut" => "Lundi",
"nom_jour_fin" => "Vendredi",
"valeur_heure_debut_1" => "09:00:00",
"valeur_heure_fin_1" => "17:00:00"
}
]
expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Vendredi : de 9:00 à 17:00")
end
it 'correctly formats opening hours with two time ranges' do
data = [
{
"nom_jour_debut" => "Lundi",
"nom_jour_fin" => "Jeudi",
"valeur_heure_debut_1" => "08:00:00",
"valeur_heure_fin_1" => "12:00:00",
"valeur_heure_debut_2" => "13:30:00",
"valeur_heure_fin_2" => "17:30:00"
}, {
"nom_jour_debut" => "Vendredi",
"nom_jour_fin" => "Vendredi",
"valeur_heure_debut_1" => "08:00:00",
"valeur_heure_fin_1" => "12:00:00"
}
]
expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Jeudi : de 8:00 à 12:00 et de 13:30 à 17:30\nVendredi : de 8:00 à 12:00")
end
it 'includes comments when present' do
data = [
{
"nom_jour_debut" => "Lundi",
"nom_jour_fin" => "Vendredi",
"valeur_heure_debut_1" => "09:00:00",
"valeur_heure_fin_1" => "17:00:00",
"commentaire" => "Fermé les jours fériés"
}
]
expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Vendredi : de 9:00 à 17:00 (Fermé les jours fériés)")
end
end
end