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 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/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/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/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb new file mode 100644 index 000000000..390d60c44 --- /dev/null +++ b/app/graphql/api/v2/schema.rb @@ -0,0 +1,70 @@ +class Api::V2::Schema < GraphQL::Schema + default_max_page_size 100 + max_complexity 300 + max_depth 15 + + 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 Commentaire + Types::MessageType + when Instructeur, User + Types::ProfileType + else + raise GraphQL::ExecutionError.new("Unexpected object: #{obj}") + 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 }) + 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/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/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/schema.graphql b/app/graphql/schema.graphql new file mode 100644 index 000000000..089f46e77 --- /dev/null +++ b/app/graphql/schema.graphql @@ -0,0 +1,585 @@ +type Avis { + answer: String + attachmentUrl: URL + createdAt: ISO8601DateTime! + email: String! + id: ID! + question: String! +} + +type CarteChamp implements Champ { + geoAreas: [GeoArea!]! + id: ID! + label: String! + stringValue: String +} + +interface Champ { + id: ID! + label: String! + stringValue: String +} + +type ChampDescriptor { + description: String + id: ID! + label: String! + required: Boolean! + type: TypeDeChamp! +} + +type CheckboxChamp implements Champ { + id: ID! + label: String! + stringValue: String + value: Boolean! +} + +""" +GeoJSON coordinates +""" +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 +} + +""" +Une demarche +""" +type Demarche { + annotationDescriptors: [ChampDescriptor!]! + archivedAt: ISO8601DateTime + champDescriptors: [ChampDescriptor!]! + createdAt: ISO8601DateTime! + + """ + 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! + id: ID! + instructeurs: [Profile!]! + + """ + Le numero de la démarche. + """ + number: ID! + state: DemarcheState! + title: String! + updatedAt: ISO8601DateTime! +} + +enum DemarcheState { + """ + Archivée + """ + archivee + + """ + Brouillon + """ + brouillon + + """ + Publiée + """ + publiee +} + +""" +Un dossier +""" +type Dossier { + annotations: [Champ!]! + archived: Boolean! + avis: [Avis!]! + champs: [Champ!]! + + """ + 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! +} + +""" +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 +} + +type DossierLinkChamp implements Champ { + dossier: Dossier + id: ID! + label: String! + stringValue: String +} + +enum DossierState { + """ + Accepté + """ + accepte + + """ + En construction + """ + en_construction + + """ + En instruction + """ + en_instruction + + """ + Refusé + """ + refuse + + """ + Sans suite + """ + 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! + stringValue: String + value: Int +} + +type LinkedDropDownListChamp implements Champ { + id: ID! + label: String! + primaryValue: String + secondaryValue: String + stringValue: String +} + +type Message { + attachmentUrl: URL + body: String! + createdAt: ISO8601DateTime! + email: String! + id: ID! +} + +type MultipleDropDownListChamp implements Champ { + id: ID! + label: String! + stringValue: String + values: [String!]! +} + +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 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! + stringValue: 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. + """ + demarche( + """ + Numéro de la démarche. + """ + number: ID! + ): Demarche! + + """ + Informations sur un dossier d'une démarche. + """ + dossier( + """ + Numéro du dossier. + """ + number: ID! + ): Dossier! +} + +type RepetitionChamp implements Champ { + champs: [Champ!]! + id: ID! + label: String! + stringValue: String +} + +type SelectionUtilisateur implements GeoArea { + geometry: GeoJSON! + id: ID! + source: GeoAreaSource! +} + +type SiretChamp implements Champ { + etablissement: PersonneMorale + id: ID! + label: String! + stringValue: String +} + +type TextChamp implements Champ { + id: ID! + label: String! + stringValue: String + value: String +} + +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 +""" +scalar URL \ No newline at end of file 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/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..ba994e214 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +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 && Flipper.enabled?(:administrateur_graphql, administrateur.user) + context[:authorized][demarche.id] = true + true + end + end + 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/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/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/demarche_type.rb b/app/graphql/types/demarche_type.rb new file mode 100644 index 000000000..6e5dabcb3 --- /dev/null +++ b/app/graphql/types/demarche_type.rb @@ -0,0 +1,57 @@ +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 + + 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 + + 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 + + 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 new file mode 100644 index 000000000..30892ca55 --- /dev/null +++ b/app/graphql/types/dossier_type.rb @@ -0,0 +1,68 @@ +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 + + field :champs, [Types::ChampType], null: false + field :annotations, [Types::ChampType], null: false + + field :messages, [Types::MessageType], null: false + field :avis, [Types::AvisType], 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 + + 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 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 + end +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/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 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/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/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/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 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 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 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 1f5cd63b0..ebde1fe6c 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -126,12 +126,12 @@ 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 } 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) } @@ -162,6 +162,32 @@ 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 @@ -447,7 +473,7 @@ class Dossier < ApplicationRecord end def spreadsheet_columns - [ + columns = [ ['ID', id.to_s], ['Email', user.email], ['Civilité', individual&.gender], @@ -462,7 +488,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/app/models/instructeur.rb b/app/models/instructeur.rb index 6334d9fa7..38d2834da 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -109,66 +109,25 @@ class Instructeur < ApplicationRecord end end - def notifications_for_procedure(procedure, state = :en_cours) - dossiers = case state - 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) + 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 = :en_cours) - dossiers = case state - when :termine - Dossier.termine - when :not_archived - Dossier.not_archived - else - Dossier.en_cours - 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 - 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) - - updated_demandes = dossiers - .joins(:champs) - .where('champs.updated_at > follows.demande_seen_at') - - updated_annotations = dossiers - .joins(:champs_private) - .where('champs.updated_at > follows.annotations_privees_seen_at') - - updated_avis = dossiers - .joins(:avis) - .where('avis.updated_at > follows.avis_seen_at') - - updated_messagerie = dossiers - .joins(:commentaires) - .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 + Procedure + .where(id: dossiers.joins(:groupe_instructeur) + .select('groupe_instructeurs.procedure_id') + .distinct) + .distinct end def mark_tab_as_seen(dossier, tab) @@ -177,11 +136,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| @@ -190,7 +144,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 @@ -203,6 +157,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) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 4688cc070..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 @@ -334,26 +338,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) @@ -481,6 +485,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) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index c4773c0a0..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 = instructeur.dossiers_id_with_notifications(dossiers) + 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/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 diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index e4bf53727..f2a192b1b 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -49,9 +49,16 @@ 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 + + @attributes = ATTRIBUTES.dup + + if procedure.routee? + @attributes << :groupe_instructeur_label + end + + @dossiers = dossiers.downloadable_sorted if ids @dossiers = @dossiers.where(id: ids) end @@ -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/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/app/views/instructeurs/procedures/index.html.haml b/app/views/instructeurs/procedures/index.html.haml index 305732605..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[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 6ae142f29..302ce3a95 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 @@ -24,13 +24,13 @@ 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).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'), @@ -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 @@ -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/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 diff --git a/app/views/shared/dossiers/_identite_entreprise.html.haml b/app/views/shared/dossiers/_identite_entreprise.html.haml index 33f0ce548..9fcd2a0c9 100644 --- a/app/views/shared/dossiers/_identite_entreprise.html.haml +++ b/app/views/shared/dossiers/_identite_entreprise.html.haml @@ -71,3 +71,8 @@ %tr %th.libelle Date de déclaration : %td= try_format_date(etablissement.association_date_declaration) + +%p + = 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 9667a54bd..21ab7a5f8 100644 --- a/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml +++ b/app/views/users/dossiers/etablissement/_infos_entreprise.html.haml @@ -47,3 +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 sur « entreprise.data.gouv.fr »', + "https://entreprise.data.gouv.fr/etablissement/#{etablissement.siret}", + target: "_blank" 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/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 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 diff --git a/config/routes.rb b/config/routes.rb index bf0542172..595299fcd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -219,12 +219,20 @@ Rails.application.routes.draw do # API # + 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 + namespace :api do namespace :v1 do resources :procedures, only: [:index, :show] do resources :dossiers, only: [:index, :show] end end + + namespace :v2 do + post :graphql, to: "graphql#execute" + end end # 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| 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": { 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 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 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/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 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 } diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index 7d9c36846..a17c8b341 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([]) } @@ -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)).to match([dossier.id]) } - it { expect(instructeur_on_procedure_2.notifications_for_procedure(procedure)).to match([]) } + 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)).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 } + 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 @@ -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 diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index ecdf1e332..235a2baa7 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] } @@ -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) } diff --git a/spec/services/procedure_export_v2_service_spec.rb b/spec/services/procedure_export_v2_service_spec.rb index 43483f931..12a3cf945 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 @@ -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