Merge pull request #6761 from betagouv/main

2021-12-18-01
This commit is contained in:
krichtof 2021-12-18 15:13:01 +01:00 committed by GitHub
commit 282b3deb93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1521 additions and 201 deletions

View file

@ -359,7 +359,8 @@
.cnaf-inputs,
.dgfip-inputs,
.pole-emploi-inputs {
.pole-emploi-inputs,
.mesri-inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;

View file

@ -0,0 +1,30 @@
@import "constants";
@import "colors";
table.mesri {
margin: 2 * $default-padding 0 $default-padding $default-padding;
width: 100%;
caption {
font-weight: bold;
margin-left: - $default-padding;
margin-bottom: $default-spacer;
text-align: left;
}
th,
td {
font-weight: normal;
padding: $default-spacer;
}
th.text-right {
text-align: right;
}
&.horizontal {
th {
border-bottom: 1px solid $grey;
}
}
}

View file

@ -60,6 +60,7 @@ $stat-card-half-horizontal-spacing: 4 * $default-space;
font-size: 26px;
font-weight: bold;
width: 200px;
text-transform: uppercase;
}
.stat-card-details {
@ -132,6 +133,7 @@ $big-number-card-padding: 2 * $segmented-control-item-border-radius;
margin: 0 auto;
margin-bottom: 20px;
color: $light-grey;
text-transform: uppercase;
&.long-title {
margin-left: -30px;

View file

@ -27,8 +27,8 @@ class StatsController < ApplicationController
"Terminé" => stat.dossiers_termines
}
@procedures_cumulative = cumulative_hash(procedures, :published_at)
@procedures_in_the_last_4_months = last_four_months_hash(procedures, :published_at)
@procedures_cumulative = cumulative_month_serie(procedures, :published_at)
@procedures_in_the_last_4_months = last_four_months_serie(procedures, :published_at)
@dossiers_cumulative = stat.dossiers_cumulative
@dossiers_in_the_last_4_months = stat.dossiers_in_the_last_4_months
@ -57,9 +57,9 @@ class StatsController < ApplicationController
"procedures.libelle",
"users.id",
"dossiers.state",
"dossiers.depose_at - dossiers.created_at",
"dossiers.en_instruction_at - dossiers.depose_at",
"dossiers.processed_at - dossiers.en_instruction_at"
Arel.sql("dossiers.depose_at - dossiers.created_at"),
Arel.sql("dossiers.en_instruction_at - dossiers.depose_at"),
Arel.sql("dossiers.processed_at - dossiers.en_instruction_at")
)
end
@ -136,27 +136,18 @@ class StatsController < ApplicationController
end
end
def last_four_months_hash(association, date_attribute)
min_date = 3.months.ago.beginning_of_month.to_date
def last_four_months_serie(association, date_attribute)
association
.where(date_attribute => min_date..max_date)
.group("DATE_TRUNC('month', #{date_attribute})")
.group_by_month(date_attribute, last: 4, current: super_admin_signed_in?)
.count
.to_a
.sort_by { |a| a[0] }
.map { |e| [I18n.l(e.first, format: "%B %Y"), e.last] }
.transform_keys { |date| l(date, format: "%B %Y") }
end
def cumulative_hash(association, date_attribute)
def cumulative_month_serie(association, date_attribute)
sum = 0
association
.where("#{date_attribute} < ?", max_date)
.group("DATE_TRUNC('month', #{date_attribute})")
.group_by_month(date_attribute, current: super_admin_signed_in?)
.count
.to_a
.sort_by { |a| a[0] }
.map { |x, y| { x => (sum += y) } }
.reduce({}, :merge)
.transform_values { |count| sum += count }
end
end

View file

@ -348,8 +348,8 @@ module Users
def champs_params
params.permit(dossier: {
champs_attributes: [
:id, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :piece_justificative_file, :departement, :code_departement, value: [],
champs_attributes: [:id, :_destroy, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :piece_justificative_file, :departement, :code_departement, value: []]
:id, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :departement, :code_departement, value: [],
champs_attributes: [:id, :_destroy, :value, :value_other, :external_id, :primary_value, :secondary_value, :numero_allocataire, :code_postal, :identifiant, :numero_fiscal, :reference_avis, :ine, :piece_justificative_file, :departement, :code_departement, value: []]
]
})
end

View file

@ -19,7 +19,11 @@ class Users::SessionsController < Devise::SessionsController
end
def link_sent
@email = params[:email]
if Devise.email_regexp.match?(params[:email])
@email = params[:email]
else
redirect_to root_path
end
end
# DELETE /resource/sign_out

View file

