Merge pull request #5838 from tchak/structured-adresse

Structured address
This commit is contained in:
Paul Chavard 2021-02-17 19:43:18 +01:00 committed by GitHub
commit 20f4ca8512
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 707 additions and 157 deletions

View file

@ -39,7 +39,8 @@ class API::V2::Schema < GraphQL::Schema
end
end
orphan_types Types::Champs::CarteChampType,
orphan_types Types::Champs::AddressChampType,
Types::Champs::CarteChampType,
Types::Champs::CheckboxChampType,
Types::Champs::CiviliteChampType,
Types::Champs::DateChampType,

View file

@ -1,3 +1,107 @@
type Address {
"""
code INSEE de la commune
"""
cityCode: String!
"""
nom de la commune
"""
cityName: String!
"""
n° de département
"""
departmentCode: String
"""
nom de département
"""
departmentName: String
"""
coordonnées géographique
"""
geometry: GeoJSON
"""
libellé complet de ladresse
"""
label: String!
"""
code postal
"""
postalCode: String!
"""
n° de region
"""
regionCode: String
"""
nom de région
"""
regionName: String
"""
numéro éventuel et nom de voie ou lieu dit
"""
streetAddress: String!
"""
nom de voie ou lieu dit
"""
streetName: String
"""
numéro avec indice de répétition éventuel (bis, ter, A, B)
"""
streetNumber: String
"""
type de résultat trouvé
"""
type: AddressType!
}
type AddressChamp implements Champ {
address: Address
id: ID!
"""
Libellé du champ.
"""
label: String!
"""
La valeur du champ sous forme texte.
"""
stringValue: String
}
enum AddressType {
"""
numéro « à la plaque »
"""
housenumber
"""
lieu-dit
"""
locality
"""
numéro « à la commune »
"""
municipality
"""
position « à la voie », placé approximativement au centre de celle-ci
"""
street
}
type Association {
dateCreation: ISO8601Date!
dateDeclaration: ISO8601Date!
@ -1362,21 +1466,22 @@ type ParcelleCadastrale implements GeoArea {
}
type PersonneMorale implements Demandeur {
adresse: String!
address: Address!
adresse: String! @deprecated(reason: "Utilisez le champ `address.label` à la place.")
association: Association
codeInseeLocalite: String!
codePostal: String!
complementAdresse: String
codeInseeLocalite: String! @deprecated(reason: "Utilisez le champ `address.city_code` à la place.")
codePostal: String! @deprecated(reason: "Utilisez le champ `address.postal_code` à la place.")
complementAdresse: String @deprecated(reason: "Utilisez le champ `address` à la place.")
entreprise: Entreprise
id: ID!
libelleNaf: String!
localite: String!
localite: String! @deprecated(reason: "Utilisez le champ `address.city_name` à la place.")
naf: String!
nomVoie: String
numeroVoie: String
nomVoie: String @deprecated(reason: "Utilisez le champ `address.street_name` à la place.")
numeroVoie: String @deprecated(reason: "Utilisez le champ `address.street_number` à la place.")
siegeSocial: Boolean!
siret: String!
typeVoie: String
typeVoie: String @deprecated(reason: "Utilisez le champ `address.street_address` à la place.")
}
type PersonnePhysique implements Demandeur {

View file

@ -0,0 +1,29 @@
module Types
class AddressType < Types::BaseObject
class AddressTypeType < Types::BaseEnum
value(:housenumber, "numéro « à la plaque »", value: :housenumber)
value(:street, "position « à la voie », placé approximativement au centre de celle-ci", value: :street)
value(:municipality, "numéro « à la commune »", value: :municipality)
value(:locality, "lieu-dit", value: :locality)
end
field :label, String, "libellé complet de ladresse", null: false
field :type, AddressTypeType, "type de résultat trouvé", null: false
field :street_address, String, "numéro éventuel et nom de voie ou lieu dit", null: false
field :street_number, String, "numéro avec indice de répétition éventuel (bis, ter, A, B)", null: true
field :street_name, String, "nom de voie ou lieu dit", null: true
field :postal_code, String, "code postal", null: false
field :city_name, String, "nom de la commune", null: false
field :city_code, String, "code INSEE de la commune", null: false
field :department_name, String, "nom de département", null: true
field :department_code, String, "n° de département", null: true
field :region_name, String, "nom de région", null: true
field :region_code, String, "n° de region", null: true
field :geometry, Types::GeoJSON, "coordonnées géographique", null: true
end
end

View file

@ -9,6 +9,12 @@ module Types
definition_methods do
def resolve_type(object, context)
case object
when ::Champs::AddressChamp
if context.has_fragment?(:AddressChamp)
Types::Champs::AddressChampType
else
Types::Champs::TextChampType
end
when ::Champs::EngagementChamp, ::Champs::YesNoChamp, ::Champs::CheckboxChamp
Types::Champs::CheckboxChampType
when ::Champs::DateChamp

View file

@ -0,0 +1,7 @@
module Types::Champs
class AddressChampType < Types::BaseObject
implements Types::ChampType
field :address, Types::AddressType, null: true
end
end

View file

@ -86,17 +86,34 @@ module Types
field :siege_social, Boolean, null: false
field :naf, String, null: false
field :libelle_naf, String, null: false
field :adresse, String, null: false
field :numero_voie, String, null: true
field :type_voie, String, null: true
field :nom_voie, String, null: true
field :complement_adresse, String, null: true
field :code_postal, String, null: false
field :localite, String, null: false
field :code_insee_localite, String, null: false
field :address, Types::AddressType, null: false
field :entreprise, EntrepriseType, null: true
field :association, AssociationType, null: true
field :adresse, String, null: false, deprecation_reason: "Utilisez le champ `address.label` à la place."
field :numero_voie, String, null: true, deprecation_reason: "Utilisez le champ `address.street_number` à la place."
field :type_voie, String, null: true, deprecation_reason: "Utilisez le champ `address.street_address` à la place."
field :nom_voie, String, null: true, deprecation_reason: "Utilisez le champ `address.street_name` à la place."
field :code_postal, String, null: false, deprecation_reason: "Utilisez le champ `address.postal_code` à la place."
field :localite, String, null: false, deprecation_reason: "Utilisez le champ `address.city_name` à la place."
field :code_insee_localite, String, null: false, deprecation_reason: "Utilisez le champ `address.city_code` à la place."
field :complement_adresse, String, null: true, deprecation_reason: "Utilisez le champ `address` à la place."
def address
{
label: object.adresse,
type: :housenumber,
street_number: object.numero_voie,
street_name: object.nom_voie,
street_address: object.nom_voie.present? ? [object.numero_voie, object.type_voie, object.nom_voie].compact.join(' ') : nil,
postal_code: object.code_postal,
city_name: object.localite,
city_code: object.code_insee_localite
}
end
def entreprise
if object.entreprise_siren.present?
object.entreprise

View file

@ -0,0 +1,11 @@
class ChampFetchExternalDataJob < ApplicationJob
def perform(champ)
if champ.external_id.present?
data = champ.fetch_external_data
if data.present?
champ.update!(data: data)
end
end
end
end

View file

@ -0,0 +1,46 @@
require 'json_schemer'
class APIAddress::AddressAdapter
class InvalidSchemaError < ::StandardError
def initialize(errors)
super(errors.map(&:to_json).join("\n"))
end
end
def initialize(search_term)
@search_term = search_term
end
def to_params
result = Geocoder.search(@search_term, limit: 1).first
if result.present? && result.national_address == @search_term
feature = result.data['features'].first
if schemer.valid?(feature)
{
label: result.national_address,
type: result.result_type,
street_address: result.street_address,
street_number: result.street_number,
street_name: result.street_name,
postal_code: result.postal_code,
city_name: result.city_name,
city_code: result.city_code,
department_name: result.department_name,
department_code: result.department_code,
region_name: result.region_name,
region_code: result.region_code,
geometry: result.geometry
}
else
errors = schemer.validate(feature).to_a
raise InvalidSchemaError.new(errors)
end
end
end
private
def schemer
@schemer ||= JSONSchemer.schema(Rails.root.join('app/schemas/adresse-ban.json'))
end
end

View file

@ -67,6 +67,8 @@ class Champ < ApplicationRecord
before_create :set_dossier_id, if: :needs_dossier_id?
before_validation :set_dossier_id, if: :needs_dossier_id?
before_save :cleanup_if_empty
after_update_commit :fetch_external_data_later
validates :type_de_champ_id, uniqueness: { scope: [:dossier_id, :row] }
@ -143,6 +145,14 @@ class Champ < ApplicationRecord
update_column(:fetch_external_data_exceptions, exceptions)
end
def fetch_external_data?
false
end
def fetch_external_data
raise NotImplemented.new(:fetch_external_data)
end
private
def needs_dossier_id?
@ -152,4 +162,22 @@ class Champ < ApplicationRecord
def set_dossier_id
self.dossier_id = parent.dossier_id
end
def cleanup_if_empty
if external_id_changed?
self.data = nil
end
end
def fetch_external_data_later
if fetch_external_data? && external_id.present? && data.nil?
ChampFetchExternalDataJob.perform_later(self)
end
end
class NotImplemented < ::StandardError
def initialize(method)
super(":#{method} not implemented")
end
end
end

View file

@ -18,4 +18,47 @@
# type_de_champ_id :integer
#
class Champs::AddressChamp < Champs::TextChamp
def full_address?
data.present?
end
def address
full_address? ? data : nil
end
def address_label
full_address? ? data['label'] : value
end
def search_terms
if full_address?
[data['label'], data['departement'], data['region'], data['city']]
else
[address_label]
end
end
def to_s
address_label
end
def for_tag
address_label
end
def for_export
value.present? ? address_label : nil
end
def for_api
address_label
end
def fetch_external_data?
true
end
def fetch_external_data
APIAddress::AddressAdapter.new(external_id).to_params
end
end

View file

@ -18,20 +18,11 @@
# type_de_champ_id :integer
#
class Champs::AnnuaireEducationChamp < Champs::TextChamp
before_save :cleanup_if_empty
after_update_commit :fetch_data
private
def cleanup_if_empty
if external_id_changed?
self.data = nil
end
def fetch_external_data?
true
end
def fetch_data
if external_id.present? && data.nil?
AnnuaireEducationUpdateJob.perform_later(self)
end
def fetch_external_data
ApiEducation::AnnuaireEducationAdapter.new(external_id).to_params
end
end

View file

@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://demarches-simplifiees.fr/adresse-ban.schema.json",
"title": "Adresse BAN",
"type": "object",
"properties": {
"properties": {
"type": "object",
"properties": {
"label": { "type": "string" },
"housenumber": { "type": "string" },
"name": { "type": "string" },
"postcode": { "type": "string" },
"citycode": { "type": "string" },
"city": { "type": "string" },
"district": { "type": "string" },
"context": { "type": "string" },
"type": {
"enum": ["housenumber", "street", "locality", "municipality"]
}
},
"required": ["label", "type", "name", "postcode", "citycode", "city"]
},
"geometry": {
"type": "object",
"properties": {
"type": {
"const": "Point"
},
"coordinates": {
"type": "array",
"minItems": 2,
"maxItems": 2
}
}
}
},
"required": ["properties"]
}

View file

@ -1 +1 @@
Geocoder.configure(lookup: :ban_data_gouv_fr)
Geocoder.configure(lookup: :ban_data_gouv_fr, use_https: true)

View file

@ -0,0 +1,131 @@
{
"type": "FeatureCollection",
"version": "draft",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.347,
48.859
]
},
"properties": {
"label": "Paris",
"score": 0.9704590909090908,
"id": "75056",
"type": "municipality",
"name": "Paris",
"postcode": "75001",
"citycode": "75056",
"x": 652089.7,
"y": 6862305.26,
"population": 2190327,
"city": "Paris",
"context": "75, Paris, Île-de-France",
"importance": 0.67505
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.551187,
48.357318
]
},
"properties": {
"label": "Paris Foret 77760 Achères-la-Forêt",
"score": 0.8608,
"id": "77001_b064",
"name": "Paris Foret",
"postcode": "77760",
"citycode": "77001",
"x": 666753.6,
"y": 6806428.85,
"city": "Achères-la-Forêt",
"context": "77, Seine-et-Marne, Île-de-France",
"type": "street",
"importance": 0.4688
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
6.069561,
43.415211
]
},
"properties": {
"label": "Paris 83170 Brignoles",
"score": 0.8607481818181817,
"id": "83023_05n1tm",
"name": "Paris",
"postcode": "83170",
"citycode": "83023",
"x": 948661.53,
"y": 6262177.77,
"city": "Brignoles",
"context": "83, Var, Provence-Alpes-Côte d'Azur",
"type": "street",
"importance": 0.46823
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-0.418837,
44.758777
]
},
"properties": {
"label": "Paris 33880 Saint-Caprais-de-Bordeaux",
"score": 0.8593318181818181,
"id": "33381_sy62ut",
"name": "Paris",
"postcode": "33880",
"citycode": "33381",
"x": 429522.28,
"y": 6412482.95,
"city": "Saint-Caprais-de-Bordeaux",
"context": "33, Gironde, Nouvelle-Aquitaine",
"type": "street",
"importance": 0.45265
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
0.156876,
47.33671
]
},
"properties": {
"label": "Paris Buton 37140 Bourgueil",
"score": 0.8570918181818181,
"id": "37031_b165",
"name": "Paris Buton",
"postcode": "37140",
"citycode": "37031",
"x": 485353.99,
"y": 6696795.92,
"city": "Bourgueil",
"context": "37, Indre-et-Loire, Centre-Val de Loire",
"type": "street",
"importance": 0.42801
}
}
],
"attribution": "BAN",
"licence": "ETALAB-2.0",
"query": "Paris",
"limit": 5
}

