diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 84d59a361..c543a75f6 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -1,4 +1,4 @@ module Mutations - class BaseMutation < GraphQL::Schema::Mutation + class BaseMutation < GraphQL::Schema::RelayClassicMutation end end diff --git a/app/graphql/mutations/create_direct_upload.rb b/app/graphql/mutations/create_direct_upload.rb new file mode 100644 index 000000000..d4c3a90e0 --- /dev/null +++ b/app/graphql/mutations/create_direct_upload.rb @@ -0,0 +1,41 @@ +module Mutations + class CreateDirectUpload < Mutations::BaseMutation + description "File information required to prepare a direct upload" + + argument :dossier_id, ID, "Dossier ID", required: true, loads: Types::DossierType + argument :filename, String, "Original file name", required: true + argument :byte_size, Int, "File size (bytes)", required: true + argument :checksum, String, "MD5 file checksum as base64", required: true + argument :content_type, String, "File content type", required: true + + class DirectUpload < Types::BaseObject + description "Represents direct upload credentials" + + field :url, String, "Upload URL", null: false + field :headers, String, "HTTP request headers (JSON-encoded)", null: false + field :blob_id, ID, "Created blob record ID", null: false + field :signed_blob_id, ID, "Created blob record signed ID", null: false + end + + field :direct_upload, DirectUpload, null: false + + def resolve(filename:, byte_size:, checksum:, content_type:, dossier:) + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: filename, + byte_size: byte_size, + checksum: checksum, + content_type: content_type + ) + + { + direct_upload: { + url: blob.service_url_for_direct_upload, + # NOTE: we pass headers as JSON since they have no schema + headers: blob.service_headers_for_direct_upload.to_json, + blob_id: blob.id, + signed_blob_id: blob.signed_id + } + } + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 6b6c5e355..a79948d38 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -40,6 +40,52 @@ GeoJSON coordinates """ scalar Coordinates +""" +Autogenerated input type of CreateDirectUpload +""" +input CreateDirectUploadInput { + """ + File size (bytes) + """ + byteSize: Int! + + """ + MD5 file checksum as base64 + """ + checksum: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + File content type + """ + contentType: String! + + """ + Dossier ID + """ + dossierId: ID! + + """ + Original file name + """ + filename: String! +} + +""" +Autogenerated return type of CreateDirectUpload +""" +type CreateDirectUploadPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + directUpload: DirectUpload! +} + type DateChamp implements Champ { id: ID! label: String! @@ -131,6 +177,31 @@ enum DemarcheState { publiee } +""" +Represents direct upload credentials +""" +type DirectUpload { + """ + Created blob record ID + """ + blobId: ID! + + """ + HTTP request headers (JSON-encoded) + """ + headers: String! + + """ + Created blob record signed ID + """ + signedBlobId: ID! + + """ + Upload URL + """ + url: String! +} + """ Un dossier """ @@ -324,6 +395,10 @@ type MultipleDropDownListChamp implements Champ { } type Mutation { + """ + File information required to prepare a direct upload + """ + createDirectUpload(input: CreateDirectUploadInput!): CreateDirectUploadPayload } """ diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 113861978..bbdb5021e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -1,4 +1,5 @@ module Types class MutationType < Types::BaseObject + field :create_direct_upload, mutation: Mutations::CreateDirectUpload end end diff --git a/app/javascript/components/TypesDeChampEditor/typeDeChampsReducer.js b/app/javascript/components/TypesDeChampEditor/typeDeChampsReducer.js index 5d27c8399..81e603082 100644 --- a/app/javascript/components/TypesDeChampEditor/typeDeChampsReducer.js +++ b/app/javascript/components/TypesDeChampEditor/typeDeChampsReducer.js @@ -56,7 +56,9 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) { state.flash.success(); done(); if (insertAfter) { - scrollToComponent(insertAfter.target.nextElementSibling); + scrollToComponent(insertAfter.target.nextElementSibling, { + duration: 300 + }); } }) .catch(message => state.flash.error(message)); @@ -219,7 +221,7 @@ function getUpdateHandler(typeDeChamp, { queue, flash }) { } function findItemToInsertAfter() { - const target = getFirstTarget(); + const target = getLastVisibleTypeDeChamp(); return { target, @@ -227,8 +229,10 @@ function findItemToInsertAfter() { }; } -function getFirstTarget() { - const [target] = document.querySelectorAll('[data-in-view]'); +function getLastVisibleTypeDeChamp() { + const typeDeChamps = document.querySelectorAll('[data-in-view]'); + const target = typeDeChamps[typeDeChamps.length - 1]; + if (target) { const parentTarget = target.closest('[data-repetition]'); if (parentTarget) { diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 9296dbdca..7d31812cd 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -33,6 +33,12 @@ class Champs::RepetitionChamp < Champ end end + # We have to truncate the label here as spreadsheets have a (30 char) limit on length. + def libelle_for_export + str = "(#{type_de_champ.stable_id}) #{libelle}" + ActiveStorage::Filename.new(str).sanitized.truncate(30) + end + class Row < Hashie::Dash property :index property :dossier_id diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d7d25ad7f..466948a7a 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -472,7 +472,19 @@ class Dossier < ApplicationRecord log_dossier_operation(avis.claimant, :demander_un_avis, avis) end - def spreadsheet_columns + def spreadsheet_columns_csv + spreadsheet_columns(with_etablissement: true) + end + + def spreadsheet_columns_xlsx + spreadsheet_columns + end + + def spreadsheet_columns_ods + spreadsheet_columns + end + + def spreadsheet_columns(with_etablissement: false) columns = [ ['ID', id.to_s], ['Email', user.email] @@ -485,6 +497,39 @@ class Dossier < ApplicationRecord ['Prénom', individual&.prenom], ['Date de naissance', individual&.birthdate] ] + elsif with_etablissement + columns += [ + ['Établissement SIRET', etablissement&.siret], + ['Établissement siège social', etablissement&.siege_social], + ['Établissement NAF', etablissement&.naf], + ['Établissement libellé NAF', etablissement&.libelle_naf], + ['Établissement Adresse', etablissement&.adresse], + ['Établissement numero voie', etablissement&.numero_voie], + ['Établissement type voie', etablissement&.type_voie], + ['Établissement nom voie', etablissement&.nom_voie], + ['Établissement complément adresse', etablissement&.complement_adresse], + ['Établissement code postal', etablissement&.code_postal], + ['Établissement localité', etablissement&.localite], + ['Établissement code INSEE localité', etablissement&.code_insee_localite], + ['Entreprise SIREN', etablissement&.entreprise_siren], + ['Entreprise capital social', etablissement&.entreprise_capital_social], + ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], + ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], + ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], + ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], + ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], + ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], + ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], + ['Entreprise date de création', etablissement&.entreprise_date_creation], + ['Entreprise nom', etablissement&.entreprise_nom], + ['Entreprise prénom', etablissement&.entreprise_prenom], + ['Association RNA', etablissement&.association_rna], + ['Association titre', etablissement&.association_titre], + ['Association objet', etablissement&.association_objet], + ['Association date de création', etablissement&.association_date_creation], + ['Association date de déclaration', etablissement&.association_date_declaration], + ['Association date de publication', etablissement&.association_date_publication] + ] else columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] end diff --git a/app/services/procedure_export_v2_service.rb b/app/services/procedure_export_v2_service.rb index 67409b723..c8ba3bffe 100644 --- a/app/services/procedure_export_v2_service.rb +++ b/app/services/procedure_export_v2_service.rb @@ -16,21 +16,21 @@ class ProcedureExportV2Service @tables = [:dossiers, :etablissements, :avis] + champs_repetables_options end - def to_csv(table = :dossiers) - SpreadsheetArchitect.to_csv(options_for(table)) + def to_csv + SpreadsheetArchitect.to_csv(options_for(:dossiers, :csv)) end def to_xlsx # We recursively build multi page spreadsheet @tables.reduce(nil) do |package, table| - SpreadsheetArchitect.to_axlsx_package(options_for(table), package) + SpreadsheetArchitect.to_axlsx_package(options_for(table, :xlsx), package) end.to_stream.read end def to_ods # We recursively build multi page spreadsheet @tables.reduce(nil) do |spreadsheet, table| - SpreadsheetArchitect.to_rodf_spreadsheet(options_for(table), spreadsheet) + SpreadsheetArchitect.to_rodf_spreadsheet(options_for(table, :ods), spreadsheet) end.bytes end @@ -53,7 +53,7 @@ class ProcedureExportV2Service [dossier.champs, dossier.champs_private] .flatten .filter { |champ| champ.is_a?(Champs::RepetitionChamp) } - end.group_by(&:libelle) + end.group_by(&:libelle_for_export) end def champs_repetables_options @@ -70,21 +70,16 @@ class ProcedureExportV2Service row_style: { background_color: nil, color: "000000", font_size: 12 } } - def sanitize_sheet_name(name) - ActiveStorage::Filename.new(name.to_s).sanitized.truncate(30) - end - - def options_for(table) + def options_for(table, format) case table when :dossiers - { instances: dossiers.to_a, sheet_name: 'Dossiers' }.merge(DEFAULT_STYLES) + { instances: dossiers.to_a, sheet_name: 'Dossiers', spreadsheet_columns: :"spreadsheet_columns_#{format}" }.merge(DEFAULT_STYLES) when :etablissements { instances: etablissements.to_a, sheet_name: 'Etablissements' }.merge(DEFAULT_STYLES) when :avis { instances: avis.to_a, sheet_name: 'Avis' }.merge(DEFAULT_STYLES) when Array - # We have to truncate the label here as spreadsheets have a (30 char) limit on length. - { instances: table.last, sheet_name: sanitize_sheet_name(table.first) }.merge(DEFAULT_STYLES) + { instances: table.last, sheet_name: table.first }.merge(DEFAULT_STYLES) end end end diff --git a/app/views/layouts/left_panels/_left_panel_admin_procedurescontroller_navbar.html.haml b/app/views/layouts/left_panels/_left_panel_admin_procedurescontroller_navbar.html.haml index 4f0284677..80a7e9d19 100644 --- a/app/views/layouts/left_panels/_left_panel_admin_procedurescontroller_navbar.html.haml +++ b/app/views/layouts/left_panels/_left_panel_admin_procedurescontroller_navbar.html.haml @@ -30,14 +30,14 @@ .procedure-list-element Administrateurs - - if !feature_enabled?(:routage) + - if !feature_enabled?(:administrateur_routage) %a#onglet-instructeurs{ href: url_for(admin_procedure_assigns_path(@procedure)) } .procedure-list-element{ class: ('active' if active == 'Instructeurs') } Instructeurs - if @procedure.missing_steps.include?(:instructeurs) %p.missing-steps (à compléter) - - if feature_enabled?(:routage) + - if feature_enabled?(:administrateur_routage) %a#onglet-instructeurs{ href: url_for(procedure_groupe_instructeurs_path(@procedure)) } .procedure-list-element Groupe d'instructeurs diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index 384d85808..8aa7c0e7b 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -166,6 +166,39 @@ describe API::V2::GraphqlController do expect(gql_data[:dossier][:champs][0][:id]).to eq(dossier.champs[0].type_de_champ.to_typed_id) end end + + context "mutations" do + context 'createDirectUpload' do + let(:query) do + "mutation { + createDirectUpload(input: { + dossierId: \"#{dossier.to_typed_id}\", + filename: \"hello.png\", + byteSize: 1234, + checksum: \"qwerty1234\", + contentType: \"image/png\" + }) { + directUpload { + url + headers + blobId + signedBlobId + } + } + }" + end + + it "should initiate a direct upload" do + expect(gql_errors).to eq(nil) + + data = gql_data[:createDirectUpload][:directUpload] + expect(data[:url]).not_to be_nil + expect(data[:headers]).not_to be_nil + expect(data[:blobId]).not_to be_nil + expect(data[:signedBlobId]).not_to be_nil + end + end + end end context "when not authenticated" do diff --git a/spec/features/routing/full_scenario_spec.rb b/spec/features/routing/full_scenario_spec.rb index 02e338c1a..3eeb141dc 100644 --- a/spec/features/routing/full_scenario_spec.rb +++ b/spec/features/routing/full_scenario_spec.rb @@ -7,7 +7,7 @@ feature 'The routing' do let(:scientifique_user) { create(:user, password: password) } let(:litteraire_user) { create(:user, password: password) } - before { Flipper.enable_actor(:routage, administrateur.user) } + before { Flipper.enable_actor(:administrateur_routage, administrateur.user) } scenario 'works' do login_as administrateur.user, scope: :user diff --git a/spec/services/procedure_export_v2_service_spec.rb b/spec/services/procedure_export_v2_service_spec.rb index 5635c0380..d8c30ecd7 100644 --- a/spec/services/procedure_export_v2_service_spec.rb +++ b/spec/services/procedure_export_v2_service_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'csv' describe ProcedureExportV2Service do describe 'to_data' do @@ -150,6 +151,91 @@ describe ProcedureExportV2Service do ] end + context 'as csv' do + subject do + Tempfile.create do |f| + f << ProcedureExportV2Service.new(procedure, procedure.dossiers).to_csv + f.rewind + CSV.read(f.path) + end + end + + let(:nominal_headers) do + [ + "ID", + "Email", + "Établissement SIRET", + "Établissement siège social", + "Établissement NAF", + "Établissement libellé NAF", + "Établissement Adresse", + "Établissement numero voie", + "Établissement type voie", + "Établissement nom voie", + "Établissement complément adresse", + "Établissement code postal", + "Établissement localité", + "Établissement code INSEE localité", + "Entreprise SIREN", + "Entreprise capital social", + "Entreprise numero TVA intracommunautaire", + "Entreprise forme juridique", + "Entreprise forme juridique code", + "Entreprise nom commercial", + "Entreprise raison sociale", + "Entreprise SIRET siège social", + "Entreprise code effectif entreprise", + "Entreprise date de création", + "Entreprise nom", + "Entreprise prénom", + "Association RNA", + "Association titre", + "Association objet", + "Association date de création", + "Association date de déclaration", + "Association date de publication", + "Archivé", + "État du dossier", + "Dernière mise à jour le", + "Déposé le", + "Passé en instruction le", + "Traité le", + "Motivation de la décision", + "Instructeurs", + "textarea", + "date", + "datetime", + "number", + "decimal_number", + "integer_number", + "checkbox", + "civilite", + "email", + "phone", + "address", + "yes_no", + "simple_drop_down_list", + "multiple_drop_down_list", + "linked_drop_down_list", + "pays", + "regions", + "departements", + "engagement", + "dossier_link", + "piece_justificative", + "siret", + "carte", + "text" + ] + end + + let(:dossiers_sheet_headers) { subject.first } + + it 'should have headers' do + expect(dossiers_sheet_headers).to match(nominal_headers) + end + end + it 'should have headers' do expect(dossiers_sheet.headers).to match(nominal_headers) @@ -225,7 +311,7 @@ describe ProcedureExportV2Service do let(:champ_repetition) { dossiers.first.champs.find { |champ| champ.type_champ == 'repetition' } } it 'should have sheets' do - expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', champ_repetition.libelle]) + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', champ_repetition.libelle_for_export]) end it 'should have headers' do @@ -247,7 +333,18 @@ describe ProcedureExportV2Service do end it 'should have valid sheet name' do - expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', "A - B - C"]) + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', "(#{champ_repetition.type_de_champ.stable_id}) A - B - C"]) + end + end + + context 'with non unique labels' do + let(:dossier) { create(:dossier, :en_instruction, :with_all_champs, :for_individual, procedure: procedure) } + let(:champ_repetition) { dossier.champs.find { |champ| champ.type_champ == 'repetition' } } + let(:type_de_champ_repetition) { create(:type_de_champ_repetition, procedure: procedure, libelle: champ_repetition.libelle) } + let!(:another_champ_repetition) { create(:champ_repetition, type_de_champ: type_de_champ_repetition, dossier: dossier) } + + it 'should have sheets' do + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', champ_repetition.libelle_for_export, another_champ_repetition.libelle_for_export]) end end end