@ -1983,6 +1983,11 @@ enum TypeDeChamp {
"""
linked_drop_down_list
"""
Données du Ministère de l'Enseignement Supérieur, de la Recherche et de l'Innovation
"""
mesri
"""
Choix multiples
"""

View file

@ -0,0 +1,9 @@
module ZoneHelper
def grouped_options_for_zone
collectivite = Zone.find_by(acronym: "COLLECTIVITE")
{
"--" => [[I18n.t('i_dont_know', scope: 'utils'), nil], [collectivite.label, collectivite.id]],
I18n.t('ministeres', scope: 'zones') => (Zone.order(:label) - [collectivite]).map { |m| [m.label, m.id] }
}
end
end

View file

@ -1,5 +1,5 @@
class ArchiveCreationJob < ApplicationJob
queue_as :exports
queue_as :archives
def perform(procedure, archive, instructeur)
ProcedureArchiveService

View file

@ -1,4 +1,20 @@
class ActiveStorage::DownloadableFile
# https://edgeapi.rubyonrails.org/classes/ActiveStorage/Blob.html#method-i-download
def self.download(attachment:, destination_path:, in_chunk: true)
byte_written = 0
File.open(destination_path, mode: 'wb') do |fd| # we expact a path as string, so we can recreate the file (ex: failure/retry on former existing fd)
if in_chunk
attachment.download do |chunk|
byte_written += fd.write(chunk)
end
else
byte_written = fd.write(attachment.download)
end
end
byte_written
end
def self.create_list_from_dossier(dossier, for_expert = false)
dossier_export = PiecesJustificativesService.generate_dossier_export(dossier)
pjs = [dossier_export] + PiecesJustificativesService.liste_documents(dossier, for_expert)

View file

@ -4,7 +4,8 @@ class APIParticulier::API
INTROSPECT_RESOURCE_NAME = "introspect"
COMPOSITION_FAMILIALE_RESOURCE_NAME = "v2/composition-familiale"
AVIS_IMPOSITION_RESOURCE_NAME = "v2/avis-imposition"
SITUATION_POLE_EMPLOI = "v2/situations-pole-emploi"
SITUATION_POLE_EMPLOI_RESOURCE_NAME = "v2/situations-pole-emploi"
ETUDIANTS_RESOURCE_NAME = "v2/etudiants"
TIMEOUT = 20
@ -31,7 +32,17 @@ class APIParticulier::API
end
def situation_pole_emploi(identifiant)
get(SITUATION_POLE_EMPLOI, identifiant: identifiant)
get(SITUATION_POLE_EMPLOI_RESOURCE_NAME, identifiant: identifiant)
end
def etudiants(ine)
# NOTE: Paramètres d'appel mutuellement exclusifs,
# l'appel par INE est réservé aux acteurs de la sphère de l'enseignement
# - INE, l'Identifiant National Étudiant
# - état civil, constitué du nom, prénom, date de naissance, sexe et lieu de naissance
# TODO: ajouter le support de l'état civil
get(ETUDIANTS_RESOURCE_NAME, ine: ine)
end
private

View file

@ -0,0 +1,48 @@
class APIParticulier::MesriAdapter
class InvalidSchemaError < ::StandardError
def initialize(errors)
super(errors.map(&:to_json).join("\n"))
end
end
def initialize(api_particulier_token, ine, requested_sources)
@api = APIParticulier::API.new(api_particulier_token)
@ine = ine
@requested_sources = requested_sources
end
def to_params
@api.etudiants(@ine)
.tap { |d| ensure_valid_schema!(d) }
.then { |d| extract_requested_sources(d) }
end
private
def ensure_valid_schema!(data)
if !schemer.valid?(data)
errors = schemer.validate(data).to_a
raise InvalidSchemaError.new(errors)
end
end
def schemer
@schemer ||= JSONSchemer.schema(Rails.root.join('app/schemas/etudiants.json'))
end
def extract_requested_sources(data)
@requested_sources['mesri']&.map do |(scope, sources)|
case scope
when 'inscriptions'
{ scope => data[scope].filter_map { |d| d.slice(*sources) if d.key?('dateDebutInscription') } }
when 'admissions'
{ scope => data['inscriptions'].filter_map { |d| d.slice(*sources) if d.key?('dateDebutAdmission') } }
when 'etablissements'
{ scope => data['inscriptions'].map { |d| d['etablissement'].slice(*sources) }.uniq }
else
{ scope => data.slice(*sources) }
end
end
&.flatten&.reduce(&:deep_merge) || {}
end
end

View file

@ -8,6 +8,7 @@ module APIParticulier
def available_sources
@procedure.api_particulier_scopes
.filter_map { |provider_and_scope| raw_scopes[provider_and_scope] }
.uniq # remove provider/scope tuples duplicates (e.g. mesri inscriptions)
.map { |provider, scope| extract_sources(provider, scope) }
.reduce({}) { |acc, el| acc.deep_merge(el) { |_, this_val, other_val| this_val + other_val } }
end
@ -80,7 +81,13 @@ module APIParticulier
'pole_emploi_identite' => ['pole_emploi', 'identite'],
'pole_emploi_adresse' => ['pole_emploi', 'adresse'],
'pole_emploi_contact' => ['pole_emploi', 'contact'],
'pole_emploi_inscription' => ['pole_emploi', 'inscription']
'pole_emploi_inscription' => ['pole_emploi', 'inscription'],
'mesri_identifiant' => ['mesri', 'identifiant'],
'mesri_identite' => ['mesri', 'identite'],
'mesri_inscription_etudiant' => ['mesri', 'inscriptions'],
'mesri_inscription_autre' => ['mesri', 'inscriptions'],
'mesri_admission' => ['mesri', 'admissions'],
'mesri_etablissements' => ['mesri', 'etablissements']
}
end
@ -123,6 +130,13 @@ module APIParticulier
'adresse' => ['INSEECommune', 'codePostal', 'localite', 'ligneVoie', 'ligneComplementDestinataire', 'ligneComplementAdresse', 'ligneComplementDistribution', 'ligneNom'],
'contact' => ['email', 'telephone', 'telephone2'],
'inscription' => ['dateInscription', 'dateCessationInscription', 'codeCertificationCNAV', 'codeCategorieInscription', 'libelleCategorieInscription']
},
'mesri' => {
'identifiant' => ['ine'],
'identite' => ['nom', 'prenom', 'dateNaissance'],
'inscriptions' => ['statut', 'regime', 'dateDebutInscription', 'dateFinInscription', 'codeCommune'],
'admissions' => ['statut', 'regime', 'dateDebutAdmission', 'dateFinAdmission', 'codeCommune'],
'etablissements' => ['uai', 'nom']
}
}
end

View file

@ -0,0 +1,20 @@
module Utils
module Retryable
# usage:
# max_attempt : retry count
# errors : only retry those errors
# with_retry(max_attempt: 10, errors: [StandardError]) do
# do_something_which_can_fail
# end
def with_retry(max_attempt: 1, errors: [StandardError], &block)
limiter = 0
begin
yield
rescue *errors
limiter += 1
retry if limiter <= max_attempt
raise
end
end
end
end

View file

@ -0,0 +1,47 @@
# == Schema Information
#
# Table name: champs
#
# id :integer not null, primary key
# data :jsonb
# fetch_external_data_exceptions :string is an Array
# private :boolean default(FALSE), not null
# rebased_at :datetime
# row :integer
# type :string
# value :string
# value_json :jsonb
# created_at :datetime
# updated_at :datetime
# dossier_id :integer
# etablissement_id :integer
# external_id :string
# parent_id :bigint
# type_de_champ_id :integer
#
class Champs::MesriChamp < Champs::TextChamp
# see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/mesri-input-validation.middleware.ts
store_accessor :value_json, :ine
def blank?
external_id.nil?
end
def fetch_external_data?
true
end
def fetch_external_data
return if !valid?
APIParticulier::MesriAdapter.new(
procedure.api_particulier_token,
ine,
procedure.api_particulier_sources
).to_params
end
def external_id
{ ine: ine }.to_json if ine.present?
end
end

View file

@ -15,6 +15,7 @@ class GroupeInstructeur < ApplicationRecord
has_many :instructeurs, through: :assign_tos
has_many :dossiers
has_and_belongs_to_many :exports, dependent: :destroy
has_and_belongs_to_many :bulk_messages, dependent: :destroy
validates :label, presence: { message: 'doit être renseigné' }, allow_nil: false
validates :label, uniqueness: { scope: :procedure, message: 'existe déjà' }

View file

@ -734,6 +734,10 @@ class Procedure < ApplicationRecord
api_particulier_sources['pole_emploi'].present?
end
def mesri_enabled?
api_particulier_sources['mesri'].present?
end
private
def validate_for_publication?

View file

@ -30,11 +30,11 @@ class Stat < ApplicationRecord
dossiers_deposes_entre_60_et_30_jours: states['dossiers_deposes_entre_60_et_30_jours'],
dossiers_not_brouillon: states['not_brouillon'],
dossiers_termines: states['termines'],
dossiers_cumulative: cumulative_hash([
dossiers_cumulative: cumulative_month_serie([
[Dossier.state_not_brouillon, :depose_at],
[DeletedDossier.where.not(state: :brouillon), :deleted_at]
]),
dossiers_in_the_last_4_months: last_four_months_hash([
dossiers_in_the_last_4_months: last_four_months_serie([
[Dossier.state_not_brouillon, :depose_at],
[DeletedDossier.where.not(state: :brouillon), :deleted_at]
]),
@ -75,39 +75,33 @@ class Stat < ApplicationRecord
)
end
def last_four_months_hash(associations_with_date_attribute)
min_date = 3.months.ago.beginning_of_month.to_date
def last_four_months_serie(associations_with_date_attribute)
timeseries = associations_with_date_attribute.map do |association, date_attribute|
association
.where(date_attribute => min_date..max_date)
.group("DATE_TRUNC('month', #{date_attribute})")
.count
association.group_by_month(date_attribute, last: 4, current: false).count
end
sum_hashes(*timeseries)
.to_a
.sort_by { |a| a[0] }
.map { |e| [I18n.l(e.first, format: "%B %Y"), e.last] }
month_serie(sum_hashes(*timeseries))
end
def cumulative_hash(associations_with_date_attribute)
def month_serie(date_serie)
date_serie.keys.sort.each_with_object({}) { |date, h| h[I18n.l(date, format: "%B %Y")] = date_serie[date] }
end
def cumulative_month_serie(associations_with_date_attribute)
timeseries = associations_with_date_attribute.map do |association, date_attribute|
association
.where("#{date_attribute} < ?", max_date)
.group("DATE_TRUNC('month', #{date_attribute})")
.count
association.group_by_month(date_attribute, current: false).count
end
cumulative_serie(sum_hashes(*timeseries))
end
def cumulative_serie(sums)
sum = 0
sum_hashes(*timeseries)
.to_a
.sort_by { |a| a[0] }
.map { |x, y| { x => (sum += y) } }
.reduce({}, :merge)
sums.keys.sort.index_with { |date| sum += sums[date] }
end
def sum_hashes(*hashes)
{}.merge(*hashes) { |_k, hash_one_value, hash_two_value| hash_one_value + hash_two_value }
{}.merge(*hashes) { |_k, v1, v2| v1 + v2 }
end
def max_date

View file

@ -43,9 +43,9 @@ class Traitement < ApplicationRecord
.to_sql
sql = <<~EOF
select date_trunc('month', r1.processed_at) as month, count(r1.processed_at)
select date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL) as month, count(r1.processed_at)
from (#{last_traitements_per_dossier}) as r1
group by date_trunc('month', r1.processed_at)
group by date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)
order by month desc
EOF

View file

@ -52,7 +52,8 @@ class TypeDeChamp < ApplicationRecord
annuaire_education: 'annuaire_education',
cnaf: 'cnaf',
dgfip: 'dgfip',
pole_emploi: 'pole_emploi'
pole_emploi: 'pole_emploi',
mesri: 'mesri'
}
belongs_to :revision, class_name: 'ProcedureRevision', optional: true
@ -330,6 +331,8 @@ class TypeDeChamp < ApplicationRecord
procedure.dgfip_enabled?
when TypeDeChamp.type_champs.fetch(:pole_emploi)
procedure.pole_emploi_enabled?
when TypeDeChamp.type_champs.fetch(:mesri)
procedure.mesri_enabled?
else
true
end

View file

@ -0,0 +1,2 @@
class TypesDeChamp::MesriTypeDeChamp < TypesDeChamp::TextTypeDeChamp
end

View file

@ -0,0 +1,57 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://demarches-simplifiees.fr/etudiants.schema.json",
"title": "statut étudiant",
"type": "object",
"properties": {
"ine": {
"type": "string"
},
"nom": {
"type": "string"
},
"prenom": {
"type": "string"
},
"dateNaissance": {
"format": "date",
"type": "string"
},
"inscriptions": {
"type": "array",
"items" : {
"type": "object",
"properties": {
"dateDebutInscription": {
"format": "date",
"type": "string"
},
"dateFinInscription": {
"format": "date",
"type": "string"
},
"statut": {
"enum": ["admis", "inscrit"]
},
"regime": {
"enum": ["formation initiale", "formation continue"]
},
"codeCommune": {
"type": "string"
},
"etablissement": {
"type": "object",
"properties": {
"uai": {
"type": "string"
},
"nom": {
"type": "string"
}
}
}
}
}
}
}
}

View file

@ -38,34 +38,32 @@
"email": {
"type": "string"
},
"$defs": {
"adresse": {
"type": "object",
"properties": {
"codePostal": {
"type": "string"
},
"INSEECommune": {
"type": "string"
},
"localite": {
"type": "string"
},
"ligneVoie": {
"type": "string"
},
"ligneComplementDestinataire": {
"type": "string"
},
"ligneComplementAdresse": {
"type": "string"
},
"ligneComplementDistribution": {
"type": "string"
},
"ligneNom": {
"type": "string"
}
"adresse": {
"type": "object",
"properties": {
"codePostal": {
"type": "string"
},
"INSEECommune": {
"type": "string"
},
"localite": {
"type": "string"
},
"ligneVoie": {
"type": "string"
},
"ligneComplementDestinataire": {
"type": "string"
},
"ligneComplementAdresse": {
"type": "string"
},
"ligneComplementDistribution": {
"type": "string"
},
"ligneNom": {
"type": "string"
}
}
},

View file

@ -1,6 +1,9 @@
require 'tempfile'
class ProcedureArchiveService
include Utils::Retryable
ARCHIVE_CREATION_DIR = ENV.fetch('ARCHIVE_CREATION_DIR') { '/tmp' }
def initialize(procedure)
@procedure = procedure
end
@ -14,6 +17,35 @@ class ProcedureArchiveService
end
def collect_files_archive(archive, instructeur)
if Flipper.enabled?(:zip_using_binary, @procedure)
new_collect_files_archive(archive, instructeur)
else
old_collect_files_archive(archive, instructeur)
end
end
def new_collect_files_archive(archive, instructeur)
## faux, ca ne doit prendre que certains groupe instructeur ?
if archive.time_span_type == 'everything'
dossiers = @procedure.dossiers.state_termine
else
dossiers = @procedure.dossiers.processed_in_month(archive.month)
end
attachments = create_list_of_attachments(dossiers)
download_and_zip(attachments) do |zip_file|
archive.file.attach(
io: File.open(zip_file),
filename: archive.filename(@procedure),
# we don't want to run virus scanner on this file
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
)
end
archive.make_available!
InstructeurMailer.send_archive(instructeur, @procedure, archive).deliver_later
end
def old_collect_files_archive(archive, instructeur)
if archive.time_span_type == 'everything'
dossiers = @procedure.dossiers.state_termine
else
@ -27,7 +59,7 @@ class ProcedureArchiveService
Zip::OutputStream.open(tmp_file) do |zipfile|
bug_reports = ''
files.each do |attachment, pj_filename|
zipfile.put_next_entry("#{zip_root_folder(@procedure)}/#{pj_filename}")
zipfile.put_next_entry("#{zip_root_folder}/#{pj_filename}")
begin
zipfile.puts(attachment.download)
rescue
@ -35,7 +67,7 @@ class ProcedureArchiveService
end
end
if !bug_reports.empty?
zipfile.put_next_entry("#{zip_root_folder(@procedure)}/LISEZMOI.txt")
zipfile.put_next_entry("#{zip_root_folder}/LISEZMOI.txt")
zipfile.puts(bug_reports)
end
end
@ -63,7 +95,51 @@ class ProcedureArchiveService
private
def zip_root_folder(procedure)
def download_and_zip(attachments, &block)
Dir.mktmpdir(nil, ARCHIVE_CREATION_DIR) do |tmp_dir|
archive_dir = File.join(tmp_dir, zip_root_folder)
zip_path = File.join(ARCHIVE_CREATION_DIR, "#{zip_root_folder}.zip")
begin
FileUtils.remove_entry_secure(archive_dir) if Dir.exist?(archive_dir)
Dir.mkdir(archive_dir)
bug_reports = ''
attachments.each do |attachment, path|
attachment_path = File.join(archive_dir, path)
attachment_dir = File.dirname(attachment_path)
FileUtils.mkdir_p(attachment_dir) if !Dir.exist?(attachment_dir)
begin
with_retry(max_attempt: 1) do
ActiveStorage::DownloadableFile.download(attachment: attachment,
destination_path: attachment_path,
in_chunk: true)
end
rescue => e
Rails.logger.error("Fail to download filename #{File.basename(attachment_path)} in procedure##{@procedure.id}, reason: #{e}")
File.delete(attachment_path) if File.exist?(attachment_path)
bug_reports += "Impossible de récupérer le fichier #{File.basename(attachment_path)}\n"
end
end
if !bug_reports.empty?
File.write(File.join(archive_dir, 'LISEZMOI.txt'), bug_reports)
end
File.delete(zip_path) if File.exist?(zip_path)
Dir.chdir(tmp_dir) do
system 'zip', '-r', zip_path, zip_root_folder
end
yield(zip_path)
ensure
FileUtils.remove_entry_secure(archive_dir) if Dir.exist?(archive_dir)
File.delete(zip_path) if File.exist?(zip_path)
end
end
end
def zip_root_folder
"procedure-#{@procedure.id}"
end

View file

@ -0,0 +1,21 @@
class UpdateZoneToProceduresService
def self.call(lines)
errors = []
lines.each do |line|
zone_label = line["POL_PUB_MINISTERE RATTACHEMENT"]
zone = Zone.find_by(acronym: zone_label)
if zone.nil?
errors << "Zone #{zone_label} introuvable"
else
id = line["id"]
procedure = Procedure.find_by(id: id)
if procedure
procedure.update(zone: zone)
else
errors << "Procedure #{id} introuvable"
end
end
end
errors
end
end

View file

@ -1,7 +1,7 @@
class MonAvisEmbedValidator < ActiveModel::Validator
def validate(record)
# We need to ensure the embed code is not any random string in order to avoid injections
r = Regexp.new('<a href="https://monavis|voxusagers.numerique.gouv.fr/Demarches/\d+.*key=[[:alnum:]]+.*">\s*<img src="(https://monavis.numerique.gouv.fr/monavis-static/bouton-blanc|bleu.png)|(https://voxusagers.numerique.gouv.fr/static/bouton-(bleu|blanc).svg)" alt="Je donne mon avis" title="Je donne mon avis sur cette démarche" />\s*</a>', Regexp::MULTILINE)
r = Regexp.new('<a href="https://monavis|voxusagers.numerique.gouv.fr/Demarches/\d+.*key=[[:alnum:]]+.*">\s*<img src="https://monavis|voxusagers.numerique.gouv.fr/(monavis-)?static/bouton-blanc|bleu.png|svg" alt="Je donne mon avis" title="Je donne mon avis sur cette démarche" />\s*</a>', Regexp::MULTILINE)
if record.monavis_embed.present? && !r.match?(record.monavis_embed)
record.errors[:base] << "Le code fourni ne correspond pas au format des codes MonAvis reconnus par la plateforme."
end

View file

@ -13,10 +13,11 @@
%span.mandatory *
= f.text_area :description, rows: '6', placeholder: 'Description de la démarche, destinataires, etc. ', class: 'form-control'
= f.label :zone do
= t('zone', scope: 'activerecord.attributes.procedure')
%span.mandatory *
= f.collection_select :zone_id, Zone.order(:label), :id, :label, prompt: true
- if Flipper.enabled? :zonage
= f.label :zone do
= t('zone', scope: 'activerecord.attributes.procedure')
%span.mandatory *
= f.select :zone_id, grouped_options_for_zone
%h3.header-subsection Logo de la démarche
= image_upload_and_render f, @procedure.logo

View file

@ -57,7 +57,7 @@
.dropdown-description
%h4 Clore
- if procedure.brouillon?
- if procedure.can_be_deleted_by_administrateur?
%li
= link_to admin_procedure_path(procedure), method: :delete, data: { confirm: "Voulez-vous vraiment supprimer la démarche ? \nToute suppression est définitive et s'appliquera aux éventuels autres administrateurs de cette démarche !" } do
%span.icon.refuse

View file

@ -0,0 +1,11 @@
%table.mesri
%caption #{t("api_particulier.providers.mesri.scopes.#{scope}.libelle")} :
- admissions.each do |admission|
- admission.slice('statut', 'regime', 'dateDebutAdmission', 'dateFinAdmission', 'codeCommune').keys.each do |key|
%tr
%th= t("api_particulier.providers.mesri.scopes.#{scope}.#{key}")
- case key
- when 'dateDebutAdmission', 'dateFinAdmission'
%td= try_format_date(Date.strptime(admission[key], "%Y-%m-%d"))
- else
%td= admission[key]

View file

@ -0,0 +1,7 @@
%table.mesri
%caption #{t("api_particulier.providers.mesri.scopes.#{scope}.libelle")} :
- etablissements.each do |etablissement|
- etablissement.slice('uai', 'nom').keys.each do |key|
%tr
%th= t("api_particulier.providers.mesri.scopes.#{scope}.#{key}")
%td= etablissement[key]

View file

@ -0,0 +1,6 @@
%table.mesri
%caption #{t("api_particulier.providers.mesri.scopes.#{scope}.libelle")} :
- identifiant.slice('ine').keys.each do |key|
%tr
%th= t("api_particulier.providers.mesri.scopes.#{scope}.#{key}")
%td= identifiant[key]

View file

@ -0,0 +1,10 @@
%table.mesri
%caption #{t("api_particulier.providers.mesri.scopes.#{scope}.libelle")} :
- identite.slice('nom', 'prenom', 'dateNaissance').keys.each do |key|
%tr
%th= t("api_particulier.providers.mesri.scopes.#{scope}.#{key}")
- case key
- when 'dateNaissance'
%td= try_format_date(Date.strptime(identite[key], "%Y-%m-%d"))
- else
%td= identite[key]

View file

@ -0,0 +1,11 @@
%table.mesri
%caption #{t("api_particulier.providers.mesri.scopes.#{scope}.libelle")} :
- inscriptions.each do |inscription|
- inscription.slice('statut', 'regime', 'dateDebutInscription', 'dateFinInscription', 'codeCommune').keys.each do |key|
%tr
%th= t("api_particulier.providers.mesri.scopes.#{scope}.#{key}")
- case key
- when 'dateDebutInscription', 'dateFinInscription'
%td= try_format_date(Date.strptime(inscription[key], "%Y-%m-%d"))
- else
%td= inscription[key]

View file

@ -0,0 +1,25 @@
- if champ.blank?
%p= t('.not_filled')
- elsif champ.data.blank?
%p= t('.fetching_data', ine: champ.ine)
- else
- if profile == 'usager'
- sources = champ.procedure.api_particulier_sources['mesri'].keys
- i18n_sources = sources.map { |s| I18n.t("#{s}.libelle", scope: 'api_particulier.providers.mesri.scopes') }
%p= t('.data_fetched', sources: i18n_sources.to_sentence, ine: champ.ine)
- if profile == 'instructeur'
%p= t('.data_fetched_title')
- champ.data.slice('identifiant', 'identite', 'inscriptions', 'admissions', 'etablissements').keys.each do |scope|
- case scope
- when 'identifiant'
= render partial: 'shared/champs/mesri/identifiant', locals: { scope: scope, identifiant: champ.data[scope] }
- when 'identite'
= render partial: 'shared/champs/mesri/identite', locals: { scope: scope, identite: champ.data[scope] }
- when 'inscriptions'
= render partial: 'shared/champs/mesri/inscriptions', locals: { scope: scope, inscriptions: champ.data[scope] }
- when 'admissions'
= render partial: 'shared/champs/mesri/admissions', locals: { scope: scope, admissions: champ.data[scope] }
- when 'etablissements'
= render partial: 'shared/champs/mesri/etablissements', locals: { scope: scope, etablissements: champ.data[scope] }

View file

@ -42,6 +42,8 @@
= render partial: "shared/champs/dgfip/show", locals: { champ: c, profile: profile }
- when TypeDeChamp.type_champs.fetch(:pole_emploi)
= render partial: "shared/champs/pole_emploi/show", locals: { champ: c, profile: profile }
- when TypeDeChamp.type_champs.fetch(:mesri)
= render partial: "shared/champs/mesri/show", locals: { champ: c, profile: profile }
- when TypeDeChamp.type_champs.fetch(:address)
= render partial: "shared/champs/address/show", locals: { champ: c }
- when TypeDeChamp.type_champs.fetch(:communes)

View file

@ -0,0 +1,7 @@
.mesri-inputs
%div
= form.label :ine, t('.ine_label')
%p.notice= t('.ine_notice')
= form.text_field :ine,
required: champ.mandatory?,
aria: { describedby: describedby_id(champ) }

View file

@ -7,45 +7,45 @@
.stat-cards
- if @usual_traitement_time.present?
.stat-card.big-number-card
%span.big-number-card-title TEMPS DE TRAITEMENT USUEL
%span.big-number-card-title= t('.usual_processing_time')
%span.big-number-card-number
= distance_of_time_in_words(@usual_traitement_time)
%span.big-number-card-detail
#{ProcedureStatsConcern::USUAL_TRAITEMENT_TIME_PERCENTILE}% des demandes des #{ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS} derniers jours ont été traitées en moins de #{distance_of_time_in_words(@usual_traitement_time)}.
= t('.processing_time_description', percentile: ProcedureStatsConcern::USUAL_TRAITEMENT_TIME_PERCENTILE, span: ProcedureStatsConcern::NB_DAYS_RECENT_DOSSIERS, days: distance_of_time_in_words(@usual_traitement_time))
.stat-cards
.stat-card.stat-card-half.pull-left
%span.stat-card-title TEMPS DE TRAITEMENT
.stat-card-details depuis le lancement de la procédure
%span.stat-card-title= t('.processing_time')
.stat-card-details= t('.since_procedure_creation')
.chart-container
.chart
- colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss
= column_chart @usual_traitement_time_by_month, ytitle: "Nb Jours", legend: "bottom", label: "Temps de traitement entre le passage en instruction et la réponse (accepté, refusé, ou classé sans suite) pour 90% des dossiers"
= column_chart @usual_traitement_time_by_month, ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description')
.stat-card.stat-card-half.pull-left
%span.stat-card-title AVANCÉE DES DOSSIERS
.stat-card-details depuis le lancement de la procédure
%span.stat-card-title= t('.status_evolution')
.stat-card-details= t('.status_evolution_details')
.chart-container
.chart
= area_chart @dossiers_funnel, ytitle: 'Nb dossiers', label: 'Nb dossiers'
= area_chart @dossiers_funnel, ytitle: t('.dossiers_count'), label: t('.dossiers_count')
.stat-cards
.stat-card.stat-card-half.pull-left
%span.stat-card-title TAUX DACCEPTATION
.stat-card-details depuis le lancement de la procédure
%span.stat-card-title= t('.acceptance_rate')
.stat-card-details= t('.acceptance_rate_details')
.chart-container
.chart
= pie_chart @termines_states,
code: true,
colors: %w(#387EC3 #AE2C2B #FAD859),
label: 'Taux',
label: t('.rate'),
suffix: '%',
library: { plotOptions: { pie: { dataLabels: { enabled: true, format: '{point.name} : {point.percentage: .1f}%' } } } }
.stat-card.stat-card-half.pull-left
%span.stat-card-title RÉPARTITION PAR SEMAINE
.stat-card-details au cours des 6 derniers mois
%span.stat-card-title= t('.weekly_distribution')
.stat-card-details= t('.weekly_distribution_details')
.chart-container
.chart
= line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"], ytitle: 'Nb dossiers'
= line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"], ytitle: t('.dossiers_count')

View file

@ -31,6 +31,26 @@
"confidence": "Weak",
"note": "explicitely sanitized even if we are using html_safe"
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "4254ed68100af9b496883716b1fd658e1943b2385a0d08de5a6ef5c600c1a8f9",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/traitement.rb",
"line": 51,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "ActiveRecord::Base.connection.execute(\"select date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL) as month, count(r1.processed_at)\\nfrom (#{Traitement.select(\"max(traitements.processed_at) as processed_at\").termine.where(:dossier => Dossier.state_termine.where(:groupe_instructeur => groupe_instructeurs)).group(:dossier_id).to_sql}) as r1\\ngroup by date_trunc('month', r1.processed_at::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\\norder by month desc\\n\")",
"render_path": null,
"location": {
"type": "method",
"class": "Traitement",
"method": "Traitement.count_dossiers_termines_by_month"
},
"user_input": "Time.zone.formatted_offset",
"confidence": "Medium",
"note": ""
},
{
"warning_type": "Cross-Site Scripting",
"warning_code": 2,
@ -62,26 +82,6 @@
"confidence": "Weak",
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "6c98e520dd368104bb0c81334875010711cd523afc28057ef86a10930f95c4b7",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/stat.rb",
"line": 83,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "association.where(date_attribute => ((3.months.ago.beginning_of_month.to_date..max_date))).group(\"DATE_TRUNC('month', #{date_attribute})\")",
"render_path": null,
"location": {
"type": "method",
"class": "Stat",
"method": "last_four_months_hash"
},
"user_input": "date_attribute",
"confidence": "Weak",
"note": "no user input, fixed value"
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
@ -102,6 +102,26 @@
"confidence": "Medium",
"note": "The table and column are escaped, which should make this safe"
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "c0f93612a68c32da58f327e0b5fa33dd42fd8beb2984cf023338c5aadbbdacca",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/stat.rb",
"line": 83,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "association.where(date_attribute => ((3.months.ago.beginning_of_month..max_date))).group(\"DATE_TRUNC('month', #{date_attribute}::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\")",
"render_path": null,
"location": {
"type": "method",
"class": "Stat",
"method": "last_four_months_hash"
},
"user_input": "date_attribute",
"confidence": "Weak",
"note": ""
},
{
"warning_type": "Redirect",
"warning_code": 18,
@ -109,7 +129,7 @@
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/instructeurs/procedures_controller.rb",
"line": 190,
"line": 195,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(Export.find_or_create_export(params[:export_format], (params[:time_span_type] or \"everything\"), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url)",
"render_path": null,
@ -125,13 +145,13 @@
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "dc6d873aff3dc5e51e3349b17e1f35039b23d0bddbf04224b0f1bca3e4608c1e",
"fingerprint": "f2bb9bc6a56e44ab36ee18152c657395841cff354baed0a302b8d18650551529",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/stat.rb",
"line": 97,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "association.where(\"#{date_attribute} < ?\", max_date).group(\"DATE_TRUNC('month', #{date_attribute})\")",
"code": "association.where(\"#{date_attribute} < ?\", max_date).group(\"DATE_TRUNC('month', #{date_attribute}::TIMESTAMPTZ AT TIME ZONE '#{Time.zone.formatted_offset}'::INTERVAL)\")",
"render_path": null,
"location": {
"type": "method",
@ -140,9 +160,9 @@
},
"user_input": "date_attribute",
"confidence": "Weak",
"note": "no user input, fixed value"
"note": ""
}
],
"updated": "2021-11-26 13:22:41 +0100",
"updated": "2021-12-01 17:39:08 -1000",
"brakeman_version": "5.1.1"
}

View file

@ -104,3 +104,32 @@ en:
codeCertificationCNAV: CNAV certification code
codeCategorieInscription: registration category code
libelleCategorieInscription: registration category label
mesri:
libelle: Student status
scopes:
identifiant:
libelle: Identifier
ine: INE
identite:
libelle: Identity
nom: last name
prenom: first name
dateNaissance: date of birth
inscriptions:
libelle: Registrations
statut: status
regime: system
dateDebutInscription: start date of registration
dateFinInscription: end date of registration
codeCommune: postal code
admissions:
libelle: Admissions
statut: status
regime: system
dateDebutAdmission: start date of admission
dateFinAdmission: end date of admission
codeCommune: postal code
etablissements:
libelle: Institutions
uai: UAI
nom: name

View file

@ -104,3 +104,32 @@ fr:
codeCertificationCNAV: code de certification CNAV
codeCategorieInscription: code de catégorie dinscription
libelleCategorieInscription: libellé de catégorie dinscription
mesri:
libelle: Statut étudiant
scopes:
identifiant:
libelle: Identifiant
ine: INE
identite:
libelle: Identité
nom: nom
prenom: prénom
dateNaissance: date de naissance
inscriptions:
libelle: Inscriptions
statut: statut
regime: régime
dateDebutInscription: date de début d'inscription
dateFinInscription: date de fin d'inscription
codeCommune: code de la commune
admissions:
libelle: Admissions
statut: statut
regime: régime
dateDebutAdmission: date de début d'admission
dateFinAdmission: date de fin d'admission
codeCommune: code de la commune
etablissements:
libelle: Établissements
uai: UAI
nom: nom

View file

@ -418,3 +418,20 @@ en:
invalid_password: "The password is not correct."
connection_done: "The accounts for FranceConnect and %{application_name} are now merged."
merger_token_expired: "Le delay to merge your FranceConnect and %{application_name} accounts is expired. Please retry."
shared:
procedures:
stats:
usual_processing_time: "Usual processing time"
processing_time_description: "%{percentile}% of submitted files in the last %{span} days were processed in less than %{days} days."
processing_time: "Processing time"
since_procedure_creation: "since the procedure was created"
nb_days: "Nb Days"
processing_time_graph_description: "Processing time between instruction and final answer (accepted, rejected or closed) for 90% of files."
status_evolution_details: "since the procedure launch"
status_evolution: "Evolution of file statuses"
acceptance_rate: "Acceptance rate"
acceptance_rate_details: "since the procedure launch"
rate: "Rate"
dossiers_count: "Nb files"
weekly_distribution: "Weekly distribution"
weekly_distribution_details: "in the last 6 months"

View file

@ -24,6 +24,7 @@ fr:
utils:
'yes': Oui
'no': Non
i_dont_know: Je ne sais pas
deconnexion: "Déconnexion"
pj: "Pièces jointes"
asterisk_html: Les champs suivis dun astérisque ( <span class = mandatory> * </span> ) sont obligatoires.
@ -420,7 +421,7 @@ fr:
jeton_particulier:
show:
configure_token: "Configurer le jeton API Particulier"
api_particulier_description_html: "%{app_name} utilise <a href=\"https://api.gouv.fr/les-api/api-particulier\">API Particulier</a> qui permet d'accéder aux données familiales (CAF), aux données fiscales (DGFiP) et au statut pôle-emploi d'un citoyen.<br />Renseignez ici le <a href=\"https://api.gouv.fr/les-api/api-particulier/demande-acces\">jeton API Particulier</a> propre à votre démarche."
api_particulier_description_html: "%{app_name} utilise <a href=\"https://api.gouv.fr/les-api/api-particulier\">API Particulier</a> qui permet d'accéder aux données familiales (CAF), aux données fiscales (DGFiP), au statut pôle-emploi et au statut étudiant d'un citoyen.<br />Renseignez ici le <a href=\"https://api.gouv.fr/les-api/api-particulier/demande-acces\">jeton API Particulier</a> propre à votre démarche."
token_description: "Il doit contenir au minimum 15 caractères."
update:
token_ok: "Le jeton a bien été mis à jour"
@ -434,7 +435,7 @@ fr:
show:
title: "Définir les sources de données"
data_sources: "Sources de données"
explication_html: "<p>API Particulier facilite laccès des administrations aux données familiales (CAF), aux données fiscales (DGFiP) et au statut pôle-emploi d'un citoyen pour simplifier les démarches administratives mises en œuvre par les collectivités et les administrations.<br> Cela permet aux administrations daccéder à des informations certifiées à la source et ainsi : </p> <ul> <li>de saffranchir des pièces justificatives lors des démarches en ligne,</li> <li>de réduire le nombre derreurs de saisie,</li> <li>décarter le risque de fraude documentaire.</li> </ul> <p> <strong>Important&nbsp;:</strong> les disposition de l'article <a href='https://www.legifrance.gouv.fr/affichCodeArticle.do?cidTexte=LEGITEXT000031366350&amp;idArticle=LEGIARTI000031367412&amp;dateTexte=&amp;categorieLien=cid'>L144-8</a> nautorisent que léchange des informations strictement nécessaires pour traiter une démarche.<br /><br />En conséquence, ne sélectionnez ici que les données auxquelles vous aurez accès dun point de vue légal.</p>"
explication_html: "<p>API Particulier facilite laccès des administrations aux données familiales (CAF), aux données fiscales (DGFiP), au statut pôle-emploi et au statut étudiant d'un citoyen pour simplifier les démarches administratives mises en œuvre par les collectivités et les administrations.<br> Cela permet aux administrations daccéder à des informations certifiées à la source et ainsi : </p> <ul> <li>de saffranchir des pièces justificatives lors des démarches en ligne,</li> <li>de réduire le nombre derreurs de saisie,</li> <li>décarter le risque de fraude documentaire.</li> </ul> <p> <strong>Important&nbsp;:</strong> les disposition de l'article <a href='https://www.legifrance.gouv.fr/affichCodeArticle.do?cidTexte=LEGITEXT000031366350&amp;idArticle=LEGIARTI000031367412&amp;dateTexte=&amp;categorieLien=cid'>L144-8</a> nautorisent que léchange des informations strictement nécessaires pour traiter une démarche.<br /><br />En conséquence, ne sélectionnez ici que les données auxquelles vous aurez accès dun point de vue légal.</p>"
update:
sources_ok: 'Mise à jour effectuée'
procedures:
@ -442,6 +443,8 @@ fr:
ready: "Validé"
needs_configuration: "À configurer"
configure_api_particulier_token: "Configurer le jeton API particulier"
zones:
ministeres: Ministères
france_connect:
particulier:
password_confirmation:
@ -462,3 +465,20 @@ fr:
invalid_password: "Mauvais mot de passe"
connection_done: "Les comptes FranceConnect et %{application_name} sont à présent fusionnés"
merger_token_expired: "Le délai pour fusionner les comptes FranceConnect et %{application_name} est expirée. Veuillez recommencer la procédure pour vous fusionner les comptes."
shared:
procedures:
stats:
usual_processing_time: "Temps de traitement usuel"
processing_time_description: "%{percentile}% des demandes des %{span} derniers jours ont été traitées en moins de %{days} jours."
processing_time: "Temps de traitement"
since_procedure_creation: "depuis le lancement de la démarche"
nb_days: "Nb Jours"
processing_time_graph_description: "Temps de traitement entre le passage en instruction et la réponse (accepté, refusé, ou classé sans suite) pour 90% des dossiers"
status_evolution_details: "depuis le lancement de la démarche"
status_evolution: "Avancée des dossiers"
acceptance_rate: "Taux dacceptation"
acceptance_rate_details: "depuis le lancement de la démarche"
rate: "Taux"
dossiers_count: "Nb dossiers"
weekly_distribution: "Répartition par semaine"
weekly_distribution_details: "au cours des 6 derniers mois"

View file

@ -39,3 +39,4 @@ fr:
cnaf: 'Données de la Caisse nationale des allocations familiales'
dgfip: 'Données de la Direction générale des Finances publiques'
pole_emploi: 'Situation Pôle emploi'
mesri: "Données du Ministère de l'Enseignement Supérieur, de la Recherche et de l'Innovation"

View file

@ -15,6 +15,9 @@ en:
pole_emploi:
identifiant_label: Identifier
identifiant_notice: It is usually composed of alphanumeric characters.
mesri:
ine_label: INE
ine_notice: Student National Number. It is usually composed of alphanumeric characters.
header:
expires_at:
brouillon: "Expires at %{date} (%{duree_conservation_totale} months after the creation of this file)"
@ -42,3 +45,9 @@ en:
fetching_data: "Fetching data for identifier %{identifiant}."
data_fetched: "Data concerning %{sources} linked to the identifier %{identifiant} has been received from Pôle emploi."
data_fetched_title: "Data received from Pôle emploi"
mesri:
show:
not_filled: not filled
fetching_data: "Fetching data for INE %{ine}."
data_fetched: "Data concerning %{sources} linked to the INE %{ine} has been received from the MESRI."
data_fetched_title: "Data received from the MESRI"

View file

@ -15,6 +15,9 @@ fr:
pole_emploi:
identifiant_label: Identifiant
identifiant_notice: Il est généralement composé de caractères alphanumériques.
mesri:
ine_label: INE
ine_notice: Identifiant National Étudiant. Il est généralement composé de caractères alphanumériques.
header:
expires_at:
brouillon: "Expirera le %{date} (%{duree_conservation_totale} mois après la création du dossier)"
@ -44,3 +47,9 @@ fr:
fetching_data: "La récupération automatique des données pour l'identifiant %{identifiant} est en cours."
data_fetched: "Des données concernant %{sources} liées à l'identifiant %{identifiant} ont été reçues depuis Pôle emploi."
data_fetched_title: "Données obtenues de Pôle emploi"
mesri:
show:
not_filled: non renseigné
fetching_data: "La récupération automatique des données pour l'INE %{ine} et en cours."
data_fetched: "Des données concernant %{sources} liées à l'INE %{ine} ont été reçues depuis le MESRI."
data_fetched_title: "Données obtenues du MESRI"

View file

@ -102,7 +102,6 @@ Rails.application.routes.draw do
get '/users/no_procedure' => 'users/sessions#no_procedure'
get 'connexion-par-jeton/:id' => 'users/sessions#sign_in_by_link', as: 'sign_in_by_link'
get 'lien-envoye' => 'users/sessions#link_sent', as: 'link_sent'
get 'lien-envoye/:email' => 'users/sessions#link_sent', constraints: { email: /.*/ }, as: 'link_sent_legacy' # legacy, can be removed as soon as the previous line is deployed to production servers
get '/users/password/reset-link-sent' => 'users/passwords#reset_link_sent'
end

View file

@ -13,13 +13,13 @@ ministeres:
label: "Ministère de l'Économie, des Finances et de la Relance"
- MJS:
label: "Ministère de la Jeunesse et des Sports"
- EN:
- MEN:
label: "Ministère de l'Éducation nationale, de la Jeunesse et des Sports"
- ESR:
label: "Ministère de l'Enseignement supérieur, de la Recherche et de l'Innovation"
- MI:
label: "Ministère de l'Intérieur"
- MInArm:
- MINARM:
label: "Ministère des Armées"
- MJ:
label: "Ministère de la Justice"
@ -27,7 +27,7 @@ ministeres:
label: "Ministère de la Transition écologique"
- MCTRCT:
label: "Ministère de la Cohésion des territoires et des Relations avec les collectivités territoriales"
- SPM:
- PM:
label: "Premier ministre"
- MER:
label: "Ministère de la Mer"

View file

@ -1,5 +1,4 @@
namespace :after_party do
desc 'Deployment task: populate_zones'
namespace :zones do
task populate_zones: :environment do
puts "Running deploy task 'populate_zones'"
@ -9,9 +8,5 @@ namespace :after_party do
acronym = ministere.keys.first
Zone.create!(acronym: acronym, label: ministere["label"])
end
# Update task as completed. If you remove the line below, the task will
# run with every deploy (or every time you call after_party:run).
AfterParty::TaskRecord
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
end
end

View file

@ -0,0 +1,23 @@
require Rails.root.join("lib", "tasks", "task_helper")
namespace :zones do
desc <<~EOD
Update zone to all procedures
rails zones:update_zone_to_procedures\[csv_path\]
EOD
task :update_zone_to_procedures, [:csv] => :environment do |_t, args|
csv = args[:csv]
lines = CSV.readlines(csv, headers: true)
rake_puts "Mise à jour des procédures en cours..."
errors =
UpdateZoneToProceduresService.call(lines)
if errors.present?
errors.each { |error| rake_puts error }
end
rake_puts "Mise à jour terminée"
end
end

View file

@ -79,7 +79,13 @@ describe Administrateurs::JetonParticulierController, type: :controller do
'pole_emploi_identite',
'pole_emploi_adresse',
'pole_emploi_contact',
'pole_emploi_inscription'
'pole_emploi_inscription',
'mesri_identifiant',
'mesri_identite',
'mesri_inscription_etudiant',
'mesri_inscription_autre',
'mesri_admission',
'mesri_etablissements'
)
expect(procedure.api_particulier_sources).to be_empty
end

View file

@ -1,4 +1,7 @@
describe StatsController, type: :controller do
before { Timecop.travel(Date.parse("2021/12/15")) }
after { Timecop.return }
describe "#last_four_months_hash" do
context "while a regular user is logged in" do
before do
@ -14,13 +17,15 @@ describe StatsController, type: :controller do
let(:association) { Procedure.all }
subject { @controller.send(:last_four_months_hash, association, :updated_at) }
subject { @controller.send(:last_four_months_serie, association, :updated_at) }
it do
expect(subject).to match_array([
[I18n.l(62.days.ago.beginning_of_month, format: "%B %Y"), 2],
[I18n.l(31.days.ago.beginning_of_month, format: "%B %Y"), 1]
])
expect(subject).to eq({
4.months.ago => 0,
3.months.ago => 0,
62.days.ago => 2,
31.days.ago => 1
}.transform_keys { |date| I18n.l(date, format: '%B %Y') })
end
end
@ -38,13 +43,15 @@ describe StatsController, type: :controller do
let (:association) { Procedure.all }
subject { @controller.send(:last_four_months_hash, association, :updated_at) }
subject { @controller.send(:last_four_months_serie, association, :updated_at) }
it do
expect(subject).to eq([
[I18n.l(45.days.ago.beginning_of_month, format: "%B %Y"), 1],
[I18n.l(1.day.ago.beginning_of_month, format: "%B %Y"), 2]
])
expect(subject).to eq({
3.months.ago => 0,
45.days.ago => 1,
1.month.ago => 0,
1.day.ago => 2
}.transform_keys { |date| I18n.l(date, format: '%B %Y') })
end
end
end
@ -66,13 +73,13 @@ describe StatsController, type: :controller do
context "while a super admin is logged in" do
before { allow(@controller).to receive(:super_admin_signed_in?).and_return(true) }
subject { @controller.send(:cumulative_hash, association, :updated_at) }
subject { @controller.send(:cumulative_month_serie, association, :updated_at) }
it do
expect(subject).to eq({
Time.utc(2016, 8, 1) => 2,
Time.utc(2016, 9, 1) => 4,
Time.utc(2016, 10, 1) => 5
Date.new(2016, 8, 1) => 2,
Date.new(2016, 9, 1) => 4,
Date.new(2016, 10, 1) => 5
})
end
end
@ -80,12 +87,12 @@ describe StatsController, type: :controller do
context "while a super admin is not logged in" do
before { allow(@controller).to receive(:super_admin_signed_in?).and_return(false) }
subject { @controller.send(:cumulative_hash, association, :updated_at) }
subject { @controller.send(:cumulative_month_serie, association, :updated_at) }
it do
expect(subject).to eq({
Time.utc(2016, 8, 1) => 2,
Time.utc(2016, 9, 1) => 4
Date.new(2016, 8, 1) => 2,
Date.new(2016, 9, 1) => 4
})
end
end

View file

@ -221,4 +221,22 @@ describe Users::SessionsController, type: :controller do
it { is_expected.to be true }
end
end
describe '#link_sent' do
render_views
before { get :link_sent, params: { email: link_email } }
context 'when the email is legit' do
let(:link_email) { 'a@a.com' }
it { expect(response.body).to include(link_email) }
end
context 'when the email is evil' do
let(:link_email) { 'Hello, I am an evil email' }
it { expect(response).to redirect_to(root_path) }
end
end
end

View file

@ -0,0 +1,10 @@
FactoryBot.define do
factory :bulk_message do
body { 'bonjour' }
dossier_count { 1 }
dossier_state { Dossier.states.fetch(:brouillon) }
sent_at { 1.day.ago }
instructeur { association :instructeur }
groupe_instructeurs { [association(:groupe_instructeur, strategy: :build)] }
end
end

View file

@ -197,6 +197,10 @@ FactoryBot.define do
type_de_champ { association :type_de_champ_pole_emploi, procedure: dossier.procedure }
end
factory :champ_mesri, class: 'Champs::MesriChamp' do
type_de_champ { association :type_de_champ_mesri, procedure: dossier.procedure }
end
factory :champ_siret, class: 'Champs::SiretChamp' do
type_de_champ { association :type_de_champ_siret, procedure: dossier.procedure }
association :etablissement, factory: [:etablissement]

View file

@ -8,5 +8,9 @@ FactoryBot.define do
trait :default do
label { GroupeInstructeur::DEFAUT_LABEL }
end
trait :with_bulk_message do
bulk_messages { [association(:bulk_message, strategy: :build)] }
end
end
end

View file

@ -72,6 +72,10 @@ FactoryBot.define do
end
end
trait :with_bulk_message do
groupe_instructeurs { [association(:groupe_instructeur, :default, :with_bulk_message, procedure: instance, strategy: :build)] }
end
trait :with_logo do
logo { Rack::Test::UploadedFile.new('spec/fixtures/files/logo_test_procedure.png', 'image/png') }
end
@ -212,6 +216,12 @@ FactoryBot.define do
end
end
trait :with_mesri do
after(:build) do |procedure, _evaluator|
build(:type_de_champ_mesri, procedure: procedure)
end
end
trait :with_explication do
after(:build) do |procedure, _evaluator|
build(:type_de_champ_explication, procedure: procedure)

View file

@ -163,6 +163,9 @@ FactoryBot.define do
factory :type_de_champ_pole_emploi do
type_champ { TypeDeChamp.type_champs.fetch(:pole_emploi) }
end
factory :type_de_champ_mesri do
type_champ { TypeDeChamp.type_champs.fetch(:mesri) }
end
factory :type_de_champ_carte do
type_champ { TypeDeChamp.type_champs.fetch(:carte) }
end

View file

@ -0,0 +1,83 @@
---
http_interactions:
- request:
method: get
uri: https://particulier.api.gouv.fr/api/v2/etudiants?ine=090601811AB
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- demarches-simplifiees.fr
Accept:
- application/json
X-Api-Key:
- c6d23f3900b8fb4b3586c4804c051af79062f54b
Expect:
- ''
response:
status:
code: 200
message: OK
headers:
Date:
- Tue, 16 Mar 2021 17:01:19 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '805'
Connection:
- keep-alive
Keep-Alive:
- timeout=5
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Expose-Headers:
- Range,Content-Range,X-Content-Range,X-API-Key
Etag:
- W/"2eb-A0NiRd+gbJKIAT0y4tR4j9tjXb0"
Server:
- nginx
Strict-Transport-Security:
- max-age=15552000
- max-age=15552000
Vary:
- Origin, Accept
X-Gravitee-Request-Id:
- 7bfb7f99-ac2d-4443-bb7f-99ac2d0443c5
X-Gravitee-Transaction-Id:
- f5dca8b3-2ab7-4c9a-9ca8-b32ab70c9a2b
body:
encoding: ASCII-8BIT
string: '{
"ine": "090601811AB",
"nom": "DUBOIS",
"prenom": "Angela Claire Louise",
"dateNaissance": "1962-08-24",
"inscriptions": [
{
"statut": "admis",
"regime": "formation continue",
"dateDebutAdmission": "2021-09-01T00:00:00.000Z",
"dateFinAdmission": "2022-08-31T00:00:00.000Z",
"etablissement": {
"uai": "0751722P",
"nom": "Université Pierre et Marie Curie - UPCM (Paris 6)"
},
"codeCommune": "75106"
},
{
"statut": "inscrit",
"regime": "formation continue",
"dateDebutInscription": "2022-09-01",
"dateFinInscription": "2023-08-31",
"etablissement": {
"uai": "0751722P",
"nom": "Université Pierre et Marie Curie - UPCM (Paris 6)"
},
"codeCommune": "75106"
}
]
}'
recorded_at: Tue, 16 Mar 2021 17:01:18 GMT
recorded_with: VCR 6.0.0

View file

@ -0,0 +1,83 @@
---
http_interactions:
- request:
method: get
uri: https://particulier.api.gouv.fr/api/v2/etudiants?ine=090601811AB
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- demarches-simplifiees.fr
Accept:
- application/json
X-Api-Key:
- c6d23f3900b8fb4b3586c4804c051af79062f54b
Expect:
- ''
response:
status:
code: 200
message: OK
headers:
Date:
- Tue, 16 Mar 2021 17:01:19 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '806'
Connection:
- keep-alive
Keep-Alive:
- timeout=5
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Expose-Headers:
- Range,Content-Range,X-Content-Range,X-API-Key
Etag:
- W/"2eb-A0NiRd+gbJKIAT0y4tR4j9tjXb0"
Server:
- nginx
Strict-Transport-Security:
- max-age=15552000
- max-age=15552000
Vary:
- Origin, Accept
X-Gravitee-Request-Id:
- 7bfb7f99-ac2d-4443-bb7f-99ac2d0443c5
X-Gravitee-Transaction-Id:
- f5dca8b3-2ab7-4c9a-9ca8-b32ab70c9a2b
body:
encoding: ASCII-8BIT
string: '{
"ine": "090601811AB",
"nom": "DUBOIS",
"prenom": "Angela Claire Louise",
"dateNaissance": "1962-08-24",
"inscriptions": [
{
"statut": "absent",
"regime": "formation continue",
"dateDebutAdmission": "2021-09-01T00:00:00.000Z",
"dateFinAdmission": "2022-08-31T00:00:00.000Z",
"etablissement": {
"uai": "0751722P",
"nom": "Université Pierre et Marie Curie - UPCM (Paris 6)"
},
"codeCommune": "75106"
},
{
"statut": "inscrit",
"regime": "formation continue",
"dateDebutInscription": "2022-09-01",
"dateFinInscription": "2023-08-31",
"etablissement": {
"uai": "0751722P",
"nom": "Université Pierre et Marie Curie - UPCM (Paris 6)"
},
"codeCommune": "75106"
}
]
}'
recorded_at: Tue, 16 Mar 2021 17:01:18 GMT
recorded_with: VCR 6.0.0

View file

@ -25,7 +25,7 @@ http_interactions:
Content-Type:
- application/json
Content-Length:
- '257'
- '1068'
Connection:
- keep-alive
Keep-Alive:
@ -38,6 +38,6 @@ http_interactions:
- max-age=15552000
body:
encoding: UTF-8
string: '{"_id":"1d99db5a-a099-4314-ad2f-2707c6b505a6","name":"Application de sandbox","scopes":["cnaf_allocataires","cnaf_enfants","cnaf_adresse","cnaf_quotient_familial","dgfip_declarant1_nom","dgfip_declarant1_nom_naissance","dgfip_declarant1_prenoms","dgfip_declarant1_date_naissance","dgfip_declarant2_nom","dgfip_declarant2_nom_naissance","dgfip_declarant2_prenoms","dgfip_declarant2_date_naissance","dgfip_date_recouvrement","dgfip_date_etablissement","dgfip_adresse_fiscale_taxation","dgfip_adresse_fiscale_annee","dgfip_nombre_parts","dgfip_nombre_personnes_a_charge","dgfip_situation_familiale","dgfip_revenu_brut_global","dgfip_revenu_imposable","dgfip_impot_revenu_net_avant_corrections","dgfip_montant_impot","dgfip_revenu_fiscal_reference","dgfip_annee_impot","dgfip_annee_revenus","dgfip_erreur_correctif","dgfip_situation_partielle", "pole_emploi_identite","pole_emploi_adresse","pole_emploi_contact","pole_emploi_inscription"]}'
string: '{"_id":"1d99db5a-a099-4314-ad2f-2707c6b505a6","name":"Application de sandbox","scopes":["cnaf_allocataires","cnaf_enfants","cnaf_adresse","cnaf_quotient_familial","dgfip_declarant1_nom","dgfip_declarant1_nom_naissance","dgfip_declarant1_prenoms","dgfip_declarant1_date_naissance","dgfip_declarant2_nom","dgfip_declarant2_nom_naissance","dgfip_declarant2_prenoms","dgfip_declarant2_date_naissance","dgfip_date_recouvrement","dgfip_date_etablissement","dgfip_adresse_fiscale_taxation","dgfip_adresse_fiscale_annee","dgfip_nombre_parts","dgfip_nombre_personnes_a_charge","dgfip_situation_familiale","dgfip_revenu_brut_global","dgfip_revenu_imposable","dgfip_impot_revenu_net_avant_corrections","dgfip_montant_impot","dgfip_revenu_fiscal_reference","dgfip_annee_impot","dgfip_annee_revenus","dgfip_erreur_correctif","dgfip_situation_partielle", "pole_emploi_identite","pole_emploi_adresse","pole_emploi_contact","pole_emploi_inscription","mesri_identifiant","mesri_identite","mesri_inscription_etudiant","mesri_inscription_autre","mesri_admission","mesri_etablissements"]}'
recorded_at: Tue, 16 Mar 2021 15:25:24 GMT
recorded_with: VCR 6.0.0

View file

@ -0,0 +1,34 @@
{
"identifiant": {
"ine": "090601811AB"
},
"identite": {
"nom": "DUBOIS",
"prenom": "Angela Claire Louise",
"dateNaissance": "1962-08-24"
},
"admissions": [
{
"statut": "admis",
"regime": "formation continue",
"dateDebutAdmission": "2021-09-01T00:00:00.000Z",
"dateFinAdmission": "2022-08-31T00:00:00.000Z",
"codeCommune": "75106"
}
],
"inscriptions": [
{
"statut": "inscrit",
"regime": "formation continue",
"dateDebutInscription": "2022-09-01",
"dateFinInscription": "2023-08-31",
"codeCommune": "75106"
}
],
"etablissements": [
{
"uai": "0751722P",
"nom": "Université Pierre et Marie Curie - UPCM (Paris 6)"
}
]
}

View file

@ -41,7 +41,13 @@ describe APIParticulier::API do
'pole_emploi_identite',
'pole_emploi_adresse',
'pole_emploi_contact',
'pole_emploi_inscription'
'pole_emploi_inscription',
'mesri_identifiant',
'mesri_identite',
'mesri_inscription_etudiant',
'mesri_inscription_autre',
'mesri_admission',
'mesri_etablissements'
)
end
end

View file

@ -0,0 +1,70 @@
describe APIParticulier::MesriAdapter do
let(:adapter) { described_class.new(api_particulier_token, ine, requested_sources) }
before { stub_const('API_PARTICULIER_URL', 'https://particulier.api.gouv.fr/api') }
describe '#to_params' do
let(:api_particulier_token) { 'c6d23f3900b8fb4b3586c4804c051af79062f54b' }
let(:ine) { '090601811AB' }
subject { VCR.use_cassette(cassette) { adapter.to_params } }
context 'when the api answer is valid' do
let(:cassette) { 'api_particulier/success/etudiants' }
context 'when the token has all the MESRI scopes' do
context 'and all the sources are requested' do
let(:requested_sources) do
{
'mesri' => {
'identifiant' => ['ine'],
'identite' => ['nom', 'prenom', 'dateNaissance'],
'inscriptions' => ['statut', 'regime', 'dateDebutInscription', 'dateFinInscription', 'codeCommune'],
'admissions' => ['statut', 'regime', 'dateDebutAdmission', 'dateFinAdmission', 'codeCommune'],
'etablissements' => ['uai', 'nom']
}
}
end
let(:result) { JSON.parse(File.read('spec/fixtures/files/api_particulier/etudiants.json')) }
it { is_expected.to eq(result) }
end
context 'when no sources is requested' do
let(:requested_sources) { {} }
it { is_expected.to eq({}) }
end
context 'when an admission statut is requested' do
let(:requested_sources) { { 'mesri' => { 'admissions' => ['statut'] } } }
it { is_expected.to eq('admissions' => [{ 'statut' => 'admis' }]) }
end
context 'when an inscription statut is requested' do
let(:requested_sources) { { 'mesri' => { 'inscriptions' => ['statut'] } } }
it { is_expected.to eq('inscriptions' => [{ 'statut' => 'inscrit' }]) }
end
context 'when a first name is requested' do
let(:requested_sources) { { 'mesri' => { 'identite' => ['prenom'] } } }
it { is_expected.to eq('identite' => { 'prenom' => 'Angela Claire Louise' }) }
end
end
end
context 'when the api answer is invalid' do
let(:cassette) { 'api_particulier/success/etudiants_invalid' }
context 'when no sources is requested' do
let(:requested_sources) { {} }
it { expect { subject }.to raise_error(APIParticulier::MesriAdapter::InvalidSchemaError) }
end
end
end
end

View file

@ -62,6 +62,21 @@ describe APIParticulier::Services::SourcesService do
it { is_expected.to match(pole_emploi_identite_et_adresse) }
end
context 'when a procedure has a mesri_identite and a mesri_etablissements scopes' do
let(:api_particulier_scopes) { ['mesri_identite', 'mesri_etablissements'] }
let(:mesri_identite_and_etablissements) do
{
'mesri' => {
'identite' => ['nom', 'prenom', 'dateNaissance'],
'etablissements' => ['uai', 'nom']
}
}
end
it { is_expected.to match(mesri_identite_and_etablissements) }
end
context 'when a procedure has an unknown scope' do
let(:api_particulier_scopes) { ['unknown_scope'] }

View file

@ -1,11 +0,0 @@
describe '20211116140232_populate_zones' do
let(:rake_task) { Rake::Task['after_party:populate_zones'] }
subject(:run_task) do
rake_task.invoke
end
it 'populates zones' do
run_task
expect(Zone.find_by(acronym: 'SPM').label).to eq "Premier ministre"
end
end

View file

@ -0,0 +1,11 @@
describe 'populate_zones' do
let(:rake_task) { Rake::Task['zones:populate_zones'] }
subject(:run_task) do
rake_task.invoke
end
it 'populates zones' do
run_task
expect(Zone.find_by(acronym: 'PM').label).to eq "Premier ministre"
end
end

View file

@ -0,0 +1,36 @@
describe Utils::Retryable do
Includer = Struct.new(:something) do
include Utils::Retryable
def caller(max_attempt:, errors:)
with_retry(max_attempt: max_attempt, errors: errors) do
yield
end
end
end
subject { Includer.new("test") }
let(:spy) { double() }
describe '#with_retry' do
it 'works while retry count is less than max attempts' do
divider_that_raise_error = 0
divider_that_works = 1
expect(spy).to receive(:divider).and_return(divider_that_raise_error, divider_that_works)
result = subject.caller(max_attempt: 2, errors: [ZeroDivisionError]) { 10 / spy.divider }
expect(result).to eq(10 / divider_that_works)
end
it 're raise error if it occures more than max_attempt' do
expect(spy).to receive(:divider).and_return(0, 0)
expect { subject.caller(max_attempt: 1, errors: [ZeroDivisionError]) { 0 / spy.divider } }
.to raise_error(ZeroDivisionError)
end
it 'does not retry other errors' do
expect(spy).to receive(:divider).and_raise(StandardError).once
expect { subject.caller(max_attempt: 2, errors: [ZeroDivisionError]) { 0 / spy.divider } }
.to raise_error(StandardError)
end
end
end

View file

@ -0,0 +1,56 @@
describe Champs::MesriChamp, type: :model do
let(:champ) { described_class.new }
describe 'INE' do
before do
champ.ine = '090601811AB'
end
it 'saves INE' do
expect(champ.ine).to eq('090601811AB')
end
end
describe 'external_id' do
context 'when no data is given' do
before do
champ.ine = ''
champ.save
end
it { expect(champ.external_id).to be_nil }
end
context 'when all data required for an external fetch are given' do
before do
champ.ine = '090601811AB'
champ.save
end
it { expect(JSON.parse(champ.external_id)).to eq("ine" => "090601811AB") }
end
end
describe '#validate' do
let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_mesri)) }
let(:validation_context) { :create }
subject { champ.valid?(validation_context) }
before do
champ.ine = ine
end
context 'when INE is valid' do
let(:ine) { '090601811AB' }
it { is_expected.to be true }
end
context 'when INE is nil' do
let(:ine) { nil }
it { is_expected.to be true }
end
end
end

View file

@ -262,6 +262,16 @@ describe Procedure do
let(:procedure) { build(:procedure, monavis_embed: monavis_bleu) }
it { expect(procedure.valid?).to eq(true) }
end
context 'Monavis embed code with voxusages is allowed' do
monavis_issue_phillipe = <<-MSG
<a href="https://voxusagers.numerique.gouv.fr/Demarches/3193?&view-mode=formulaire-avis&nd_mode=en-ligne-enti%C3%A8rement&nd_source=button&key=58e099a09c02abe629c14905ed2b055d">
<img src="https://monavis.numerique.gouv.fr/monavis-static/bouton-bleu.png" alt="Je donne mon avis" title="Je donne mon avis sur cette démarche" />
</a>
MSG
let(:procedure) { build(:procedure, monavis_embed: monavis_issue_phillipe) }
it { expect(procedure.valid?).to eq(true) }
end
end
shared_examples 'duree de conservation' do
@ -1115,7 +1125,7 @@ describe Procedure do
end
describe "#destroy" do
let(:procedure) { create(:procedure, :closed, :with_type_de_champ) }
let(:procedure) { create(:procedure, :closed, :with_type_de_champ, :with_bulk_message) }
before do
procedure.discard!

View file

@ -57,24 +57,24 @@ describe Stat do
create(:dossier, state: :en_construction, depose_at: i.months.ago)
create(:deleted_dossier, dossier_id: i + 100, state: :en_construction, deleted_at: i.month.ago)
end
rs = Stat.send(:cumulative_hash, [
rs = Stat.send(:cumulative_month_serie, [
[Dossier.state_not_brouillon, :depose_at],
[DeletedDossier.where.not(state: :brouillon), :deleted_at]
])
expect(rs).to eq({
12.months.ago.utc.beginning_of_month => 2,
11.months.ago.utc.beginning_of_month => 4,
10.months.ago.utc.beginning_of_month => 6,
9.months.ago.utc.beginning_of_month => 8,
8.months.ago.utc.beginning_of_month => 10,
7.months.ago.utc.beginning_of_month => 12,
6.months.ago.utc.beginning_of_month => 14,
5.months.ago.utc.beginning_of_month => 16,
4.months.ago.utc.beginning_of_month => 18,
3.months.ago.utc.beginning_of_month => 20,
2.months.ago.utc.beginning_of_month => 22,
1.month.ago.utc.beginning_of_month => 24
})
12 => 2,
11 => 4,
10 => 6,
9 => 8,
8 => 10,
7 => 12,
6 => 14,
5 => 16,
4 => 18,
3 => 20,
2 => 22,
1 => 24
}.transform_keys { |i| i.months.ago.beginning_of_month.to_date })
end
end
@ -85,15 +85,16 @@ describe Stat do
create(:dossier, state: :en_construction, depose_at: i.months.ago)
create(:deleted_dossier, dossier_id: i + 100, state: :en_construction, deleted_at: i.month.ago)
end
rs = Stat.send(:last_four_months_hash, [
rs = Stat.send(:last_four_months_serie, [
[Dossier.state_not_brouillon, :depose_at],
[DeletedDossier.where.not(state: :brouillon), :deleted_at]
])
expect(rs).to eq([
["août 2021", 2],
["septembre 2021", 2],
["octobre 2021", 2]
])
expect(rs).to eq({
"juillet 2021" => 2,
"août 2021" => 2,
"septembre 2021" => 2,
"octobre 2021" => 2
})
end
end
end

View file

@ -5,6 +5,7 @@ describe ProcedureArchiveService do
let(:year) { 2020 }
let(:month) { 3 }
let(:date_month) { Date.strptime("#{year}-#{month}", "%Y-%m") }
describe '#create_pending_archive' do
context 'for a specific month' do
it 'creates a pending archive' do
@ -27,7 +28,7 @@ describe ProcedureArchiveService do
end
end
describe '#collect_files_archive' do
describe '#old_collect_files_archive' do
before do
create_dossier_for_month(year, month)
create_dossier_for_month(2020, month)
@ -116,6 +117,123 @@ describe ProcedureArchiveService do
end
end
describe '#new_collect_files_archive' do
before { Flipper.enable_actor(:zip_using_binary, procedure) }
let!(:dossier) { create_dossier_for_month(year, month) }
let!(:dossier_2020) { create_dossier_for_month(2020, month) }
after { Timecop.return }
context 'for a specific month' do
let(:archive) { create(:archive, time_span_type: 'monthly', status: 'pending', month: date_month) }
let(:year) { 2021 }
let(:mailer) { double('mailer', deliver_later: true) }
it 'collect files' do
expect(InstructeurMailer).to receive(:send_archive).and_return(mailer)
service.collect_files_archive(archive, instructeur)
archive.file.open do |f|
files = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [
"procedure-#{procedure.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/pieces_justificatives/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2021-00-00-#{dossier.attestation.pdf.id % 10000}.pdf",
"procedure-#{procedure.id}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id}.pdf"
]
expect(files.map(&:filename)).to match_array(structure)
end
expect(archive.file.attached?).to be_truthy
end
context 'with a missing file' do
let(:pj) do
PiecesJustificativesService::FakeAttachment.new(
file: StringIO.new('coucou'),
filename: "export-dossier.pdf",
name: 'pdf_export_for_instructeur',
id: 1,
created_at: Time.zone.now
)
end
let(:bad_pj) do
PiecesJustificativesService::FakeAttachment.new(
file: nil,
filename: "cni.png",
name: 'cni.png',
id: 2,
created_at: Time.zone.now
)
end
let(:documents) { [pj, bad_pj] }
before do
allow(PiecesJustificativesService).to receive(:liste_documents).and_return(documents)
end
it 'collect files without raising exception' do
expect { service.collect_files_archive(archive, instructeur) }.not_to raise_exception
end
it 'add bug report to archive' do
service.collect_files_archive(archive, instructeur)
archive.file.open do |f|
zip_entries = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [
"procedure-#{procedure.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/export-dossier-05-03-2020-00-00-1.pdf",
"procedure-#{procedure.id}/dossier-#{dossier.id}/pieces_justificatives/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id}.pdf",
"procedure-#{procedure.id}/LISEZMOI.txt"
]
expect(zip_entries.map(&:filename)).to match_array(structure)
zip_entries.map do |entry|
next unless entry.filename == "procedure-#{procedure.id}/LISEZMOI.txt"
extracted_content = ""
extractor = entry.extractor_from(f)
extracted_content << extractor.extract(1024 * 1024) until extractor.eof?
expect(extracted_content).to match(/Impossible de .* .*cni.*png/)
end
end
end
end
end
context 'for all months' do
let(:archive) { create(:archive, time_span_type: 'everything', status: 'pending') }
let(:mailer) { double('mailer', deliver_later: true) }
it 'collect files' do
expect(InstructeurMailer).to receive(:send_archive).and_return(mailer)
service.collect_files_archive(archive, instructeur)
archive = Archive.last
archive.file.open do |f|
files = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [
"procedure-#{procedure.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/pieces_justificatives/",
"procedure-#{procedure.id}/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier.attestation.pdf.id % 10000}.pdf",
"procedure-#{procedure.id}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2020-00-00-#{dossier.id}.pdf",
"procedure-#{procedure.id}/dossier-#{dossier_2020.id}/",
"procedure-#{procedure.id}/dossier-#{dossier_2020.id}/export-#{dossier_2020.id}-05-03-2020-00-00-#{dossier_2020.id}.pdf",
"procedure-#{procedure.id}/dossier-#{dossier_2020.id}/pieces_justificatives/",
"procedure-#{procedure.id}/dossier-#{dossier_2020.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier_2020.attestation.pdf.id % 10000}.pdf"
]
expect(files.map(&:filename)).to match_array(structure)
end
expect(archive.file.attached?).to be_truthy
end
end
end
private
def create_dossier_for_month(year, month)

View file

@ -80,6 +80,7 @@ describe ProcedureExportService do
"cnaf",
"dgfip",
"pole_emploi",
"mesri",
"text"
]
end
@ -170,6 +171,7 @@ describe ProcedureExportService do
"cnaf",
"dgfip",
"pole_emploi",
"mesri",
"text"
]
end
@ -256,6 +258,7 @@ describe ProcedureExportService do
"cnaf",
"dgfip",
"pole_emploi",
"mesri",
"text"
]
end

View file

@ -0,0 +1,57 @@
describe UpdateZoneToProceduresService do
before(:all) do
Rake::Task['zones:populate_zones'].invoke
end
after(:all) do
Zone.destroy_all
end
describe '#call' do
let(:procedure1) { create(:procedure, zone: nil) }
let(:procedure2) { create(:procedure, zone: nil) }
subject { described_class.call(lines) }
context 'nominal case' do
let(:lines) do
[
{ "id" => procedure1.id, "POL_PUB_MINISTERE RATTACHEMENT" => "PM" },
{ "id" => procedure2.id, "POL_PUB_MINISTERE RATTACHEMENT" => "MI" }
]
end
it 'updates zone to procedures' do
errors = subject
expect(errors).to eq []
expect(procedure1.reload.zone.acronym).to eq("PM")
expect(procedure2.reload.zone.acronym).to eq("MI")
end
end
context 'with unknown procedure' do
let(:lines) do
[
{ "id" => procedure1.id + procedure2.id, "POL_PUB_MINISTERE RATTACHEMENT" => "PM" }
]
end
it 'returns errors' do
errors = subject
expect(errors).to eq ["Procedure #{procedure1.id + procedure2.id} introuvable"]
end
end
context 'with unknown zone' do
let(:lines) do
[
{ "id" => procedure1.id, "POL_PUB_MINISTERE RATTACHEMENT" => "YOUPI" }
]
end
it 'returns errors' do
errors = subject
expect(errors).to eq ["Zone YOUPI introuvable"]
end
end
end
end

View file

@ -26,6 +26,13 @@ describe 'fetch API Particulier Data', js: true do
'adresse' => ['INSEECommune', 'codePostal', 'localite', 'ligneVoie', 'ligneComplementDestinataire', 'ligneComplementAdresse', 'ligneComplementDistribution', 'ligneNom'],
'contact' => ['email', 'telephone', 'telephone2'],
'inscription' => ['dateInscription', 'dateCessationInscription', 'codeCertificationCNAV', 'codeCategorieInscription', 'libelleCategorieInscription']
},
'mesri' => {
'identifiant' => ['ine'],
'identite' => ['nom', 'prenom', 'dateNaissance'],
'inscriptions' => ['statut', 'regime', 'dateDebutInscription', 'dateFinInscription', 'codeCommune'],
'admissions' => ['statut', 'regime', 'dateDebutAdmission', 'dateFinAdmission', 'codeCommune'],
'etablissements' => ['uai', 'nom']
}
}
end
@ -158,6 +165,37 @@ describe 'fetch API Particulier Data', js: true do
check("libellé de catégorie dinscription")
end
within('#mesri-identifiant') do
check('INE')
end
within('#mesri-identite') do
check('nom')
check('prénom')
check('date de naissance')
end
within('#mesri-inscriptions') do
check('statut')
check('régime')
check("date de début d'inscription")
check("date de fin d'inscription")
check("code de la commune")
end
within('#mesri-admissions') do
check('statut')
check('régime')
check("date de début d'admission")
check("date de fin d'admission")
check("code de la commune")
end
within('#mesri-etablissements') do
check('UAI')
check('nom')
end
click_on 'Enregistrer'
within('#cnaf-enfants') do
@ -166,10 +204,11 @@ describe 'fetch API Particulier Data', js: true do
procedure.reload
expect(procedure.api_particulier_sources.keys).to contain_exactly('cnaf', 'dgfip', 'pole_emploi')
expect(procedure.api_particulier_sources.keys).to contain_exactly('cnaf', 'dgfip', 'pole_emploi', 'mesri')
expect(procedure.api_particulier_sources['cnaf'].keys).to contain_exactly('adresse', 'allocataires', 'enfants', 'quotient_familial')
expect(procedure.api_particulier_sources['dgfip'].keys).to contain_exactly('declarant1', 'declarant2', 'echeance_avis', 'foyer_fiscal', 'agregats_fiscaux', 'complements')
expect(procedure.api_particulier_sources['pole_emploi'].keys).to contain_exactly('identite', 'adresse', 'contact', 'inscription')
expect(procedure.api_particulier_sources['mesri'].keys).to contain_exactly('identifiant', 'identite', 'inscriptions', 'admissions', 'etablissements')
procedure.api_particulier_sources.each do |provider, scopes|
scopes.each do |scope, fields|
@ -203,10 +242,11 @@ describe 'fetch API Particulier Data', js: true do
let(:reference_avis) { '2097699999077' }
let(:instructeur) { create(:instructeur) }
let(:identifiant) { 'georges_moustaki_77' }
let(:ine) { '090601811AB' }
let(:api_particulier_token) { '29eb50b65f64e8e00c0847a8bbcbd150e1f847' }
let(:procedure) do
create(:procedure, :for_individual, :with_service, :with_cnaf, :with_dgfip, :with_pole_emploi, :published,
create(:procedure, :for_individual, :with_service, :with_cnaf, :with_dgfip, :with_pole_emploi, :with_mesri, :published,
libelle: 'libellé de la procédure',
path: 'libelle-de-la-procedure',
instructeurs: [instructeur],
@ -336,6 +376,67 @@ describe 'fetch API Particulier Data', js: true do
end
end
context 'MESRI' do
let(:api_particulier_token) { 'c6d23f3900b8fb4b3586c4804c051af79062f54b' }
scenario 'it can fill a MESRI field' do
visit commencer_path(path: procedure.path)
click_on 'Commencer la démarche'
choose 'Madame'
fill_in 'individual_nom', with: 'Dubois'
fill_in 'individual_prenom', with: 'Angela Claire Louise'
click_button('Continuer')
fill_in "INE", with: 'wrong code'
blur
expect(page).to have_css('span', text: 'Brouillon enregistré', visible: true)
dossier = Dossier.last
mesri_champ = dossier.champs.fourth
expect(mesri_champ.ine).to eq('wrong code')
fill_in "INE", with: ine
VCR.use_cassette('api_particulier/success/etudiants') do
perform_enqueued_jobs { click_on 'Déposer le dossier' }
end
visit demande_dossier_path(dossier)
expect(page).to have_content(/Des données.*ont été reçues depuis le MESRI/)
log_out
login_as instructeur.user, scope: :user
visit instructeur_dossier_path(procedure, dossier)
expect(page).to have_content('INE 090601811AB')
expect(page).to have_content('nom DUBOIS')
expect(page).to have_content('prénom Angela Claire Louise')
expect(page).to have_content('date de naissance 24 août 1962')
expect(page).to have_content('statut inscrit')
expect(page).to have_content('régime formation continue')
expect(page).to have_content("date de début d'inscription 1 septembre 2022")
expect(page).to have_content("date de fin d'inscription 31 août 2023")
expect(page).to have_content('code de la commune 75106')
expect(page).to have_content('statut admis')
expect(page).to have_content('régime formation continue')
expect(page).to have_content("date de début d'admission 1 septembre 2021")
expect(page).to have_content("date de fin d'admission 31 août 2022")
expect(page).to have_content('code de la commune 75106')
expect(page).to have_content('UAI 0751722P')
expect(page).to have_content('nom Université Pierre et Marie Curie - UPCM (Paris 6)')
end
end
scenario 'it can fill a DGFiP field' do
visit commencer_path(path: procedure.path)
click_on 'Commencer la démarche'

View file

@ -8,9 +8,9 @@ describe 'users/statistiques/show.html.haml', type: :view do
subject { render }
it "display stats" do
expect(subject).to have_text("RÉPARTITION PAR SEMAINE")
expect(subject).to have_text("AVANCÉE DES DOSSIERS")
expect(subject).to have_text("TAUX DACCEPTATION")
expect(subject).to have_text("Répartition par semaine")
expect(subject).to have_text("Avancée des dossiers")
expect(subject).to have_text("Taux dacceptation")
expect(subject).to have_text(procedure.libelle)
end
end