View file

@ -0,0 +1,130 @@
{
"type": "FeatureCollection",
"version": "draft",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.347,
48.859
]
},
"properties": {
"label": "Paris",
"score": 0.9704590909090908,
"id": "75056",
"name": "Paris",
"postcode": "75001",
"citycode": "75056",
"x": 652089.7,
"y": 6862305.26,
"population": 2190327,
"city": "Paris",
"context": "75, Paris, Île-de-France",
"importance": 0.67505
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.551187,
48.357318
]
},
"properties": {
"label": "Paris Foret 77760 Achères-la-Forêt",
"score": 0.8608,
"id": "77001_b064",
"name": "Paris Foret",
"postcode": "77760",
"citycode": "77001",
"x": 666753.6,
"y": 6806428.85,
"city": "Achères-la-Forêt",
"context": "77, Seine-et-Marne, Île-de-France",
"type": "street",
"importance": 0.4688
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
6.069561,
43.415211
]
},
"properties": {
"label": "Paris 83170 Brignoles",
"score": 0.8607481818181817,
"id": "83023_05n1tm",
"name": "Paris",
"postcode": "83170",
"citycode": "83023",
"x": 948661.53,
"y": 6262177.77,
"city": "Brignoles",
"context": "83, Var, Provence-Alpes-Côte d'Azur",
"type": "street",
"importance": 0.46823
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-0.418837,
44.758777
]
},
"properties": {
"label": "Paris 33880 Saint-Caprais-de-Bordeaux",
"score": 0.8593318181818181,
"id": "33381_sy62ut",
"name": "Paris",
"postcode": "33880",
"citycode": "33381",
"x": 429522.28,
"y": 6412482.95,
"city": "Saint-Caprais-de-Bordeaux",
"context": "33, Gironde, Nouvelle-Aquitaine",
"type": "street",
"importance": 0.45265
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
0.156876,
47.33671
]
},
"properties": {
"label": "Paris Buton 37140 Bourgueil",
"score": 0.8570918181818181,
"id": "37031_b165",
"name": "Paris Buton",
"postcode": "37140",
"citycode": "37031",
"x": 485353.99,
"y": 6696795.92,
"city": "Bourgueil",
"context": "37, Indre-et-Loire, Centre-Val de Loire",
"type": "street",
"importance": 0.42801
}
}
],
"attribution": "BAN",
"licence": "ETALAB-2.0",
"query": "Paris",
"limit": 5
}

