diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0021c637f..855504262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails + - name: Install fonts pickable by ImageMagick + run: sudo apt-get install -y gsfonts + - name: Pre-compile assets uses: ./.github/actions/ci-setup-assets diff --git a/README.md b/README.md index a427f1fe2..add1b33f2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Vous souhaitez y apporter des changements ou des améliorations ? Lisez notre [ #### Tous environnements - postgresql +- imagemagick et gsfonts pour générer les filigranes sur les titres d'identité. #### Développement diff --git a/app/assets/images/favicons/16x16.png b/app/assets/images/favicons/16x16.png index c73712dba..ed096c4e0 100644 Binary files a/app/assets/images/favicons/16x16.png and b/app/assets/images/favicons/16x16.png differ diff --git a/app/assets/images/favicons/32x32.png b/app/assets/images/favicons/32x32.png index d23898479..706a82259 100644 Binary files a/app/assets/images/favicons/32x32.png and b/app/assets/images/favicons/32x32.png differ diff --git a/app/assets/images/favicons/96x96.png b/app/assets/images/favicons/96x96.png index a4d73f1d7..fa679a50c 100644 Binary files a/app/assets/images/favicons/96x96.png and b/app/assets/images/favicons/96x96.png differ diff --git a/app/assets/images/favicons/apple-touch-icon.png b/app/assets/images/favicons/apple-touch-icon.png new file mode 100644 index 000000000..dc28ab041 Binary files /dev/null and b/app/assets/images/favicons/apple-touch-icon.png differ diff --git a/app/assets/images/watermark.png b/app/assets/images/watermark.png deleted file mode 100644 index 07114bdcf..000000000 Binary files a/app/assets/images/watermark.png and /dev/null differ diff --git a/app/assets/stylesheets/dossiers_table.scss b/app/assets/stylesheets/dossiers_table.scss index 4f903da50..1ea907f69 100644 --- a/app/assets/stylesheets/dossiers_table.scss +++ b/app/assets/stylesheets/dossiers_table.scss @@ -62,11 +62,6 @@ width: 110px; } - .action-col { - text-align: right; - padding-left: $default-spacer; - padding-right: $default-spacer; - } .follow-col { width: 450px; diff --git a/app/components/dossiers/notified_toggle_component.rb b/app/components/dossiers/notified_toggle_component.rb index 053994e70..6b2f14473 100644 --- a/app/components/dossiers/notified_toggle_component.rb +++ b/app/components/dossiers/notified_toggle_component.rb @@ -15,10 +15,6 @@ class Dossiers::NotifiedToggleComponent < ApplicationComponent sorted_by_notifications? && order_desc? end - def icon_class_name - active? ? 'fr-fi-checkbox' : 'fr-fi-checkbox-blank' - end - def order_desc? current_order == 'desc' end diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 53da63089..4f6eca594 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -1,5 +1,6 @@ = form_tag update_sort_instructeur_procedure_path(procedure_id: @procedure.id, table: 'notifications', column: 'notifications', order: opposite_order), method: :get, data: { controller: 'autosubmit' } do - .fr-toggle - = check_box_tag :order, opposite_order, active?, class: 'fr-toggle__input' - = label_tag :order, t('.show_notified_first'), class: 'fr-toggle__label fr-pl-1w' - = submit_tag t('.show_notified_first'), data: {"checkbox-target": 'submit' }, class: 'visually-hidden' + .fr-fieldset__element.fr-m-0 + .fr-checkbox-group.fr-checkbox-group--sm + = check_box_tag :order, opposite_order, active? + = label_tag :order, t('.show_notified_first'), class: 'fr-label' + = submit_tag t('.show_notified_first'), data: {"checkbox-target": 'submit' }, class: 'visually-hidden' diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml index 1d98b2d9d..9d5187625 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml @@ -4,7 +4,7 @@ fr: Le routage permet d’acheminer les dossiers vers différents groupes d’instructeurs. routing_configuration_notice_2_html: |

Pour le configurer, votre formulaire doit comporter - au moins un champ de type « choix simple » ou « départements ».

+ au moins un champ de type « choix simple », « départements » ou « régions ».

Ajoutez ce champ dans la page « Configuration des champs ».

delete_title: Aucun dossier ne sera supprimé. Les groupes d'instructeurs vont être supprimés. Seuls les instructeurs du groupe « %{defaut_label} » resteront affectés à la démarche. delete_confirmation: | diff --git a/app/components/procedure/one_groupe_management_component.rb b/app/components/procedure/one_groupe_management_component.rb index 4fc003628..b700f1c66 100644 --- a/app/components/procedure/one_groupe_management_component.rb +++ b/app/components/procedure/one_groupe_management_component.rb @@ -81,6 +81,8 @@ class Procedure::OneGroupeManagementComponent < ApplicationComponent case @revision.types_de_champ_public.find_by(stable_id: targeted_champ.stable_id).type_champ when TypeDeChamp.type_champs.fetch(:departements) departements_for_select + when TypeDeChamp.type_champs.fetch(:regions) + regions_for_select when TypeDeChamp.type_champs.fetch(:drop_down_list) targeted_champ .options(@revision.types_de_champ_public) @@ -91,4 +93,8 @@ class Procedure::OneGroupeManagementComponent < ApplicationComponent def departements_for_select APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", constant(_1[:code]).to_json] } end + + def regions_for_select + APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", constant(_1[:code]).to_json] } + end end diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 147aaa260..3238cb719 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -43,23 +43,13 @@ module Administrateurs case tdc.type_champ when TypeDeChamp.type_champs.fetch(:departements) tdc_options = APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } - tdc_options.each do |code_and_name, code| - routing_rule = ds_eq(champ_value(stable_id), constant(code)) - @procedure - .groupe_instructeurs - .find_or_create_by(label: code_and_name) - .update(instructeurs: [current_administrateur.instructeur], routing_rule:) - end - + create_groups_from_territorial_tdc(tdc_options, stable_id) + when TypeDeChamp.type_champs.fetch(:regions) + tdc_options = APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + create_groups_from_territorial_tdc(tdc_options, stable_id) when TypeDeChamp.type_champs.fetch(:drop_down_list) tdc_options = tdc.drop_down_options.reject(&:empty?) - tdc_options.each do |option_label| - routing_rule = ds_eq(champ_value(stable_id), constant(option_label)) - @procedure - .groupe_instructeurs - .find_or_create_by(label: option_label) - .update(instructeurs: [current_administrateur.instructeur], routing_rule:) - end + create_groups_from_drop_down_list_tdc(tdc_options, stable_id) end if tdc.drop_down_other? @@ -460,5 +450,25 @@ module Administrateurs def flash_message_for_invalid_csv flash[:alert] = "Importation impossible, veuillez importer un csv suivant #{view_context.link_to('ce modèle', "/csv/import-instructeurs-test.csv")} pour une procédure sans routage ou #{view_context.link_to('celui-ci', "/csv/#{I18n.locale}/import-groupe-test.csv")} pour une procédure routée" end + + def create_groups_from_territorial_tdc(tdc_options, stable_id) + tdc_options.each do |label, code| + routing_rule = ds_eq(champ_value(stable_id), constant(code)) + @procedure + .groupe_instructeurs + .find_or_create_by(label: label) + .update(instructeurs: [current_administrateur.instructeur], routing_rule:) + end + end + + def create_groups_from_drop_down_list_tdc(tdc_options, stable_id) + tdc_options.each do |label| + routing_rule = ds_eq(champ_value(stable_id), constant(label)) + @procedure + .groupe_instructeurs + .find_or_create_by(label: label) + .update(instructeurs: [current_administrateur.instructeur], routing_rule:) + end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 894af0f6f..4933ea04d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,7 +21,7 @@ class ApplicationController < ActionController::Base around_action :switch_locale helper_method :multiple_devise_profile_connect?, :instructeur_signed_in?, :current_instructeur, :current_expert, :expert_signed_in?, - :administrateur_signed_in?, :current_administrateur, :current_account, :localization_enabled?, :set_locale + :administrateur_signed_in?, :current_administrateur, :current_account, :localization_enabled?, :set_locale, :current_expert_not_instructeur? before_action do Current.request_id = request.uuid @@ -62,6 +62,10 @@ class ApplicationController < ActionController::Base current_user&.expert end + def current_expert_not_instructeur? + current_user&.expert? && !current_user&.instructeur? + end + def expert_signed_in? current_expert.present? end diff --git a/app/controllers/instructeurs/contact_informations_controller.rb b/app/controllers/instructeurs/contact_informations_controller.rb new file mode 100644 index 000000000..7a1b4331b --- /dev/null +++ b/app/controllers/instructeurs/contact_informations_controller.rb @@ -0,0 +1,60 @@ +module Instructeurs + class ContactInformationsController < InstructeurController + def new + assign_procedure_and_groupe_instructeur + @contact_information = @groupe_instructeur.build_contact_information + end + + def create + assign_procedure_and_groupe_instructeur + @contact_information = @groupe_instructeur.build_contact_information(contact_information_params) + if @contact_information.save + redirect_to_groupe_instructeur("Les informations de contact ont bien été ajoutées") + else + flash[:alert] = @contact_information.errors.full_messages + render :new + end + end + + def edit + assign_procedure_and_groupe_instructeur + @contact_information = @groupe_instructeur.contact_information + end + + def update + assign_procedure_and_groupe_instructeur + @contact_information = @groupe_instructeur.contact_information + if @contact_information.update(contact_information_params) + redirect_to_groupe_instructeur("Les informations de contact ont bien été modifiées") + else + flash[:alert] = @contact_information.errors.full_messages + render :edit + end + end + + def destroy + assign_procedure_and_groupe_instructeur + @groupe_instructeur.contact_information.destroy + redirect_to_groupe_instructeur("Les informations de contact ont bien été supprimées") + end + + private + + def redirect_to_groupe_instructeur(notice) + if params[:from_admin] == "true" + redirect_to admin_procedure_groupe_instructeur_path(@procedure, @groupe_instructeur), notice: notice + else + redirect_to instructeur_groupe_path(@procedure, @groupe_instructeur), notice: notice + end + end + + def assign_procedure_and_groupe_instructeur + @procedure = current_instructeur.procedures.find params[:procedure_id] + @groupe_instructeur = current_instructeur.groupe_instructeurs.find params[:groupe_id] + end + + def contact_information_params + params.require(:contact_information).permit(:nom, :email, :telephone, :horaires, :adresse) + end + end +end diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index fe19bb1e9..339b814f2 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -73,6 +73,8 @@ module Instructeurs @avis = Avis.new if @dossier.procedure.experts_require_administrateur_invitation? @experts_emails = dossier.procedure.experts_procedures.where(revoked_at: nil).map(&:expert).map(&:email).sort + else + @experts_emails = @dossier.procedure.experts.map(&:email).sort end end @@ -81,6 +83,8 @@ module Instructeurs @avis = Avis.new if @dossier.procedure.experts_require_administrateur_invitation? @experts_emails = dossier.procedure.experts_procedures.where(revoked_at: nil).map(&:expert).map(&:email).sort + else + @experts_emails = @dossier.procedure.experts.map(&:email).sort end end diff --git a/app/jobs/archive_creation_job.rb b/app/jobs/archive_creation_job.rb index 661996ffb..7a7abbfa2 100644 --- a/app/jobs/archive_creation_job.rb +++ b/app/jobs/archive_creation_job.rb @@ -1,4 +1,6 @@ class ArchiveCreationJob < ApplicationJob + discard_on ActiveRecord::RecordNotFound + queue_as :archives def max_run_time diff --git a/app/jobs/titre_identite_watermark_job.rb b/app/jobs/titre_identite_watermark_job.rb index 580dc204d..491530d05 100644 --- a/app/jobs/titre_identite_watermark_job.rb +++ b/app/jobs/titre_identite_watermark_job.rb @@ -10,77 +10,18 @@ class TitreIdentiteWatermarkJob < ApplicationJob # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 - MAX_IMAGE_SIZE = 1500 - SCALE = 0.9 - WATERMARK = URI.parse(WATERMARK_FILE).is_a?(URI::HTTP) ? WATERMARK_FILE : Rails.root.join("app/assets/images/#{WATERMARK_FILE}") - def perform(blob) return if blob.watermark_done? raise FileNotScannedYetError if blob.virus_scanner.pending? blob.open do |file| - watermark = resize_watermark(file) - - if watermark.present? - processed = watermark_image(file, watermark) + Tempfile.create(["watermarked", File.extname(file)]) do |output| + processed = WatermarkService.new.process(file, output) + return if processed.blank? blob.upload(processed) blob.touch(:watermarked_at) end end end - - private - - def watermark_image(file, watermark) - ImageProcessing::MiniMagick - .source(file) - .convert("png") - .resize_to_limit(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE) - .composite(watermark, mode: "over", gravity: "center") - .call - end - - def resize_watermark(file) - metadata = image_metadata(file) - - if metadata[:width].present? && metadata[:height].present? - width = [metadata[:width], MAX_IMAGE_SIZE].min * SCALE - height = [metadata[:height], MAX_IMAGE_SIZE].min * SCALE - diagonal = Math.sqrt(height**2 + width**2) - angle = Math.asin(height / diagonal) * 180 / Math::PI - - ImageProcessing::MiniMagick - .source(WATERMARK) - .resize_to_limit(diagonal, diagonal / 2) - .rotate(-angle, background: :transparent) - .call - end - end - - def image_metadata(file) - read_image(file) do |image| - if rotated_image?(image) - { width: image.height, height: image.width } - else - { width: image.width, height: image.height } - end - end - end - - def read_image(file) - require "mini_magick" - image = MiniMagick::Image.new(file.path) - - if image.valid? - yield image - else - logger.info "Skipping image analysis because ImageMagick doesn't support the file" - {} - end - end - - def rotated_image?(image) - ['RightTop', 'LeftBottom'].include?(image["%[orientation]"]) - end end diff --git a/app/models/contact_information.rb b/app/models/contact_information.rb new file mode 100644 index 000000000..c716f2d40 --- /dev/null +++ b/app/models/contact_information.rb @@ -0,0 +1,17 @@ +class ContactInformation < ApplicationRecord + belongs_to :groupe_instructeur + + validates :nom, presence: { message: 'doit être renseigné' }, allow_nil: false + validates :nom, uniqueness: { scope: :groupe_instructeur, message: 'existe déjà' } + validates :email, format: { with: Devise.email_regexp, message: "n'est pas valide" }, presence: { message: 'doit être renseigné' }, allow_nil: false + validates :telephone, phone: { possible: true, allow_blank: false } + validates :horaires, presence: { message: 'doivent être renseignés' }, allow_nil: false + validates :adresse, presence: { message: 'doit être renseignée' }, allow_nil: false + validates :groupe_instructeur, presence: { message: 'doit être renseigné' }, allow_nil: false + + def telephone_url + if telephone.present? + "tel:#{telephone.gsub(/[[:blank:]]/, '')}" + end + end +end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 3ba28e16b..d13310604 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1304,6 +1304,10 @@ class Dossier < ApplicationRecord ) end + def service + groupe_instructeur&.contact_information || procedure.service + end + private def create_missing_traitemets diff --git a/app/models/groupe_instructeur.rb b/app/models/groupe_instructeur.rb index 0e48fb391..43ef657ee 100644 --- a/app/models/groupe_instructeur.rb +++ b/app/models/groupe_instructeur.rb @@ -13,6 +13,7 @@ class GroupeInstructeur < ApplicationRecord has_and_belongs_to_many :bulk_messages, dependent: :destroy has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur + has_one :contact_information validates :label, presence: true, allow_nil: false validates :label, uniqueness: { scope: :procedure } @@ -106,6 +107,8 @@ class GroupeInstructeur < ApplicationRecord options = case routing_tdc.type_champ when TypeDeChamp.type_champs.fetch(:departements) APIGeoService.departements.map { _1[:code] } + when TypeDeChamp.type_champs.fetch(:regions) + APIGeoService.regions.map { _1[:code] } when TypeDeChamp.type_champs.fetch(:drop_down_list) routing_tdc.options_with_drop_down_other end diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index 4a1c9dcc2..e3d6985ff 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -44,7 +44,7 @@ class Logic::ChampValue < Logic::Term targeted_champ.selected when "Champs::MultipleDropDownListChamp" targeted_champ.selected_options - when "Champs::DepartementChamp" + when "Champs::DepartementChamp", "Champs::RegionChamp" targeted_champ.code end end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 3436e11fe..b5a1c22a6 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -469,7 +469,7 @@ class Procedure < ApplicationRecord dossier_submitted_message: [] } } - include_list[:groupe_instructeurs] = :instructeurs if !is_different_admin + include_list[:groupe_instructeurs] = [:instructeurs, :contact_information] if !is_different_admin procedure = self.deep_clone(include: include_list) do |original, kopy| PiecesJustificativesService.clone_attachments(original, kopy) end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 716334e57..545754516 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -224,7 +224,7 @@ class ProcedureRevision < ApplicationRecord end def routable_types_de_champ - types_de_champ_public.filter { |tdc| [:drop_down_list, :departements].include?(tdc.type_champ.to_sym) } + types_de_champ_public.filter { |tdc| [:drop_down_list, :departements, :regions].include?(tdc.type_champ.to_sym) } end private diff --git a/app/services/watermark_service.rb b/app/services/watermark_service.rb new file mode 100644 index 000000000..1f192f879 --- /dev/null +++ b/app/services/watermark_service.rb @@ -0,0 +1,118 @@ +class WatermarkService + POINTSIZE = 20 + KERNING = 1.2 + ANGLE = 45 + FILL_COLOR = "rgba(0,0,0,0.4)" + + attr_reader :text + attr_reader :text_length + + def initialize(text = APPLICATION_NAME) + @text = " #{text} " # give more space around each occurence + @text_length = @text.length + end + + def process(file, output) + metadata = image_metadata(file) + + return if metadata.blank? + + watermark_image(file, output, metadata) + + output + end + + private + + def watermark_image(file, output, metadata) + MiniMagick::Tool::Convert.new do |convert| + setup_conversion_commands(convert, file) + apply_watermark(convert, metadata) + convert << output.to_path + end + end + + def setup_conversion_commands(convert, file) + convert << file.to_path + convert << "-pointsize" + convert << POINTSIZE + convert << "-kerning" + convert << KERNING + convert << "-fill" + convert << FILL_COLOR + convert << "-gravity" + convert << "northwest" + end + + # Parcourt l'image ligne par ligne et colonne par colonne en y apposant un filigrane + # en alternant un décalage horizontal sur chaque ligne + def apply_watermark(convert, metadata) + stride_x, stride_y, initial_offsets_x, initial_offset_y = calculate_watermark_params + + 0.step(by: stride_y, to: metadata[:height] + stride_y * 2).with_index do |offset_y, index| + initial_offset_x = initial_offsets_x[index % 2] + + 0.step(by: stride_x, to: metadata[:width] + stride_x * 2) do |offset_x| + x = initial_offset_x + offset_x + y = initial_offset_y + offset_y + draw_text(convert, x, y) + end + end + end + + def calculate_watermark_params + # Approximation de la longueur du texte, qui marche bien pour les constantes par défaut + char_width_approx = POINTSIZE / 2 + char_height_approx = POINTSIZE * 3 / 4 + + # Dimensions du rectangle de texte + text_width_approx = char_width_approx * text_length * Math.cos(ANGLE * (Math::PI / 180)).abs + text_height_approx = char_width_approx * text_length * Math.sin(ANGLE * (Math::PI / 180)).abs + char_height_approx + diagonal_length = Math.sqrt(text_width_approx**2 + text_height_approx**2) + + # Calcul des décalages entre chaque colonne et ligne + # afin que chaque occurence "suive" la précédente + stride_x = ((diagonal_length + char_width_approx) / Math.cos(ANGLE * (Math::PI / 180))) + stride_y = text_height_approx + + initial_offsets_x = [0, (0 - stride_x / 2).round] # Motif de damier en alternant le décalage horizontal + initial_offset_y = 0 - stride_y # Offset négatif pour mieux couvrir le nord ouest + + [stride_x.round, stride_y.round, initial_offsets_x, initial_offset_y.round] + end + + def draw_text(convert, x, y) + # A chaque insertion de texte, positionne le curseur, définit la rotation, puis réinitialise ces paramètres pour la prochaine occurence + # Note: x and y can be negative value + convert << "-draw" + convert << "translate #{x},#{y} rotate #{-ANGLE} text 0,0 '#{text}' rotate #{ANGLE} translate #{-x},#{-y}" + end + + def image_metadata(file) + read_image(file) do |image| + width = image.width + height = image.height + + if rotated_image?(image) + width, height = height, width + end + + { width: width, height: height } + end + end + + def read_image(file) + image = MiniMagick::Image.new(file.to_path) + + if image.valid? + yield image + else + Rails.logger.info "Skipping image analysis because ImageMagick doesn't support the file #{file}" + nil + end + end + + def rotated_image?(image) + ['RightTop', 'LeftBottom'].include?(image["%[orientation]"]) + end +end diff --git a/app/views/administrateurs/groupe_instructeurs/_contact_information.html.haml b/app/views/administrateurs/groupe_instructeurs/_contact_information.html.haml new file mode 100644 index 000000000..5fe916c04 --- /dev/null +++ b/app/views/administrateurs/groupe_instructeurs/_contact_information.html.haml @@ -0,0 +1,23 @@ +.card.mt-2 + .card-title Informations de contact + - service = groupe_instructeur.contact_information + - if service.nil? + = "Le groupe #{groupe_instructeur.label} n'a pas d'informations de contact. Les informations de contact affichées à l'usager seront celles du service de la procédure" + %p.mt-3 + - if groupe_instructeur.instructeurs.include?(current_administrateur.user.instructeur) + = link_to "+ Ajouter des informations de contact", new_instructeur_groupe_contact_information_path(procedure_id: procedure.id, groupe_id: groupe_instructeur.id, from_admin: true), class: "fr-btn" + - else + Si vous souhaitez créer un service pour ce groupe, vous devez faire partie du groupe instructeur + - else + %p.mt-3 + - if groupe_instructeur.instructeurs.include?(current_administrateur.user.instructeur) + = link_to "Modifier les informations de contact", edit_instructeur_groupe_contact_information_path(procedure_id: procedure.id, groupe_id: groupe_instructeur.id, from_admin: true), class: "fr-btn" + - else + Si vous souhaitez modifier ce service, vous devez faire partie du groupe instructeur + %p.mt-3= service.nom + = render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'}) + = service.email + - if service.telephone.present? + %p= service.telephone + - if service.horaires.present? + %p= service.horaires diff --git a/app/views/administrateurs/groupe_instructeurs/show.html.haml b/app/views/administrateurs/groupe_instructeurs/show.html.haml index 13f5d1581..d61e17433 100644 --- a/app/views/administrateurs/groupe_instructeurs/show.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/show.html.haml @@ -13,3 +13,6 @@ instructeurs: @instructeurs, available_instructeur_emails: @available_instructeur_emails, disabled_as_super_admin: administrateur_as_manager? } + = render partial: 'administrateurs/groupe_instructeurs/contact_information', + locals: { procedure: @procedure, + groupe_instructeur: @groupe_instructeur } diff --git a/app/views/graphql/playground.html.haml b/app/views/graphql/playground.html.haml index 1f2a5dad6..c45cd4fb1 100644 --- a/app/views/graphql/playground.html.haml +++ b/app/views/graphql/playground.html.haml @@ -4,14 +4,14 @@ %meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" } %meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" } %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } + %meta{ name: "application-name", content: APPLICATION_NAME } + %meta{ name: "apple-mobile-web-app-title", content: APPLICATION_NAME } = csrf_meta_tags %title = content_for?(:title) ? "#{yield(:title)} · #{APPLICATION_NAME}" : APPLICATION_NAME - = favicon_link_tag(image_url("#{FAVICON_16PX_SRC}"), type: "image/png", sizes: "16x16") - = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") - = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") + = render partial: "layouts/favicons" = vite_client_tag = vite_typescript_tag 'playground' diff --git a/app/views/instructeurs/contact_informations/_form.html.haml b/app/views/instructeurs/contact_informations/_form.html.haml new file mode 100644 index 000000000..c08656e14 --- /dev/null +++ b/app/views/instructeurs/contact_informations/_form.html.haml @@ -0,0 +1,39 @@ += form_with url: instructeur_groupe_contact_information_path, model: @contact_information, local: true do |f| + = hidden_field_tag :from_admin, params[:from_admin] + + = render Dsfr::CalloutComponent.new(title: "Informations de contact") do |c| + - c.body do + Votre démarche est hébergée par #{APPLICATION_NAME} – mais nous ne pouvons pas assurer le support des démarches. Et malgré la dématérialisation, les usagers se posent parfois des questions légitimes sur le processus administratif. + %br + %br + %strong Il est donc indispensable que les usagers puissent vous contacter + par le moyen de leur choix s’ils ont des questions sur votre démarche. + %br + %br + Ces informations de contact seront visibles par les utilisateurs de la démarche, affichées dans le menu « Aide », ainsi qu’en pied de page lors du dépôt d’un dossier. + %br + %br + ⚠️ En cas d’informations invalides, #{APPLICATION_NAME} se réserve le droit de suspendre la publication de la démarche. + + = render Dsfr::InputComponent.new(form: f, attribute: :nom, input_type: :text_field) do |c| + - c.with_hint do + Indiquez le nom à utiliser pour contacter le groupe instructeur + (Exemple: Secrétariat de la Mairie) + + = render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field) + = render Dsfr::InputComponent.new(form: f, attribute: :telephone, input_type: :telephone_field) + = render Dsfr::InputComponent.new(form: f, attribute: :horaires, input_type: :text_area) + = render Dsfr::InputComponent.new(form: f, attribute: :adresse, input_type: :text_area) + + - if procedure_id.present? + = hidden_field_tag :procedure_id, procedure_id + + .sticky-action-footer + = f.submit "Enregistrer", class: "fr-btn fr-mr-2w" + = link_to "Annuler", instructeur_groupe_path(@groupe_instructeur, procedure_id: procedure_id), class: "fr-btn fr-btn--secondary" + - if [ "edit", "update"].include? params[:action] + = link_to 'Supprimer', + instructeur_groupe_contact_information_path(procedure_id: @procedure.id, groupe_id: @groupe_instructeur.id), + method: :delete, + data: { confirm: "Confirmez vous la suppression de ces informations de contact ?" }, + class: 'fr-btn fr-btn--secondary' diff --git a/app/views/instructeurs/contact_informations/edit.html.haml b/app/views/instructeurs/contact_informations/edit.html.haml new file mode 100644 index 000000000..607acfa12 --- /dev/null +++ b/app/views/instructeurs/contact_informations/edit.html.haml @@ -0,0 +1,10 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + ['Groupes d’instructeurs', instructeur_groupes_path(@procedure)], + [@groupe_instructeur.label, instructeur_groupe_path(@groupe_instructeur, procedure_id: @procedure.id) ], + ['Service']]} +.container + %h1 Modifier les informations de contact + + = render partial: 'form', + locals: { contact_information: @contact_information, procedure_id: @procedure.id } diff --git a/app/views/instructeurs/contact_informations/new.html.haml b/app/views/instructeurs/contact_informations/new.html.haml new file mode 100644 index 000000000..18450ac3e --- /dev/null +++ b/app/views/instructeurs/contact_informations/new.html.haml @@ -0,0 +1,10 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + ['Groupes d’instructeurs', instructeur_groupes_path(@procedure)], + [@groupe_instructeur.label, instructeur_groupe_path(@groupe_instructeur, procedure_id: @procedure.id) ], + ['Service']]} +.container + %h1 Informations de contact + + = render partial: 'form', + locals: { contact_information: @contact_information, procedure_id: @procedure.id } diff --git a/app/views/instructeurs/groupe_instructeurs/show.html.haml b/app/views/instructeurs/groupe_instructeurs/show.html.haml index ef16232c2..1cabdabf3 100644 --- a/app/views/instructeurs/groupe_instructeurs/show.html.haml +++ b/app/views/instructeurs/groupe_instructeurs/show.html.haml @@ -48,3 +48,20 @@ class: 'button' } = paginate @instructeurs, views_prefix: 'shared' + .card.mt-2 + .card-title Informations de contact + - service = @groupe_instructeur.contact_information + - if service.nil? + = "Le groupe #{@groupe_instructeur.label} n'a pas d'informations de contact. Les informations de contact affichées à l'usager seront celles du service de la procédure" + %p.mt-3 + = link_to "+ Ajouter des informations de contact", new_instructeur_groupe_contact_information_path(procedure_id: @procedure.id, groupe_id: @groupe_instructeur.id), class: "fr-btn" + - else + %p.mt-3 + = link_to "Modifier les informations de contact", edit_instructeur_groupe_contact_information_path(procedure_id: @procedure.id, groupe_id: @groupe_instructeur.id), class: "fr-btn" + %p.mt-3= service.nom + = render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'}) + = service.email + - if service.telephone.present? + %p= service.telephone + - if service.horaires.present? + %p= service.horaires diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index 534f8a66d..f3db643a3 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -7,5 +7,5 @@ - if i > 0 = " ou " = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, field: "#{filter['table']}/#{filter['column']}", value: filter['value'] }), - class: "fr-tag fr-tag--dismiss fr-mb-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do + class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 6c39ff86a..bf73cb667 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -65,23 +65,7 @@ = render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) .fr-ml-auto - = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', 'fr-btn--secondary'] }, menu_options: { id: 'custom-menu' }) do |menu| - - menu.with_button_inner_html do - = t('views.instructeurs.dossiers.personalize') - - menu.with_form do - = form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do - = hidden_field_tag :values, nil - = react_component("ComboMultiple", - options: @displayable_fields_for_select, - selected: @displayable_fields_selected, - disabled: [], - label: 'Colonne à afficher', - group: '.columns-form', - name: 'values') - = submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary' - - .fr-ml-2w - if @statut == 'archives' = link_to deleted_dossiers_instructeur_procedure_path(@procedure), class: "fr-link fr-icon-delete-line fr-link--icon-left fr-mr-2w" do = t('views.instructeurs.dossiers.show_deleted_dossiers') @@ -122,10 +106,26 @@ - @procedure_presentation.displayed_fields_for_headers.each do |field| = render partial: "header_field", locals: { field: field, classname: field['classname'] } - %th.action-col.follow-col + %th.follow-col Actions - %tr + %th.text-right + = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', 'fr-btn--tertiary-no-outline', 'fr-btn--icon-right', 'fr-icon-settings-5-line'] }, menu_options: { id: 'custom-menu' }) do |menu| + - menu.with_button_inner_html do + = t('views.instructeurs.dossiers.personalize') + - menu.with_form do + = form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do + = hidden_field_tag :values, nil + = react_component("ComboMultiple", + options: @displayable_fields_for_select, + selected: @displayable_fields_selected, + disabled: [], + label: 'Colonne à afficher', + group: '.columns-form', + name: 'values') + + = submit_tag t('views.instructeurs.dossiers.save'), class: 'fr-btn fr-btn--secondary' + %tbody = render Dossiers::BatchSelectMoreComponent.new(dossiers_count: @dossiers_count, filtered_sorted_ids: @filtered_sorted_ids) @@ -179,7 +179,7 @@ %span.cell-link = link_to_if p.hidden_by_administration_at.blank?, render(Instructeurs::SVASVRDecisionBadgeComponent.new(projection_or_dossier: p, procedure: @procedure)), path - %td.action-col.follow-col + %td.follow-col{ colspan:'2' } %ul.inline.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline.fr-btns-group--icon-right = render partial: 'instructeurs/procedures/dossier_actions', locals: { procedure_id: @procedure.id, dossier_id: p.dossier_id, @@ -191,6 +191,7 @@ sva_svr: @procedure.sva_svr_enabled?, turbo: false, with_menu: false } + %tfoot %tr %td.force-table-100{ colspan: @procedure_presentation.displayed_fields_for_headers.size + 2 } diff --git a/app/views/layouts/_favicons.html.haml b/app/views/layouts/_favicons.html.haml new file mode 100644 index 000000000..4c2ba2744 --- /dev/null +++ b/app/views/layouts/_favicons.html.haml @@ -0,0 +1,5 @@ += favicon_link_tag(image_url(FAVICONS_SRC["16px"]), type: "image/png", sizes: "16x16") if FAVICONS_SRC.key?("16px") += favicon_link_tag(image_url(FAVICONS_SRC["32px"]), type: "image/png", sizes: "32x32") if FAVICONS_SRC.key?("32px") += favicon_link_tag(image_url(FAVICONS_SRC["96px"]), type: "image/png", sizes: "96x96") if FAVICONS_SRC.key?("96px") += favicon_link_tag(image_url(FAVICONS_SRC["apple_touch"]), type: nil, sizes: "152x152", rel: "apple-touch-icon") if FAVICONS_SRC.key?("apple_touch") +%meta{ name: "theme-color", content: "#ffffff" } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 281018841..932431329 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -4,14 +4,14 @@ %meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" } %meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" } %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } + %meta{ name: "application-name", content: APPLICATION_NAME } + %meta{ name: "apple-mobile-web-app-title", content: APPLICATION_NAME } = csrf_meta_tags %title = content_for?(:title) ? "#{sanitize(yield(:title))} · #{APPLICATION_NAME}" : APPLICATION_NAME - = favicon_link_tag(image_url("#{FAVICON_16PX_SRC}"), type: "image/png", sizes: "16x16") - = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") - = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") + = render partial: "layouts/favicons" = Gon::Base.render_data(camel_case: true, init: true, nonce: request.content_security_policy_nonce) diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml index 82fcf217c..da52c5d1f 100644 --- a/app/views/layouts/component_preview.html.haml +++ b/app/views/layouts/component_preview.html.haml @@ -4,14 +4,14 @@ %meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" } %meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" } %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } + %meta{ name: "application-name", content: APPLICATION_NAME } + %meta{ name: "apple-mobile-web-app-title", content: APPLICATION_NAME } = csrf_meta_tags %title = content_for?(:title) ? "#{yield(:title)} · #{APPLICATION_NAME}" : APPLICATION_NAME - = favicon_link_tag(image_url("#{FAVICON_16PX_SRC}"), type: "image/png", sizes: "16x16") - = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") - = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") + = render partial: "layouts/favicons" = vite_client_tag = vite_javascript_tag 'application' diff --git a/app/views/layouts/print.html.haml b/app/views/layouts/print.html.haml index 5b3baa7b0..2de782bd2 100644 --- a/app/views/layouts/print.html.haml +++ b/app/views/layouts/print.html.haml @@ -3,14 +3,14 @@ %meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" } %meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" } %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } + %meta{ name: "application-name", content: APPLICATION_NAME } + %meta{ name: "apple-mobile-web-app-title", content: APPLICATION_NAME } = csrf_meta_tags %title = t("dynamics.page_title") - = favicon_link_tag(image_url("#{FAVICON_16PX_SRC}"), type: "image/png", sizes: "16x16") - = favicon_link_tag(image_url("#{FAVICON_32PX_SRC}"), type: "image/png", sizes: "32x32") - = favicon_link_tag(image_url("#{FAVICON_96PX_SRC}"), type: "image/png", sizes: "96x96") + = render partial: "layouts/favicons" %body = yield diff --git a/app/views/recherche/index.html.haml b/app/views/recherche/index.html.haml index ff17ddc37..3e652405d 100644 --- a/app/views/recherche/index.html.haml +++ b/app/views/recherche/index.html.haml @@ -21,7 +21,7 @@ %th Démarche %th Demandeur %th.status-col Statut - %th.action-col.follow-col + %th.follow-col %tbody - @projected_dossiers.each do |p| - procedure_libelle, user_email, procedure_id = p.columns @@ -67,7 +67,7 @@ - if instructeur_dossier && expert_dossier - %td.action-col.follow-col + %td.follow-col = render Dropdown::MenuComponent.new(wrapper: :div, button_options: {class: ['fr-btn--sm']}) do |menu| - menu.with_button_inner_html do Actions @@ -86,12 +86,12 @@ - elsif instructeur_dossier - if hidden_by_administration - %td.action-col.follow-col + %td.follow-col = link_to restore_instructeur_dossier_path(procedure_id, p.dossier_id), method: :patch, class: "button primary" do = t('views.instructeurs.dossiers.restore') - else - %td.action-col.follow-col + %td.follow-col %ul.inline.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline.fr-btns-group--icon-right = render partial: "instructeurs/procedures/dossier_actions", locals: { procedure_id: procedure_id, diff --git a/app/views/shared/avis/_form.html.haml b/app/views/shared/avis/_form.html.haml index 78d468b8e..43f3dad28 100644 --- a/app/views/shared/avis/_form.html.haml +++ b/app/views/shared/avis/_form.html.haml @@ -13,7 +13,7 @@ = hidden_field_tag 'avis[emails]', nil .fr-input-group = react_component("ComboMultiple", - options: @dossier.procedure.experts_require_administrateur_invitation ? @experts_emails : [], + options: current_expert_not_instructeur? ? [] : @experts_emails, selected: [], disabled: [], label: 'Emails', group: '.ask-avis', diff --git a/app/views/users/_procedure_footer.html.haml b/app/views/users/_procedure_footer.html.haml index 0ac5c0417..0eb2066b1 100644 --- a/app/views/users/_procedure_footer.html.haml +++ b/app/views/users/_procedure_footer.html.haml @@ -1,5 +1,5 @@ %footer.fr-footer.footer-procedure#footer{ role: "contentinfo" } - - service = procedure.service + - service = dossier&.service || procedure.service .fr-footer__top.fr-mb-0 .fr-container .fr-grid-row.fr-grid-row--start.fr-grid-row--gutters @@ -21,6 +21,7 @@ = I18n.t('users.procedure_footer.contact.email.link') = link_to service.email, "mailto:#{service.email}", class: "fr-footer__top-link" + - if service.present? - if service.telephone.present? || service.horaires.present? %li - horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}" diff --git a/config/env.example.optional b/config/env.example.optional index 8935825e9..1970b05bd 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -46,9 +46,12 @@ DS_ENV="staging" # STATUS_PAGE_URL="" # Instance customization: Favicons ---> to be put in "app/assets/images" +# Search "real favicon generator" to find websites generating all these formats from a single image source. +# An empty string disable the icon if you don't care. # FAVICON_16PX_SRC="favicons/16x16.png" # FAVICON_32PX_SRC="favicons/32x32.png" # FAVICON_96PX_SRC="favicons/96x96.png" +# FAVICON_APPLE_TOUCH_152PX_SRC="favicons/apple-touch-icon.png" # Instance customization: Application logo ---> to be put in "app/assets/images" # HEADER_LOGO_SRC="marianne.png" @@ -68,9 +71,6 @@ DS_ENV="staging" # Instance customization: PDF export logo ---> to be put in "app/assets/images" # DOSSIER_PDF_EXPORT_LOGO_SRC="app/assets/images/header/logo-ds-wide.png" -# Instance customization: watermark for identity documents -# WATERMARK_FILE="" - # Enabling maintenance mode # MAINTENANCE_MODE="true" diff --git a/config/initializers/images.rb b/config/initializers/images.rb index 161c9fc24..9bf0804dc 100644 --- a/config/initializers/images.rb +++ b/config/initializers/images.rb @@ -1,7 +1,10 @@ # Favicons -FAVICON_16PX_SRC = ENV.fetch("FAVICON_16PX_SRC", "favicons/16x16.png") -FAVICON_32PX_SRC = ENV.fetch("FAVICON_32PX_SRC", "favicons/32x32.png") -FAVICON_96PX_SRC = ENV.fetch("FAVICON_96PX_SRC", "favicons/96x96.png") +FAVICONS_SRC = { + "16px" => ENV.fetch("FAVICON_16PX_SRC", "favicons/16x16.png"), + "32px" => ENV.fetch("FAVICON_32PX_SRC", "favicons/32x32.png"), + "96px" => ENV.fetch("FAVICON_96PX_SRC", "favicons/96x96.png"), + "apple_touch" => ENV.fetch("FAVICON_APPLE_TOUCH_152PX_SRC", "favicons/apple-touch-icon.png") +}.compact_blank.freeze # Header logo HEADER_LOGO_SRC = ENV.fetch("HEADER_LOGO_SRC", "marianne.png") diff --git a/config/initializers/watermark.rb b/config/initializers/watermark.rb deleted file mode 100644 index 8c87c9cda..000000000 --- a/config/initializers/watermark.rb +++ /dev/null @@ -1 +0,0 @@ -WATERMARK_FILE = ENV.fetch('WATERMARK_FILE', 'watermark.png') diff --git a/config/locales/en.yml b/config/locales/en.yml index 9825a22c5..e9af564fd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -395,7 +395,7 @@ en: batch_operation: enabled: "Add file %{dossier_id} to the selection for the bulk operation" disabled: "Impossible to add file %{dossier_id} to the selection because it is already in a bulk operation" - personalize: Personalize the table + personalize: Personalize show_deleted_dossiers: Show deleted files follow_file: Follow-up the file save: Save diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 5648f07fa..edfa4ddaa 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -397,7 +397,7 @@ fr: enabled: "Ajouter le dossier %{dossier_id} à la sélection pour un traitement de masse" disabled: "Impossible d'ajouter le dossier %{dossier_id} à la selection car il est déjà dans un traitement de masse" show_deleted_dossiers: Afficher les dossiers supprimés - personalize: Personnaliser le tableau + personalize: Personnaliser download: Télécharger un dossier follow_file: Suivre le dossier stop_follow: Ne plus suivre diff --git a/config/locales/models/service/fr.yml b/config/locales/models/service/fr.yml index 40b6a2e0b..1abe6afdb 100644 --- a/config/locales/models/service/fr.yml +++ b/config/locales/models/service/fr.yml @@ -5,7 +5,7 @@ fr: one: 'Service' other: 'Services' attributes: - service: + service: &service adresse: 'Adresse postale' email: 'Email de contact' telephone: 'Téléphone' @@ -27,6 +27,7 @@ fr: Exemple : Du lundi au vendredi de 9h30 à 17h30, le samedi de 9h30 à 12h. adresse: | Indiquez l’adresse à laquelle un usager peut vous contacter, par exemple s’il n’est pas en capacité de compléter son formulaire en ligne. + contact_information: *service errors: models: diff --git a/config/routes.rb b/config/routes.rb index dbe210a06..77fa5ca91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -392,6 +392,7 @@ Rails.application.routes.draw do resources :archives, only: [:index, :create] resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do + resource :contact_information member do post 'add_instructeur' delete 'remove_instructeur' diff --git a/db/migrate/20230809151357_create_contact_informations.rb b/db/migrate/20230809151357_create_contact_informations.rb new file mode 100644 index 000000000..fcd4aa13e --- /dev/null +++ b/db/migrate/20230809151357_create_contact_informations.rb @@ -0,0 +1,15 @@ +class CreateContactInformations < ActiveRecord::Migration[7.0] + def change + create_table :contact_informations do |t| + t.belongs_to :groupe_instructeur, null: false, foreign_key: true + t.text :adresse, null: false + t.string :email, null: false + t.text :horaires, null: false + t.string :nom, null: false + t.string :telephone, null: false + + t.timestamps + end + add_index :contact_informations, [:groupe_instructeur_id, :nom], unique: true, name: 'index_contact_informations_on_gi_and_nom' + end +end diff --git a/db/schema.rb b/db/schema.rb index 8837e43d6..0badfef05 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[7.0].define(version: 2023_08_02_161011) do +ActiveRecord::Schema[7.0].define(version: 2023_08_09_151357) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -612,6 +612,19 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_02_161011) do t.index ["source"], name: "index_geo_areas_on_source" end + create_table "contact_informations", force: :cascade do |t| + t.text "adresse", null: false + t.datetime "created_at", null: false + t.string "email", null: false + t.bigint "groupe_instructeur_id", null: false + t.text "horaires", null: false + t.string "nom", null: false + t.string "telephone", null: false + t.datetime "updated_at", null: false + t.index ["groupe_instructeur_id", "nom"], name: "index_contact_informations_on_gi_and_nom", unique: true + t.index ["groupe_instructeur_id"], name: "index_contact_informations_on_groupe_instructeur_id" + end + create_table "groupe_instructeurs", force: :cascade do |t| t.boolean "closed", default: false t.datetime "created_at", precision: 6, null: false @@ -1044,6 +1057,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_02_161011) do add_foreign_key "commentaires", "dossiers" add_foreign_key "commentaires", "experts" add_foreign_key "commentaires", "instructeurs" + add_foreign_key "contact_informations", "groupe_instructeurs" add_foreign_key "dossier_assignments", "dossiers" add_foreign_key "dossier_batch_operations", "batch_operations" add_foreign_key "dossier_batch_operations", "dossiers" diff --git a/lib/tasks/pjs.rake b/lib/tasks/pjs.rake index 6b52198fc..73d952e3e 100644 --- a/lib/tasks/pjs.rake +++ b/lib/tasks/pjs.rake @@ -22,4 +22,32 @@ namespace :pjs do blobs.in_batches { |batch| batch.ids.each { |id| PjsMigrationJob.perform_later(id) } } end + + desc "Watermark demo. Usage: noglob rake pjs:watermark_demo[tmp/carte-identite-demo-1.jpg]" + task :watermark_demo, [:file_path] => :environment do |_t, args| + file = Pathname.new(args[:file_path]) + output_file = Rails.root.join('tmp', "#{file.basename(file.extname)}_watermarked#{file.extname}") + + processed = WatermarkService.new.process(file, output_file) + + if processed + rake_puts "Watermarked: #{processed}" + else + rake_puts "File #{file} not watermarked. Read application log for more information" + end + end + + desc "Watermark demo all defined demo files. Usage: noglob rake pjs:watermark_demo_all" + task :watermark_demo_all => :environment do + # You must have these filenames in tmp/ to run this demo (download ID cards specimens) + filenames = [ + "carte-identite-demo-1.jpg", "carte-identite-demo-2.jpg", "carte-identite-demo-3.png", "carte-identite-demo-4.jpg", + "carte-identite-demo-5.jpg", "passeport-1.jpg", "passeport-2.jpg" + ] + + filenames.each do |file| + Rake::Task["pjs:watermark_demo"].invoke("tmp/#{file}") + Rake::Task["pjs:watermark_demo"].reenable + end + end end diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 40c731ae4..5ea80ee2f 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -761,6 +761,26 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do expect(procedure3.routing_enabled).to be_truthy end end + + context 'with a regions type de champ' do + let!(:procedure3) do + create(:procedure, + types_de_champ_public: [{ type: :regions }], + administrateurs: [admin]) + end + + let!(:regions_tdc) { procedure3.draft_revision.types_de_champ.first } + + before { post :create_simple_routing, params: { procedure_id: procedure3.id, create_simple_routing: { stable_id: regions_tdc.stable_id } } } + + it do + expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) + expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Guadeloupe") + expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(regions_tdc.stable_id), constant('01'))) + expect(procedure3.routing_enabled).to be_truthy + end + end end describe '#wizard' do diff --git a/spec/controllers/instructeurs/contact_informations_controller_spec.rb b/spec/controllers/instructeurs/contact_informations_controller_spec.rb new file mode 100644 index 000000000..70cdae50c --- /dev/null +++ b/spec/controllers/instructeurs/contact_informations_controller_spec.rb @@ -0,0 +1,123 @@ +describe Instructeurs::ContactInformationsController, type: :controller do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure) } + let(:assign_to) { create(:assign_to, instructeur: instructeur, groupe_instructeur: build(:groupe_instructeur, procedure: procedure)) } + let(:gi) { assign_to.groupe_instructeur } + let(:from_admin) { nil } + + before do + sign_in(instructeur.user) + end + + describe '#create' do + context 'when submitting a new contact_information' do + let(:params) do + { + contact_information: { + nom: 'super service', + email: 'email@toto.com', + telephone: '1234', + horaires: 'horaires', + adresse: 'adresse' + }, + procedure_id: procedure.id, + groupe_id: gi.id, + from_admin: from_admin + } + end + + it do + post :create, params: params + expect(flash.alert).to be_nil + expect(flash.notice).to eq('Les informations de contact ont bien été ajoutées') + expect(ContactInformation.last.nom).to eq('super service') + expect(ContactInformation.last.email).to eq('email@toto.com') + expect(ContactInformation.last.telephone).to eq('1234') + expect(ContactInformation.last.horaires).to eq('horaires') + expect(ContactInformation.last.adresse).to eq('adresse') + end + + context 'from admin' do + let(:from_admin) { true } + it do + post :create, params: params + expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(gi, procedure_id: procedure.id)) + end + end + end + + context 'when submitting an invalid contact_information' do + before do + post :create, params: params + end + + let(:params) { + { + contact_information: { + nom: 'super service' + }, + procedure_id: procedure.id, + groupe_id: gi.id + } + } + + it { expect(flash.alert).not_to be_nil } + it { expect(response).to render_template(:new) } + it { expect(assigns(:contact_information).nom).to eq('super service') } + end + end + + describe '#update' do + let(:contact_information) { create(:contact_information, groupe_instructeur: gi) } + let(:contact_information_params) { + { + nom: 'nom' + } + } + let(:params) { + { + id: contact_information.id, + contact_information: contact_information_params, + procedure_id: procedure.id, + groupe_id: gi.id, + from_admin: from_admin + } + } + + before do + patch :update, params: params + end + + context 'when updating a contact_information' do + it { expect(flash.alert).to be_nil } + it { expect(flash.notice).to eq('Les informations de contact ont bien été modifiées') } + it { expect(ContactInformation.last.nom).to eq('nom') } + it { expect(response).to redirect_to(instructeur_groupe_path(gi, procedure_id: procedure.id)) } + end + + context 'when updating a contact_information as an admin' do + let(:from_admin) { true } + it { expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(gi, procedure_id: procedure.id)) } + end + + context 'when updating a contact_information with invalid data' do + let(:contact_information_params) { { nom: '' } } + + it { expect(flash.alert).not_to be_nil } + it { expect(response).to render_template(:edit) } + end + end + + describe '#destroy' do + let(:contact_information) { create(:contact_information, groupe_instructeur: gi) } + + before do + delete :destroy, params: { id: contact_information.id, procedure_id: procedure.id, groupe_id: gi.id } + end + + it { expect { contact_information.reload }.to raise_error(ActiveRecord::RecordNotFound) } + it { expect(flash.alert).to be_nil } + it { expect(flash.notice).to eq("Les informations de contact ont bien été supprimées") } + it { expect(response).to redirect_to(instructeur_groupe_path(gi, procedure_id: procedure.id)) } + end +end diff --git a/spec/factories/contact_information.rb b/spec/factories/contact_information.rb new file mode 100644 index 000000000..7b89daca8 --- /dev/null +++ b/spec/factories/contact_information.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :contact_information do + sequence(:nom) { |n| "Service #{n}" } + email { 'email@toto.com' } + telephone { '1234' } + horaires { 'de 9 h à 18 h' } + adresse { 'adresse' } + + association :groupe_instructeur + end +end diff --git a/spec/models/contact_information_spec.rb b/spec/models/contact_information_spec.rb new file mode 100644 index 000000000..37e2278ff --- /dev/null +++ b/spec/models/contact_information_spec.rb @@ -0,0 +1,62 @@ +describe ContactInformation, type: :model do + describe 'validation' do + let(:gi) { create(:groupe_instructeur) } + let(:params) do + { + nom: 'service des jardins', + email: 'super@email.com', + telephone: '012345678', + horaires: 'du lundi au vendredi', + adresse: '12 rue des schtroumpfs', + groupe_instructeur_id: gi.id + } + end + + subject { ContactInformation.new(params) } + + it { expect(subject).to be_valid } + + it 'should forbid invalid phone numbers' do + invalid_phone_numbers = ["1", "Néant", "01 60 50 40 30 20"] + + invalid_phone_numbers.each do |tel| + subject.telephone = tel + expect(subject).not_to be_valid + end + end + + it 'should not accept no phone numbers' do + subject.telephone = nil + expect(subject).not_to be_valid + end + + it 'should accept valid phone numbers' do + valid_phone_numbers = ["3646", "273115", "0160376983", "01 60 50 40 30 ", "+33160504030"] + + valid_phone_numbers.each do |tel| + subject.telephone = tel + expect(subject).to be_valid + end + end + + context 'when a contact information already exists' do + before { ContactInformation.create(params) } + + context 'checks uniqueness of administrateur, name couple' do + it { expect(ContactInformation.create(params)).not_to be_valid } + end + end + + context 'of nom' do + it 'should be set' do + expect(ContactInformation.new(params.except(:nom))).not_to be_valid + end + end + + context 'of groupe instructeur' do + it 'should be set' do + expect(ContactInformation.new(params.except(:groupe_instructeur_id))).not_to be_valid + end + end + end +end diff --git a/spec/models/logic/champ_value_spec.rb b/spec/models/logic/champ_value_spec.rb index 4e359032a..a482935d3 100644 --- a/spec/models/logic/champ_value_spec.rb +++ b/spec/models/logic/champ_value_spec.rb @@ -76,6 +76,12 @@ describe Logic::ChampValue do it { is_expected.to eq(true) } end + context 'region tdc' do + let(:champ) { create(:champ_regions, value: 'La Réunion') } + + it { is_expected.to eq('04') } + end + describe 'errors' do let(:champ) { create(:champ) } diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index b6011ca24..6204d3d43 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -948,9 +948,10 @@ describe ProcedureRevision do p.draft_revision.add_type_de_champ(type_champ: :text, libelle: 'l1') p.draft_revision.add_type_de_champ(type_champ: :drop_down_list, libelle: 'l2') p.draft_revision.add_type_de_champ(type_champ: :departements, libelle: 'l3') + p.draft_revision.add_type_de_champ(type_champ: :regions, libelle: 'l4') end end - it { expect(draft.routable_types_de_champ.pluck(:libelle)).to eq(['l2', 'l3']) } + it { expect(draft.routable_types_de_champ.pluck(:libelle)).to eq(['l2', 'l3', 'l4']) } end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 9291e15cd..8d3f9c21f 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -563,7 +563,7 @@ describe Procedure do let(:logo) { Rack::Test::UploadedFile.new('spec/fixtures/files/white.png', 'image/png') } let(:signature) { Rack::Test::UploadedFile.new('spec/fixtures/files/black.png', 'image/png') } - let(:groupe_instructeur_1) { create(:groupe_instructeur, procedure: procedure, label: "groupe_1") } + let(:groupe_instructeur_1) { create(:groupe_instructeur, procedure: procedure, label: "groupe_1", contact_information: create(:contact_information)) } let(:instructeur_1) { create(:instructeur) } let(:instructeur_2) { create(:instructeur) } let!(:assign_to_1) { create(:assign_to, procedure: procedure, groupe_instructeur: groupe_instructeur_1, instructeur: instructeur_1) } @@ -594,6 +594,11 @@ describe Procedure do expect { subject }.not_to raise_error end + + it 'should clone groupe instructeur services' do + expect(procedure.groupe_instructeurs.last.contact_information).not_to eq nil + expect(subject.groupe_instructeurs.last.contact_information).not_to eq nil + end end it 'should reset duree_conservation_etendue_par_ds' do @@ -707,6 +712,13 @@ describe Procedure do expect(subject.service).to eq(nil) end + context 'with groupe instructeur services' do + it 'should not clone groupe instructeur services' do + expect(procedure.groupe_instructeurs.last.contact_information).not_to eq nil + expect(subject.groupe_instructeurs.last.contact_information).to eq nil + end + end + it 'should discard old pj information' do subject.draft_revision.types_de_champ_public.each do |stc| expect(stc.old_pj).to be_nil diff --git a/spec/models/routing_engine_spec.rb b/spec/models/routing_engine_spec.rb index 38468ebb6..8d6574d4a 100644 --- a/spec/models/routing_engine_spec.rb +++ b/spec/models/routing_engine_spec.rb @@ -92,6 +92,25 @@ describe RoutingEngine, type: :model do end end + context 'with a regions type de champ' do + let(:procedure) do + create(:procedure, types_de_champ_public: [{ type: :regions }]).tap do |p| + p.groupe_instructeurs.create(label: 'a third group') + end + end + + let(:regions_tdc) { procedure.draft_revision.types_de_champ.first } + + context 'with a matching rule' do + before do + gi_2.update(routing_rule: ds_eq(champ_value(regions_tdc.stable_id), constant('04'))) + dossier.champs.first.update(value: 'La Réunion') + end + + it { is_expected.to eq(gi_2) } + end + end + context 'routing rules priorities' do let(:procedure) do create(:procedure, diff --git a/spec/services/watermark_service_spec.rb b/spec/services/watermark_service_spec.rb new file mode 100644 index 000000000..abe0da8b4 --- /dev/null +++ b/spec/services/watermark_service_spec.rb @@ -0,0 +1,23 @@ +RSpec.describe WatermarkService do + let(:image) { file_fixture("logo_test_procedure.png") } + let(:watermark_service) { WatermarkService.new } + + describe '#process' do + it 'returns a tempfile if watermarking succeeds' do + Tempfile.create do |output| + watermark_service.process(image, output) + # output size should always be a little greater than input size + expect(output.size).to be_between(image.size, image.size * 1.5) + end + end + + it 'returns nil if metadata is blank' do + allow(watermark_service).to receive(:image_metadata).and_return(nil) + + Tempfile.create do |output| + expect(watermark_service.process(image.to_path, output)).to be_nil + expect(output.size).to eq(0) + end + end + end +end