From 8660d4af3075662cdd8062d2ea3cf3e7cf5e55de Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 18 Sep 2019 16:51:45 +0200 Subject: [PATCH 01/39] Instructeur: only export its dossiers --- .../instructeurs/procedures_controller.rb | 8 +++++--- app/models/procedure.rb | 18 +++++++++--------- app/services/procedure_export_service.rb | 4 ++-- app/services/procedure_export_v2_service.rb | 4 ++-- .../instructeurs/procedures_controller_spec.rb | 10 +++++++++- spec/services/procedure_export_service_spec.rb | 2 +- .../procedure_export_v2_service_spec.rb | 2 +- 7 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 414a571df..139d311ed 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -187,17 +187,19 @@ module Instructeurs def download_dossiers options = params.permit(:version, :limit, :since, tables: []) + dossiers = current_instructeur.dossiers.for_procedure(procedure) + respond_to do |format| format.csv do - send_data(procedure.to_csv(options), + send_data(procedure.to_csv(dossiers, options), filename: procedure.export_filename(:csv)) end format.xlsx do - send_data(procedure.to_xlsx(options), + send_data(procedure.to_xlsx(dossiers, options), filename: procedure.export_filename(:xlsx)) end format.ods do - send_data(procedure.to_ods(options), + send_data(procedure.to_ods(dossiers, options), filename: procedure.export_filename(:ods)) end end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 4688cc070..24806f6ba 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -334,26 +334,26 @@ class Procedure < ApplicationRecord "dossiers_#{procedure_identifier}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}" end - def export(options = {}) + def export(dossiers, options = {}) version = options.delete(:version) if version == 'v2' options.delete(:tables) - ProcedureExportV2Service.new(self, **options.to_h.symbolize_keys) + ProcedureExportV2Service.new(self, dossiers, **options.to_h.symbolize_keys) else - ProcedureExportService.new(self, **options.to_h.symbolize_keys) + ProcedureExportService.new(self, dossiers, **options.to_h.symbolize_keys) end end - def to_csv(options = {}) - export(options).to_csv + def to_csv(dossiers, options = {}) + export(dossiers, options).to_csv end - def to_xlsx(options = {}) - export(options).to_xlsx + def to_xlsx(dossiers, options = {}) + export(dossiers, options).to_xlsx end - def to_ods(options = {}) - export(options).to_ods + def to_ods(dossiers, options = {}) + export(dossiers, options).to_ods end def procedure_overview(start_date) diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index e4bf53727..1708e4d4b 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -49,9 +49,9 @@ class ProcedureExportService :prenom ] - def initialize(procedure, tables: [], ids: nil, since: nil, limit: nil) + def initialize(procedure, dossiers, tables: [], ids: nil, since: nil, limit: nil) @procedure = procedure - @dossiers = procedure.dossiers.downloadable_sorted + @dossiers = dossiers.downloadable_sorted if ids @dossiers = @dossiers.where(id: ids) end diff --git a/app/services/procedure_export_v2_service.rb b/app/services/procedure_export_v2_service.rb index 0d7d3b74c..67409b723 100644 --- a/app/services/procedure_export_v2_service.rb +++ b/app/services/procedure_export_v2_service.rb @@ -1,9 +1,9 @@ class ProcedureExportV2Service attr_reader :dossiers - def initialize(procedure, ids: nil, since: nil, limit: nil) + def initialize(procedure, dossiers, ids: nil, since: nil, limit: nil) @procedure = procedure - @dossiers = procedure.dossiers.downloadable_sorted + @dossiers = dossiers.downloadable_sorted if ids @dossiers = @dossiers.where(id: ids) end diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index ed52b8781..c95044d79 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -414,6 +414,9 @@ describe Instructeurs::ProceduresController, type: :controller do describe "#download_dossiers" do let(:instructeur) { create(:instructeur) } let!(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let!(:gi_2) { procedure.groupe_instructeurs.create(label: '2') } + let!(:dossier_1) { create(:dossier, procedure: procedure) } + let!(:dossier_2) { create(:dossier, groupe_instructeur: gi_2) } context "when logged in" do before do @@ -421,7 +424,12 @@ describe Instructeurs::ProceduresController, type: :controller do end context "csv" do - before { get :download_dossiers, params: { procedure_id: procedure.id }, format: 'csv' } + before do + expect_any_instance_of(Procedure).to receive(:to_csv) + .with(instructeur.dossiers.for_procedure(procedure), {}) + + get :download_dossiers, params: { procedure_id: procedure.id }, format: 'csv' + end it { expect(response).to have_http_status(:ok) } end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index ecdf1e332..c8b1425d9 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -4,7 +4,7 @@ describe ProcedureExportService do describe 'to_data' do let(:procedure) { create(:procedure, :published, :with_all_champs) } let(:table) { :dossiers } - subject { ProcedureExportService.new(procedure).to_data(table) } + subject { ProcedureExportService.new(procedure, procedure.dossiers).to_data(table) } let(:headers) { subject[:headers] } let(:data) { subject[:data] } diff --git a/spec/services/procedure_export_v2_service_spec.rb b/spec/services/procedure_export_v2_service_spec.rb index 43483f931..74d7d8e5b 100644 --- a/spec/services/procedure_export_v2_service_spec.rb +++ b/spec/services/procedure_export_v2_service_spec.rb @@ -5,7 +5,7 @@ describe ProcedureExportV2Service do let(:procedure) { create(:procedure, :published, :with_all_champs) } subject do Tempfile.create do |f| - f << ProcedureExportV2Service.new(procedure).to_xlsx + f << ProcedureExportV2Service.new(procedure, procedure.dossiers).to_xlsx f.rewind SimpleXlsxReader.open(f.path) end From 3bc20bdb06ce55a4fd77e7dc6ab1a2edb842b907 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 18 Sep 2019 21:58:23 +0200 Subject: [PATCH 02/39] Procedure.routee? --- app/models/procedure.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 24806f6ba..27d38ab28 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -481,6 +481,10 @@ class Procedure < ApplicationRecord !AssignTo.exists?(groupe_instructeur: groupe_instructeurs) end + def routee? + groupe_instructeurs.count > 1 + end + private def move_type_de_champ_attributes(types_de_champ, type_de_champ, new_index) From f577484aa2765c73d603bd3bc1b578bcc9d0c3e2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 18 Sep 2019 17:52:02 +0200 Subject: [PATCH 03/39] Add groupe_instructeur_label to export v1 --- app/services/procedure_export_service.rb | 13 ++++++++-- .../services/procedure_export_service_spec.rb | 25 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 1708e4d4b..f2a192b1b 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -51,6 +51,13 @@ class ProcedureExportService def initialize(procedure, dossiers, tables: [], ids: nil, since: nil, limit: nil) @procedure = procedure + + @attributes = ATTRIBUTES.dup + + if procedure.routee? + @attributes << :groupe_instructeur_label + end + @dossiers = dossiers.downloadable_sorted if ids @dossiers = @dossiers.where(id: ids) @@ -137,7 +144,7 @@ class ProcedureExportService end def dossiers_headers - headers = ATTRIBUTES.map(&:to_s) + + headers = @attributes.map(&:to_s) + @procedure.types_de_champ.reject(&:exclude_from_export?).map(&:libelle) + @procedure.types_de_champ_private.reject(&:exclude_from_export?).map(&:libelle) + ETABLISSEMENT_ATTRIBUTES.map { |key| "etablissement.#{key}" } + @@ -148,7 +155,7 @@ class ProcedureExportService def dossiers_data @dossiers.map do |dossier| - values = ATTRIBUTES.map do |key| + values = @attributes.map do |key| case key when :email dossier.user.email @@ -168,6 +175,8 @@ class ProcedureExportService dossier.individual&.gender when :emails_instructeurs dossier.followers_instructeurs.map(&:email).join(' ') + when :groupe_instructeur_label + dossier.groupe_instructeur.label else dossier.read_attribute(key) end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index c8b1425d9..235a2baa7 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -19,8 +19,8 @@ describe ProcedureExportService do end context 'dossiers' do - it 'should have headers' do - expect(headers).to eq([ + let(:nominal_header) do + [ :id, :created_at, :updated_at, @@ -86,7 +86,19 @@ describe ProcedureExportService do :entreprise_date_creation, :entreprise_nom, :entreprise_prenom - ]) + ] + end + + it 'should have headers' do + expect(headers).to eq(nominal_header) + end + + context 'with a procedure routee' do + before { procedure.groupe_instructeurs.create(label: '2') } + + let(:routee_header) { nominal_header.insert(nominal_header.index(:textarea), :groupe_instructeur_label) } + + it { expect(headers).to eq(routee_header) } end it 'should have empty values' do @@ -139,6 +151,13 @@ describe ProcedureExportService do ]) end + context 'with a procedure routee' do + before { procedure.groupe_instructeurs.create(label: '2') } + + it { expect(data.first[15]).to eq('défaut') } + it { expect(data.first.count).to eq(dossier_data.count + champs_data.count + etablissement_data.count + 1) } + end + context 'and etablissement' do let!(:dossier) { create(:dossier, :en_instruction, :with_all_champs, :with_entreprise, procedure: procedure) } From a2f82ab8be428f28c68b8d7eb4ce4b637797798c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 18 Sep 2019 21:09:52 +0200 Subject: [PATCH 04/39] Add groupe_instructeur_label to export v2 --- app/models/dossier.rb | 10 ++++++++-- .../procedure_export_v2_service_spec.rb | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 1f5cd63b0..38854e61e 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -447,7 +447,7 @@ class Dossier < ApplicationRecord end def spreadsheet_columns - [ + columns = [ ['ID', id.to_s], ['Email', user.email], ['Civilité', individual&.gender], @@ -462,7 +462,13 @@ class Dossier < ApplicationRecord ['Traité le', :processed_at], ['Motivation de la décision', :motivation], ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] - ] + champs_for_export + annotations_for_export + ] + + if procedure.routee? + columns << ['Groupe instructeur', groupe_instructeur.label] + end + + columns + champs_for_export + annotations_for_export end def champs_for_export diff --git a/spec/services/procedure_export_v2_service_spec.rb b/spec/services/procedure_export_v2_service_spec.rb index 74d7d8e5b..12a3cf945 100644 --- a/spec/services/procedure_export_v2_service_spec.rb +++ b/spec/services/procedure_export_v2_service_spec.rb @@ -34,8 +34,8 @@ describe ProcedureExportV2Service do context 'with dossier' do let!(:dossier) { create(:dossier, :en_instruction, :with_all_champs, :for_individual, procedure: procedure) } - it 'should have headers' do - expect(dossiers_sheet.headers).to eq([ + let(:nominal_headers) do + [ "ID", "Email", "Civilité", @@ -74,7 +74,11 @@ describe ProcedureExportV2Service do "siret", "carte", "text" - ]) + ] + end + + it 'should have headers' do + expect(dossiers_sheet.headers).to match(nominal_headers) end it 'should have data' do @@ -88,6 +92,15 @@ describe ProcedureExportV2Service do expect(en_construction_at).to eq(dossier.en_construction_at.round) expect(en_instruction_at).to eq(dossier.en_instruction_at.round) end + + context 'with a procedure routee' do + before { procedure.groupe_instructeurs.create(label: '2') } + + let(:routee_header) { nominal_headers.insert(nominal_headers.index('textarea'), 'Groupe instructeur') } + + it { expect(dossiers_sheet.headers).to match(routee_header) } + it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') } + end end context 'with etablissement' do From faddbc23ef2cdcfb3bbcbf654422ba1ca5b79055 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 19 Sep 2019 12:20:23 +0200 Subject: [PATCH 05/39] Eager load procedure: :groupe_instructeurs for export --- app/models/dossier.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 38854e61e..6a7ab2243 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -126,7 +126,8 @@ class Dossier < ApplicationRecord champs_private: { etablissement: :champ, type_de_champ: :drop_down_list - } + }, + procedure: :groupe_instructeurs ).order(en_construction_at: 'asc') } scope :en_cours, -> { not_archived.state_en_construction_ou_instruction } From 4f0871dab007086cbf1cab39356b05112e3e06ad Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 08:51:54 +0200 Subject: [PATCH 06/39] Use a single query in dossiers_id_with_notifications (instead of four) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is used in /procedures#show and /procedures#index, to display badges on the “suivis” and “traités” tabs of each procedure. Rails cache helps when it’s the exact same query, but it’s not the case for different tabs. I’m not certain it’ll be a visible performance improvement but it shouldn’t hurt. --- app/models/instructeur.rb | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 6334d9fa7..32080dc76 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -145,30 +145,26 @@ class Instructeur < ApplicationRecord def dossiers_id_with_notifications(dossiers) dossiers = dossiers.followed_by(self) - updated_demandes = dossiers - .joins(:champs) + # Relations passed to #or must be “structurally compatible”, i.e. query the same tables. + joined_dossiers = dossiers + .left_outer_joins(:champs, :champs_private, :avis, :commentaires) + + updated_demandes = joined_dossiers .where('champs.updated_at > follows.demande_seen_at') - updated_annotations = dossiers - .joins(:champs_private) - .where('champs.updated_at > follows.annotations_privees_seen_at') + # We join `:champs` twice, the second time with `has_many :champs_privates`. ActiveRecord generates the SQL: 'LEFT OUTER JOIN "champs" "champs_privates_dossiers" ON …'. We can then use this `champs_privates_dossiers` alias to disambiguate the table in this WHERE clause. + updated_annotations = joined_dossiers + .where('champs_privates_dossiers.updated_at > follows.annotations_privees_seen_at') - updated_avis = dossiers - .joins(:avis) + updated_avis = joined_dossiers .where('avis.updated_at > follows.avis_seen_at') - updated_messagerie = dossiers - .joins(:commentaires) + updated_messagerie = joined_dossiers .where('commentaires.updated_at > follows.messagerie_seen_at') .where.not(commentaires: { email: OLD_CONTACT_EMAIL }) .where.not(commentaires: { email: CONTACT_EMAIL }) - [ - updated_demandes, - updated_annotations, - updated_avis, - updated_messagerie - ].flat_map { |query| query.distinct.ids }.uniq + updated_demandes.or(updated_annotations).or(updated_avis).or(updated_messagerie).ids end def mark_tab_as_seen(dossier, tab) From 8a06c01f13220ddb3ca6c378e1b6019e89ceb1dd Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 10:41:47 +0200 Subject: [PATCH 07/39] Reorder methods in instructeur.rb Move token stuff together, out of notification-related stuff. --- app/models/instructeur.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 32080dc76..88e3587e8 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -137,11 +137,6 @@ class Instructeur < ApplicationRecord Dossier.joins(:groupe_instructeur).where(id: dossiers_id_with_notifications(dossiers)).group('groupe_instructeurs.procedure_id').count end - def create_trusted_device_token - trusted_device_token = trusted_device_tokens.create - trusted_device_token.token - end - def dossiers_id_with_notifications(dossiers) dossiers = dossiers.followed_by(self) @@ -173,11 +168,6 @@ class Instructeur < ApplicationRecord Follow.where(instructeur: self, dossier: dossier).update_all(attributes) end - def young_login_token? - trusted_device_token = trusted_device_tokens.order(created_at: :desc).first - trusted_device_token&.token_young? - end - def email_notification_data groupe_instructeur_with_email_notifications .reduce([]) do |acc, groupe| @@ -199,6 +189,16 @@ class Instructeur < ApplicationRecord end end + def create_trusted_device_token + trusted_device_token = trusted_device_tokens.create + trusted_device_token.token + end + + def young_login_token? + trusted_device_token = trusted_device_tokens.order(created_at: :desc).first + trusted_device_token&.token_young? + end + private def annotations_hash(demande, annotations_privees, avis, messagerie) From 769621de46df7b0bea2765ce47024af171e0be7f Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 10:35:44 +0200 Subject: [PATCH 08/39] =?UTF-8?q?Explicitly=20pass=20the=20=E2=80=9Cstate?= =?UTF-8?q?=E2=80=9D=20param=20in=20notifications=5F*=5Fprocedure=20method?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/instructeur.rb | 12 ++++++------ app/views/instructeurs/procedures/index.html.haml | 2 +- app/views/instructeurs/procedures/show.html.haml | 2 +- spec/models/instructeur_spec.rb | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 88e3587e8..b0f72b9da 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -109,29 +109,29 @@ class Instructeur < ApplicationRecord end end - def notifications_for_procedure(procedure, state = :en_cours) + def notifications_for_procedure(procedure, state) dossiers = case state + when :en_cours + procedure.defaut_groupe_instructeur.dossiers.en_cours when :termine procedure.defaut_groupe_instructeur.dossiers.termine when :not_archived procedure.defaut_groupe_instructeur.dossiers.not_archived when :all procedure.defaut_groupe_instructeur.dossiers - else - procedure.defaut_groupe_instructeur.dossiers.en_cours end dossiers_id_with_notifications(dossiers) end - def notifications_per_procedure(state = :en_cours) + def notifications_per_procedure(state) dossiers = case state + when :en_cours + Dossier.en_cours when :termine Dossier.termine when :not_archived Dossier.not_archived - else - Dossier.en_cours end Dossier.joins(:groupe_instructeur).where(id: dossiers_id_with_notifications(dossiers)).group('groupe_instructeurs.procedure_id').count diff --git a/app/views/instructeurs/procedures/index.html.haml b/app/views/instructeurs/procedures/index.html.haml index 305732605..b219822c3 100644 --- a/app/views/instructeurs/procedures/index.html.haml +++ b/app/views/instructeurs/procedures/index.html.haml @@ -27,7 +27,7 @@ %li %object = link_to(instructeur_procedure_path(p, statut: 'suivis')) do - - if current_instructeur.notifications_per_procedure[p.id].present? + - if current_instructeur.notifications_per_procedure(:en_cours)[p.id].present? %span.notifications{ 'aria-label': "notifications" } - followed_count = @followed_dossiers_count_per_procedure[p.id] || 0 .stats-number diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 6ae142f29..bb2c2f189 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -24,7 +24,7 @@ instructeur_procedure_path(@procedure, statut: 'suivis'), active: @statut == 'suivis', badge: @followed_dossiers.count, - notification: current_instructeur.notifications_for_procedure(@procedure).present?) + notification: current_instructeur.notifications_for_procedure(@procedure, :en_cours).present?) = tab_item(t('pluralize.processed', count: @termines_dossiers.count), instructeur_procedure_path(@procedure, statut: 'traites'), diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index 7d9c36846..b65fab374 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -248,7 +248,7 @@ describe Instructeur, type: :model do instructeur_2.followed_dossiers << dossier end - subject { instructeur.notifications_for_procedure(procedure) } + subject { instructeur.notifications_for_procedure(procedure, :en_cours) } context 'when the instructeur has just followed the dossier' do it { is_expected.to match([]) } @@ -258,8 +258,8 @@ describe Instructeur, type: :model do before { dossier.champs.first.update_attribute('value', 'toto') } it { is_expected.to match([dossier.id]) } - it { expect(instructeur_2.notifications_for_procedure(procedure)).to match([dossier.id]) } - it { expect(instructeur_on_procedure_2.notifications_for_procedure(procedure)).to match([]) } + it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier.id]) } + it { expect(instructeur_on_procedure_2.notifications_for_procedure(procedure, :en_cours)).to match([]) } context 'and there is a modification on private champs' do before { dossier.champs_private.first.update_attribute('value', 'toto') } @@ -273,7 +273,7 @@ describe Instructeur, type: :model do before { follow.update_attribute('demande_seen_at', Time.zone.now) } it { is_expected.to match([]) } - it { expect(instructeur_2.notifications_for_procedure(procedure)).to match([dossier.id]) } + it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier.id]) } end end @@ -315,7 +315,7 @@ describe Instructeur, type: :model do let(:instructeur) { dossier.follows.first.instructeur } let(:procedure) { dossier.procedure } - subject { instructeur.notifications_per_procedure } + subject { instructeur.notifications_per_procedure(:en_cours) } context 'when there is a modification on public champs' do before { dossier.champs.first.update_attribute('value', 'toto') } From a2131d0fcce115ce3aaf3b2d4d1d3552c011d129 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Wed, 14 Aug 2019 18:02:37 +0200 Subject: [PATCH 09/39] =?UTF-8?q?Don=E2=80=99t=20unfollow=20when=20archivi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was slightly broken in two ways: - it doesn’t make other instructeurs unfollow the dossier - if the dossier is unarchived, instructeurs don’t refollow automatically. --- app/controllers/instructeurs/dossiers_controller.rb | 1 - spec/controllers/instructeurs/dossiers_controller_spec.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index ac8343631..400c06eb5 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -78,7 +78,6 @@ module Instructeurs def archive dossier.update(archived: true) - current_instructeur.unfollow(dossier) redirect_back(fallback_location: instructeur_procedures_url) end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 01033f0b1..e3fae8012 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -82,7 +82,6 @@ describe Instructeurs::DossiersController, type: :controller do it { expect(dossier.archived).to be true } it { expect(response).to redirect_to(instructeur_procedures_url) } - it { expect(instructeur.followed_dossiers).not_to include(dossier) } end describe '#unarchive' do From f74fde3f809c63b39dc78166c089d8b47a019556 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 10:45:44 +0200 Subject: [PATCH 10/39] Avoid sending notification emails about archived dossiers --- app/models/instructeur.rb | 2 +- spec/models/instructeur_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index b0f72b9da..ff9254a5d 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -176,7 +176,7 @@ class Instructeur < ApplicationRecord h = { nb_en_construction: groupe.dossiers.en_construction.count, - nb_notification: notifications_for_procedure(procedure, :all).count + nb_notification: notifications_for_procedure(procedure, :not_archived).count } if h[:nb_en_construction] > 0 || h[:nb_notification] > 0 diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index b65fab374..f218cb4b6 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -389,7 +389,7 @@ describe Instructeur, type: :model do context 'when a notification exists' do before do allow(instructeur).to receive(:notifications_for_procedure) - .with(procedure_to_assign, :all) + .with(procedure_to_assign, :not_archived) .and_return([1, 2, 3]) end From 00b1aa0ea175d3399a67ce9a49b35d5887d2348d Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Mon, 23 Sep 2019 14:25:04 +0200 Subject: [PATCH 11/39] Disable turbolinks for links to Stats pages This is a workaround for #350 --- app/views/instructeurs/procedures/show.html.haml | 2 +- app/views/layouts/_footer.html.haml | 2 +- app/views/root/_footer.html.haml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index bb2c2f189..5239c1e8d 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -11,7 +11,7 @@ %h1= procedure_libelle @procedure = link_to 'gestion des notifications', email_notifications_instructeur_procedure_path(@procedure), class: 'header-link' | - = link_to 'statistiques', stats_instructeur_procedure_path(@procedure), class: 'header-link' + = link_to 'statistiques', stats_instructeur_procedure_path(@procedure), class: 'header-link', data: { turbolinks: false } # Turbolinks disabled for Chartkick. See Issue #350 %ul.tabs diff --git a/app/views/layouts/_footer.html.haml b/app/views/layouts/_footer.html.haml index 4310d5bd8..711e0da84 100644 --- a/app/views/layouts/_footer.html.haml +++ b/app/views/layouts/_footer.html.haml @@ -5,7 +5,7 @@ \- = link_to 'Nouveautés', 'https://github.com/betagouv/demarches-simplifiees.fr/releases', target: '_blank' \- - = link_to 'Statistiques', stats_path + = link_to 'Statistiques', stats_path, data: { turbolinks: false } # Turbolinks disabled for Chartkick. See Issue #350 \- = link_to 'CGU / Mentions légales', CGU_URL \- diff --git a/app/views/root/_footer.html.haml b/app/views/root/_footer.html.haml index 08934bef8..ac7462fec 100644 --- a/app/views/root/_footer.html.haml +++ b/app/views/root/_footer.html.haml @@ -23,7 +23,7 @@ %li.footer-link = link_to "Nouveautés", "https://github.com/betagouv/demarches-simplifiees.fr/releases", :class => "footer-link" %li.footer-link - = link_to "Statistiques", stats_path, :class => "footer-link" + = link_to "Statistiques", stats_path, :class => "footer-link", data: { turbolinks: false } # Turbolinks disabled for Chartkick. See Issue #350 %li.footer-link = link_to "CGU", CGU_URL, :class => "footer-link", :target => "_blank", rel: "noopener noreferrer" %li.footer-link From e962fb26e00ae2ba49cbae436b8357eecd0ef7a6 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Mon, 23 Sep 2019 15:36:27 +0200 Subject: [PATCH 12/39] =?UTF-8?q?Allow=20sorting=20Dossiers=20by=20?= =?UTF-8?q?=E2=80=9Chas=20notifications=E2=80=9D=20in=20`traites`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #4351 --- app/views/instructeurs/procedures/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 5239c1e8d..ec5cc79c3 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -85,7 +85,7 @@ %table.table.dossiers-table.hoverable %thead %tr - - if @statut == 'suivis' || @statut == 'tous' + - if @statut.in? %w(suivis traites tous) = render partial: "header_field", locals: { field: { "label" => "●", "table" => "notifications", "column" => "notifications" }, classname: "notification-col" } - else %th.notification-col From 7f09d0e430ea9351daaaf36d99ccf4fcb58e79d8 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 20:54:29 +0100 Subject: [PATCH 13/39] Add graphql gems --- Gemfile | 5 +++++ Gemfile.lock | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Gemfile b/Gemfile index 0286da028..455fe2e23 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,10 @@ gem 'flipper-ui' gem 'fog-openstack' gem 'font-awesome-rails' gem 'gon' +gem 'graphiql-rails' +gem 'graphql' +gem 'graphql-batch' +gem 'graphql-rails_logger' gem 'groupdate' gem 'haml-rails' gem 'hashie' @@ -105,6 +109,7 @@ end group :development, :test do gem 'byebug' # Call 'byebug' anywhere in the code to stop execution and get a debugger console + gem 'graphql-schema_comparator' gem 'mina', git: 'https://github.com/mina-deploy/mina.git', require: false # Deploy gem 'pry-byebug' gem 'rspec-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 4692f4edf..fdc04cea0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -250,6 +250,22 @@ GEM actionpack (>= 3.0) multi_json request_store (>= 1.0) + graphiql-rails (1.7.0) + railties + sprockets-rails + graphql (1.9.10) + graphql-batch (0.4.1) + graphql (>= 1.3, < 2) + promise.rb (~> 0.7.2) + graphql-rails_logger (1.2.0) + actionpack (~> 5.0) + activesupport (~> 5.0) + railties (~> 5.0) + rouge (~> 3.0) + graphql-schema_comparator (0.6.1) + bundler (>= 1.14) + graphql (~> 1.6) + thor (>= 0.19, < 2.0) groupdate (4.1.1) activesupport (>= 4.2) guard (2.15.0) @@ -430,6 +446,7 @@ GEM premailer-rails (1.10.2) actionmailer (>= 3, < 6) premailer (~> 1.7, >= 1.7.9) + promise.rb (0.7.4) pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -517,6 +534,7 @@ GEM activesupport (>= 3.0) builder (>= 3.0) rubyzip (>= 1.0) + rouge (3.9.0) rspec (3.8.0) rspec-core (~> 3.8.0) rspec-expectations (~> 3.8.0) @@ -737,6 +755,11 @@ DEPENDENCIES fog-openstack font-awesome-rails gon + graphiql-rails + graphql + graphql-batch + graphql-rails_logger + graphql-schema_comparator groupdate guard guard-livereload From 2e854f2ac3b4224484d9f50bd08d937826e46b30 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 20:55:24 +0100 Subject: [PATCH 14/39] Add graph loading scopes to Dossier and Procedure --- app/models/dossier.rb | 3 +++ app/models/procedure.rb | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 6a7ab2243..a4c3296aa 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -163,6 +163,9 @@ class Dossier < ApplicationRecord } scope :for_procedure, -> (procedure) { includes(:user, :groupe_instructeur).where(groupe_instructeurs: { procedure: procedure }) } + scope :for_api_v2, -> { + includes(procedure: [:administrateurs], etablissement: [], individual: []) + } accepts_nested_attributes_for :individual diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 27d38ab28..550a5e32d 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -62,6 +62,10 @@ class Procedure < ApplicationRecord accepte: 'accepte' } + scope :for_api_v2, -> { + includes(administrateurs: :user) + } + validates :libelle, presence: true, allow_blank: false, allow_nil: false validates :description, presence: true, allow_blank: false, allow_nil: false validates :administrateurs, presence: true From d2fdaacb5d2011a4ebeddf111acda0a3bda46696 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 20:58:51 +0100 Subject: [PATCH 15/39] Add Api::V2::Schema --- app/graphql/api/v2/schema.rb | 26 ++++++++++++++++++++++++++ lib/tasks/graphql.rake | 2 ++ package.json | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 app/graphql/api/v2/schema.rb create mode 100644 lib/tasks/graphql.rake diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb new file mode 100644 index 000000000..740cff27a --- /dev/null +++ b/app/graphql/api/v2/schema.rb @@ -0,0 +1,26 @@ +class Api::V2::Schema < GraphQL::Schema + default_max_page_size 100 + max_complexity 300 + max_depth 15 + + def self.unauthorized_object(error) + # Add a top-level error to the response instead of returning nil: + raise GraphQL::ExecutionError.new("An object of type #{error.type.graphql_name} was hidden due to permissions", extensions: { code: :unauthorized }) + end + + middleware(GraphQL::Schema::TimeoutMiddleware.new(max_seconds: 5) do |_, query| + Rails.logger.info("GraphQL Timeout: #{query.query_string}") + end) + + if Rails.env.development? + query_analyzer(GraphQL::Analysis::QueryComplexity.new do |_, complexity| + Rails.logger.info("[GraphQL Query Complexity] #{complexity}") + end) + query_analyzer(GraphQL::Analysis::QueryDepth.new do |_, depth| + Rails.logger.info("[GraphQL Query Depth] #{depth}") + end) + end + + use GraphQL::Batch + use GraphQL::Tracing::SkylightTracing +end diff --git a/lib/tasks/graphql.rake b/lib/tasks/graphql.rake new file mode 100644 index 000000000..a029b14b8 --- /dev/null +++ b/lib/tasks/graphql.rake @@ -0,0 +1,2 @@ +require "graphql/rake_task" +GraphQL::RakeTask.new(schema_name: "Api::V2::Schema", directory: 'app/graphql') diff --git a/package.json b/package.json index d0d84c66b..986da2f62 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "webpack-dev-server": "^3.7.2" }, "scripts": { - "lint:ec": "eclint check $({ git ls-files ; find vendor -type f ; echo 'db/schema.rb' ; } | sort | uniq -u)", + "lint:ec": "eclint check $({ git ls-files | grep -v app/graphql/schema.graphql ; find vendor -type f ; echo 'db/schema.rb' ; } | sort | uniq -u)", "lint:js": "eslint ./app/javascript ./app/assets/javascripts ./config/webpack" }, "engines": { From 9bb52dfb8c7014292eae2a7455cbefe7885f2238 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 20 Nov 2018 22:59:13 +0100 Subject: [PATCH 16/39] Add graphql base types --- app/graphql/mutations/base_mutation.rb | 4 ++++ app/graphql/types/base_enum.rb | 4 ++++ app/graphql/types/base_input_object.rb | 4 ++++ app/graphql/types/base_interface.rb | 5 +++++ app/graphql/types/base_object.rb | 4 ++++ app/graphql/types/base_scalar.rb | 4 ++++ app/graphql/types/base_union.rb | 4 ++++ app/graphql/types/url.rb | 18 ++++++++++++++++++ 8 files changed, 47 insertions(+) create mode 100644 app/graphql/mutations/base_mutation.rb create mode 100644 app/graphql/types/base_enum.rb create mode 100644 app/graphql/types/base_input_object.rb create mode 100644 app/graphql/types/base_interface.rb create mode 100644 app/graphql/types/base_object.rb create mode 100644 app/graphql/types/base_scalar.rb create mode 100644 app/graphql/types/base_union.rb create mode 100644 app/graphql/types/url.rb diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb new file mode 100644 index 000000000..84d59a361 --- /dev/null +++ b/app/graphql/mutations/base_mutation.rb @@ -0,0 +1,4 @@ +module Mutations + class BaseMutation < GraphQL::Schema::Mutation + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 000000000..b45a845f7 --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,4 @@ +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 000000000..309e336e6 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,4 @@ +module Types + class BaseInputObject < GraphQL::Schema::InputObject + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 000000000..69e72dc58 --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,5 @@ +module Types + module BaseInterface + include GraphQL::Schema::Interface + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 000000000..40a81ccd2 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,4 @@ +module Types + class BaseObject < GraphQL::Schema::Object + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 000000000..c0aa38be2 --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,4 @@ +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 000000000..36337fc6e --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,4 @@ +module Types + class BaseUnion < GraphQL::Schema::Union + end +end diff --git a/app/graphql/types/url.rb b/app/graphql/types/url.rb new file mode 100644 index 000000000..287b5789e --- /dev/null +++ b/app/graphql/types/url.rb @@ -0,0 +1,18 @@ +module Types + class URL < Types::BaseScalar + description "A valid URL, transported as a string" + + def self.coerce_input(input_value, context) + url = URI.parse(input_value) + if url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS) + url + else + raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid URL" + end + end + + def self.coerce_result(ruby_value, context) + ruby_value.to_s + end + end +end From 52e84f2ffe8541ea49a086f48b946b7dafef3d90 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 20 Nov 2018 22:27:40 +0100 Subject: [PATCH 17/39] Add graphql object types --- app/graphql/extensions/attachment.rb | 36 ++++++++++++++ app/graphql/loaders/association.rb | 66 ++++++++++++++++++++++++++ app/graphql/loaders/record.rb | 30 ++++++++++++ app/graphql/types/demarche_type.rb | 50 +++++++++++++++++++ app/graphql/types/dossier_type.rb | 42 ++++++++++++++++ app/graphql/types/profile_type.rb | 6 +++ config/locales/models/dossier/fr.yml | 6 +++ config/locales/models/procedure/fr.yml | 4 ++ 8 files changed, 240 insertions(+) create mode 100644 app/graphql/extensions/attachment.rb create mode 100644 app/graphql/loaders/association.rb create mode 100644 app/graphql/loaders/record.rb create mode 100644 app/graphql/types/demarche_type.rb create mode 100644 app/graphql/types/dossier_type.rb create mode 100644 app/graphql/types/profile_type.rb diff --git a/app/graphql/extensions/attachment.rb b/app/graphql/extensions/attachment.rb new file mode 100644 index 000000000..f0ac99aed --- /dev/null +++ b/app/graphql/extensions/attachment.rb @@ -0,0 +1,36 @@ +# references: +# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls + +module Extensions + class Attachment < GraphQL::Schema::FieldExtension + attr_reader :attachment_assoc + + def apply + # Here we try to define the attachment name: + # - it could be set explicitly via extension options + # - or we imply that is the same as the field name w/o "_url" + # suffix (e.g., "avatar_url" => "avatar") + attachment = options&.[](:attachment) || field.original_name.to_s.sub(/_url$/, "") + + # that's the name of the Active Record association + @attachment_assoc = "#{attachment}_attachment" + end + + # This method resolves (as it states) the field itself + # (it's the same as defining a method within a type) + def resolve(object:, **_rest) + Loaders::Association.for( + object.object.class, + attachment_assoc => :blob + ).load(object.object) + end + + # This method is called if the result of the `resolve` + # is a lazy value (e.g., a Promise – like in our case) + def after_resolve(value:, **_rest) + return if value.nil? + + Rails.application.routes.url_helpers.url_for(value) + end + end +end diff --git a/app/graphql/loaders/association.rb b/app/graphql/loaders/association.rb new file mode 100644 index 000000000..693bc9b58 --- /dev/null +++ b/app/graphql/loaders/association.rb @@ -0,0 +1,66 @@ +# references: +# https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb +# https://gist.github.com/palkan/03eb5306a1a3e8addbe8df97a298a466 +# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls + +module Loaders + class Association < GraphQL::Batch::Loader + def self.validate(model, association_name) + new(model, association_name) + nil + end + + def initialize(model, association_schema) + @model = model + @association_schema = association_schema + @association_name = extract_association_id(association_schema) + validate + end + + def load(record) + raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model) + return Promise.resolve(read_association(record)) if association_loaded?(record) + super + end + + # We want to load the associations on all records, even if they have the same id + def cache_key(record) + record.object_id + end + + def perform(records) + preload_association(records.uniq) + records.each { |record| fulfill(record, read_association(record)) } + end + + private + + def validate + unless @model.reflect_on_association(@association_name) + raise ArgumentError, "No association #{@association_name} on #{@model}" + end + end + + def preload_association(records) + ::ActiveRecord::Associations::Preloader.new.preload(records, @association_schema) + end + + def read_association(record) + record.public_send(@association_name) + end + + def association_loaded?(record) + record.association(@association_name).loaded? + end + + def extract_association_id(id_or_hash) + return id_or_hash unless id_or_hash.is_a?(Hash) + + if id_or_hash.keys.size != 1 + raise ArgumentError, "You can only preload exactly one association! You passed: #{id_or_hash}" + end + + id_or_hash.keys.first + end + end +end diff --git a/app/graphql/loaders/record.rb b/app/graphql/loaders/record.rb new file mode 100644 index 000000000..9602a3b60 --- /dev/null +++ b/app/graphql/loaders/record.rb @@ -0,0 +1,30 @@ +# references: +# https://github.com/Shopify/graphql-batch/blob/master/examples/record_loader.rb + +module Loaders + class Record < GraphQL::Batch::Loader + def initialize(model, column: model.primary_key, where: nil) + @model = model + @column = column.to_s + @column_type = model.type_for_attribute(@column) + @where = where + end + + def load(key) + super(@column_type.cast(key)) + end + + def perform(keys) + query(keys).each { |record| fulfill(record.public_send(@column), record) } + keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } + end + + private + + def query(keys) + scope = @model + scope = scope.where(@where) if @where + scope.where(@column => keys) + end + end +end diff --git a/app/graphql/types/demarche_type.rb b/app/graphql/types/demarche_type.rb new file mode 100644 index 000000000..2f561b052 --- /dev/null +++ b/app/graphql/types/demarche_type.rb @@ -0,0 +1,50 @@ +module Types + class DemarcheType < Types::BaseObject + class DemarcheState < Types::BaseEnum + Procedure.aasm.states.reject { |state| state.name == :hidden }.each do |state| + value(state.name.to_s, state.display_name, value: state.name) + end + end + + description "Une demarche" + + global_id_field :id + field :number, ID, "Le numero de la démarche.", null: false, method: :id + field :title, String, null: false, method: :libelle + field :description, String, "Déscription de la démarche.", null: false + field :state, DemarcheState, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :archived_at, GraphQL::Types::ISO8601DateTime, null: true + + field :instructeurs, [Types::ProfileType], null: false + + field :dossiers, Types::DossierType.connection_type, "Liste de tous les dossiers d'une démarche.", null: false do + argument :ids, [ID], required: false, description: "Filtrer les dossiers par ID." + argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers crées depuis la date." + end + + def state + object.aasm.current_state + end + + def instructeurs + Loaders::Association.for(Procedure, :instructeurs).load(object) + end + + def dossiers(ids: nil, since: nil) + dossiers = object.dossiers.for_api_v2 + + if ids.present? + dossiers = dossiers.where(id: ids) + end + + if since.present? + dossiers = dossiers.since(since) + end + + dossiers + end + end +end diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb new file mode 100644 index 000000000..9d44f1f69 --- /dev/null +++ b/app/graphql/types/dossier_type.rb @@ -0,0 +1,42 @@ +module Types + class DossierType < Types::BaseObject + class DossierState < Types::BaseEnum + Dossier.aasm.states.reject { |state| state.name == :brouillon }.each do |state| + value(state.name.to_s, state.display_name, value: state.name.to_s) + end + end + + description "Un dossier" + + global_id_field :id + field :number, ID, "Le numero du dossier.", null: false, method: :id + field :state, DossierState, "L'état du dossier.", null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, "Date de dernière mise à jour.", null: false + + field :date_passage_en_construction, GraphQL::Types::ISO8601DateTime, "Date de dépôt.", null: false, method: :en_construction_at + field :date_passage_en_instruction, GraphQL::Types::ISO8601DateTime, "Date de passage en instruction.", null: true, method: :en_instruction_at + field :date_traitement, GraphQL::Types::ISO8601DateTime, "Date de traitement.", null: true, method: :processed_at + + field :archived, Boolean, null: false + + field :motivation, String, null: true + field :motivation_attachment_url, Types::URL, null: true, extensions: [ + { Extensions::Attachment => { attachment: :justificatif_motivation } } + ] + + field :usager, Types::ProfileType, null: false + field :instructeurs, [Types::ProfileType], null: false + + def state + object.state + end + + def usager + Loaders::Record.for(User).load(object.user_id) + end + + def instructeurs + Loaders::Association.for(object.class, :followers_instructeurs).load(object) + end + end +end diff --git a/app/graphql/types/profile_type.rb b/app/graphql/types/profile_type.rb new file mode 100644 index 000000000..f3f056fd3 --- /dev/null +++ b/app/graphql/types/profile_type.rb @@ -0,0 +1,6 @@ +module Types + class ProfileType < Types::BaseObject + global_id_field :id + field :email, String, null: false + end +end diff --git a/config/locales/models/dossier/fr.yml b/config/locales/models/dossier/fr.yml index 3fc73da1e..729d6ec12 100644 --- a/config/locales/models/dossier/fr.yml +++ b/config/locales/models/dossier/fr.yml @@ -17,3 +17,9 @@ fr: refuse: "Refusé" sans_suite: "Sans suite" autorisation_donnees: Acceptation des CGU + state/brouillon: Brouillon + state/en_construction: En construction + state/en_instruction: En instruction + state/accepte: Accepté + state/refuse: Refusé + state/sans_suite: Sans suite diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index f06c0a673..ce2e28fd7 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -10,3 +10,7 @@ fr: organisation: Organisme duree_conservation_dossiers_dans_ds: Durée de conservation des dossiers sur demarches-simplifiees.fr duree_conservation_dossiers_hors_ds: Durée de conservation des dossiers hors demarches-simplifiees.fr + aasm_state/brouillon: Brouillon + aasm_state/publiee: Publiée + aasm_state/archivee: Archivée + aasm_state/hidden: Suprimée From e06051bc96b71d69d86c5423cbfd9b0b902729da Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 21:06:13 +0100 Subject: [PATCH 18/39] Add graphql root types --- app/graphql/api/v2/schema.rb | 3 +++ app/graphql/types/mutation_type.rb | 4 ++++ app/graphql/types/query_type.rb | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 app/graphql/types/mutation_type.rb create mode 100644 app/graphql/types/query_type.rb diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 740cff27a..a47c3cc4c 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -3,6 +3,9 @@ class Api::V2::Schema < GraphQL::Schema max_complexity 300 max_depth 15 + query Types::QueryType + mutation Types::MutationType + def self.unauthorized_object(error) # Add a top-level error to the response instead of returning nil: raise GraphQL::ExecutionError.new("An object of type #{error.type.graphql_name} was hidden due to permissions", extensions: { code: :unauthorized }) diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 000000000..113861978 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,4 @@ +module Types + class MutationType < Types::BaseObject + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 000000000..9d29ef0f8 --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,27 @@ +module Types + class QueryType < Types::BaseObject + field :demarche, DemarcheType, null: false, description: "Informations concernant une démarche." do + argument :number, ID, "Numéro de la démarche.", required: true + end + + field :dossier, DossierType, null: false, description: "Informations sur un dossier d'une démarche." do + argument :number, ID, "Numéro du dossier.", required: true + end + + def demarche(number:) + Procedure.for_api_v2.find(number) + rescue => e + raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found }) + end + + def dossier(number:) + Dossier.for_api_v2.find(number) + rescue => e + raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found }) + end + + def self.accessible?(context) + context[:token] || context[:administrateur_id] + end + end +end From a7fc4df09beab983cc91091fa1bb3d5e44e26c64 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 20 Nov 2018 22:59:51 +0100 Subject: [PATCH 19/39] Resolve models and types --- app/graphql/api/v2/schema.rb | 23 +++++++++++++++++++++++ app/models/application_record.rb | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index a47c3cc4c..b3cdbbbea 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -6,6 +6,29 @@ class Api::V2::Schema < GraphQL::Schema query Types::QueryType mutation Types::MutationType + def self.id_from_object(object, type_definition, ctx) + object.to_typed_id + end + + def self.object_from_id(id, query_ctx) + ApplicationRecord.record_from_typed_id(id) + rescue => e + raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found }) + end + + def self.resolve_type(type, obj, ctx) + case obj + when Procedure + Types::DemarcheType + when Dossier + Types::DossierType + when Instructeur, User + Types::ProfileType + else + raise GraphQL::ExecutionError.new("Unexpected object: #{obj}") + end + end + def self.unauthorized_object(error) # Add a top-level error to the response instead of returning nil: raise GraphQL::ExecutionError.new("An object of type #{error.type.graphql_name} was hidden due to permissions", extensions: { code: :unauthorized }) diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 10a4cba84..cece5f813 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,19 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + def self.record_from_typed_id(id) + class_name, record_id = GraphQL::Schema::UniqueWithinType.decode(id) + + if defined?(class_name) + Object.const_get(class_name).find(record_id) + else + raise ActiveRecord::RecordNotFound, "Unexpected object: #{class_name}" + end + rescue => e + raise ActiveRecord::RecordNotFound, e.message + end + + def to_typed_id + GraphQL::Schema::UniqueWithinType.encode(self.class.name, id) + end end From ba683a107c72095270eb3223d2a12dcc58a491bc Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 22 Nov 2018 18:43:53 +0100 Subject: [PATCH 20/39] Add authorizations to root queries --- app/graphql/types/base_object.rb | 21 +++++++++++++++++++++ app/graphql/types/demarche_type.rb | 4 ++++ app/graphql/types/dossier_type.rb | 4 ++++ 3 files changed, 29 insertions(+) diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index 40a81ccd2..d4dc9aaba 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -1,4 +1,25 @@ module Types class BaseObject < GraphQL::Schema::Object + def self.authorized_demarche?(demarche, context) + # We are caching authorization logic because it is called for each node + # of the requested graph and can be expensive. Context is reset per request so it is safe. + context[:authorized] ||= {} + if context[:authorized][demarche.id] + return true + end + + administrateur = demarche.administrateurs.find do |administrateur| + if context[:token] + administrateur.valid_api_token?(context[:token]) + else + administrateur.id == context[:administrateur_id] + end + end + + if administrateur + context[:authorized][demarche.id] = true + true + end + end end end diff --git a/app/graphql/types/demarche_type.rb b/app/graphql/types/demarche_type.rb index 2f561b052..92633d67d 100644 --- a/app/graphql/types/demarche_type.rb +++ b/app/graphql/types/demarche_type.rb @@ -46,5 +46,9 @@ module Types dossiers end + + def self.authorized?(object, context) + authorized_demarche?(object, context) + end end end diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb index 9d44f1f69..14da77748 100644 --- a/app/graphql/types/dossier_type.rb +++ b/app/graphql/types/dossier_type.rb @@ -38,5 +38,9 @@ module Types def instructeurs Loaders::Association.for(object.class, :followers_instructeurs).load(object) end + + def self.authorized?(object, context) + authorized_demarche?(object.procedure, context) + end end end From 5a7eb734fff941acba16b8a85ac9d625761f19fb Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 20 Nov 2018 22:21:38 +0100 Subject: [PATCH 21/39] Dump graphql schema for reference --- app/graphql/schema.graphql | 251 +++++++++++++++++++++++++++++++++ spec/lib/tasks/graphql_spec.rb | 11 ++ 2 files changed, 262 insertions(+) create mode 100644 app/graphql/schema.graphql create mode 100644 spec/lib/tasks/graphql_spec.rb diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql new file mode 100644 index 000000000..9b4f2080c --- /dev/null +++ b/app/graphql/schema.graphql @@ -0,0 +1,251 @@ +""" +Une demarche +""" +type Demarche { + archivedAt: ISO8601DateTime + createdAt: ISO8601DateTime! + + """ + Lien vers le texte qui justifie le droit de collecter les données demandées dans votre démarche auprès des usagers. + """ + deliberationUrl: URL + + """ + Déscription de la démarche. + """ + description: String! + + """ + Liste de tous les dossiers d'une démarche. + """ + dossiers( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Filtrer les dossiers par ID. + """ + ids: [ID!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Dossiers crées depuis la date. + """ + since: ISO8601DateTime + ): DossierConnection! + + """ + L'ID de la démarche. + """ + id: ID! + instructeurs: [Profile!]! + + """ + Lien public de la démarche. + """ + link: URL + + """ + Lien vers la notice explicative de la démarche. + """ + noticeUrl: URL + + """ + Lien vers le site internet de la démarche. + """ + siteWebUrl: URL + state: DemarcheState! + title: String! + updatedAt: ISO8601DateTime! +} + +enum DemarcheState { + """ + Archivee + """ + archivee + + """ + Brouillon + """ + brouillon + + """ + Hidden + """ + hidden + + """ + Publiee + """ + publiee +} + +""" +Un dossier +""" +type Dossier { + createdAt: ISO8601DateTime! + + """ + L'ID du dossier. + """ + id: ID! + instructeurs: [Profile!]! + motivation: String + + """ + L'état du dossier. + """ + state: DossierState! + updatedAt: ISO8601DateTime! + usager: Profile! +} + +""" +The connection type for Dossier. +""" +type DossierConnection { + """ + A list of edges. + """ + edges: [DossierEdge] + + """ + A list of nodes. + """ + nodes: [Dossier] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type DossierEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Dossier +} + +enum DossierState { + """ + Accepté + """ + accepte + + """ + Brouillon + """ + brouillon + + """ + En construction + """ + en_construction + + """ + En instruction + """ + en_instruction + + """ + Refusé + """ + refuse + + """ + Sans suite + """ + sans_suite +} + +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime + +type Mutation { +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String +} + +type Profile { + email: String! + id: ID! +} + +type Query { + """ + Informations concernant une démarche. + """ + demarche( + """ + Numéro de la démarche. + """ + id: ID! + ): Demarche! + + """ + Informations sur un dossier d'une démarche. + """ + dossier( + """ + Numéro du dossier. + """ + id: ID! + ): Dossier! +} + +""" +A valid URL, transported as a string +""" +scalar URL diff --git a/spec/lib/tasks/graphql_spec.rb b/spec/lib/tasks/graphql_spec.rb new file mode 100644 index 000000000..3543a6de5 --- /dev/null +++ b/spec/lib/tasks/graphql_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe 'graphql' do + let(:current_defn) { Api::V2::Schema.to_definition } + let(:printout_defn) { File.read(Rails.root.join('app', 'graphql', 'schema.graphql')) } + + it "update the printed schema with `bin/rake graphql:schema:idl`" do + result = GraphQL::SchemaComparator.compare(current_defn, printout_defn) + expect(result.identical?).to be_truthy + end +end From bf6fbbf2b6e405de23569bb95cb6774629eaad8b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 21:18:17 +0100 Subject: [PATCH 22/39] Add graphql end point --- app/controllers/api/v2/base_controller.rb | 20 +++++++++ app/controllers/api/v2/graphql_controller.rb | 45 ++++++++++++++++++++ config/routes.rb | 4 ++ 3 files changed, 69 insertions(+) create mode 100644 app/controllers/api/v2/base_controller.rb create mode 100644 app/controllers/api/v2/graphql_controller.rb diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb new file mode 100644 index 000000000..a14859081 --- /dev/null +++ b/app/controllers/api/v2/base_controller.rb @@ -0,0 +1,20 @@ +class API::V2::BaseController < ApplicationController + protect_from_forgery with: :null_session + + private + + def context + { + administrateur_id: current_administrateur&.id, + token: authorization_bearer_token + } + end + + def authorization_bearer_token + received_token = nil + authenticate_with_http_token do |token, _options| + received_token = token + end + received_token + end +end diff --git a/app/controllers/api/v2/graphql_controller.rb b/app/controllers/api/v2/graphql_controller.rb new file mode 100644 index 000000000..2e2f783bd --- /dev/null +++ b/app/controllers/api/v2/graphql_controller.rb @@ -0,0 +1,45 @@ +class API::V2::GraphqlController < API::V2::BaseController + def execute + variables = ensure_hash(params[:variables]) + + result = Api::V2::Schema.execute(params[:query], + variables: variables, + context: context, + operation_name: params[:operationName]) + + render json: result + rescue => e + if Rails.env.development? + handle_error_in_development e + else + raise e + end + end + + private + + # Handle form data, JSON body, or a blank value + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash, ActionController::Parameters + ambiguous_param + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end + + def handle_error_in_development(e) + logger.error e.message + logger.error e.backtrace.join("\n") + + render json: { error: { message: e.message, backtrace: e.backtrace }, data: {} }, status: 500 + end +end diff --git a/config/routes.rb b/config/routes.rb index bf0542172..6e0a33d20 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -225,6 +225,10 @@ Rails.application.routes.draw do resources :dossiers, only: [:index, :show] end end + + namespace :v2 do + post :graphql, to: "graphql#execute" + end end # From 91ad9bd7d3d9ff0fd1cce9605373b4ca04e1cdfb Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 20:55:45 +0100 Subject: [PATCH 23/39] Configure GraphQL::RailsLogger --- config/initializers/graphql.rb | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/initializers/graphql.rb diff --git a/config/initializers/graphql.rb b/config/initializers/graphql.rb new file mode 100644 index 000000000..e669da38b --- /dev/null +++ b/config/initializers/graphql.rb @@ -0,0 +1,5 @@ +GraphQL::RailsLogger.configure do |config| + config.white_list = { + 'API::V2::GraphqlController' => ['execute'] + } +end From a51fc75628a006c2e06676a8a8f112b00b036e30 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 19 Nov 2018 21:18:39 +0100 Subject: [PATCH 24/39] Expose graphiql --- config/routes.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index 6e0a33d20..3a4beb78b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -219,6 +219,10 @@ Rails.application.routes.draw do # API # + authenticated :user, lambda { |user| user.administrateur_id } do + mount GraphiQL::Rails::Engine, at: "/graphql", graphql_path: "/api/v2/graphql", via: :get + end + namespace :api do namespace :v1 do resources :procedures, only: [:index, :show] do From 8928eaba11835b643e0c4066ff834c4f05dfdd4a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 23 Nov 2018 18:55:54 +0100 Subject: [PATCH 25/39] Add demarche champs and annotations types --- app/graphql/schema.graphql | 147 +++++++++++++++++++++ app/graphql/types/champ_descriptor_type.rb | 17 +++ app/graphql/types/demarche_type.rb | 3 + app/models/type_de_champ.rb | 4 + 4 files changed, 171 insertions(+) create mode 100644 app/graphql/types/champ_descriptor_type.rb diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 9b4f2080c..4c9fe67c8 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -1,8 +1,18 @@ +type ChampInfo { + description: String + id: ID! + label: String! + required: Boolean! + type: TypeDeChamp! +} + """ Une demarche """ type Demarche { + annotations: [ChampInfo!]! archivedAt: ISO8601DateTime + champs: [ChampInfo!]! createdAt: ISO8601DateTime! """ @@ -245,6 +255,143 @@ type Query { ): Dossier! } +enum TypeDeChamp { + """ + Adresse + """ + address + + """ + Carte + """ + carte + + """ + Case à cocher + """ + checkbox + + """ + Civilité + """ + civilite + + """ + Date + """ + date + + """ + Date et Heure + """ + datetime + + """ + Nombre décimal + """ + decimal_number + + """ + Départements + """ + departements + + """ + Lien vers un autre dossier + """ + dossier_link + + """ + Menu déroulant + """ + drop_down_list + + """ + Email + """ + email + + """ + Engagement + """ + engagement + + """ + Explication + """ + explication + + """ + Titre de section + """ + header_section + + """ + Nombre entier + """ + integer_number + + """ + Deux menus déroulants liés + """ + linked_drop_down_list + + """ + Menu déroulant à choix multiples + """ + multiple_drop_down_list + + """ + Nombre entier + """ + number + + """ + Pays + """ + pays + + """ + Téléphone + """ + phone + + """ + Pièce justificative + """ + piece_justificative + + """ + Régions + """ + regions + + """ + Bloc répétable + """ + repetition + + """ + SIRET + """ + siret + + """ + Texte + """ + text + + """ + Zone de texte + """ + textarea + + """ + Oui/Non + """ + yes_no +} + """ A valid URL, transported as a string """ diff --git a/app/graphql/types/champ_descriptor_type.rb b/app/graphql/types/champ_descriptor_type.rb new file mode 100644 index 000000000..2a9a18db1 --- /dev/null +++ b/app/graphql/types/champ_descriptor_type.rb @@ -0,0 +1,17 @@ +module Types + class ChampDescriptorType < Types::BaseObject + class TypeDeChampType < Types::BaseEnum + TypeDeChamp.type_champs.each do |symbol_name, string_name| + value(string_name, + I18n.t(symbol_name, scope: [:activerecord, :attributes, :type_de_champ, :type_champs]), + value: symbol_name) + end + end + + global_id_field :id + field :type, TypeDeChampType, null: false, method: :type_champ + field :label, String, null: false, method: :libelle + field :description, String, null: true + field :required, Boolean, null: false, method: :mandatory? + end +end diff --git a/app/graphql/types/demarche_type.rb b/app/graphql/types/demarche_type.rb index 92633d67d..6e5dabcb3 100644 --- a/app/graphql/types/demarche_type.rb +++ b/app/graphql/types/demarche_type.rb @@ -25,6 +25,9 @@ module Types argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers crées depuis la date." end + field :champ_descriptors, [Types::ChampDescriptorType], null: false, method: :types_de_champ + field :annotation_descriptors, [Types::ChampDescriptorType], null: false, method: :types_de_champ_private + def state object.aasm.current_state end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 924672ec9..6d7903eaa 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -185,6 +185,10 @@ class TypeDeChamp < ApplicationRecord self.drop_down_list_attributes = { value: value } end + def to_typed_id + GraphQL::Schema::UniqueWithinType.encode('Champ', stable_id) + end + private def set_default_drop_down_list From 1c10718c118f2565f7f325348a24734200966040 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 23 Nov 2018 18:54:51 +0100 Subject: [PATCH 26/39] Add messages and avis types --- app/graphql/api/v2/schema.rb | 2 ++ app/graphql/schema.graphql | 17 +++++++++++++++++ app/graphql/types/avis_type.rb | 12 ++++++++++++ app/graphql/types/dossier_type.rb | 11 +++++++++++ app/graphql/types/message_type.rb | 15 +++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 app/graphql/types/avis_type.rb create mode 100644 app/graphql/types/message_type.rb diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index b3cdbbbea..7ae8535e0 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -22,6 +22,8 @@ class Api::V2::Schema < GraphQL::Schema Types::DemarcheType when Dossier Types::DossierType + when Commentaire + Types::MessageType when Instructeur, User Types::ProfileType else diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 4c9fe67c8..5e74530be 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -1,3 +1,11 @@ +type Avis { + answer: String + createdAt: ISO8601DateTime! + email: String! + question: String! + updatedAt: ISO8601DateTime! +} + type ChampInfo { description: String id: ID! @@ -111,6 +119,7 @@ enum DemarcheState { Un dossier """ type Dossier { + avis: [Avis!]! createdAt: ISO8601DateTime! """ @@ -118,6 +127,7 @@ type Dossier { """ id: ID! instructeurs: [Profile!]! + messages: [Message!]! motivation: String """ @@ -200,6 +210,13 @@ An ISO 8601-encoded datetime """ scalar ISO8601DateTime +type Message { + attachment: URL + body: String! + createdAt: ISO8601DateTime! + email: String! +} + type Mutation { } diff --git a/app/graphql/types/avis_type.rb b/app/graphql/types/avis_type.rb new file mode 100644 index 000000000..3e6371427 --- /dev/null +++ b/app/graphql/types/avis_type.rb @@ -0,0 +1,12 @@ +module Types + class AvisType < Types::BaseObject + global_id_field :id + field :email, String, null: false + field :question, String, null: false, method: :introduction + field :answer, String, null: true + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :attachment_url, Types::URL, null: true, extensions: [ + { Extensions::Attachment => { attachment: :piece_justificative_file } } + ] + end +end diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb index 14da77748..57fcec248 100644 --- a/app/graphql/types/dossier_type.rb +++ b/app/graphql/types/dossier_type.rb @@ -27,6 +27,9 @@ module Types field :usager, Types::ProfileType, null: false field :instructeurs, [Types::ProfileType], null: false + field :messages, [Types::MessageType], null: false + field :avis, [Types::AvisType], null: false + def state object.state end @@ -39,6 +42,14 @@ module Types Loaders::Association.for(object.class, :followers_instructeurs).load(object) end + def messages + Loaders::Association.for(object.class, commentaires: [:instructeur, :user]).load(object) + end + + def avis + Loaders::Association.for(object.class, avis: [:instructeur, :claimant]).load(object) + end + def self.authorized?(object, context) authorized_demarche?(object.procedure, context) end diff --git a/app/graphql/types/message_type.rb b/app/graphql/types/message_type.rb new file mode 100644 index 000000000..0c5ab3472 --- /dev/null +++ b/app/graphql/types/message_type.rb @@ -0,0 +1,15 @@ +module Types + class MessageType < Types::BaseObject + global_id_field :id + field :email, String, null: false + field :body, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :attachment_url, Types::URL, null: true, extensions: [ + { Extensions::Attachment => { attachment: :piece_jointe } } + ] + + def body + object.body.nil? ? "" : object.body + end + end +end From eb7aba18e6f224588abfbb571ed66282468f1656 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 28 Aug 2019 11:25:41 +0200 Subject: [PATCH 27/39] Add dossier champs and annotations types --- app/graphql/api/v2/schema.rb | 16 ++ app/graphql/schema.graphql | 150 ++++++++++++++++++ app/graphql/types/champ_type.rb | 40 +++++ app/graphql/types/champs/carte_champ_type.rb | 11 ++ .../types/champs/checkbox_champ_type.rb | 16 ++ app/graphql/types/champs/date_champ_type.rb | 13 ++ .../types/champs/decimal_number_champ_type.rb | 13 ++ .../types/champs/dossier_link_champ_type.rb | 13 ++ .../types/champs/integer_number_champ_type.rb | 13 ++ .../linked_drop_down_list_champ_type.rb | 8 + .../multiple_drop_down_list_champ_type.rb | 7 + .../champs/piece_justificative_champ_type.rb | 10 ++ .../types/champs/repetition_champ_type.rb | 11 ++ app/graphql/types/champs/siret_champ_type.rb | 13 ++ app/graphql/types/champs/text_champ_type.rb | 7 + app/graphql/types/dossier_type.rb | 11 ++ app/graphql/types/geo_area_type.rb | 30 ++++ .../geo_areas/parcelle_cadastrale_type.rb | 15 ++ .../geo_areas/quartier_prioritaire_type.rb | 9 ++ .../geo_areas/selection_utilisateur_type.rb | 5 + app/graphql/types/geo_json.rb | 14 ++ app/graphql/types/personne_morale_type.rb | 16 ++ app/models/champ.rb | 8 + app/models/champs/yes_no_champ.rb | 4 + app/models/dossier.rb | 4 +- 25 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 app/graphql/types/champ_type.rb create mode 100644 app/graphql/types/champs/carte_champ_type.rb create mode 100644 app/graphql/types/champs/checkbox_champ_type.rb create mode 100644 app/graphql/types/champs/date_champ_type.rb create mode 100644 app/graphql/types/champs/decimal_number_champ_type.rb create mode 100644 app/graphql/types/champs/dossier_link_champ_type.rb create mode 100644 app/graphql/types/champs/integer_number_champ_type.rb create mode 100644 app/graphql/types/champs/linked_drop_down_list_champ_type.rb create mode 100644 app/graphql/types/champs/multiple_drop_down_list_champ_type.rb create mode 100644 app/graphql/types/champs/piece_justificative_champ_type.rb create mode 100644 app/graphql/types/champs/repetition_champ_type.rb create mode 100644 app/graphql/types/champs/siret_champ_type.rb create mode 100644 app/graphql/types/champs/text_champ_type.rb create mode 100644 app/graphql/types/geo_area_type.rb create mode 100644 app/graphql/types/geo_areas/parcelle_cadastrale_type.rb create mode 100644 app/graphql/types/geo_areas/quartier_prioritaire_type.rb create mode 100644 app/graphql/types/geo_areas/selection_utilisateur_type.rb create mode 100644 app/graphql/types/geo_json.rb create mode 100644 app/graphql/types/personne_morale_type.rb diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 7ae8535e0..390d60c44 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -31,6 +31,22 @@ class Api::V2::Schema < GraphQL::Schema end end + orphan_types Types::Champs::CarteChampType, + Types::Champs::CheckboxChampType, + Types::Champs::DateChampType, + Types::Champs::DecimalNumberChampType, + Types::Champs::DossierLinkChampType, + Types::Champs::IntegerNumberChampType, + Types::Champs::LinkedDropDownListChampType, + Types::Champs::MultipleDropDownListChampType, + Types::Champs::PieceJustificativeChampType, + Types::Champs::RepetitionChampType, + Types::Champs::SiretChampType, + Types::Champs::TextChampType, + Types::GeoAreas::ParcelleCadastraleType, + Types::GeoAreas::QuartierPrioritaireType, + Types::GeoAreas::SelectionUtilisateurType + def self.unauthorized_object(error) # Add a top-level error to the response instead of returning nil: raise GraphQL::ExecutionError.new("An object of type #{error.type.graphql_name} was hidden due to permissions", extensions: { code: :unauthorized }) diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 5e74530be..ff39081bc 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -6,6 +6,17 @@ type Avis { updatedAt: ISO8601DateTime! } +type CarteChamp implements Champ { + geoAreas: [GeoArea!]! + id: ID! + label: String! +} + +interface Champ { + id: ID! + label: String! +} + type ChampInfo { description: String id: ID! @@ -14,6 +25,29 @@ type ChampInfo { type: TypeDeChamp! } +type CheckboxChamp implements Champ { + id: ID! + label: String! + value: Boolean! +} + +""" +GeoJSON coordinates +""" +scalar Coordinates + +type DateChamp implements Champ { + id: ID! + label: String! + value: ISO8601DateTime +} + +type DecimalNumberChamp implements Champ { + id: ID! + label: String! + value: Float +} + """ Une demarche """ @@ -119,7 +153,9 @@ enum DemarcheState { Un dossier """ type Dossier { + annotations: [Champ!]! avis: [Avis!]! + champs: [Champ!]! createdAt: ISO8601DateTime! """ @@ -173,6 +209,12 @@ type DossierEdge { node: Dossier } +type DossierLinkChamp implements Champ { + dossier: Dossier + id: ID! + label: String! +} + enum DossierState { """ Accepté @@ -205,11 +247,50 @@ enum DossierState { sans_suite } +interface GeoArea { + geometry: GeoJSON! + id: ID + source: GeoAreaSource! +} + +enum GeoAreaSource { + """ + translation missing: fr.activerecord.attributes.geo_area.source.cadastre + """ + cadastre + + """ + translation missing: fr.activerecord.attributes.geo_area.source.parcelle_agricole + """ + parcelle_agricole + + """ + translation missing: fr.activerecord.attributes.geo_area.source.quartier_prioritaire + """ + quartier_prioritaire + + """ + translation missing: fr.activerecord.attributes.geo_area.source.selection_utilisateur + """ + selection_utilisateur +} + +type GeoJSON { + coordinates: Coordinates! + type: String! +} + """ An ISO 8601-encoded datetime """ scalar ISO8601DateTime +type IntegerNumberChamp implements Champ { + id: ID! + label: String! + value: Int +} + type Message { attachment: URL body: String! @@ -245,11 +326,56 @@ type PageInfo { startCursor: String } +type ParcelleCadastrale implements GeoArea { + codeArr: String! + codeCom: String! + codeDep: String! + feuille: Int! + geometry: GeoJSON! + id: ID + nomCom: String! + numero: String! + section: String! + source: GeoAreaSource! + surfaceIntersection: Float! + surfaceParcelle: Float! +} + +type PersonneMorale { + adresse: String! + codeInseeLocalite: String! + codePostal: String! + complementAdresse: String! + libelleNaf: String! + localite: String! + naf: String! + nomVoie: String! + numeroVoie: String! + siegeSocial: String! + siret: String! + typeVoie: String! +} + +type PieceJustificativeChamp implements Champ { + id: ID! + label: String! + url: URL +} + type Profile { email: String! id: ID! } +type QuartierPrioritaire implements GeoArea { + code: String! + commune: String! + geometry: GeoJSON! + id: ID + nom: String! + source: GeoAreaSource! +} + type Query { """ Informations concernant une démarche. @@ -272,6 +398,30 @@ type Query { ): Dossier! } +type RepetitionChamp implements Champ { + champs: [Champ!]! + id: ID! + label: String! +} + +type SelectionUtilisateur implements GeoArea { + geometry: GeoJSON! + id: ID + source: GeoAreaSource! +} + +type SiretChamp implements Champ { + etablissement: PersonneMorale + id: ID! + label: String! +} + +type TextChamp implements Champ { + id: ID! + label: String! + value: String +} + enum TypeDeChamp { """ Adresse diff --git a/app/graphql/types/champ_type.rb b/app/graphql/types/champ_type.rb new file mode 100644 index 000000000..dc76378d9 --- /dev/null +++ b/app/graphql/types/champ_type.rb @@ -0,0 +1,40 @@ +module Types + module ChampType + include Types::BaseInterface + + global_id_field :id + field :label, String, null: false, method: :libelle + field :string_value, String, null: true, method: :for_api_v2 + + definition_methods do + def resolve_type(object, context) + case object + when ::Champs::EngagementChamp, ::Champs::YesNoChamp, ::Champs::CheckboxChamp + Types::Champs::CheckboxChampType + when ::Champs::DateChamp, ::Champs::DatetimeChamp + Types::Champs::DateChampType + when ::Champs::DossierLinkChamp + Types::Champs::DossierLinkChampType + when ::Champs::PieceJustificativeChamp + Types::Champs::PieceJustificativeChampType + when ::Champs::CarteChamp + Types::Champs::CarteChampType + when ::Champs::NumberChamp, ::Champs::IntegerNumberChamp + Types::Champs::IntegerNumberChampType + when ::Champs::DecimalNumberChamp + Types::Champs::DecimalNumberChampType + when ::Champs::SiretChamp + Types::Champs::SiretChampType + when ::Champs::RepetitionChamp + Types::Champs::RepetitionChampType + when ::Champs::MultipleDropDownListChamp + Types::Champs::MultipleDropDownListChampType + when ::Champs::LinkedDropDownListChamp + Types::Champs::LinkedDropDownListChampType + else + Types::Champs::TextChampType + end + end + end + end +end diff --git a/app/graphql/types/champs/carte_champ_type.rb b/app/graphql/types/champs/carte_champ_type.rb new file mode 100644 index 000000000..f7b4aab7b --- /dev/null +++ b/app/graphql/types/champs/carte_champ_type.rb @@ -0,0 +1,11 @@ +module Types::Champs + class CarteChampType < Types::BaseObject + implements Types::ChampType + + field :geo_areas, [Types::GeoAreaType], null: false + + def geo_areas + Loaders::Association.for(Champs::CarteChamp, :geo_areas).load(object) + end + end +end diff --git a/app/graphql/types/champs/checkbox_champ_type.rb b/app/graphql/types/champs/checkbox_champ_type.rb new file mode 100644 index 000000000..30706da19 --- /dev/null +++ b/app/graphql/types/champs/checkbox_champ_type.rb @@ -0,0 +1,16 @@ +module Types::Champs + class CheckboxChampType < Types::BaseObject + implements Types::ChampType + + field :value, Boolean, null: false + + def value + case object.value + when 'true', 'on', '1' + true + else + false + end + end + end +end diff --git a/app/graphql/types/champs/date_champ_type.rb b/app/graphql/types/champs/date_champ_type.rb new file mode 100644 index 000000000..042de0546 --- /dev/null +++ b/app/graphql/types/champs/date_champ_type.rb @@ -0,0 +1,13 @@ +module Types::Champs + class DateChampType < Types::BaseObject + implements Types::ChampType + + field :value, GraphQL::Types::ISO8601DateTime, null: true + + def value + if object.value.present? + Time.zone.parse(object.value) + end + end + end +end diff --git a/app/graphql/types/champs/decimal_number_champ_type.rb b/app/graphql/types/champs/decimal_number_champ_type.rb new file mode 100644 index 000000000..1351c2d17 --- /dev/null +++ b/app/graphql/types/champs/decimal_number_champ_type.rb @@ -0,0 +1,13 @@ +module Types::Champs + class DecimalNumberChampType < Types::BaseObject + implements Types::ChampType + + field :value, Float, null: true + + def value + if object.value.present? + object.value.to_f + end + end + end +end diff --git a/app/graphql/types/champs/dossier_link_champ_type.rb b/app/graphql/types/champs/dossier_link_champ_type.rb new file mode 100644 index 000000000..8737d2ab4 --- /dev/null +++ b/app/graphql/types/champs/dossier_link_champ_type.rb @@ -0,0 +1,13 @@ +module Types::Champs + class DossierLinkChampType < Types::BaseObject + implements Types::ChampType + + field :dossier, Types::DossierType, null: true + + def dossier + if object.value.present? + Loaders::Record.for(Dossier).load(object.value) + end + end + end +end diff --git a/app/graphql/types/champs/integer_number_champ_type.rb b/app/graphql/types/champs/integer_number_champ_type.rb new file mode 100644 index 000000000..d9d1790af --- /dev/null +++ b/app/graphql/types/champs/integer_number_champ_type.rb @@ -0,0 +1,13 @@ +module Types::Champs + class IntegerNumberChampType < Types::BaseObject + implements Types::ChampType + + field :value, Int, null: true + + def value + if object.value.present? + object.value.to_i + end + end + end +end diff --git a/app/graphql/types/champs/linked_drop_down_list_champ_type.rb b/app/graphql/types/champs/linked_drop_down_list_champ_type.rb new file mode 100644 index 000000000..1ff054fa8 --- /dev/null +++ b/app/graphql/types/champs/linked_drop_down_list_champ_type.rb @@ -0,0 +1,8 @@ +module Types::Champs + class LinkedDropDownListChampType < Types::BaseObject + implements Types::ChampType + + field :primary_value, String, null: true + field :secondary_value, String, null: true + end +end diff --git a/app/graphql/types/champs/multiple_drop_down_list_champ_type.rb b/app/graphql/types/champs/multiple_drop_down_list_champ_type.rb new file mode 100644 index 000000000..e0d8e815d --- /dev/null +++ b/app/graphql/types/champs/multiple_drop_down_list_champ_type.rb @@ -0,0 +1,7 @@ +module Types::Champs + class MultipleDropDownListChampType < Types::BaseObject + implements Types::ChampType + + field :values, [String], null: false, method: :selected_options + end +end diff --git a/app/graphql/types/champs/piece_justificative_champ_type.rb b/app/graphql/types/champs/piece_justificative_champ_type.rb new file mode 100644 index 000000000..5062125e7 --- /dev/null +++ b/app/graphql/types/champs/piece_justificative_champ_type.rb @@ -0,0 +1,10 @@ +module Types::Champs + class PieceJustificativeChampType < Types::BaseObject + include Rails.application.routes.url_helpers + implements Types::ChampType + + field :url, Types::URL, null: true, extensions: [ + { Extensions::Attachment => { attachment: :piece_justificative_file } } + ] + end +end diff --git a/app/graphql/types/champs/repetition_champ_type.rb b/app/graphql/types/champs/repetition_champ_type.rb new file mode 100644 index 000000000..53a554afc --- /dev/null +++ b/app/graphql/types/champs/repetition_champ_type.rb @@ -0,0 +1,11 @@ +module Types::Champs + class RepetitionChampType < Types::BaseObject + implements Types::ChampType + + field :champs, [Types::ChampType], null: false + + def champs + Loaders::Association.for(object.class, :champs).load(object) + end + end +end diff --git a/app/graphql/types/champs/siret_champ_type.rb b/app/graphql/types/champs/siret_champ_type.rb new file mode 100644 index 000000000..045ac9a45 --- /dev/null +++ b/app/graphql/types/champs/siret_champ_type.rb @@ -0,0 +1,13 @@ +module Types::Champs + class SiretChampType < Types::BaseObject + implements Types::ChampType + + field :etablissement, Types::PersonneMoraleType, null: true + + def etablissement + if object.etablissement_id.present? + Loaders::Record.for(Etablissement).load(object.etablissement_id) + end + end + end +end diff --git a/app/graphql/types/champs/text_champ_type.rb b/app/graphql/types/champs/text_champ_type.rb new file mode 100644 index 000000000..9f19db52d --- /dev/null +++ b/app/graphql/types/champs/text_champ_type.rb @@ -0,0 +1,7 @@ +module Types::Champs + class TextChampType < Types::BaseObject + implements Types::ChampType + + field :value, String, null: true + end +end diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb index 57fcec248..30892ca55 100644 --- a/app/graphql/types/dossier_type.rb +++ b/app/graphql/types/dossier_type.rb @@ -27,6 +27,9 @@ module Types field :usager, Types::ProfileType, null: false field :instructeurs, [Types::ProfileType], null: false + field :champs, [Types::ChampType], null: false + field :annotations, [Types::ChampType], null: false + field :messages, [Types::MessageType], null: false field :avis, [Types::AvisType], null: false @@ -50,6 +53,14 @@ module Types Loaders::Association.for(object.class, avis: [:instructeur, :claimant]).load(object) end + def champs + Loaders::Association.for(object.class, :champs).load(object) + end + + def annotations + Loaders::Association.for(object.class, :champs_private).load(object) + end + def self.authorized?(object, context) authorized_demarche?(object.procedure, context) end diff --git a/app/graphql/types/geo_area_type.rb b/app/graphql/types/geo_area_type.rb new file mode 100644 index 000000000..1d55b2fac --- /dev/null +++ b/app/graphql/types/geo_area_type.rb @@ -0,0 +1,30 @@ +module Types + module GeoAreaType + include Types::BaseInterface + + class GeoAreaSource < Types::BaseEnum + GeoArea.sources.each do |symbol_name, string_name| + value(string_name, + I18n.t(symbol_name, scope: [:activerecord, :attributes, :geo_area, :source]), + value: symbol_name) + end + end + + global_id_field :id + field :source, GeoAreaSource, null: false + field :geometry, Types::GeoJSON, null: false + + definition_methods do + def resolve_type(object, context) + case object.source + when GeoArea.sources.fetch(:cadastre) + Types::GeoAreas::ParcelleCadastraleType + when GeoArea.sources.fetch(:quartier_prioritaire) + Types::GeoAreas::QuartierPrioritaireType + when GeoArea.sources.fetch(:selection_utilisateur) + Types::GeoAreas::SelectionUtilisateurType + end + end + end + end +end diff --git a/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb b/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb new file mode 100644 index 000000000..22b40c5d9 --- /dev/null +++ b/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb @@ -0,0 +1,15 @@ +module Types::GeoAreas + class ParcelleCadastraleType < Types::BaseObject + implements Types::GeoAreaType + + field :surface_intersection, Float, null: false + field :surface_parcelle, Float, null: false + field :numero, String, null: false + field :feuille, Int, null: false + field :section, String, null: false + field :code_dep, String, null: false + field :nom_com, String, null: false + field :code_com, String, null: false + field :code_arr, String, null: false + end +end diff --git a/app/graphql/types/geo_areas/quartier_prioritaire_type.rb b/app/graphql/types/geo_areas/quartier_prioritaire_type.rb new file mode 100644 index 000000000..682ea7319 --- /dev/null +++ b/app/graphql/types/geo_areas/quartier_prioritaire_type.rb @@ -0,0 +1,9 @@ +module Types::GeoAreas + class QuartierPrioritaireType < Types::BaseObject + implements Types::GeoAreaType + + field :code, String, null: false + field :nom, String, null: false + field :commune, String, null: false + end +end diff --git a/app/graphql/types/geo_areas/selection_utilisateur_type.rb b/app/graphql/types/geo_areas/selection_utilisateur_type.rb new file mode 100644 index 000000000..004f07583 --- /dev/null +++ b/app/graphql/types/geo_areas/selection_utilisateur_type.rb @@ -0,0 +1,5 @@ +module Types::GeoAreas + class SelectionUtilisateurType < Types::BaseObject + implements Types::GeoAreaType + end +end diff --git a/app/graphql/types/geo_json.rb b/app/graphql/types/geo_json.rb new file mode 100644 index 000000000..dbb96220c --- /dev/null +++ b/app/graphql/types/geo_json.rb @@ -0,0 +1,14 @@ +module Types + class GeoJSON < Types::BaseObject + class CoordinatesType < Types::BaseScalar + description "GeoJSON coordinates" + + def self.coerce_result(ruby_value, context) + ruby_value + end + end + + field :type, String, null: false + field :coordinates, CoordinatesType, null: false + end +end diff --git a/app/graphql/types/personne_morale_type.rb b/app/graphql/types/personne_morale_type.rb new file mode 100644 index 000000000..999fafee3 --- /dev/null +++ b/app/graphql/types/personne_morale_type.rb @@ -0,0 +1,16 @@ +module Types + class PersonneMoraleType < Types::BaseObject + field :siret, String, null: false + field :siege_social, String, null: false + field :naf, String, null: false + field :libelle_naf, String, null: false + field :adresse, String, null: false + field :numero_voie, String, null: false + field :type_voie, String, null: false + field :nom_voie, String, null: false + field :complement_adresse, String, null: false + field :code_postal, String, null: false + field :localite, String, null: false + field :code_insee_localite, String, null: false + end +end diff --git a/app/models/champ.rb b/app/models/champ.rb index b67164328..e64ecd583 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -54,6 +54,10 @@ class Champ < ApplicationRecord value end + def for_api_v2 + to_s + end + def for_tag value.present? ? value.to_s : '' end @@ -62,6 +66,10 @@ class Champ < ApplicationRecord :value end + def to_typed_id + type_de_champ.to_typed_id + end + private def needs_dossier_id? diff --git a/app/models/champs/yes_no_champ.rb b/app/models/champs/yes_no_champ.rb index 6ba7534b7..8dec6c5ef 100644 --- a/app/models/champs/yes_no_champ.rb +++ b/app/models/champs/yes_no_champ.rb @@ -21,6 +21,10 @@ class Champs::YesNoChamp < Champ value == 'true' end + def for_api_v2 + true? ? 'true' : 'false' + end + private def processed_value diff --git a/app/models/dossier.rb b/app/models/dossier.rb index a4c3296aa..0da01b6e4 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -163,9 +163,7 @@ class Dossier < ApplicationRecord } scope :for_procedure, -> (procedure) { includes(:user, :groupe_instructeur).where(groupe_instructeurs: { procedure: procedure }) } - scope :for_api_v2, -> { - includes(procedure: [:administrateurs], etablissement: [], individual: []) - } + scope :for_api_v2, -> { includes(procedure: [:administrateurs], etablissement: [], individual: []) } accepts_nested_attributes_for :individual From 5a7e41547495ccaa04b8e6aec506bc1413efd32c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 19 Sep 2019 13:35:21 +0200 Subject: [PATCH 28/39] Put graphql behind a feature flag --- app/graphql/types/base_object.rb | 2 +- config/initializers/flipper.rb | 1 + config/routes.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index d4dc9aaba..ba994e214 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -16,7 +16,7 @@ module Types end end - if administrateur + if administrateur && Flipper.enabled?(:administrateur_graphql, administrateur.user) context[:authorized][demarche.id] = true true end diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index dee6e022e..53d6f2820 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -26,6 +26,7 @@ end # A list of features to be deployed on first push features = [ :administrateur_champ_integer_number, + :administrateur_graphql, :administrateur_web_hook, :insee_api_v3, :instructeur_bypass_email_login_token, diff --git a/config/routes.rb b/config/routes.rb index 3a4beb78b..595299fcd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -219,7 +219,7 @@ Rails.application.routes.draw do # API # - authenticated :user, lambda { |user| user.administrateur_id } do + authenticated :user, lambda { |user| user.administrateur_id && Flipper.enabled?(:administrateur_graphql, user) } do mount GraphiQL::Rails::Engine, at: "/graphql", graphql_path: "/api/v2/graphql", via: :get end From 4b885f7a125a31b8ae031ea4ab42afe37eee69fc Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 18 Sep 2019 18:45:24 +0200 Subject: [PATCH 29/39] Add basic graphql test --- .../api/v2/graphql_controller_spec.rb | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 spec/controllers/api/v2/graphql_controller_spec.rb diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb new file mode 100644 index 000000000..d7df04f8e --- /dev/null +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -0,0 +1,179 @@ +require 'spec_helper' + +describe API::V2::GraphqlController do + let(:admin) { create(:administrateur) } + let(:token) { admin.renew_api_token } + let(:procedure) { create(:procedure, :with_all_champs, administrateurs: [admin]) } + let(:dossier) do + dossier = create(:dossier, + :en_construction, + :with_all_champs, + procedure: procedure) + create(:commentaire, dossier: dossier, email: 'test@test.com') + dossier + end + + let(:query) do + "{ + demarche(number: #{procedure.id}) { + id + number + title + description + state + createdAt + updatedAt + archivedAt + champDescriptors { + id + type + label + description + required + } + dossiers { + nodes { + id + } + } + } + }" + end + let(:body) { JSON.parse(subject.body, symbolize_names: true) } + let(:gql_data) { body[:data] } + let(:gql_errors) { body[:errors] } + + subject { post :execute, params: { query: query } } + + before do + Flipper.enable(:administrateur_graphql, admin.user) + end + + context "when authenticated" do + let(:authorization_header) { ActionController::HttpAuthentication::Token.encode_credentials(token) } + + before do + request.env['HTTP_AUTHORIZATION'] = authorization_header + end + + it "should return demarche" do + expect(gql_errors).to eq(nil) + expect(gql_data).to eq(demarche: { + id: procedure.to_typed_id, + number: procedure.id.to_s, + title: procedure.libelle, + description: procedure.description, + state: 'brouillon', + archivedAt: nil, + createdAt: procedure.created_at.iso8601, + updatedAt: procedure.updated_at.iso8601, + champDescriptors: procedure.types_de_champ.map do |tdc| + { + id: tdc.to_typed_id, + label: tdc.libelle, + type: tdc.type_champ, + description: tdc.description, + required: tdc.mandatory? + } + end, + dossiers: { + nodes: [] + } + }) + end + + context "dossier" do + let(:query) do + "{ + dossier(number: #{dossier.id}) { + id + number + state + updatedAt + datePassageEnConstruction + datePassageEnInstruction + dateTraitement + motivation + motivationAttachmentUrl + usager { + id + email + } + instructeurs { + id + email + } + messages { + email + body + attachmentUrl + } + avis { + email + question + answer + attachmentUrl + } + champs { + id + label + stringValue + } + } + }" + end + + it "should return dossier" do + expect(gql_errors).to eq(nil) + expect(gql_data).to eq(dossier: { + id: dossier.to_typed_id, + number: dossier.id.to_s, + state: 'en_construction', + updatedAt: dossier.updated_at.iso8601, + datePassageEnConstruction: dossier.en_construction_at.iso8601, + datePassageEnInstruction: nil, + dateTraitement: nil, + motivation: nil, + motivationAttachmentUrl: nil, + usager: { + id: dossier.user.to_typed_id, + email: dossier.user.email + }, + instructeurs: [], + messages: dossier.commentaires.map do |commentaire| + { + body: commentaire.body, + attachmentUrl: nil, + email: commentaire.email + } + end, + avis: [], + champs: dossier.champs.map do |champ| + { + id: champ.to_typed_id, + label: champ.libelle, + stringValue: champ.for_api_v2 + } + end + }) + expect(gql_data[:dossier][:champs][0][:id]).to eq(dossier.champs[0].type_de_champ.to_typed_id) + end + end + end + + context "when not authenticated" do + it "should return error" do + expect(gql_data).to eq(nil) + expect(gql_errors).not_to eq(nil) + end + + context "dossier" do + let(:query) { "{ dossier(number: #{dossier.id}) { id number usager { email } } }" } + + it "should return error" do + expect(gql_data).to eq(nil) + expect(gql_errors).not_to eq(nil) + end + end + end +end From da4523612a34a1c6be7b64732dc19ee4cbef1df4 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 18 Sep 2019 18:45:53 +0200 Subject: [PATCH 30/39] Update graphql schema --- app/graphql/schema.graphql | 114 ++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index ff39081bc..089f46e77 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -1,23 +1,26 @@ type Avis { answer: String + attachmentUrl: URL createdAt: ISO8601DateTime! email: String! + id: ID! question: String! - updatedAt: ISO8601DateTime! } type CarteChamp implements Champ { geoAreas: [GeoArea!]! id: ID! label: String! + stringValue: String } interface Champ { id: ID! label: String! + stringValue: String } -type ChampInfo { +type ChampDescriptor { description: String id: ID! label: String! @@ -28,6 +31,7 @@ type ChampInfo { type CheckboxChamp implements Champ { id: ID! label: String! + stringValue: String value: Boolean! } @@ -39,12 +43,14 @@ scalar Coordinates type DateChamp implements Champ { id: ID! label: String! + stringValue: String value: ISO8601DateTime } type DecimalNumberChamp implements Champ { id: ID! label: String! + stringValue: String value: Float } @@ -52,16 +58,11 @@ type DecimalNumberChamp implements Champ { Une demarche """ type Demarche { - annotations: [ChampInfo!]! + annotationDescriptors: [ChampDescriptor!]! archivedAt: ISO8601DateTime - champs: [ChampInfo!]! + champDescriptors: [ChampDescriptor!]! createdAt: ISO8601DateTime! - """ - Lien vers le texte qui justifie le droit de collecter les données demandées dans votre démarche auprès des usagers. - """ - deliberationUrl: URL - """ Déscription de la démarche. """ @@ -101,27 +102,13 @@ type Demarche { """ since: ISO8601DateTime ): DossierConnection! - - """ - L'ID de la démarche. - """ id: ID! instructeurs: [Profile!]! """ - Lien public de la démarche. + Le numero de la démarche. """ - link: URL - - """ - Lien vers la notice explicative de la démarche. - """ - noticeUrl: URL - - """ - Lien vers le site internet de la démarche. - """ - siteWebUrl: URL + number: ID! state: DemarcheState! title: String! updatedAt: ISO8601DateTime! @@ -129,7 +116,7 @@ type Demarche { enum DemarcheState { """ - Archivee + Archivée """ archivee @@ -139,12 +126,7 @@ enum DemarcheState { brouillon """ - Hidden - """ - hidden - - """ - Publiee + Publiée """ publiee } @@ -154,22 +136,43 @@ Un dossier """ type Dossier { annotations: [Champ!]! + archived: Boolean! avis: [Avis!]! champs: [Champ!]! - createdAt: ISO8601DateTime! """ - L'ID du dossier. + Date de dépôt. """ + datePassageEnConstruction: ISO8601DateTime! + + """ + Date de passage en instruction. + """ + datePassageEnInstruction: ISO8601DateTime + + """ + Date de traitement. + """ + dateTraitement: ISO8601DateTime id: ID! instructeurs: [Profile!]! messages: [Message!]! motivation: String + motivationAttachmentUrl: URL + + """ + Le numero du dossier. + """ + number: ID! """ L'état du dossier. """ state: DossierState! + + """ + Date de dernière mise à jour. + """ updatedAt: ISO8601DateTime! usager: Profile! } @@ -213,6 +216,7 @@ type DossierLinkChamp implements Champ { dossier: Dossier id: ID! label: String! + stringValue: String } enum DossierState { @@ -221,11 +225,6 @@ enum DossierState { """ accepte - """ - Brouillon - """ - brouillon - """ En construction """ @@ -249,7 +248,7 @@ enum DossierState { interface GeoArea { geometry: GeoJSON! - id: ID + id: ID! source: GeoAreaSource! } @@ -288,14 +287,31 @@ scalar ISO8601DateTime type IntegerNumberChamp implements Champ { id: ID! label: String! + stringValue: String value: Int } +type LinkedDropDownListChamp implements Champ { + id: ID! + label: String! + primaryValue: String + secondaryValue: String + stringValue: String +} + type Message { - attachment: URL + attachmentUrl: URL body: String! createdAt: ISO8601DateTime! email: String! + id: ID! +} + +type MultipleDropDownListChamp implements Champ { + id: ID! + label: String! + stringValue: String + values: [String!]! } type Mutation { @@ -332,7 +348,7 @@ type ParcelleCadastrale implements GeoArea { codeDep: String! feuille: Int! geometry: GeoJSON! - id: ID + id: ID! nomCom: String! numero: String! section: String! @@ -359,6 +375,7 @@ type PersonneMorale { type PieceJustificativeChamp implements Champ { id: ID! label: String! + stringValue: String url: URL } @@ -371,7 +388,7 @@ type QuartierPrioritaire implements GeoArea { code: String! commune: String! geometry: GeoJSON! - id: ID + id: ID! nom: String! source: GeoAreaSource! } @@ -384,7 +401,7 @@ type Query { """ Numéro de la démarche. """ - id: ID! + number: ID! ): Demarche! """ @@ -394,7 +411,7 @@ type Query { """ Numéro du dossier. """ - id: ID! + number: ID! ): Dossier! } @@ -402,11 +419,12 @@ type RepetitionChamp implements Champ { champs: [Champ!]! id: ID! label: String! + stringValue: String } type SelectionUtilisateur implements GeoArea { geometry: GeoJSON! - id: ID + id: ID! source: GeoAreaSource! } @@ -414,11 +432,13 @@ type SiretChamp implements Champ { etablissement: PersonneMorale id: ID! label: String! + stringValue: String } type TextChamp implements Champ { id: ID! label: String! + stringValue: String value: String } @@ -562,4 +582,4 @@ enum TypeDeChamp { """ A valid URL, transported as a string """ -scalar URL +scalar URL \ No newline at end of file From 606c084914975e801a1b9a4240eb06d52518901a Mon Sep 17 00:00:00 2001 From: Vini75 <55738774+Vini75@users.noreply.github.com> Date: Tue, 24 Sep 2019 15:49:25 +0200 Subject: [PATCH 31/39] Add link to entreprises datagouv.fr --- app/views/shared/dossiers/_identite_entreprise.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/shared/dossiers/_identite_entreprise.html.haml b/app/views/shared/dossiers/_identite_entreprise.html.haml index 33f0ce548..6b271d21b 100644 --- a/app/views/shared/dossiers/_identite_entreprise.html.haml +++ b/app/views/shared/dossiers/_identite_entreprise.html.haml @@ -71,3 +71,6 @@ %tr %th.libelle Date de déclaration : %td= try_format_date(etablissement.association_date_declaration) + +%p + = link_to 'Autres informations sur l’organisme', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}" From 7f1b34926ff247763dcb21e97f07cc3dcf8e69dc Mon Sep 17 00:00:00 2001 From: Vini75 <55738774+Vini75@users.noreply.github.com> Date: Tue, 24 Sep 2019 15:55:06 +0200 Subject: [PATCH 32/39] link to entreprisedatagouv.fr on user --- .../users/dossiers/etablissement/_infos_entreprise.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml index 9667a54bd..8eec4460b 100644 --- a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml +++ b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml @@ -47,3 +47,6 @@ - if etablissement.exercices.present? %p.etablissement-exercices Les exercices comptables des trois dernières années seront joints à votre dossier. + +%p + = link_to 'Autres informations sur l’organisme', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}" From 9968eb69c00b6a0d96f6071cd8d4a558ecb3075c Mon Sep 17 00:00:00 2001 From: Vini75 <55738774+Vini75@users.noreply.github.com> Date: Tue, 24 Sep 2019 16:14:23 +0200 Subject: [PATCH 33/39] Adjust link to entreprisedatagouv --- app/views/shared/dossiers/_identite_entreprise.html.haml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/shared/dossiers/_identite_entreprise.html.haml b/app/views/shared/dossiers/_identite_entreprise.html.haml index 6b271d21b..17d1caf02 100644 --- a/app/views/shared/dossiers/_identite_entreprise.html.haml +++ b/app/views/shared/dossiers/_identite_entreprise.html.haml @@ -73,4 +73,6 @@ %td= try_format_date(etablissement.association_date_declaration) %p - = link_to 'Autres informations sur l’organisme', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}" + = link_to '➡ Autres informations sur l’organisme sur « entreprises.data.gouv.fr »', + "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}", + target: "_blank" From 8c977dca09246d003f9731571080eaa03dc18ef4 Mon Sep 17 00:00:00 2001 From: Vini75 <55738774+Vini75@users.noreply.github.com> Date: Tue, 24 Sep 2019 16:14:31 +0200 Subject: [PATCH 34/39] Adjust link to entreprisedatagouv --- .../dossiers/etablissement/_infos_entreprise.html.haml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml index 8eec4460b..2e5a6fda9 100644 --- a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml +++ b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml @@ -47,6 +47,8 @@ - if etablissement.exercices.present? %p.etablissement-exercices Les exercices comptables des trois dernières années seront joints à votre dossier. - + %p - = link_to 'Autres informations sur l’organisme', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}" + = link_to '➡ Autres informations sur l’organisme sur « entreprises.data.gouv.fr »', + "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}", + target: "_blank" From 650e973d0a9807eba2bf9641d6a8c554d997ac45 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Tue, 24 Sep 2019 16:20:34 +0200 Subject: [PATCH 35/39] Fix plural in entreprise(s).data.gouv.fr --- app/views/shared/dossiers/_identite_entreprise.html.haml | 2 +- .../users/dossiers/etablissement/_infos_entreprise.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/shared/dossiers/_identite_entreprise.html.haml b/app/views/shared/dossiers/_identite_entreprise.html.haml index 17d1caf02..9fcd2a0c9 100644 --- a/app/views/shared/dossiers/_identite_entreprise.html.haml +++ b/app/views/shared/dossiers/_identite_entreprise.html.haml @@ -73,6 +73,6 @@ %td= try_format_date(etablissement.association_date_declaration) %p - = link_to '➡ Autres informations sur l’organisme sur « entreprises.data.gouv.fr »', + = link_to '➡ Autres informations sur l’organisme sur « entreprise.data.gouv.fr »', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}", target: "_blank" diff --git a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml index 2e5a6fda9..21ab7a5f8 100644 --- a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml +++ b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml @@ -49,6 +49,6 @@ %p.etablissement-exercices Les exercices comptables des trois dernières années seront joints à votre dossier. %p - = link_to '➡ Autres informations sur l’organisme sur « entreprises.data.gouv.fr »', + = link_to '➡ Autres informations sur l’organisme sur « entreprise.data.gouv.fr »', "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}", target: "_blank" From a4166d3c57ef4ccedf812a3b5de9c84874280a19 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 12:42:10 +0200 Subject: [PATCH 36/39] Refactor Instructeur.notifications_* methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename `dossiers_id_with_notifications` to `followed_dossiers_with_notifications`, - rename `notifications_per_procedure` to `procedures_with_notifications`, - return an ActiveRecord::Relation instead of the result of the query, so that the call place can compose it, - `merge` with the wanted Dossier scope in the call places, don’t bother passing it as a parameter, - use the “state” (now “scope”) parameter as a scope method that can be just applied on `Dossier`. --- app/models/dossier.rb | 1 - app/models/instructeur.rb | 45 +++++++------------ app/models/procedure_presentation.rb | 2 +- .../instructeurs/procedures/index.html.haml | 4 +- .../instructeurs/procedures/show.html.haml | 2 +- spec/models/instructeur_spec.rb | 18 ++++---- 6 files changed, 30 insertions(+), 42 deletions(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 0da01b6e4..9f097bb89 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -132,7 +132,6 @@ class Dossier < ApplicationRecord } scope :en_cours, -> { not_archived.state_en_construction_ou_instruction } scope :without_followers, -> { left_outer_joins(:follows).where(follows: { id: nil }) } - scope :followed_by, -> (instructeur) { joins(:follows).where(follows: { instructeur: instructeur }) } scope :with_champs, -> { includes(champs: :type_de_champ) } scope :nearing_end_of_retention, -> (duration = '1 month') { joins(:procedure).where("en_instruction_at + (duree_conservation_dossiers_dans_ds * interval '1 month') - now() < interval ?", duration) } scope :since, -> (since) { where('dossiers.en_construction_at >= ?', since) } diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index ff9254a5d..ba4603b65 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -109,39 +109,28 @@ class Instructeur < ApplicationRecord end end - def notifications_for_procedure(procedure, state) - dossiers = case state - when :en_cours - procedure.defaut_groupe_instructeur.dossiers.en_cours - when :termine - procedure.defaut_groupe_instructeur.dossiers.termine - when :not_archived - procedure.defaut_groupe_instructeur.dossiers.not_archived - when :all - procedure.defaut_groupe_instructeur.dossiers - end - - dossiers_id_with_notifications(dossiers) + def notifications_for_procedure(procedure, scope) + procedure + .defaut_groupe_instructeur.dossiers + .send(scope) # :en_cours or :termine or :not_archived (or any other Dossier scope) + .merge(followed_dossiers_with_notifications) end - def notifications_per_procedure(state) - dossiers = case state - when :en_cours - Dossier.en_cours - when :termine - Dossier.termine - when :not_archived - Dossier.not_archived - end + def procedures_with_notifications(scope) + dossiers = Dossier + .send(scope) # :en_cours or :termine (or any other Dossier scope) + .merge(followed_dossiers_with_notifications) - Dossier.joins(:groupe_instructeur).where(id: dossiers_id_with_notifications(dossiers)).group('groupe_instructeurs.procedure_id').count + Procedure + .where(id: dossiers.joins(:groupe_instructeur) + .select('groupe_instructeurs.procedure_id') + .distinct) + .distinct end - def dossiers_id_with_notifications(dossiers) - dossiers = dossiers.followed_by(self) - + def followed_dossiers_with_notifications # Relations passed to #or must be “structurally compatible”, i.e. query the same tables. - joined_dossiers = dossiers + joined_dossiers = self.followed_dossiers .left_outer_joins(:champs, :champs_private, :avis, :commentaires) updated_demandes = joined_dossiers @@ -159,7 +148,7 @@ class Instructeur < ApplicationRecord .where.not(commentaires: { email: OLD_CONTACT_EMAIL }) .where.not(commentaires: { email: CONTACT_EMAIL }) - updated_demandes.or(updated_annotations).or(updated_avis).or(updated_messagerie).ids + updated_demandes.or(updated_annotations).or(updated_avis).or(updated_messagerie) end def mark_tab_as_seen(dossier, tab) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index c4773c0a0..733bf36c7 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -80,7 +80,7 @@ class ProcedurePresentation < ApplicationRecord case table when 'notifications' - dossiers_id_with_notification = instructeur.dossiers_id_with_notifications(dossiers) + dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers_with_notifications).ids if order == 'desc' return dossiers_id_with_notification + (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) diff --git a/app/views/instructeurs/procedures/index.html.haml b/app/views/instructeurs/procedures/index.html.haml index b219822c3..70488d4f4 100644 --- a/app/views/instructeurs/procedures/index.html.haml +++ b/app/views/instructeurs/procedures/index.html.haml @@ -27,7 +27,7 @@ %li %object = link_to(instructeur_procedure_path(p, statut: 'suivis')) do - - if current_instructeur.notifications_per_procedure(:en_cours)[p.id].present? + - if current_instructeur.procedures_with_notifications(:en_cours).include?(p) %span.notifications{ 'aria-label': "notifications" } - followed_count = @followed_dossiers_count_per_procedure[p.id] || 0 .stats-number @@ -37,7 +37,7 @@ %li %object = link_to(instructeur_procedure_path(p, statut: 'traites')) do - - if current_instructeur.notifications_per_procedure(:termine)[p.id].present? + - if current_instructeur.procedures_with_notifications(:termine).include?(p) %span.notifications{ 'aria-label': "notifications" } - termines_count = @dossiers_termines_count_per_procedure[p.id] || 0 .stats-number diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index ec5cc79c3..04dd70c5e 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -116,7 +116,7 @@ %td.folder-col = link_to(instructeur_dossier_path(@procedure, dossier), class: 'cell-link') do %span.icon.folder - - if current_instructeur.notifications_for_procedure(@procedure, :not_archived).include?(dossier.id) + - if current_instructeur.notifications_for_procedure(@procedure, :not_archived).include?(dossier) %span.notifications{ 'aria-label': 'notifications' } %td.number-col diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index f218cb4b6..a17c8b341 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -257,14 +257,14 @@ describe Instructeur, type: :model do context 'when there is a modification on public champs' do before { dossier.champs.first.update_attribute('value', 'toto') } - it { is_expected.to match([dossier.id]) } - it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier.id]) } + it { is_expected.to match([dossier]) } + it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier]) } it { expect(instructeur_on_procedure_2.notifications_for_procedure(procedure, :en_cours)).to match([]) } context 'and there is a modification on private champs' do before { dossier.champs_private.first.update_attribute('value', 'toto') } - it { is_expected.to match([dossier.id]) } + it { is_expected.to match([dossier]) } end context 'when instructeur update it s public champs last seen' do @@ -273,7 +273,7 @@ describe Instructeur, type: :model do before { follow.update_attribute('demande_seen_at', Time.zone.now) } it { is_expected.to match([]) } - it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier.id]) } + it { expect(instructeur_2.notifications_for_procedure(procedure, :en_cours)).to match([dossier]) } end end @@ -286,20 +286,20 @@ describe Instructeur, type: :model do context 'when there is a modification on private champs' do before { dossier.champs_private.first.update_attribute('value', 'toto') } - it { is_expected.to match([dossier.id]) } + it { is_expected.to match([dossier]) } end context 'when there is a modification on avis' do before { create(:avis, dossier: dossier) } - it { is_expected.to match([dossier.id]) } + it { is_expected.to match([dossier]) } end context 'the messagerie' do context 'when there is a new commentaire' do before { create(:commentaire, dossier: dossier, email: 'a@b.com') } - it { is_expected.to match([dossier.id]) } + it { is_expected.to match([dossier]) } end context 'when there is a new commentaire issued by tps' do @@ -315,12 +315,12 @@ describe Instructeur, type: :model do let(:instructeur) { dossier.follows.first.instructeur } let(:procedure) { dossier.procedure } - subject { instructeur.notifications_per_procedure(:en_cours) } + subject { instructeur.procedures_with_notifications(:en_cours) } context 'when there is a modification on public champs' do before { dossier.champs.first.update_attribute('value', 'toto') } - it { is_expected.to match({ procedure.id => 1 }) } + it { is_expected.to match([procedure]) } end end From 1505d45be19eae4212a070e31d144fa0077b7fd5 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 12:42:10 +0200 Subject: [PATCH 37/39] Optimize notifications queries for badges by using exists?, not present? `ActiveRecord::Relation.exists?` yields a SQL EXISTS query, while `present?` is a method of Enumerable, which needs the actual result array to be queried. --- app/views/instructeurs/procedures/show.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 04dd70c5e..302ce3a95 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -24,13 +24,13 @@ instructeur_procedure_path(@procedure, statut: 'suivis'), active: @statut == 'suivis', badge: @followed_dossiers.count, - notification: current_instructeur.notifications_for_procedure(@procedure, :en_cours).present?) + notification: current_instructeur.notifications_for_procedure(@procedure, :en_cours).exists?) = tab_item(t('pluralize.processed', count: @termines_dossiers.count), instructeur_procedure_path(@procedure, statut: 'traites'), active: @statut == 'traites', badge: @termines_dossiers.count, - notification: current_instructeur.notifications_for_procedure(@procedure, :termine).present?) + notification: current_instructeur.notifications_for_procedure(@procedure, :termine).exists?) = tab_item('tous les dossiers', instructeur_procedure_path(@procedure, statut: 'tous'), From 780e1571901c9725830eaeac6abce178ee7a3f92 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Fri, 20 Sep 2019 15:47:01 +0200 Subject: [PATCH 38/39] Add indexes to Dossier.state, Dossier.archived, Follow.unfollowed_at We make a ton of queries on these attributes (for example in /procedures#index and /procedures#show). I think it should help. --- db/migrate/20190920122228_add_indexes_to_dossier.rb | 7 +++++++ db/schema.rb | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20190920122228_add_indexes_to_dossier.rb diff --git a/db/migrate/20190920122228_add_indexes_to_dossier.rb b/db/migrate/20190920122228_add_indexes_to_dossier.rb new file mode 100644 index 000000000..53f930bff --- /dev/null +++ b/db/migrate/20190920122228_add_indexes_to_dossier.rb @@ -0,0 +1,7 @@ +class AddIndexesToDossier < ActiveRecord::Migration[5.2] + def change + add_index :dossiers, :state + add_index :dossiers, :archived + add_index :follows, :unfollowed_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 9914c4180..b9636b970 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_09_17_151652) do +ActiveRecord::Schema.define(version: 2019_09_20_122228) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -254,9 +254,11 @@ ActiveRecord::Schema.define(version: 2019_09_17_151652) do t.bigint "groupe_instructeur_id" t.index "to_tsvector('french'::regconfig, (search_terms || private_search_terms))", name: "index_dossiers_on_search_terms_private_search_terms", using: :gin t.index "to_tsvector('french'::regconfig, search_terms)", name: "index_dossiers_on_search_terms", using: :gin + t.index ["archived"], name: "index_dossiers_on_archived" t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id" t.index ["hidden_at"], name: "index_dossiers_on_hidden_at" t.index ["procedure_id"], name: "index_dossiers_on_procedure_id" + t.index ["state"], name: "index_dossiers_on_state" t.index ["user_id"], name: "index_dossiers_on_user_id" end @@ -353,6 +355,7 @@ ActiveRecord::Schema.define(version: 2019_09_17_151652) do t.index ["dossier_id"], name: "index_follows_on_dossier_id" t.index ["instructeur_id", "dossier_id", "unfollowed_at"], name: "uniqueness_index", unique: true t.index ["instructeur_id"], name: "index_follows_on_instructeur_id" + t.index ["unfollowed_at"], name: "index_follows_on_unfollowed_at" end create_table "france_connect_informations", id: :serial, force: :cascade do |t| From 03c950ea9750d5863aec5d959509337a3f25eb53 Mon Sep 17 00:00:00 2001 From: Nicolas Bouilleaud Date: Mon, 23 Sep 2019 14:38:12 +0200 Subject: [PATCH 39/39] Move followed_dossiers_with_notifications to a Dossier scope Instead of instructeur.followed_dossiers_with_notifications, we can now write instructeur.followed_dossiers.with_notifications. Yay composition! --- app/models/dossier.rb | 25 ++++++++++++++++++++++++ app/models/instructeur.rb | 29 ++++------------------------ app/models/procedure_presentation.rb | 2 +- spec/models/dossier_spec.rb | 21 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 9f097bb89..ebde1fe6c 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -164,6 +164,31 @@ class Dossier < ApplicationRecord scope :for_procedure, -> (procedure) { includes(:user, :groupe_instructeur).where(groupe_instructeurs: { procedure: procedure }) } scope :for_api_v2, -> { includes(procedure: [:administrateurs], etablissement: [], individual: []) } + scope :with_notifications, -> do + # This scope is meant to be composed, typically with Instructeur.followed_dossiers, which means that the :follows table is already INNER JOINed; + # it will fail otherwise + + # Relations passed to #or must be “structurally compatible”, i.e. query the same tables. + joined_dossiers = left_outer_joins(:champs, :champs_private, :avis, :commentaires) + + updated_demandes = joined_dossiers + .where('champs.updated_at > follows.demande_seen_at') + + # We join `:champs` twice, the second time with `has_many :champs_privates`. ActiveRecord generates the SQL: 'LEFT OUTER JOIN "champs" "champs_privates_dossiers" ON …'. We can then use this `champs_privates_dossiers` alias to disambiguate the table in this WHERE clause. + updated_annotations = joined_dossiers + .where('champs_privates_dossiers.updated_at > follows.annotations_privees_seen_at') + + updated_avis = joined_dossiers + .where('avis.updated_at > follows.avis_seen_at') + + updated_messagerie = joined_dossiers + .where('commentaires.updated_at > follows.messagerie_seen_at') + .where.not(commentaires: { email: OLD_CONTACT_EMAIL }) + .where.not(commentaires: { email: CONTACT_EMAIL }) + + updated_demandes.or(updated_annotations).or(updated_avis).or(updated_messagerie).distinct + end + accepts_nested_attributes_for :individual delegate :siret, :siren, to: :etablissement, allow_nil: true diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index ba4603b65..38d2834da 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -113,13 +113,15 @@ class Instructeur < ApplicationRecord procedure .defaut_groupe_instructeur.dossiers .send(scope) # :en_cours or :termine or :not_archived (or any other Dossier scope) - .merge(followed_dossiers_with_notifications) + .merge(followed_dossiers) + .with_notifications end def procedures_with_notifications(scope) dossiers = Dossier .send(scope) # :en_cours or :termine (or any other Dossier scope) - .merge(followed_dossiers_with_notifications) + .merge(followed_dossiers) + .with_notifications Procedure .where(id: dossiers.joins(:groupe_instructeur) @@ -128,29 +130,6 @@ class Instructeur < ApplicationRecord .distinct end - def followed_dossiers_with_notifications - # Relations passed to #or must be “structurally compatible”, i.e. query the same tables. - joined_dossiers = self.followed_dossiers - .left_outer_joins(:champs, :champs_private, :avis, :commentaires) - - updated_demandes = joined_dossiers - .where('champs.updated_at > follows.demande_seen_at') - - # We join `:champs` twice, the second time with `has_many :champs_privates`. ActiveRecord generates the SQL: 'LEFT OUTER JOIN "champs" "champs_privates_dossiers" ON …'. We can then use this `champs_privates_dossiers` alias to disambiguate the table in this WHERE clause. - updated_annotations = joined_dossiers - .where('champs_privates_dossiers.updated_at > follows.annotations_privees_seen_at') - - updated_avis = joined_dossiers - .where('avis.updated_at > follows.avis_seen_at') - - updated_messagerie = joined_dossiers - .where('commentaires.updated_at > follows.messagerie_seen_at') - .where.not(commentaires: { email: OLD_CONTACT_EMAIL }) - .where.not(commentaires: { email: CONTACT_EMAIL }) - - updated_demandes.or(updated_annotations).or(updated_avis).or(updated_messagerie) - end - def mark_tab_as_seen(dossier, tab) attributes = {} attributes["#{tab}_seen_at"] = Time.zone.now diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 733bf36c7..4c29d3a03 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -80,7 +80,7 @@ class ProcedurePresentation < ApplicationRecord case table when 'notifications' - dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers_with_notifications).ids + dossiers_id_with_notification = dossiers.with_notifications.merge(instructeur.followed_dossiers).ids if order == 'desc' return dossiers_id_with_notification + (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 5da59042b..d790e7814 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -53,6 +53,27 @@ describe Dossier do end end + describe 'with_notifications' do + let(:dossier) { create(:dossier) } + let(:instructeur) { create(:instructeur) } + + before do + create(:follow, dossier: dossier, instructeur: instructeur, messagerie_seen_at: 2.hours.ago) + end + + subject { instructeur.followed_dossiers.with_notifications } + + context('without changes') do + it { is_expected.to eq [] } + end + + context('with changes') do + before { dossier.commentaires << create(:commentaire, email: 'test@exemple.fr') } + + it { is_expected.to match([dossier]) } + end + end + describe 'methods' do let(:dossier) { create(:dossier, :with_entreprise, user: user) } let(:etablissement) { dossier.etablissement }