View file

@ -1,9 +0,0 @@
{
"limit": 5,
"attribution": "BAN",
"version": "draft",
"licence": "ODbL 1.0",
"query": "Paris",
"type": "FeatureCollection",
"features": []
}

View file

@ -1,117 +0,0 @@
{
"limit": 5,
"attribution": "BAN",
"version": "draft",
"licence": "ODbL 1.0",
"query": "Paris",
"type": "FeatureCollection",
"features": [
{
"geometry": {
"type": "Point",
"coordinates": [
2.3469,
48.8589
]
},
"properties": {
"adm_weight": "6",
"citycode": "75056",
"name": "Paris",
"city": "Paris",
"postcode": "75000",
"context": "75, \u00cele-de-France",
"score": 1.0,
"label": "Paris",
"id": "75056",
"type": "city",
"population": "2244"
},
"type": "Feature"
},
{
"geometry": {
"type": "Point",
"coordinates": [
4.366801,
44.425528
]
},
"properties": {
"citycode": "07330",
"postcode": "07150",
"name": "Paris",
"id": "07330_B095_bd3524",
"context": "07, Ard\u00e8che, Rh\u00f4ne-Alpes",
"score": 0.8291454545454544,
"label": "Paris 07150 Vallon-Pont-d'Arc",
"city": "Vallon-Pont-d'Arc",
"type": "locality"
},
"type": "Feature"
},
{
"geometry": {
"type": "Point",
"coordinates": [
3.564293,
45.766413
]
},
"properties": {
"citycode": "63125",
"postcode": "63120",
"name": "Paris",
"city": "Courpi\u00e8re",
"context": "63, Puy-de-D\u00f4me, Auvergne",
"score": 0.8255363636363636,
"label": "Paris 63120 Courpi\u00e8re",
"id": "63125_B221_03549b",
"type": "locality"
},
"type": "Feature"
},
{
"geometry": {
"type": "Point",
"coordinates": [
1.550208,
44.673592
]
},
"properties": {
"citycode": "46138",
"postcode": "46240",
"name": "PARIS (Vaillac)",
"city": "C\u0153ur de Causse",
"context": "46, Lot, Midi-Pyr\u00e9n\u00e9es",
"score": 0.824090909090909,
"label": "PARIS (Vaillac) 46240 C\u0153ur de Causse",
"id": "46138_XXXX_6ee4ec",
"type": "street"
},
"type": "Feature"
},
{
"geometry": {
"type": "Point",
"coordinates": [
-0.526884,
43.762253
]
},
"properties": {
"citycode": "40282",
"postcode": "40500",
"name": "Paris",
"city": "Saint-Sever",
"context": "40, Landes, Aquitaine",
"score": 0.8236181818181818,
"label": "Paris 40500 Saint-Sever",
"id": "40282_B237_2364e3",
"type": "locality"
},
"type": "Feature"
}
]
}

