diff --git a/.scss-lint.yml b/.scss-lint.yml
index 835a46690..45ead4f7b 100644
--- a/.scss-lint.yml
+++ b/.scss-lint.yml
@@ -1,4 +1,7 @@
-exclude: 'app/assets/stylesheets/new_design/reset.scss'
+exclude:
+ - 'app/assets/stylesheets/new_design/reset.scss'
+ - 'app/assets/stylesheets/direct_uploads.scss'
+ - 'app/assets/stylesheets/new_design/direct_uploads.scss'
linters:
BangFormat:
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 62f9b7d15..133fc652a 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -10,6 +10,7 @@
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
+//= require activestorage
//= require jquery
//= require jquery_ujs
//= require turbolinks
diff --git a/app/assets/javascripts/new_design/application.js b/app/assets/javascripts/new_design/application.js
index e5d95ac00..ec093c6e2 100644
--- a/app/assets/javascripts/new_design/application.js
+++ b/app/assets/javascripts/new_design/application.js
@@ -11,6 +11,7 @@
// about supported directives.
//
//= require ./init
+//= require activestorage
//= require jquery
//= require jquery_ujs
//= require turbolinks
diff --git a/app/assets/javascripts/new_design/direct_uploads.js b/app/assets/javascripts/new_design/direct_uploads.js
new file mode 100644
index 000000000..1ac788eec
--- /dev/null
+++ b/app/assets/javascripts/new_design/direct_uploads.js
@@ -0,0 +1,45 @@
+addEventListener("direct-upload:initialize", function (event) {
+ var target = event.target,
+ detail = event.detail,
+ id = detail.id,
+ file = detail.file;
+
+ target.insertAdjacentHTML("beforebegin", "\n
\n
\n
" +
+ file.name +
+ "\n
\n");
+});
+
+addEventListener("direct-upload:start", function (event) {
+ var id = event.detail.id,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.remove("direct-upload--pending");
+});
+
+addEventListener("direct-upload:progress", function (event) {
+ var id = event.detail.id,
+ progress = event.detail.progress,
+ progressElement = document.getElementById("direct-upload-progress-" + id);
+
+ progressElement.style.width = progress + "%";
+});
+
+addEventListener("direct-upload:error", function (event) {
+ event.preventDefault();
+ var id = event.detail.id,
+ error = event.detail.error,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.add("direct-upload--error");
+ element.setAttribute("title", error);
+});
+
+addEventListener("direct-upload:end", function (event) {
+ var id = event.detail.id,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.add("direct-upload--complete");
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/old_design/direct_uploads.js b/app/assets/javascripts/old_design/direct_uploads.js
new file mode 100644
index 000000000..47b5aa23c
--- /dev/null
+++ b/app/assets/javascripts/old_design/direct_uploads.js
@@ -0,0 +1,57 @@
+addEventListener("direct-upload:initialize", function (event) {
+ var target = event.target,
+ detail = event.detail,
+ id = detail.id,
+ file = detail.file;
+
+ target.insertAdjacentHTML("beforebegin", "\n\n
\n
" +
+ file.name +
+ "\n
\n");
+});
+
+addEventListener("direct-upload:start", function (event) {
+ var id = event.detail.id,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.remove("direct-upload--pending");
+});
+
+addEventListener("direct-upload:progress", function (event) {
+ var id = event.detail.id,
+ progress = event.detail.progress,
+ progressElement = document.getElementById("direct-upload-progress-" + id);
+
+ progressElement.style.width = progress + "%";
+});
+
+addEventListener("direct-upload:error", function (event) {
+ event.preventDefault();
+ var id = event.detail.id,
+ error = event.detail.error,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.add("direct-upload--error");
+ element.setAttribute("title", error);
+});
+
+addEventListener("direct-upload:end", function (event) {
+ var id = event.detail.id,
+ element = document.getElementById("direct-upload-" + id);
+
+ element.classList.add("direct-upload--complete");
+});
+
+addEventListener('load', function() {
+ var submitButtons = document.querySelectorAll('form button[type=submit][data-action]');
+ var hiddenInput = document.querySelector('form input[type=hidden][name=submit_action]');
+ submitButtons = [].slice.call(submitButtons);
+
+ submitButtons.forEach(function(button) {
+ button.addEventListener('click', function() {
+ hiddenInput.value = button.getAttribute('data-action');
+ });
+ });
+});
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index e8f02b0cc..f2c861d76 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -21,6 +21,7 @@
// = require custom_mails
// = require default_data_block
// = require description
+// = require direct_uploads
// = require dossier_show
// = require dossiers
// = require etapes
diff --git a/app/assets/stylesheets/direct_uploads.scss b/app/assets/stylesheets/direct_uploads.scss
new file mode 100644
index 000000000..eda8e75c7
--- /dev/null
+++ b/app/assets/stylesheets/direct_uploads.scss
@@ -0,0 +1,37 @@
+.direct-upload {
+ display: inline-block;
+ position: relative;
+ padding: 2px 4px;
+ margin: 0 3px 3px 0;
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ border-radius: 3px;
+ font-size: 11px;
+ line-height: 13px;
+}
+
+.direct-upload--pending {
+ opacity: 0.6;
+}
+
+.direct-upload__progress {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ opacity: 0.2;
+ background: #0076ff;
+ transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
+ transform: translate3d(0, 0, 0);
+}
+
+.direct-upload--complete .direct-upload__progress {
+ opacity: 0.4;
+}
+
+.direct-upload--error {
+ border-color: red;
+}
+
+input[type=file][data-direct-upload-url][disabled] {
+ display: none;
+}
diff --git a/app/assets/stylesheets/new_design/direct_uploads.scss b/app/assets/stylesheets/new_design/direct_uploads.scss
new file mode 100644
index 000000000..eda8e75c7
--- /dev/null
+++ b/app/assets/stylesheets/new_design/direct_uploads.scss
@@ -0,0 +1,37 @@
+.direct-upload {
+ display: inline-block;
+ position: relative;
+ padding: 2px 4px;
+ margin: 0 3px 3px 0;
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ border-radius: 3px;
+ font-size: 11px;
+ line-height: 13px;
+}
+
+.direct-upload--pending {
+ opacity: 0.6;
+}
+
+.direct-upload__progress {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ opacity: 0.2;
+ background: #0076ff;
+ transition: width 120ms ease-out, opacity 60ms 60ms ease-in;
+ transform: translate3d(0, 0, 0);
+}
+
+.direct-upload--complete .direct-upload__progress {
+ opacity: 0.4;
+}
+
+.direct-upload--error {
+ border-color: red;
+}
+
+input[type=file][data-direct-upload-url][disabled] {
+ display: none;
+}
diff --git a/app/controllers/new_gestionnaire/dossiers_controller.rb b/app/controllers/new_gestionnaire/dossiers_controller.rb
index 58eea7a09..d5e9c1106 100644
--- a/app/controllers/new_gestionnaire/dossiers_controller.rb
+++ b/app/controllers/new_gestionnaire/dossiers_controller.rb
@@ -165,6 +165,7 @@ module NewGestionnaire
def update_annotations
dossier = current_gestionnaire.dossiers.includes(champs_private: :type_de_champ).find(params[:dossier_id])
+ # FIXME: add attachements validation, cf. Champ#piece_justificative_file_errors
dossier.update_attributes(champs_private_params)
redirect_to annotations_privees_dossier_path(procedure, dossier)
end
@@ -189,7 +190,7 @@ module NewGestionnaire
end
def champs_private_params
- params.require(:dossier).permit(champs_private_attributes: [:id, :value, value: []])
+ params.require(:dossier).permit(champs_private_attributes: [:id, :piece_justificative_file, :value, value: []])
end
def check_attestation_emailable
diff --git a/app/controllers/users/description_controller.rb b/app/controllers/users/description_controller.rb
index e991408d7..ccf4b48fc 100644
--- a/app/controllers/users/description_controller.rb
+++ b/app/controllers/users/description_controller.rb
@@ -36,7 +36,7 @@ class Users::DescriptionController < UsersController
return redirect_to_description_with_errors(dossier, cerfa.errors.full_messages) if !cerfa.save
end
- errors_upload = PiecesJustificativesService.upload!(dossier, current_user, params)
+ errors_upload = PiecesJustificativesService.upload!(dossier, current_user, params) + ChampsService.check_piece_justificative_files(dossier.champs)
return redirect_to_description_with_errors(dossier, errors_upload) if errors_upload.any?
if params[:champs] && !(brouillon_submission? || brouillon_then_dashboard_submission?)
@@ -113,11 +113,11 @@ class Users::DescriptionController < UsersController
end
def brouillon_submission?
- params[:submit] && params[:submit]['brouillon'].present?
+ params[:submit_action] == 'brouillon'
end
def brouillon_then_dashboard_submission?
- params[:submit] && params[:submit]['brouillon_then_dashboard'].present?
+ params[:submit_action] == 'brouillon_then_dashboard'
end
def check_autorisation_donnees
diff --git a/app/helpers/type_de_champ_helper.rb b/app/helpers/type_de_champ_helper.rb
new file mode 100644
index 000000000..bacc16e7a
--- /dev/null
+++ b/app/helpers/type_de_champ_helper.rb
@@ -0,0 +1,11 @@
+module TypeDeChampHelper
+ def tdc_options(current_administrateur)
+ tdcs = TypeDeChamp.type_de_champs_list_fr
+
+ if !current_administrateur.id.in?(Features.champ_pj_allowed_for_admin_ids)
+ tdcs.reject! { |tdc| tdc.last == "piece_justificative" }
+ end
+
+ tdcs
+ end
+end
diff --git a/app/models/champ.rb b/app/models/champ.rb
index 4ff77ccd5..a67c4e581 100644
--- a/app/models/champ.rb
+++ b/app/models/champ.rb
@@ -4,6 +4,7 @@ class Champ < ActiveRecord::Base
belongs_to :dossier, touch: true
belongs_to :type_de_champ, inverse_of: :champ
has_many :commentaires
+ has_one_attached :piece_justificative_file
delegate :libelle, :type_champ, :order_place, :mandatory?, :description, :drop_down_list, to: :type_de_champ
@@ -15,6 +16,23 @@ class Champ < ActiveRecord::Base
scope :public_only, -> { where(type: 'ChampPublic').or(where(private: false)) }
scope :private_only, -> { where(type: 'ChampPrivate').or(where(private: true)) }
+ PIECE_JUSTIFICATIVE_FILE_MAX_SIZE = 200.megabytes
+
+ PIECE_JUSTIFICATIVE_FILE_ACCEPTED_FORMATS = [
+ "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.oasis.opendocument.text",
+ "application/vnd.oasis.opendocument.presentation",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "image/png",
+ "image/jpeg"
+ ]
+
def public?
!private?
end
@@ -32,7 +50,11 @@ class Champ < ActiveRecord::Base
end
def mandatory_and_blank?
- mandatory? && value.blank?
+ if type_champ == 'piece_justificative'
+ mandatory? && !piece_justificative_file.attached?
+ else
+ mandatory? && value.blank?
+ end
end
def same_date? num, compare
@@ -88,6 +110,28 @@ class Champ < ActiveRecord::Base
end
end
+ def piece_justificative_file_errors
+ errors = []
+
+ if piece_justificative_file.attached? && piece_justificative_file.previous_changes.present?
+ if piece_justificative_file.blob.byte_size > PIECE_JUSTIFICATIVE_FILE_MAX_SIZE
+ errors << "Le fichier #{piece_justificative_file.filename.to_s} est trop lourd, il doit faire au plus #{PIECE_JUSTIFICATIVE_FILE_MAX_SIZE.to_s(:human_size, precision: 2)}"
+ end
+
+ if !piece_justificative_file.blob.content_type.in?(PIECE_JUSTIFICATIVE_FILE_ACCEPTED_FORMATS)
+ errors << "Le fichier #{piece_justificative_file.filename.to_s} est dans un format que nous n'acceptons pas"
+ end
+
+ # FIXME: add Clamav check
+ end
+
+ if errors.present?
+ piece_justificative_file.purge
+ end
+
+ errors
+ end
+
private
def format_date_to_iso
diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb
index 937a7e000..20c4639a8 100644
--- a/app/models/type_de_champ.rb
+++ b/app/models/type_de_champ.rb
@@ -21,7 +21,8 @@ class TypeDeChamp < ActiveRecord::Base
engagement: 'engagement',
header_section: 'header_section',
explication: 'explication',
- dossier_link: 'dossier_link'
+ dossier_link: 'dossier_link',
+ piece_justificative: 'piece_justificative'
}
belongs_to :procedure
diff --git a/app/services/champs_service.rb b/app/services/champs_service.rb
index 62a1d7342..0374b1b4a 100644
--- a/app/services/champs_service.rb
+++ b/app/services/champs_service.rb
@@ -11,12 +11,25 @@ class ChampsService
.map { |c| "Le champ #{c.libelle.truncate(200)} doit être rempli." }
end
+ def check_piece_justificative_files(champs)
+ champs.select do |champ|
+ champ.type_champ == 'piece_justificative'
+ end.map { |c| c.piece_justificative_file_errors }.flatten
+ end
+
private
def fill_champs(champs, h)
datetimes, not_datetimes = champs.partition { |c| c.type_champ == 'datetime' }
- not_datetimes.each { |c| c.value = h[:champs]["'#{c.id}'"] }
+ not_datetimes.each do |c|
+ if c.type_champ == 'piece_justificative' && h["champs"]["'#{c.id}'"].present?
+ c.piece_justificative_file.attach(h["champs"]["'#{c.id}'"])
+ else
+ c.value = h[:champs]["'#{c.id}'"]
+ end
+ end
+
datetimes.each { |c| c.value = parse_datetime(c.id, h) }
end
diff --git a/app/views/admin/types_de_champ/_fields.html.haml b/app/views/admin/types_de_champ/_fields.html.haml
index bbadc32d8..1d03612e4 100644
--- a/app/views/admin/types_de_champ/_fields.html.haml
+++ b/app/views/admin/types_de_champ/_fields.html.haml
@@ -8,7 +8,8 @@
.form-group.type
%h4 Type
- = ff.select :type_champ, TypeDeChamp.type_de_champs_list_fr, {}, { class: 'form-control type-champ' }
+ - tdc_options = tdc_options(current_administrateur)
+ = ff.select :type_champ, tdc_options, {}, { class: 'form-control type-champ' }
.form-group.description
%h4 Description
diff --git a/app/views/dossiers/_infos_dossier.html.haml b/app/views/dossiers/_infos_dossier.html.haml
index f1c09ddf9..de2ee2545 100644
--- a/app/views/dossiers/_infos_dossier.html.haml
+++ b/app/views/dossiers/_infos_dossier.html.haml
@@ -37,7 +37,7 @@
.col-xs-1.comments-off
= "-"
.col-xs-5.depositaire-info{ id: "champ-#{champ.id}-value" }
- - if champ.decorate.value.present?
+ - if champ.decorate.value.present? || champ.piece_justificative_file.attached?
- if champ.type_champ == 'dossier_link'
- dossier = Dossier.includes(:procedure).find_by(id: champ.decorate.value)
- if dossier
@@ -46,6 +46,10 @@
= sanitize(dossier.text_summary)
- else
Pas de dossier associé
+ - elsif champ.type_champ == 'piece_justificative'
+ - pj = champ.piece_justificative_file
+ %a{ href: url_for(pj), target: '_blank' }
+ = pj.filename.to_s
- else
= sanitize(champ.decorate.value)
diff --git a/app/views/new_gestionnaire/dossiers/_champs.html.haml b/app/views/new_gestionnaire/dossiers/_champs.html.haml
index 45609eaa2..9e44d4e7a 100644
--- a/app/views/new_gestionnaire/dossiers/_champs.html.haml
+++ b/app/views/new_gestionnaire/dossiers/_champs.html.haml
@@ -30,6 +30,13 @@
= sanitize(dossier.text_summary)
- else
Pas de dossier associé
+ - when "piece_justificative"
+ %th.libelle
+ = "#{c.libelle} :"
+ %td.rich-text
+ - pj = c.piece_justificative_file
+ %a{ href: url_for(pj), target: '_blank' }
+ = pj.filename.to_s
- else
%th.libelle
= "#{c.libelle} :"
diff --git a/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml b/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml
new file mode 100644
index 000000000..fe59efd3f
--- /dev/null
+++ b/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml
@@ -0,0 +1,14 @@
+- pj = champ.piece_justificative_file
+
+- if !pj.attached?
+ = form.file_field :piece_justificative_file,
+ id: "champs_#{champ.id}",
+ direct_upload: true
+- else
+ %a{ href: url_for(pj), target: '_blank' }
+ = pj.filename.to_s
+ %br
+ Modifier :
+ = form.file_field :piece_justificative_file,
+ id: "champs_#{champ.id}",
+ direct_upload: true
diff --git a/app/views/users/description/_show.html.haml b/app/views/users/description/_show.html.haml
index cbb86df67..56234b031 100644
--- a/app/views/users/description/_show.html.haml
+++ b/app/views/users/description/_show.html.haml
@@ -40,6 +40,26 @@
- elsif !@dossier.brouillon?
= render partial: '/layouts/modifications_terminees'
- else
- = submit_tag 'Soumettre mon dossier', id: 'suivant', name: 'submit[nouveaux]', class: 'btn btn btn-success', style: 'float: right;', disabled: @procedure.archivee?, data: { disable_with: 'Soumettre votre dossier', submit: true }
- = submit_tag 'Enregistrer un brouillon', id: 'brouillon', name: 'submit[brouillon]', class: 'btn btn-xs btn-default', style: 'float: right; margin-right: 10px; margin-top: 6px;', disabled: @procedure.archivee?, data: { disable_with: 'Enregistrer un brouillon', submit: true }
- = submit_tag "Enregistrer et voir mes dossiers", id: 'brouillon_then_dashboard', name: 'submit[brouillon_then_dashboard]', class: 'btn btn-xs btn-default', style: 'float: right; margin-right: 10px; margin-top: 6px;', disabled: @procedure.archivee?, data: { disable_with: 'Voir mes brouillons et dossiers', submit: true }
+ = hidden_field_tag 'submit_action', 'brouillon'
+ = submit_tag 'Bonjour Active Storage !', style: 'display: none;'
+ = button_tag 'Soumettre mon dossier',
+ id: 'suivant',
+ type: 'submit',
+ class: 'btn btn btn-success',
+ style: 'float: right;',
+ disabled: @procedure.archivee?,
+ data: { disable: true, action: 'nouveaux' }
+ = button_tag 'Enregistrer un brouillon',
+ id: 'brouillon',
+ type: 'submit',
+ class: 'btn btn-xs btn-default',
+ style: 'float: right; margin-right: 10px; margin-top: 6px;',
+ disabled: @procedure.archivee?,
+ data: { disable: true, action: 'brouillon' }
+ = button_tag "Enregistrer et voir mes dossiers",
+ id: 'brouillon_then_dashboard',
+ type: 'submit',
+ class: 'btn btn-xs btn-default',
+ style: 'float: right; margin-right: 10px; margin-top: 6px;',
+ disabled: @procedure.archivee?,
+ data: { disable: true, action: 'brouillon_then_dashboard' }
diff --git a/app/views/users/description/champs/_piece_justificative.html.haml b/app/views/users/description/champs/_piece_justificative.html.haml
new file mode 100644
index 000000000..e32428721
--- /dev/null
+++ b/app/views/users/description/champs/_piece_justificative.html.haml
@@ -0,0 +1,15 @@
+- pj = champ.piece_justificative_file
+
+- if !pj.attached?
+ = file_field_tag "champs['#{champ.id}']",
+ id: "champs_#{champ.id}",
+ direct_upload: true,
+ mandatory: champ.mandatory?
+- else
+ %a{ href: url_for(pj), target: '_blank' }
+ = pj.filename.to_s
+ %br
+ Modifier :
+ = file_field_tag "champs['#{champ.id}']",
+ id: "champs_#{champ.id}",
+ direct_upload: true
diff --git a/app/views/users/description/champs/_render_list_champs.html.haml b/app/views/users/description/champs/_render_list_champs.html.haml
index 8115c6cac..e7f5dab2a 100644
--- a/app/views/users/description/champs/_render_list_champs.html.haml
+++ b/app/views/users/description/champs/_render_list_champs.html.haml
@@ -50,6 +50,9 @@
- when 'date'
= render partial: 'users/description/champs/date', locals: { champ: champ }
+ - when 'piece_justificative'
+ = render partial: 'users/description/champs/piece_justificative', locals: { champ: champ }
+
- else
%input.form-control{ name: "champs['#{champ.id}']",
placeholder: champ.libelle,
diff --git a/config/deploy.rb b/config/deploy.rb
index f5b8304cd..7752478fe 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -56,6 +56,7 @@ set :shared_paths, [
'config/database.yml',
"config/skylight.yml",
"config/fog_credentials.yml",
+ 'config/storage.yml',
'config/initializers/secret_token.rb',
'config/initializers/features.yml',
"config/environments/#{rails_env}.rb",
diff --git a/config/environments/development.rb b/config/environments/development.rb
index d0370faea..acf2838a6 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -19,6 +19,8 @@ Rails.application.configure do
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
+ config.active_storage.service = :local
+
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 60eaafedd..c27fd85c1 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -68,6 +68,8 @@ Rails.application.configure do
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
+ config.active_storage.service = :clever_cloud
+
# Send deprecation notices to registered listeners.
config.active_support.deprecation = :notify
diff --git a/config/initializers/active_storage_conf_override.rb b/config/initializers/active_storage_conf_override.rb
new file mode 100644
index 000000000..be56d2df4
--- /dev/null
+++ b/config/initializers/active_storage_conf_override.rb
@@ -0,0 +1,4 @@
+# FIXME: remove this once we moved to a properly structured infrastructure
+if Rails.env.production? || Rails.env.staging?
+ Rails.application.config.active_storage.service = :clever_cloud
+end
diff --git a/config/initializers/features.rb b/config/initializers/features.rb
index 761494df0..56541d0e3 100644
--- a/config/initializers/features.rb
+++ b/config/initializers/features.rb
@@ -11,9 +11,9 @@ class Features
if File.exist?("#{File.dirname(__FILE__)}/features.yml")
features_map = YAML.load_file("#{File.dirname(__FILE__)}/features.yml")
if features_map
- features_map.each do |feature, is_active|
+ features_map.each do |feature, value|
define_method("#{feature}") do
- is_active
+ value
end
end
end
diff --git a/config/initializers/features.yml b/config/initializers/features.yml
index 7776e7e30..b0210bbe9 100644
--- a/config/initializers/features.yml
+++ b/config/initializers/features.yml
@@ -1,2 +1,4 @@
remote_storage: false
weekly_overview: false
+champ_pj_allowed_for_admin_ids:
+ - 0
diff --git a/config/initializers/monkey_patches.rb b/config/initializers/monkey_patches.rb
new file mode 100644
index 000000000..ffe0a92ba
--- /dev/null
+++ b/config/initializers/monkey_patches.rb
@@ -0,0 +1,12 @@
+# Monkey patch ActiveStorage to make Range query compatible with CleverCloud Cellar
+#
+# FIXME : remove when better fix is available
+ActiveStorage::Identification.class_eval do
+ private
+
+ def identifiable_chunk
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |client|
+ client.get(uri, "Range" => "bytes=0-4096").body
+ end
+ end
+end
diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml
index 1e3414f1f..7d0d1e66c 100644
--- a/config/locales/models/type_de_champ/fr.yml
+++ b/config/locales/models/type_de_champ/fr.yml
@@ -25,3 +25,4 @@ fr:
explication: 'Explication'
multiple_drop_down_list: 'Menu déroulant à choix multiples'
dossier_link: 'Lien vers un autre dossier'
+ piece_justificative: 'Pièce justificative'
diff --git a/config/storage.yml b/config/storage.yml
new file mode 100644
index 000000000..2c6762e0d
--- /dev/null
+++ b/config/storage.yml
@@ -0,0 +1,3 @@
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
diff --git a/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb b/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb
new file mode 100644
index 000000000..360e0d1b7
--- /dev/null
+++ b/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb
@@ -0,0 +1,26 @@
+# This migration comes from active_storage (originally 20170806125915)
+class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
+ def change
+ create_table :active_storage_blobs do |t|
+ t.string :key, null: false
+ t.string :filename, null: false
+ t.string :content_type
+ t.text :metadata
+ t.bigint :byte_size, null: false
+ t.string :checksum, null: false
+ t.datetime :created_at, null: false
+
+ t.index [ :key ], unique: true
+ end
+
+ create_table :active_storage_attachments do |t|
+ t.string :name, null: false
+ t.references :record, null: false, polymorphic: true, index: false
+ t.references :blob, null: false
+
+ t.datetime :created_at, null: false
+
+ t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8db9e6a21..d5260c7ee 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -16,6 +16,27 @@ ActiveRecord::Schema.define(version: 2018_02_09_133452) do
enable_extension "plpgsql"
enable_extension "unaccent"
+ create_table "active_storage_attachments", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "record_type", null: false
+ t.bigint "record_id", null: false
+ t.bigint "blob_id", null: false
+ t.datetime "created_at", null: false
+ t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
+ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
+ end
+
+ create_table "active_storage_blobs", force: :cascade do |t|
+ t.string "key", null: false
+ t.string "filename", null: false
+ t.string "content_type"
+ t.text "metadata"
+ t.bigint "byte_size", null: false
+ t.string "checksum", null: false
+ t.datetime "created_at", null: false
+ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
+ end
+
create_table "administrateurs", id: :serial, force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
diff --git a/lib/active_storage/service/cellar_service.rb b/lib/active_storage/service/cellar_service.rb
new file mode 100644
index 000000000..3cb916d3b
--- /dev/null
+++ b/lib/active_storage/service/cellar_service.rb
@@ -0,0 +1,167 @@
+require 'base64'
+require 'net/http'
+require 'openssl'
+
+module ActiveStorage
+ class Service::CellarService < Service
+ def initialize(access_key_id:, secret_access_key:, bucket:, **)
+ @endpoint = URI::HTTPS.build(host: "#{bucket}.cellar.services.clever-cloud.com")
+ @access_key_id = access_key_id
+ @secret_access_key = secret_access_key
+ @bucket = bucket
+ end
+
+ def download(key)
+ # TODO: error handling
+ if block_given?
+ instrument :streaming_download, key: key do
+ http_start do |http|
+ http.request(get_request(key)) do |response|
+ response.read_body do |chunk|
+ yield(chunk.force_encoding(Encoding::BINARY))
+ end
+ end
+ end
+ end
+ else
+ instrument :download, key: key do
+ http_start do |http|
+ response = http.request(get_request(key))
+ if response.is_a?(Net::HTTPSuccess)
+ response.body.force_encoding(Encoding::BINARY)
+ end
+ end
+ end
+ end
+ end
+
+ def delete(key)
+ # TODO: error handling
+ instrument :delete, key: key do
+ http_start do |http|
+ perform_delete(http, key)
+ end
+ end
+ end
+
+ def delete_prefixed(prefix)
+ # TODO: error handling
+ # TODO: handle pagination if more than 1000 keys
+ instrument :delete_prefixed, prefix: prefix do
+ http_start do |http|
+ list_prefixed(http, prefix).each do |key|
+ # TODO: use bulk delete instead
+ perform_delete(http, key)
+ end
+ end
+ end
+ end
+
+ def url(key, expires_in:, filename:, disposition:, content_type:)
+ instrument :url, key: key do |payload|
+ generated_url = presigned_url(
+ method: 'GET',
+ key: key,
+ expires_in: expires_in,
+ "response-content-disposition": content_disposition_with(type: disposition, filename: filename),
+ "response-content-type": content_type
+ )
+ payload[:url] = generated_url
+ generated_url
+ end
+ end
+
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
+ instrument :url, key: key do |payload|
+ generated_url = presigned_url(
+ method: 'PUT',
+ key: key,
+ expires_in: expires_in,
+ content_type: content_type,
+ checksum: checksum
+ )
+ payload[:url] = generated_url
+ generated_url
+ end
+ end
+
+ def headers_for_direct_upload(key, content_type:, checksum:, **)
+ { "Content-Type" => content_type, "Content-MD5" => checksum }
+ end
+
+ private
+
+ def http_start(&block)
+ Net::HTTP.start(@endpoint.host, @endpoint.port, use_ssl: true, &block)
+ end
+
+ def sign(request, key, checksum: '')
+ date = Time.now.httpdate
+ sig = signature(method: request.method, key: key, date: date, checksum: checksum)
+ request['date'] = date
+ request['authorization'] = "AWS #{@access_key_id}:#{sig}"
+ end
+
+ def presigned_url(method:, key:, expires_in:, content_type: '', checksum: '', **query_params)
+ expires = expires_in.from_now.to_i
+
+ query = query_params.merge({
+ AWSAccessKeyId: @access_key_id,
+ Expires: expires,
+ Signature: signature(method: method, key: key, expires: expires, content_type: content_type, checksum: checksum)
+ })
+
+ generated_url = URI::join(@endpoint, "/#{key}","?#{query.to_query}").to_s
+ end
+
+ def signature(method:, key:, expires: '', date: '', content_type: '', checksum: '')
+ canonicalized_amz_headers = ""
+ canonicalized_resource = "/#{@bucket}/#{key}"
+ string_to_sign = "#{method}\n#{checksum}\n#{content_type}\n#{expires}#{date}\n" +
+ "#{canonicalized_amz_headers}#{canonicalized_resource}"
+ Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), @secret_access_key, string_to_sign)).strip
+ end
+
+ def list_prefixed(http, prefix)
+ request = Net::HTTP::Get.new(URI::join(@endpoint, "/?list-type=2&prefix=#{prefix}"))
+ sign(request, "")
+ response = http.request(request)
+ if response.is_a?(Net::HTTPSuccess)
+ parse_bucket_listing(response.body)
+ end
+ end
+
+ def parse_bucket_listing(bucket_listing_xml)
+ doc = Nokogiri::XML(bucket_listing_xml)
+ doc
+ .xpath('//xmlns:Contents/xmlns:Key')
+ .map{ |k| k.text }
+ end
+
+ def get_request(key)
+ request = Net::HTTP::Get.new(URI::join(@endpoint, "/#{key}"))
+ sign(request, key)
+ request
+ end
+
+ def bulk_deletion_request_body(keys)
+ builder = Nokogiri::XML::Builder.new(:encoding => 'UTF-8') do |xml|
+ xml.Delete do
+ xml.Quiet("true")
+ keys.each do |k|
+ xml.Object do
+ xml.Key(k)
+ end
+ end
+ end
+ end
+ builder.to_xml
+ end
+
+ def perform_delete(http, key)
+ request = Net::HTTP::Delete.new(URI::join(@endpoint, "/#{key}"))
+ sign(request, key)
+ http.request(request)
+ end
+ end
+end
diff --git a/spec/controllers/users/description_controller_shared_example.rb b/spec/controllers/users/description_controller_shared_example.rb
index 24d0a7039..f3d024fc8 100644
--- a/spec/controllers/users/description_controller_shared_example.rb
+++ b/spec/controllers/users/description_controller_shared_example.rb
@@ -107,12 +107,12 @@ shared_examples 'description_controller_spec' do
let(:state) { 'brouillon' }
def submit_dossier
- post :update, params: { dossier_id: dossier_id, submit: submit }
+ post :update, params: { dossier_id: dossier_id, submit_action: submit }
dossier.reload
end
context "when the user submits the dossier" do
- let(:submit) { { nouveaux: 'nouveaux' } }
+ let(:submit) { 'nouveaux' }
it "redirection vers la page recapitulative" do
submit_dossier
@@ -142,7 +142,7 @@ shared_examples 'description_controller_spec' do
end
context 'when user saves a brouillon' do
- let(:submit) { { brouillon: 'brouillon' } }
+ let(:submit) { 'brouillon' }
it "reste sur la page du dossier" do
submit_dossier
@@ -156,7 +156,7 @@ shared_examples 'description_controller_spec' do
end
context 'when user saves a brouillon and goes to dashboard' do
- let(:submit) { { brouillon_then_dashboard: 'brouillon_then_dashboard' } }
+ let(:submit) { 'brouillon_then_dashboard' }
it "goes to dashboard" do
submit_dossier
diff --git a/spec/features/users/dossier_creation_spec.rb b/spec/features/users/dossier_creation_spec.rb
index a1c7ab759..1ba1d94f4 100644
--- a/spec/features/users/dossier_creation_spec.rb
+++ b/spec/features/users/dossier_creation_spec.rb
@@ -27,6 +27,7 @@ feature 'As a User I wanna create a dossier' do
expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s))
page.find_by_id('etape_suivante').click
fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1'
+ find(:css, '[name=submit_action]').set('nouveaux')
page.find_by_id('suivant').click
expect(user.dossiers.first.individual.birthdate).to eq("1987-10-14")
expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s))
@@ -38,6 +39,7 @@ feature 'As a User I wanna create a dossier' do
expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s))
page.find_by_id('etape_suivante').click
fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1'
+ find(:css, '[name=submit_action]').set('nouveaux')
page.find_by_id('suivant').click
expect(user.dossiers.first.individual.birthdate).to eq("1987-10-14")
expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s))
@@ -52,6 +54,7 @@ feature 'As a User I wanna create a dossier' do
expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s))
page.find_by_id('etape_suivante').click
fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1'
+ find(:css, '[name=submit_action]').set('nouveaux')
page.find_by_id('suivant').click
expect(user.dossiers.first.individual.birthdate).to eq(nil)
expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s))
diff --git a/spec/helpers/type_de_champ_helper_spec.rb b/spec/helpers/type_de_champ_helper_spec.rb
new file mode 100644
index 000000000..4dc202ed8
--- /dev/null
+++ b/spec/helpers/type_de_champ_helper_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+RSpec.describe TypeDeChampHelper, type: :helper do
+ describe ".tdc_options" do
+ let(:current_administrateur) { create(:administrateur) }
+ let(:pj_option) { ["Pièce justificative", "piece_justificative"] }
+
+ subject { tdc_options(current_administrateur) }
+
+ context "when the champ_pj_allowed_for_admin_id matches the current_administrateur's id" do
+ before { allow(Features).to receive(:champ_pj_allowed_for_admin_ids).and_return([current_administrateur.id]) }
+
+ it { is_expected.to include(pj_option) }
+ end
+
+ context "when the champ_pj_allowed_for_admin_id does not match the current_administrateur's id" do
+ before { allow(Features).to receive(:champ_pj_allowed_for_admin_ids).and_return([1000]) }
+
+ it { is_expected.not_to include(pj_option) }
+ end
+ end
+end
diff --git a/spec/lib/active_storage/service/cellar_service_spec.rb b/spec/lib/active_storage/service/cellar_service_spec.rb
new file mode 100644
index 000000000..eae73bd32
--- /dev/null
+++ b/spec/lib/active_storage/service/cellar_service_spec.rb
@@ -0,0 +1,166 @@
+require 'active_storage/service/cellar_service'
+require 'cgi'
+require 'net/http'
+require 'uri'
+
+describe 'CellarService' do
+ let(:cellar_service) do
+ # These are actual keys, but they’re safe to put here because
+ # - they never had any rights attached, and
+ # - the keys were revoked before copying them here
+
+ ActiveStorage::Service::CellarService.new(
+ access_key_id: 'AKIAJFTRSGRH3RXX6D5Q',
+ secret_access_key: '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',
+ bucket: 'rogets'
+ )
+ end
+
+ before { Timecop.freeze(Time.gm(2016, 10, 2)) }
+ after { Timecop.return }
+
+ describe 'signature generation' do
+ context 'for presigned URLs' do
+ subject do
+ cellar_service.send(
+ :signature,
+ {
+ method: 'GET',
+ key: 'fichier',
+ expires: 5.minutes.from_now.to_i
+ }
+ )
+ end
+
+ it { is_expected.to eq('nzCsB6cip8oofkuOdvvJs6FafkA=') }
+ end
+
+ context 'for server-side requests' do
+ subject do
+ Net::HTTP::Delete.new('https://rogets.cellar.services.clever-cloud.com/fichier')
+ end
+
+ before { cellar_service.send(:sign, subject, 'fichier') }
+
+ it { expect(subject['date']).to eq(Time.now.httpdate) }
+ it { expect(subject['authorization']).to eq('AWS AKIAJFTRSGRH3RXX6D5Q:nkvviwZYb1V9HDrKyJZmY3Z8sSA=') }
+ end
+ end
+
+ describe 'presigned url for download' do
+ subject do
+ URI.parse(
+ cellar_service.url(
+ 'fichier',
+ expires_in: 5.minutes,
+ filename: ActiveStorage::Filename.new("toto.png"),
+ disposition: 'attachment',
+ content_type: 'image/png'
+ )
+ )
+ end
+
+ it do
+ is_expected.to have_attributes(
+ scheme: 'https',
+ host: 'rogets.cellar.services.clever-cloud.com',
+ path: '/fichier'
+ )
+ end
+
+ it do
+ expect(CGI::parse(subject.query)).to eq(
+ {
+ 'AWSAccessKeyId' => ['AKIAJFTRSGRH3RXX6D5Q'],
+ 'Expires' => ['1475366700'],
+ 'Signature' => ['nzCsB6cip8oofkuOdvvJs6FafkA='],
+ 'response-content-disposition' => ["attachment; filename=\"toto.png\"; filename*=UTF-8''toto.png"],
+ 'response-content-type' => ['image/png'],
+ }
+ )
+ end
+ end
+
+ describe 'presigned url for direct upload' do
+ subject do
+ URI.parse(
+ cellar_service.url_for_direct_upload(
+ 'fichier',
+ expires_in: 5.minutes,
+ content_type: 'image/png',
+ content_length: 2713,
+ checksum: 'DEADBEEF'
+ )
+ )
+ end
+
+ it do
+ is_expected.to have_attributes(
+ scheme: 'https',
+ host: 'rogets.cellar.services.clever-cloud.com',
+ path: '/fichier'
+ )
+ end
+
+ it do
+ expect(CGI::parse(subject.query)).to eq(
+ {
+ 'AWSAccessKeyId' => ['AKIAJFTRSGRH3RXX6D5Q'],
+ 'Expires' => ['1475366700'],
+ 'Signature' => ['VwsX5nxGfTC3dxXjS6wSeU64r5o=']
+ }
+ )
+ end
+ end
+
+ describe 'parse_bucket_listing' do
+ let(:response) do
+ '
+ example-bucket
+
+ 2
+ 1000
+ /
+ false
+
+ sample1.jpg
+ 2011-02-26T01:56:20.000Z
+ "bf1d737a4d46a19f3bced6905cc8b902"
+ 142863
+ STANDARD
+
+
+ sample2.jpg
+ 2011-02-26T01:56:20.000Z
+ "bf1d737a4d46a19f3bced6905cc8b902"
+ 142863
+ STANDARD
+
+ '
+ end
+
+ subject { cellar_service.send(:parse_bucket_listing, response) }
+
+ it { is_expected.to eq(["sample1.jpg", "sample2.jpg"]) }
+ end
+
+ describe 'bulk_deletion_request_body' do
+ let(:expected_response) do
+ '
+
+ true
+
+
+
+'
+ end
+
+ subject { cellar_service.send(:bulk_deletion_request_body, ['chapi', 'chapo']) }
+
+ it { is_expected.to eq(expected_response) }
+ end
+end
diff --git a/spec/views/admin/types_de_champ/show.html.haml_spec.rb b/spec/views/admin/types_de_champ/show.html.haml_spec.rb
index 566542f23..32c6fb9c1 100644
--- a/spec/views/admin/types_de_champ/show.html.haml_spec.rb
+++ b/spec/views/admin/types_de_champ/show.html.haml_spec.rb
@@ -3,6 +3,9 @@ require 'spec_helper'
describe 'admin/types_de_champ/show.html.haml', type: :view do
let(:procedure) { create(:procedure) }
+ # FIXME: delete this when support for pj champ is generalized
+ before { allow(view).to receive(:current_administrateur).and_return(create(:administrateur)) }
+
describe 'fields sorted' do
let(:first_libelle) { 'salut la compagnie' }
let(:last_libelle) { 'je suis bien sur la page' }
diff --git a/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb b/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb
index 7e9708199..587ec1b80 100644
--- a/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb
+++ b/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb
@@ -3,6 +3,9 @@ require 'spec_helper'
describe 'admin/types_de_champ/show.html.haml', type: :view do
let(:procedure) { create(:procedure) }
+ # FIXME: delete this when support for pj champ is generalized
+ before { allow(view).to receive(:current_administrateur).and_return(create(:administrateur)) }
+
describe 'fields sorted' do
let(:first_libelle) { 'salut la compagnie' }
let(:last_libelle) { 'je suis bien sur la page' }