diff --git a/app/models/concerns/prefillable_from_service_public_concern.rb b/app/models/concerns/prefillable_from_service_public_concern.rb new file mode 100644 index 000000000..ba421dc49 --- /dev/null +++ b/app/models/concerns/prefillable_from_service_public_concern.rb @@ -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 diff --git a/app/models/service.rb b/app/models/service.rb index 67d6ac761..487bc15af 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Service < ApplicationRecord + include PrefillableFromServicePublicConcern + has_many :procedures belongs_to :administrateur, optional: false diff --git a/app/schemas/service-public.json b/app/schemas/service-public.json new file mode 100644 index 000000000..f5ceb71c5 --- /dev/null +++ b/app/schemas/service-public.json @@ -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"] +} diff --git a/app/services/annuaire_service_public_service.rb b/app/services/annuaire_service_public_service.rb new file mode 100644 index 000000000..7c5728fd8 --- /dev/null +++ b/app/services/annuaire_service_public_service.rb @@ -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 diff --git a/app/services/api_geo_service.rb b/app/services/api_geo_service.rb index c12804c2a..a6d051e36 100644 --- a/app/services/api_geo_service.rb +++ b/app/services/api_geo_service.rb @@ -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) diff --git a/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml b/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml new file mode 100644 index 000000000..f38317634 --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml @@ -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 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml new file mode 100644 index 000000000..92b868dbf --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml @@ -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 diff --git a/spec/models/concerns/prefillable_from_service_public_concern_spec.rb b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb new file mode 100644 index 000000000..2475972ae --- /dev/null +++ b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb @@ -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