View file

@ -0,0 +1,45 @@
describe APIAddress::AddressAdapter do
let(:search_term) { 'Paris' }
let(:adapter) { described_class.new(search_term) }
subject { adapter.to_params }
before do
Geocoder.configure(lookup: :ban_data_gouv_fr, use_https: true)
stub_request(:get, /https:\/\/api-adresse.data.gouv.fr\/search/)
.to_return(body: body, status: status)
end
after do
Geocoder.configure(lookup: :test)
end
context "when responds with valid schema" do
let(:body) { File.read('spec/fixtures/files/api_address/address.json') }
let(:status) { 200 }
it '#to_params returns a valid' do
expect(subject).to be_an_instance_of(Hash)
expect(subject[:city_name]).to eq(search_term)
expect(subject[:city_code]).to eq('75056')
end
end
context "when responds with an address which is not a direct match to search term" do
let(:body) { File.read('spec/fixtures/files/api_address/address.json') }
let(:status) { 200 }
let(:search_term) { 'Lyon' }
it '#to_params ignores the response' do
expect(subject).to be_nil
end
end
context "when responds with invalid schema" do
let(:body) { File.read('spec/fixtures/files/api_address/address_invalid.json') }
let(:status) { 200 }
it '#to_params raise exception' do
expect { subject }.to raise_exception(APIAddress::AddressAdapter::InvalidSchemaError)
end
end
end

