demarches-normaliennes/app/models/procedure.rb
2019-08-22 11:28:03 +02:00

567 lines
17 KiB
Ruby
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require Rails.root.join('lib', 'percentile')
class Procedure < ApplicationRecord
MAX_DUREE_CONSERVATION = 36
has_many :types_de_champ, -> { root.public_only.ordered }, inverse_of: :procedure, dependent: :destroy
has_many :types_de_champ_private, -> { root.private_only.ordered }, class_name: 'TypeDeChamp', inverse_of: :procedure, dependent: :destroy
has_many :dossiers, dependent: :restrict_with_exception
has_many :deleted_dossiers, dependent: :destroy
has_one :module_api_carto, dependent: :destroy
has_one :attestation_template, dependent: :destroy
belongs_to :parent_procedure, class_name: 'Procedure'
belongs_to :service
has_many :assign_to, dependent: :destroy
has_many :administrateurs_procedures
has_many :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! }
has_many :instructeurs, through: :assign_to
has_one :initiated_mail, class_name: "Mails::InitiatedMail", dependent: :destroy
has_one :received_mail, class_name: "Mails::ReceivedMail", dependent: :destroy
has_one :closed_mail, class_name: "Mails::ClosedMail", dependent: :destroy
has_one :refused_mail, class_name: "Mails::RefusedMail", dependent: :destroy
has_one :without_continuation_mail, class_name: "Mails::WithoutContinuationMail", dependent: :destroy
has_one_attached :logo_active_storage
has_one_attached :notice
has_one_attached :deliberation
accepts_nested_attributes_for :types_de_champ, reject_if: proc { |attributes| attributes['libelle'].blank? }, allow_destroy: true
accepts_nested_attributes_for :types_de_champ_private, reject_if: proc { |attributes| attributes['libelle'].blank? }, allow_destroy: true
mount_uploader :logo, ProcedureLogoUploader
default_scope { where(hidden_at: nil) }
scope :brouillons, -> { where(aasm_state: :brouillon) }
scope :publiees, -> { where(aasm_state: :publiee) }
scope :archivees, -> { where(aasm_state: :archivee) }
scope :publiees_ou_archivees, -> { where(aasm_state: [:publiee, :archivee]) }
scope :by_libelle, -> { order(libelle: :asc) }
scope :created_during, -> (range) { where(created_at: range) }
scope :cloned_from_library, -> { where(cloned_from_library: true) }
scope :avec_lien, -> { where.not(path: nil) }
scope :declarative, -> { where.not(declarative_with_state: nil) }
scope :for_api, -> {
includes(
:administrateurs,
:types_de_champ_private,
:types_de_champ,
:module_api_carto
)
}
enum declarative_with_state: {
en_instruction: 'en_instruction',
accepte: 'accepte'
}
validates :libelle, presence: true, allow_blank: false, allow_nil: false
validates :description, presence: true, allow_blank: false, allow_nil: false
validates :administrateurs, presence: true
validate :check_juridique
validates :path, format: { with: /\A[a-z0-9_\-]{3,50}\z/ }, uniqueness: { scope: :aasm_state, case_sensitive: false }, presence: true, allow_blank: false, allow_nil: true
# FIXME: remove duree_conservation_required flag once all procedures are converted to the new style
validates :duree_conservation_dossiers_dans_ds, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_DUREE_CONSERVATION }, if: :durees_conservation_required
validates :duree_conservation_dossiers_hors_ds, allow_nil: false, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :durees_conservation_required
validates :duree_conservation_dossiers_dans_ds, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_DUREE_CONSERVATION }, unless: :durees_conservation_required
validates :duree_conservation_dossiers_hors_ds, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, unless: :durees_conservation_required
validates_with MonAvisEmbedValidator
before_save :update_juridique_required
before_save :update_durees_conservation_required
before_create :ensure_path_exists
include AASM
aasm whiny_persistence: true do
state :brouillon, initial: true
state :publiee
state :archivee
state :hidden
event :publish, after: :after_publish, guard: :can_publish? do
transitions from: :brouillon, to: :publiee
end
event :reopen, after: :after_reopen, guard: :can_publish? do
transitions from: :archivee, to: :publiee
end
event :archive, after: :after_archive do
transitions from: :publiee, to: :archivee
end
event :hide, after: :after_hide do
transitions from: :brouillon, to: :hidden
transitions from: :publiee, to: :hidden
transitions from: :archivee, to: :hidden
end
event :draft, after: :after_draft do
transitions from: :publiee, to: :brouillon
end
end
def publish_or_reopen!(administrateur, path, lien_site_web)
if archivee? && may_reopen?(administrateur, path, lien_site_web)
reopen!(administrateur, path, lien_site_web)
elsif may_publish?(administrateur, path, lien_site_web)
reset!
publish!(administrateur, path, lien_site_web)
end
end
def reset!
if locked?
raise "Can not reset a locked procedure."
else
dossiers.destroy_all
end
end
def locked?
publiee_ou_archivee?
end
def accepts_new_dossiers?
!archivee?
end
# This method is needed for transition. Eventually this will be the same as brouillon?.
def brouillon_avec_lien?
brouillon? && path.present?
end
def publiee_ou_archivee?
publiee? || archivee?
end
def expose_legacy_carto_api?
module_api_carto&.use_api_carto? && module_api_carto&.migrated?
end
def declarative?
declarative_with_state.present?
end
def declarative_accepte?
declarative_with_state == Procedure.declarative_with_states.fetch(:accepte)
end
# Warning: dossier after_save build_default_champs must be removed
# to save a dossier created from this method
def new_dossier
Dossier.new(procedure: self, champs: build_champs, champs_private: build_champs_private)
end
def build_champs
types_de_champ.map(&:build_champ)
end
def build_champs_private
types_de_champ_private.map(&:build_champ)
end
def default_path
libelle&.parameterize&.first(50)
end
def organisation_name
service&.nom || organisation
end
def self.active(id)
publiees.find(id)
end
def switch_types_de_champ(index_of_first_element)
switch_list_order(types_de_champ, index_of_first_element)
end
def switch_types_de_champ_private(index_of_first_element)
switch_list_order(types_de_champ_private, index_of_first_element)
end
def switch_list_order(list, index_of_first_element)
if index_of_first_element < 0 ||
index_of_first_element == list.count - 1 ||
list.count < 1
false
else
list[index_of_first_element].update(order_place: index_of_first_element + 1)
list[index_of_first_element + 1].update(order_place: index_of_first_element)
reload
true
end
end
def clone(admin, from_library)
is_different_admin = !admin.owns?(self)
populate_champ_stable_ids
procedure = self.deep_clone(include:
{
attestation_template: nil,
types_de_champ: [:drop_down_list, types_de_champ: :drop_down_list],
types_de_champ_private: [:drop_down_list, types_de_champ: :drop_down_list]
}, &method(:clone_attachments))
procedure.path = SecureRandom.uuid
procedure.aasm_state = :brouillon
procedure.test_started_at = nil
procedure.archived_at = nil
procedure.published_at = nil
procedure.logo_secure_token = nil
if logo.present?
procedure.remote_logo_url = self.logo_url
end
procedure.lien_notice = nil
if is_different_admin || from_library
procedure.types_de_champ.each { |tdc| tdc.options&.delete(:old_pj) }
end
if is_different_admin
procedure.administrateurs = [admin]
else
procedure.administrateurs = administrateurs
end
procedure.initiated_mail = initiated_mail&.dup
procedure.received_mail = received_mail&.dup
procedure.closed_mail = closed_mail&.dup
procedure.refused_mail = refused_mail&.dup
procedure.without_continuation_mail = without_continuation_mail&.dup
procedure.cloned_from_library = from_library
procedure.parent_procedure = self
if from_library
procedure.service = nil
elsif self.service.present? && is_different_admin
procedure.service = self.service.clone_and_assign_to_administrateur(admin)
end
admin.instructeur.assign_to_procedure(procedure)
procedure
end
def clone_attachments(original, kopy)
if original.is_a?(TypeDeChamp)
clone_attachment(:piece_justificative_template, original, kopy)
elsif original.is_a?(Procedure)
clone_attachment(:logo_active_storage, original, kopy)
clone_attachment(:notice, original, kopy)
clone_attachment(:deliberation, original, kopy)
end
end
def clone_attachment(attribute, original, kopy)
original_attachment = original.send(attribute)
if original_attachment.attached?
kopy.send(attribute).attach({
io: StringIO.new(original_attachment.download),
filename: original_attachment.filename,
content_type: original_attachment.content_type,
# we don't want to run virus scanner on cloned file
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
})
end
end
def whitelisted?
whitelisted_at.present?
end
def total_dossier
self.dossiers.state_not_brouillon.size
end
def export_filename(format)
procedure_identifier = path || "procedure-#{id}"
"dossiers_#{procedure_identifier}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}"
end
def export(options = {})
version = options.delete(:version)
if version == 'v2'
options.delete(:tables)
ProcedureExportV2Service.new(self, **options.to_h.symbolize_keys)
else
ProcedureExportService.new(self, **options.to_h.symbolize_keys)
end
end
def to_csv(options = {})
export(options).to_csv
end
def to_xlsx(options = {})
export(options).to_xlsx
end
def to_ods(options = {})
export(options).to_ods
end
def procedure_overview(start_date)
ProcedureOverview.new(self, start_date)
end
def initiated_mail_template
initiated_mail || Mails::InitiatedMail.default_for_procedure(self)
end
def received_mail_template
received_mail || Mails::ReceivedMail.default_for_procedure(self)
end
def closed_mail_template
closed_mail || Mails::ClosedMail.default_for_procedure(self)
end
def refused_mail_template
refused_mail || Mails::RefusedMail.default_for_procedure(self)
end
def without_continuation_mail_template
without_continuation_mail || Mails::WithoutContinuationMail.default_for_procedure(self)
end
def self.default_sort
{
'table' => 'self',
'column' => 'id',
'order' => 'desc'
}
end
def whitelist!
update_attribute('whitelisted_at', Time.zone.now)
end
def closed_mail_template_attestation_inconsistency_state
# As an optimization, dont check the predefined templates (they are presumed correct)
if closed_mail.present?
tag_present = closed_mail.body.include?("--lien attestation--")
if attestation_template&.activated? && !tag_present
:missing_tag
elsif !attestation_template&.activated? && tag_present
:extraneous_tag
end
end
end
def usual_traitement_time
percentile_time(:en_construction_at, :processed_at, 90)
end
PATH_AVAILABLE = :available
PATH_AVAILABLE_PUBLIEE = :available_publiee
PATH_NOT_AVAILABLE = :not_available
PATH_NOT_AVAILABLE_BROUILLON = :not_available_brouillon
PATH_CAN_PUBLISH = [PATH_AVAILABLE, PATH_AVAILABLE_PUBLIEE]
def path_availability(administrateur, path)
Procedure.path_availability(administrateur, path, id)
end
def self.path_availability(administrateur, path, exclude_id = nil)
if exclude_id.present?
procedure = where.not(id: exclude_id).find_by(path: path)
else
procedure = find_by(path: path)
end
if procedure.blank?
PATH_AVAILABLE
elsif administrateur.owns?(procedure)
if procedure.brouillon?
PATH_NOT_AVAILABLE_BROUILLON
else
PATH_AVAILABLE_PUBLIEE
end
else
PATH_NOT_AVAILABLE
end
end
def self.find_with_path(path)
where.not(aasm_state: :archivee).where("path LIKE ?", "%#{path}%")
end
def populate_champ_stable_ids
TypeDeChamp.where(procedure: self, stable_id: nil).find_each do |type_de_champ|
type_de_champ.update_column(:stable_id, type_de_champ.id)
end
end
def missing_steps
result = []
if service.nil?
result << :service
end
if instructeurs.empty?
result << :instructeurs
end
result
end
def move_type_de_champ(type_de_champ, new_index)
types_de_champ, collection_attribute_name = if type_de_champ.parent&.repetition?
if type_de_champ.parent.private?
[type_de_champ.parent.types_de_champ, :types_de_champ_private_attributes]
else
[type_de_champ.parent.types_de_champ, :types_de_champ_attributes]
end
elsif type_de_champ.private?
[self.types_de_champ_private, :types_de_champ_private_attributes]
else
[self.types_de_champ, :types_de_champ_attributes]
end
attributes = move_type_de_champ_attributes(types_de_champ.to_a, type_de_champ, new_index)
if type_de_champ.parent&.repetition?
attributes = [
{
id: type_de_champ.parent.id,
libelle: type_de_champ.parent.libelle,
types_de_champ_attributes: attributes
}
]
end
update!(collection_attribute_name => attributes)
end
def process_dossiers!
case declarative_with_state
when Procedure.declarative_with_states.fetch(:en_instruction)
dossiers
.state_en_construction
.find_each(&:passer_automatiquement_en_instruction!)
when Procedure.declarative_with_states.fetch(:accepte)
dossiers
.state_en_construction
.find_each(&:accepter_automatiquement!)
end
end
def logo?
logo.present? || logo_active_storage.attached?
end
def logo_url
if !logo?
ActionController::Base.helpers.image_url("marianne.svg")
elsif logo_active_storage.attached?
Rails.application.routes.url_helpers.url_for(logo_active_storage)
else
if Rails.application.secrets.fog[:enabled]
RemoteDownloader.new(logo.filename).url
else
# FIXME: this is horrible but used only in dev and will be removed after migration
File.join(LOCAL_DOWNLOAD_URL, logo.url)
end
end
end
private
def move_type_de_champ_attributes(types_de_champ, type_de_champ, new_index)
old_index = types_de_champ.index(type_de_champ)
types_de_champ.insert(new_index, types_de_champ.delete_at(old_index))
.map.with_index do |type_de_champ, index|
{
id: type_de_champ.id,
libelle: type_de_champ.libelle,
order_place: index
}
end
end
def claim_path_ownership!(path)
procedure = Procedure.joins(:administrateurs)
.where(administrateurs: { id: administrateur_ids })
.find_by(path: path)
if procedure&.publiee? && procedure != self
procedure.archive!
end
update!(path: path)
end
def can_publish?(administrateur, path, lien_site_web)
path_availability(administrateur, path).in?(PATH_CAN_PUBLISH) && lien_site_web.present?
end
def after_publish(administrateur, path, lien_site_web)
update!(published_at: Time.zone.now)
claim_path_ownership!(path)
end
def after_reopen(administrateur, path, lien_site_web)
update!(published_at: Time.zone.now, archived_at: nil)
claim_path_ownership!(path)
end
def after_archive
update!(archived_at: Time.zone.now, path: SecureRandom.uuid)
end
def after_hide
now = Time.zone.now
update!(hidden_at: now, path: SecureRandom.uuid)
dossiers.update_all(hidden_at: now)
end
def after_draft
update!(published_at: nil)
end
def update_juridique_required
self.juridique_required ||= (cadre_juridique.present? || deliberation.attached?)
true
end
def check_juridique
if juridique_required? && (cadre_juridique.blank? && !deliberation.attached?)
errors.add(:cadre_juridique, " : veuillez remplir le texte de loi ou la délibération")
end
end
def update_durees_conservation_required
self.durees_conservation_required ||= duree_conservation_dossiers_hors_ds.present? && duree_conservation_dossiers_dans_ds.present?
true
end
def percentile_time(start_attribute, end_attribute, p)
times = dossiers
.where.not(start_attribute => nil, end_attribute => nil)
.where(end_attribute => 1.month.ago..Time.zone.now)
.pluck(start_attribute, end_attribute)
.map { |(start_date, end_date)| end_date - start_date }
if times.present?
times.percentile(p).ceil
end
end
def ensure_path_exists
if self.path.nil?
self.path = SecureRandom.uuid
end
end
end