View file

@ -522,4 +522,31 @@ describe Champ do
it { expect(champ.fetch_external_data_exceptions).to eq(['#<StandardError: My special exception!>']) }
end
end
describe "fetch_external_data" do
let(:champ) { create(:champ_text, data: 'some data') }
context "cleanup_if_empty" do
it "remove data if external_id changes" do
expect(champ.data).to_not be_nil
champ.update(external_id: 'external_id')
expect(champ.data).to be_nil
end
end
context "fetch_external_data_later" do
include ActiveJob::TestHelper
let(:data) { 'some other data' }
it "fill data from external source" do
expect(champ).to receive(:fetch_external_data?) { true }
expect_any_instance_of(Champs::TextChamp).to receive(:fetch_external_data) { data }
perform_enqueued_jobs do
champ.update(external_id: 'external_id')
end
expect(champ.reload.data).to eq data
end
end
end
end

View file

@ -0,0 +1,20 @@
describe Champs::AddressChamp do
let(:champ) { Champs::AddressChamp.new(value: value, data: data, type_de_champ: create(:type_de_champ_address)) }
let(:value) { '' }
let(:data) { nil }
context "with value but no data" do
let(:value) { 'Paris' }
it { expect(champ.address_label).to eq('Paris') }
it { expect(champ.full_address?).to be_falsey }
end
context "with value and data" do
let(:value) { 'Paris' }
let(:data) { { label: 'Paris' } }
it { expect(champ.address_label).to eq('Paris') }
it { expect(champ.full_address?).to be_truthy }
end
end