commit
faeb75ffb1
87 changed files with 1192 additions and 307 deletions
19
Gemfile.lock
19
Gemfile.lock
|
@ -67,8 +67,11 @@ GEM
|
|||
activemodel (>= 4.1, < 6.2)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
active_storage_validations (0.9.2)
|
||||
rails (>= 5.2.0)
|
||||
active_storage_validations (0.9.6)
|
||||
activejob (>= 5.2.0)
|
||||
activemodel (>= 5.2.0)
|
||||
activestorage (>= 5.2.0)
|
||||
activesupport (>= 5.2.0)
|
||||
activejob (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
globalid (>= 0.3.6)
|
||||
|
@ -376,7 +379,7 @@ GEM
|
|||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.2.2)
|
||||
jwt (2.3.0)
|
||||
kaminari (1.2.1)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.1)
|
||||
|
@ -421,7 +424,7 @@ GEM
|
|||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2021.0212)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.1)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.6.1)
|
||||
minitest (5.14.4)
|
||||
momentjs-rails (2.20.1)
|
||||
|
@ -437,7 +440,7 @@ GEM
|
|||
mini_portile2 (~> 2.6.1)
|
||||
racc (~> 1.4)
|
||||
open4 (1.3.4)
|
||||
openid_connect (1.2.0)
|
||||
openid_connect (1.3.0)
|
||||
activemodel
|
||||
attr_required (>= 1.0.0)
|
||||
json-jwt (>= 1.5.0)
|
||||
|
@ -492,7 +495,7 @@ GEM
|
|||
rack (>= 1.0, < 3)
|
||||
rack-mini-profiler (2.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-oauth2 (1.16.0)
|
||||
rack-oauth2 (1.19.0)
|
||||
activesupport
|
||||
attr_required
|
||||
httpclient
|
||||
|
@ -700,7 +703,7 @@ GEM
|
|||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
swd (1.2.0)
|
||||
swd (1.3.0)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
httpclient (>= 2.4)
|
||||
|
@ -747,7 +750,7 @@ GEM
|
|||
nokogiri (~> 1.6)
|
||||
rubyzip (>= 1.3.0)
|
||||
selenium-webdriver (>= 3.0, < 4.0)
|
||||
webfinger (1.1.0)
|
||||
webfinger (1.2.0)
|
||||
activesupport
|
||||
httpclient (>= 2.4)
|
||||
webmock (3.11.2)
|
||||
|
|
BIN
app/assets/images/logo-agent-connect.png
Normal file
BIN
app/assets/images/logo-agent-connect.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
|
@ -1,9 +1,7 @@
|
|||
@import "colors";
|
||||
@import "constants";
|
||||
|
||||
#dossier-show {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.dossier-container {
|
||||
.sub-header {
|
||||
.label {
|
||||
float: right;
|
11
app/assets/stylesheets/france-connect-agent-login.scss
Normal file
11
app/assets/stylesheets/france-connect-agent-login.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
@import "colors";
|
||||
@import "constants";
|
||||
|
||||
.france-connect-agent-login-button {
|
||||
background-image: image-url("logo-agent-connect.png");
|
||||
display: block;
|
||||
height: 60px;
|
||||
width: 230px;
|
||||
margin: 20px auto;
|
||||
font-size: 0;
|
||||
}
|
|
@ -50,6 +50,8 @@
|
|||
color: $black;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mt-1 {
|
||||
margin-top: $default-spacer;
|
||||
}
|
||||
|
@ -58,6 +60,10 @@
|
|||
margin-top: 2 * $default-spacer;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 3 * $default-spacer;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 4 * $default-spacer;
|
||||
}
|
||||
|
@ -66,10 +72,62 @@
|
|||
margin-top: 8 * $default-spacer;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: $default-spacer;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 2 * $default-spacer;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: $default-spacer;
|
||||
.mb-3 {
|
||||
margin-bottom: 3 * $default-spacer;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 4 * $default-spacer;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 8 * $default-spacer;
|
||||
}
|
||||
|
||||
.pt-1 {
|
||||
padding-top: $default-spacer;
|
||||
}
|
||||
|
||||
.pt-2 {
|
||||
padding-top: 2 * $default-spacer;
|
||||
}
|
||||
|
||||
.pt-3 {
|
||||
padding-top: 3 * $default-spacer;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 4 * $default-spacer;
|
||||
}
|
||||
|
||||
.pt-8 {
|
||||
padding-top: 8 * $default-spacer;
|
||||
}
|
||||
|
||||
.pb-1 {
|
||||
padding-bottom: $default-spacer;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 2 * $default-spacer;
|
||||
}
|
||||
|
||||
.pb-3 {
|
||||
padding-bottom: 3 * $default-spacer;
|
||||
}
|
||||
|
||||
.pb-4 {
|
||||
padding-bottom: 4 * $default-spacer;
|
||||
}
|
||||
|
||||
.pb-8 {
|
||||
padding-bottom: 8 * $default-spacer;
|
||||
}
|
||||
|
|
53
app/controllers/agent_connect/agent_controller.rb
Normal file
53
app/controllers/agent_connect/agent_controller.rb
Normal file
|
@ -0,0 +1,53 @@
|
|||
# doc: https://github.com/france-connect/Documentation-AgentConnect
|
||||
class AgentConnect::AgentController < ApplicationController
|
||||
before_action :redirect_to_login_if_fc_aborted, only: [:callback]
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def login
|
||||
redirect_to AgentConnectService.authorization_uri
|
||||
end
|
||||
|
||||
def callback
|
||||
user_info = AgentConnectService.user_info(params[:code])
|
||||
|
||||
instructeur = Instructeur.find_by(agent_connect_id: user_info['sub'])
|
||||
|
||||
if instructeur.nil?
|
||||
instructeur = Instructeur.find_by(users: { email: santized_email(user_info) })
|
||||
instructeur&.update(agent_connect_id: user_info['sub'])
|
||||
end
|
||||
|
||||
if instructeur.nil?
|
||||
user = User.create_or_promote_to_instructeur(santized_email(user_info), Devise.friendly_token[0, 20])
|
||||
instructeur = user.instructeur
|
||||
instructeur.update(agent_connect_id: user_info['sub'])
|
||||
end
|
||||
|
||||
sign_in(:user, instructeur.user)
|
||||
|
||||
redirect_to instructeur_procedures_path
|
||||
|
||||
rescue Rack::OAuth2::Client::Error => e
|
||||
Rails.logger.error e.message
|
||||
redirect_france_connect_error_connection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def santized_email(user_info)
|
||||
user_info['email'].strip.downcase
|
||||
end
|
||||
|
||||
def redirect_to_login_if_fc_aborted
|
||||
if params[:code].blank?
|
||||
redirect_to new_user_session_path
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_france_connect_error_connection
|
||||
flash.alert = t('errors.messages.france_connect.connexion')
|
||||
redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
|
@ -1,4 +1,6 @@
|
|||
class API::V2::GraphqlController < API::V2::BaseController
|
||||
include GraphqlOperationLogConcern
|
||||
|
||||
def execute
|
||||
variables = ensure_hash(params[:variables])
|
||||
|
||||
|
@ -8,6 +10,8 @@ class API::V2::GraphqlController < API::V2::BaseController
|
|||
operation_name: params[:operationName])
|
||||
|
||||
render json: result
|
||||
rescue GraphQL::ParseError => exception
|
||||
handle_parse_error(exception)
|
||||
rescue => exception
|
||||
if Rails.env.production?
|
||||
handle_error_in_production(exception)
|
||||
|
@ -22,43 +26,10 @@ class API::V2::GraphqlController < API::V2::BaseController
|
|||
super
|
||||
|
||||
payload.merge!({
|
||||
graphql_operation: operation_log(params[:query], params[:operationName], params[:variables])
|
||||
graphql_operation: operation_log(params[:query], params[:operationName], params[:variables]&.to_unsafe_h)
|
||||
})
|
||||
end
|
||||
|
||||
def operation_log(query, operation_name, variables)
|
||||
return "NoQuery" if query.nil?
|
||||
|
||||
operation = GraphQL.parse(query).children.find do |node|
|
||||
if node.is_a?(GraphQL::Language::Nodes::OperationDefinition)
|
||||
node.name == operation_name
|
||||
end
|
||||
end
|
||||
|
||||
return "InvalidQuery" if operation.nil?
|
||||
return "IntrospectionQuery" if operation.name == "IntrospectionQuery"
|
||||
|
||||
message = operation.operation_type
|
||||
if operation.name
|
||||
message += ": #{operation.name} { "
|
||||
end
|
||||
message += operation.selections.map(&:name).join(', ')
|
||||
message += " }"
|
||||
if variables.present?
|
||||
message += " "
|
||||
message += variables.to_unsafe_h.flat_map do |(name, value)|
|
||||
if name == "input"
|
||||
value.map do |(name, value)|
|
||||
"#{name}: \"#{value.to_s.truncate(10)}\""
|
||||
end
|
||||
else
|
||||
"#{name}: \"#{value.to_s.truncate(10)}\""
|
||||
end
|
||||
end.join(', ')
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
def process_action(*args)
|
||||
super
|
||||
rescue ActionDispatch::Http::Parameters::ParseError => exception
|
||||
|
@ -88,6 +59,15 @@ class API::V2::GraphqlController < API::V2::BaseController
|
|||
end
|
||||
end
|
||||
|
||||
def handle_parse_error(exception)
|
||||
render json: {
|
||||
errors: [
|
||||
{ message: exception.message }
|
||||
],
|
||||
data: nil
|
||||
}, status: 400
|
||||
end
|
||||
|
||||
def handle_error_in_development(exception)
|
||||
logger.error exception.message
|
||||
logger.error exception.backtrace.join("\n")
|
||||
|
|
62
app/controllers/concerns/graphql_operation_log_concern.rb
Normal file
62
app/controllers/concerns/graphql_operation_log_concern.rb
Normal file
|
@ -0,0 +1,62 @@
|
|||
module GraphqlOperationLogConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# This method parses GraphQL query and creates a short description of the query. It is useful for logging.
|
||||
def operation_log(query, operation_name, variables)
|
||||
return "NoQuery" if query.nil?
|
||||
|
||||
operation = parse_graphql_query(query, operation_name)
|
||||
|
||||
return "InvalidQuery" if operation.nil?
|
||||
return "IntrospectionQuery" if operation.name == "IntrospectionQuery"
|
||||
|
||||
message = "#{operation.operation_type}: "
|
||||
message += if operation.name.present?
|
||||
"#{operation.name} { "
|
||||
else
|
||||
"{ "
|
||||
end
|
||||
message += operation.selections.map(&:name).join(', ')
|
||||
message += " } "
|
||||
message += if variables.present?
|
||||
variables.flat_map do |(name, value)|
|
||||
format_graphql_variable(name, value)
|
||||
end
|
||||
else
|
||||
operation.selections.flat_map(&:arguments).flat_map do |argument|
|
||||
format_graphql_variable(argument.name, argument.value)
|
||||
end
|
||||
end.join(', ')
|
||||
|
||||
message.strip
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_graphql_query(query, operation_name)
|
||||
operations = GraphQL.parse(query).children.filter do |node|
|
||||
node.is_a?(GraphQL::Language::Nodes::OperationDefinition)
|
||||
end
|
||||
if operations.size == 1
|
||||
operations.first
|
||||
else
|
||||
operations.find { |node| node.name == operation_name }
|
||||
end
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
|
||||
def format_graphql_variable(name, value)
|
||||
if value.is_a?(Hash)
|
||||
value.map do |(name, value)|
|
||||
format_graphql_variable(name, value)
|
||||
end
|
||||
elsif value.is_a?(GraphQL::Language::Nodes::InputObject)
|
||||
value.arguments.map do |argument|
|
||||
format_graphql_variable(argument.name, argument.value)
|
||||
end
|
||||
else
|
||||
"#{name}: \"#{value.to_s.truncate(10)}\""
|
||||
end
|
||||
end
|
||||
end
|
|
@ -24,7 +24,8 @@ module Users
|
|||
.with_dossiers
|
||||
.where(email: current_user.email)
|
||||
.page(page)
|
||||
@statut = statut(@user_dossiers, @dossiers_invites, @dossiers_supprimes, @dossier_transfers, params[:statut])
|
||||
@dossiers_close_to_expiration = current_user.dossiers.close_to_expiration.page(page)
|
||||
@statut = statut(@user_dossiers, @dossiers_invites, @dossiers_supprimes, @dossier_transfers, @dossiers_close_to_expiration, params[:statut])
|
||||
end
|
||||
|
||||
def show
|
||||
|
@ -291,12 +292,13 @@ module Users
|
|||
# if the status tab is filled, then this tab
|
||||
# else first filled tab
|
||||
# else mes-dossiers
|
||||
def statut(mes_dossiers, dossiers_invites, dossiers_supprimes, dossier_transfers, params_statut)
|
||||
def statut(mes_dossiers, dossiers_invites, dossiers_supprimes, dossier_transfers, dossiers_close_to_expiration, params_statut)
|
||||
tabs = {
|
||||
'mes-dossiers' => mes_dossiers.present?,
|
||||
'dossiers-invites' => dossiers_invites.present?,
|
||||
'dossiers-supprimes' => dossiers_supprimes.present?,
|
||||
'dossiers-transferes' => dossier_transfers.present?
|
||||
'dossiers-transferes' => dossier_transfers.present?,
|
||||
'dossiers-expirant' => dossiers_close_to_expiration.present?
|
||||
}
|
||||
if tabs[params_statut]
|
||||
params_statut
|
||||
|
|
|
@ -98,6 +98,11 @@ module DossierHelper
|
|||
end
|
||||
end
|
||||
|
||||
def safe_expiration_date(dossier)
|
||||
date = dossier.expiration_date.presence || dossier.approximative_expiration_date
|
||||
l(date, format: '%d/%m/%Y')
|
||||
end
|
||||
|
||||
def annuaire_link(siren)
|
||||
base_url = "https://annuaire-entreprises.data.gouv.fr"
|
||||
return base_url if siren.blank?
|
||||
|
|
|
@ -105,7 +105,8 @@ function ComboCommunesSearch(params) {
|
|||
<div>
|
||||
<div className="notice" id={communeDescribedBy}>
|
||||
<p>
|
||||
Choisissez la commune. Vous pouver entre le nom ou le code postal.
|
||||
Choisissez la commune. Vous pouvez entrer le nom ou le code
|
||||
postal.
|
||||
</p>
|
||||
</div>
|
||||
<ComboSearch
|
||||
|
|
9
app/models/agent_connect_client.rb
Normal file
9
app/models/agent_connect_client.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class AgentConnectClient < OpenIDConnect::Client
|
||||
def initialize(code = nil)
|
||||
super(AGENT_CONNECT)
|
||||
|
||||
if code.present?
|
||||
self.authorization_code = code
|
||||
end
|
||||
end
|
||||
end
|
|
@ -14,7 +14,6 @@
|
|||
class AttestationTemplate < ApplicationRecord
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include TagsSubstitutionConcern
|
||||
include FileValidationConcern
|
||||
|
||||
belongs_to :procedure, optional: false
|
||||
|
||||
|
@ -24,8 +23,8 @@ class AttestationTemplate < ApplicationRecord
|
|||
validates :footer, length: { maximum: 190 }
|
||||
|
||||
FILE_MAX_SIZE = 1.megabytes
|
||||
validates :logo, content_type: ['image/png', 'image/jpg', 'image/jpeg'], size: file_size_validation(FILE_MAX_SIZE)
|
||||
validates :signature, content_type: ['image/png', 'image/jpg', 'image/jpeg'], size: file_size_validation(FILE_MAX_SIZE)
|
||||
validates :logo, content_type: ['image/png', 'image/jpg', 'image/jpeg'], size: { less_than: FILE_MAX_SIZE }
|
||||
validates :signature, content_type: ['image/png', 'image/jpg', 'image/jpeg'], size: { less_than: FILE_MAX_SIZE }
|
||||
|
||||
DOSSIER_STATE = Dossier.states.fetch(:accepte)
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
#
|
||||
class Avis < ApplicationRecord
|
||||
include EmailSanitizableConcern
|
||||
include FileValidationConcern
|
||||
|
||||
belongs_to :dossier, inverse_of: :avis, touch: true, optional: false
|
||||
belongs_to :experts_procedure, optional: false
|
||||
|
@ -31,16 +30,16 @@ class Avis < ApplicationRecord
|
|||
FILE_MAX_SIZE = 20.megabytes
|
||||
validates :piece_justificative_file,
|
||||
content_type: AUTHORIZED_CONTENT_TYPES,
|
||||
size: file_size_validation(FILE_MAX_SIZE)
|
||||
size: { less_than: FILE_MAX_SIZE }
|
||||
|
||||
validates :introduction_file,
|
||||
content_type: AUTHORIZED_CONTENT_TYPES,
|
||||
size: file_size_validation(FILE_MAX_SIZE)
|
||||
size: { less_than: FILE_MAX_SIZE }
|
||||
|
||||
validates :email, format: { with: Devise.email_regexp, message: "n'est pas valide" }, allow_nil: true
|
||||
validates :claimant, presence: true
|
||||
validates :piece_justificative_file, size: file_size_validation(FILE_MAX_SIZE)
|
||||
validates :introduction_file, size: file_size_validation(FILE_MAX_SIZE)
|
||||
validates :piece_justificative_file, size: { less_than: FILE_MAX_SIZE }
|
||||
validates :introduction_file, size: { less_than: FILE_MAX_SIZE }
|
||||
before_validation -> { sanitize_email(:email) }
|
||||
|
||||
default_scope { joins(:dossier) }
|
||||
|
|
|
@ -20,11 +20,10 @@
|
|||
# type_de_champ_id :integer
|
||||
#
|
||||
class Champs::PieceJustificativeChamp < Champ
|
||||
include FileValidationConcern
|
||||
FILE_MAX_SIZE = 200.megabytes
|
||||
|
||||
validates :piece_justificative_file,
|
||||
size: file_size_validation(FILE_MAX_SIZE),
|
||||
size: { less_than: FILE_MAX_SIZE },
|
||||
if: -> { !type_de_champ.skip_pj_validation }
|
||||
|
||||
validates :piece_justificative_file,
|
||||
|
|
|
@ -20,20 +20,9 @@
|
|||
# type_de_champ_id :integer
|
||||
#
|
||||
class Champs::TitreIdentiteChamp < Champ
|
||||
include FileValidationConcern
|
||||
FILE_MAX_SIZE = 20.megabytes
|
||||
|
||||
ACCEPTED_FORMATS = [
|
||||
"image/png",
|
||||
"image/jpeg"
|
||||
]
|
||||
|
||||
# TODO: once we're running on Rails 6, re-enable this validation.
|
||||
# See https://github.com/betagouv/demarches-simplifiees.fr/issues/4926
|
||||
#
|
||||
validates :piece_justificative_file,
|
||||
content_type: ACCEPTED_FORMATS,
|
||||
size: file_size_validation(FILE_MAX_SIZE)
|
||||
ACCEPTED_FORMATS = ['image/png', 'image/jpeg']
|
||||
validates :piece_justificative_file, content_type: ACCEPTED_FORMATS, size: { less_than: FILE_MAX_SIZE }
|
||||
|
||||
def main_value_name
|
||||
:piece_justificative_file
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
# instructeur_id :bigint
|
||||
#
|
||||
class Commentaire < ApplicationRecord
|
||||
include FileValidationConcern
|
||||
include Discard::Model
|
||||
|
||||
self.ignored_columns = [:user_id]
|
||||
|
@ -31,7 +30,7 @@ class Commentaire < ApplicationRecord
|
|||
FILE_MAX_SIZE = 20.megabytes
|
||||
validates :piece_jointe,
|
||||
content_type: AUTHORIZED_CONTENT_TYPES,
|
||||
size: file_size_validation(FILE_MAX_SIZE)
|
||||
size: { less_than: FILE_MAX_SIZE }
|
||||
|
||||
default_scope { order(created_at: :asc) }
|
||||
scope :updated_since?, -> (date) { where('commentaires.updated_at > ?', date) }
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
module FileValidationConcern
|
||||
extend ActiveSupport::Concern
|
||||
class_methods do
|
||||
# This method works around missing `%{min_size}` and `%{max_size}` variables in active_record_validation
|
||||
# default error message.
|
||||
#
|
||||
# Hopefully this will be fixed upstream in https://github.com/igorkasyanchuk/active_storage_validations/pull/134
|
||||
def file_size_validation(file_max_size = 200.megabytes)
|
||||
{ less_than: file_max_size, message: I18n.t('errors.messages.file_size_out_of_range', file_size_limit: ActiveSupport::NumberHelper.number_to_human_size(file_max_size)) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,6 +10,7 @@
|
|||
# conservation_extension :interval default(0 seconds)
|
||||
# declarative_triggered_at :datetime
|
||||
# deleted_user_email_never_send :string
|
||||
# depose_at :datetime
|
||||
# en_construction_at :datetime
|
||||
# en_construction_close_to_expiration_notice_sent_at :datetime
|
||||
# en_instruction_at :datetime
|
||||
|
@ -60,8 +61,11 @@ class Dossier < ApplicationRecord
|
|||
|
||||
REMAINING_DAYS_BEFORE_CLOSING = 2
|
||||
INTERVAL_BEFORE_CLOSING = "#{REMAINING_DAYS_BEFORE_CLOSING} days"
|
||||
INTERVAL_BEFORE_EXPIRATION = '2 weeks'
|
||||
INTERVAL_EXPIRATION = '1 month 5 days'
|
||||
REMAINING_WEEKS_BEFORE_EXPIRATION = 2
|
||||
INTERVAL_BEFORE_EXPIRATION = "#{REMAINING_WEEKS_BEFORE_EXPIRATION} weeks"
|
||||
MONTHS_AFTER_EXPIRATION = 1
|
||||
DAYS_AFTER_EXPIRATION = 5
|
||||
INTERVAL_EXPIRATION = "#{MONTHS_AFTER_EXPIRATION} month #{DAYS_AFTER_EXPIRATION} days"
|
||||
|
||||
has_one :etablissement, dependent: :destroy
|
||||
has_one :individual, validate: false, dependent: :destroy
|
||||
|
@ -286,25 +290,39 @@ class Dossier < ApplicationRecord
|
|||
.where.not(user_id: nil)
|
||||
end
|
||||
|
||||
scope :interval_brouillon_close_to_expiration, -> do
|
||||
state_brouillon.where("dossiers.created_at + dossiers.conservation_extension + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
end
|
||||
scope :interval_en_construction_close_to_expiration, -> do
|
||||
state_en_construction.where("dossiers.en_construction_at + dossiers.conservation_extension + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
end
|
||||
scope :interval_en_instruction_close_to_expiration, -> do
|
||||
state_en_instruction.where("dossiers.en_instruction_at + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
end
|
||||
scope :interval_termine_close_to_expiration, -> do
|
||||
state_termine.where(id: Traitement.termine_close_to_expiration.select(:dossier_id).distinct)
|
||||
end
|
||||
|
||||
scope :brouillon_close_to_expiration, -> do
|
||||
state_brouillon
|
||||
.joins(:procedure)
|
||||
.where("dossiers.created_at + dossiers.conservation_extension + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
joins(:procedure).interval_brouillon_close_to_expiration
|
||||
end
|
||||
scope :en_construction_close_to_expiration, -> do
|
||||
state_en_construction
|
||||
.joins(:procedure)
|
||||
.where("dossiers.en_construction_at + dossiers.conservation_extension + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
joins(:procedure).interval_en_construction_close_to_expiration
|
||||
end
|
||||
scope :en_instruction_close_to_expiration, -> do
|
||||
state_en_instruction
|
||||
.joins(:procedure)
|
||||
.where("dossiers.en_instruction_at + (duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: INTERVAL_BEFORE_EXPIRATION })
|
||||
joins(:procedure).interval_en_instruction_close_to_expiration
|
||||
end
|
||||
scope :termine_close_to_expiration, -> do
|
||||
state_termine
|
||||
.joins(:procedure)
|
||||
.where(id: Traitement.termine_close_to_expiration.select(:dossier_id).distinct)
|
||||
joins(:procedure).interval_termine_close_to_expiration
|
||||
end
|
||||
|
||||
scope :close_to_expiration, -> do
|
||||
joins(:procedure).scoping do
|
||||
interval_brouillon_close_to_expiration
|
||||
.or(interval_en_construction_close_to_expiration)
|
||||
.or(interval_en_instruction_close_to_expiration)
|
||||
.or(interval_termine_close_to_expiration)
|
||||
end
|
||||
end
|
||||
|
||||
scope :brouillon_expired, -> do
|
||||
|
@ -522,16 +540,50 @@ class Dossier < ApplicationRecord
|
|||
!brouillon? && !user_deleted? && !archived
|
||||
end
|
||||
|
||||
def en_construction_close_to_expiration?
|
||||
self.class.en_construction_close_to_expiration.exists?(id: self)
|
||||
def expirable?
|
||||
[brouillon?, en_construction?, termine?].any?
|
||||
end
|
||||
|
||||
def brouillon_close_to_expiration?
|
||||
self.class.brouillon_close_to_expiration.exists?(id: self)
|
||||
def approximative_expiration_date_reference
|
||||
if brouillon?
|
||||
created_at
|
||||
elsif en_construction?
|
||||
en_construction_at
|
||||
elsif termine?
|
||||
processed_at
|
||||
else
|
||||
fail "approximative_expiration_date_reference should not be called in state #{self.state}"
|
||||
end
|
||||
end
|
||||
|
||||
def approximative_expiration_date
|
||||
[
|
||||
approximative_expiration_date_reference,
|
||||
conservation_extension,
|
||||
procedure.duree_conservation_dossiers_dans_ds.months
|
||||
].sum - REMAINING_WEEKS_BEFORE_EXPIRATION.weeks
|
||||
end
|
||||
|
||||
def close_to_expiration?
|
||||
en_construction_close_to_expiration? || brouillon_close_to_expiration?
|
||||
approximative_expiration_date < Time.zone.now
|
||||
end
|
||||
|
||||
def expiration_date
|
||||
if brouillon? && brouillon_close_to_expiration_notice_sent_at.present?
|
||||
brouillon_close_to_expiration_notice_sent_at + duration_after_notice
|
||||
elsif en_construction? && en_construction_close_to_expiration_notice_sent_at.present?
|
||||
en_construction_close_to_expiration_notice_sent_at + duration_after_notice
|
||||
elsif termine? && termine_close_to_expiration_notice_sent_at.present?
|
||||
termine_close_to_expiration_notice_sent_at + duration_after_notice
|
||||
end
|
||||
end
|
||||
|
||||
def duration_after_notice
|
||||
MONTHS_AFTER_EXPIRATION.month + DAYS_AFTER_EXPIRATION.days
|
||||
end
|
||||
|
||||
def expiration_can_be_extended?
|
||||
brouillon? || en_construction?
|
||||
end
|
||||
|
||||
def show_groupe_instructeur_details?
|
||||
|
@ -725,7 +777,7 @@ class Dossier < ApplicationRecord
|
|||
|
||||
def after_passer_en_construction
|
||||
self.conservation_extension = 0.days
|
||||
self.en_construction_at = self.traitements
|
||||
self.depose_at = self.en_construction_at = self.traitements
|
||||
.passer_en_construction
|
||||
.processed_at
|
||||
save!
|
||||
|
@ -758,6 +810,8 @@ class Dossier < ApplicationRecord
|
|||
end
|
||||
|
||||
def after_repasser_en_construction(instructeur)
|
||||
create_missing_traitemets
|
||||
|
||||
self.en_construction_close_to_expiration_notice_sent_at = nil
|
||||
self.conservation_extension = 0.days
|
||||
self.en_construction_at = self.traitements
|
||||
|
@ -768,6 +822,8 @@ class Dossier < ApplicationRecord
|
|||
end
|
||||
|
||||
def after_repasser_en_instruction(instructeur, disable_notification: false)
|
||||
create_missing_traitemets
|
||||
|
||||
self.archived = false
|
||||
self.termine_close_to_expiration_notice_sent_at = nil
|
||||
self.conservation_extension = 0.days
|
||||
|
@ -1037,6 +1093,16 @@ class Dossier < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def create_missing_traitemets
|
||||
if en_construction_at.present? && traitements.en_construction.empty?
|
||||
self.traitements.passer_en_construction(processed_at: en_construction_at)
|
||||
self.depose_at ||= en_construction_at
|
||||
end
|
||||
if en_instruction_at.present? && traitements.en_instruction.empty?
|
||||
self.traitements.passer_en_instruction(processed_at: en_instruction_at)
|
||||
end
|
||||
end
|
||||
|
||||
def deleted_dossier
|
||||
@deleted_dossier ||= DeletedDossier.find_by(dossier_id: id)
|
||||
end
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
# login_token_created_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# agent_connect_id :string
|
||||
#
|
||||
class Instructeur < ApplicationRecord
|
||||
has_many :administrateurs_instructeurs
|
||||
|
|
|
@ -54,7 +54,6 @@ class Procedure < ApplicationRecord
|
|||
self.ignored_columns = [:duree_conservation_dossiers_hors_ds]
|
||||
include ProcedureStatsConcern
|
||||
include EncryptableConcern
|
||||
include FileValidationConcern
|
||||
|
||||
include Discard::Model
|
||||
self.discard_column = :hidden_at
|
||||
|
@ -235,7 +234,6 @@ class Procedure < ApplicationRecord
|
|||
validates :description, presence: true, allow_blank: false, allow_nil: false
|
||||
validates :administrateurs, presence: true
|
||||
validates :lien_site_web, presence: true, if: :publiee?
|
||||
validate :validate_for_publication, on: :publication
|
||||
validate :check_juridique
|
||||
validates :path, presence: true, format: { with: /\A[a-z0-9_\-]{3,200}\z/ }, uniqueness: { scope: [:path, :closed_at, :hidden_at, :unpublished_at], case_sensitive: false }
|
||||
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 }
|
||||
|
@ -254,7 +252,7 @@ class Procedure < ApplicationRecord
|
|||
"image/jpg",
|
||||
"image/png",
|
||||
"text/plain"
|
||||
], size: file_size_validation(FILE_MAX_SIZE), if: -> { new_record? || created_at > Date.new(2020, 2, 28) }
|
||||
], size: { less_than: FILE_MAX_SIZE }, if: -> { new_record? || created_at > Date.new(2020, 2, 28) }
|
||||
|
||||
validates :deliberation, content_type: [
|
||||
"application/msword",
|
||||
|
@ -265,11 +263,11 @@ class Procedure < ApplicationRecord
|
|||
"image/jpg",
|
||||
"image/png",
|
||||
"text/plain"
|
||||
], size: file_size_validation(FILE_MAX_SIZE), if: -> { new_record? || created_at > Date.new(2020, 4, 29) }
|
||||
], size: { less_than: FILE_MAX_SIZE }, if: -> { new_record? || created_at > Date.new(2020, 4, 29) }
|
||||
|
||||
LOGO_MAX_SIZE = 5.megabytes
|
||||
validates :logo, content_type: ['image/png', 'image/jpg', 'image/jpeg'],
|
||||
size: file_size_validation(LOGO_MAX_SIZE),
|
||||
size: { less_than: LOGO_MAX_SIZE },
|
||||
if: -> { new_record? || created_at > Date.new(2020, 11, 13) }
|
||||
|
||||
validates :api_entreprise_token, jwt_token: true, allow_blank: true
|
||||
|
@ -325,18 +323,6 @@ class Procedure < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def validate_for_publication
|
||||
old_attributes = self.slice(:aasm_state, :closed_at)
|
||||
self.aasm_state = :publiee
|
||||
self.closed_at = nil
|
||||
|
||||
is_valid = validate
|
||||
|
||||
self.attributes = old_attributes
|
||||
|
||||
is_valid
|
||||
end
|
||||
|
||||
def suggested_path(administrateur)
|
||||
if path_customized?
|
||||
return path
|
||||
|
@ -692,7 +678,9 @@ class Procedure < ApplicationRecord
|
|||
end
|
||||
|
||||
def create_new_revision
|
||||
draft_revision.deep_clone(include: [:revision_types_de_champ, :revision_types_de_champ_private])
|
||||
draft_revision
|
||||
.deep_clone(include: [:revision_types_de_champ, :revision_types_de_champ_private])
|
||||
.tap(&:save!)
|
||||
end
|
||||
|
||||
def average_dossier_weight
|
||||
|
|
|
@ -17,14 +17,6 @@ class Traitement < ApplicationRecord
|
|||
scope :en_instruction, -> { where(state: Dossier.states.fetch(:en_instruction)) }
|
||||
scope :termine, -> { where(state: Dossier::TERMINE) }
|
||||
|
||||
scope :termine_close_to_expiration, -> do
|
||||
joins(dossier: :procedure)
|
||||
.termine
|
||||
.where(process_expired: true)
|
||||
.where('dossiers.state' => Dossier::TERMINE)
|
||||
.where("traitements.processed_at + (procedures.duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: Dossier::INTERVAL_BEFORE_EXPIRATION })
|
||||
end
|
||||
|
||||
scope :for_traitement_time_stats, -> (procedure) do
|
||||
includes(:dossier)
|
||||
.termine
|
||||
|
@ -33,6 +25,14 @@ class Traitement < ApplicationRecord
|
|||
.order(:processed_at)
|
||||
end
|
||||
|
||||
scope :termine_close_to_expiration, -> do
|
||||
joins(dossier: :procedure)
|
||||
.termine
|
||||
.where(process_expired: true)
|
||||
.where('dossiers.state' => Dossier::TERMINE)
|
||||
.where("traitements.processed_at + (procedures.duree_conservation_dossiers_dans_ds * INTERVAL '1 month') - INTERVAL :expires_in < :now", { now: Time.zone.now, expires_in: Dossier::INTERVAL_BEFORE_EXPIRATION })
|
||||
end
|
||||
|
||||
def self.count_dossiers_termines_by_month(groupe_instructeurs)
|
||||
last_traitements_per_dossier = Traitement
|
||||
.select('max(traitements.processed_at) as processed_at')
|
||||
|
|
24
app/services/agent_connect_service.rb
Normal file
24
app/services/agent_connect_service.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
class AgentConnectService
|
||||
def self.enabled?
|
||||
ENV.fetch("AGENT_CONNECT_ENABLED", "enabled") == "enabled"
|
||||
end
|
||||
|
||||
def self.authorization_uri
|
||||
client = AgentConnectClient.new
|
||||
|
||||
client.authorization_uri(
|
||||
scope: [:openid, :email],
|
||||
state: SecureRandom.hex(16),
|
||||
nonce: SecureRandom.hex(16),
|
||||
acr_values: 'eidas1'
|
||||
)
|
||||
end
|
||||
|
||||
def self.user_info(code)
|
||||
client = AgentConnectClient.new(code)
|
||||
|
||||
client.access_token!(client_auth_method: :secret)
|
||||
.userinfo!
|
||||
.raw_attributes
|
||||
end
|
||||
end
|
8
app/views/agent_connect/agent/index.html.haml
Normal file
8
app/views/agent_connect/agent/index.html.haml
Normal file
|
@ -0,0 +1,8 @@
|
|||
- content_for(:title, t('.cta'))
|
||||
|
||||
.container
|
||||
%h1.mt-2.mb-2= t('.connect')
|
||||
|
||||
%p= t('.intro_html', app_name: APPLICATION_NAME)
|
||||
|
||||
= link_to t('.cta'), agent_connect_login_path, class: "france-connect-agent-login-button"
|
|
@ -30,14 +30,5 @@
|
|||
autocomplete: 'off',
|
||||
placeholder: 'https://exemple.gouv.fr/ma_demarche')
|
||||
|
||||
- procedure.validate(:publication)
|
||||
- errors = procedure.errors
|
||||
-# Ignore the :taken error if the path can be claimed
|
||||
- if errors.details[:path]&.pluck(:error)&.include?(:taken) && procedure.path_available?(administrateur, procedure.path)
|
||||
- errors.delete(:path)
|
||||
|
||||
- options = { class: "button primary", id: 'publish' }
|
||||
- if errors.details[:path].present?
|
||||
- options[:disabled] = :disabled
|
||||
.flex.justify-end
|
||||
= submit_tag procedure_publish_label(procedure, :submit), options
|
||||
= submit_tag procedure_publish_label(procedure, :submit), { class: "button primary", id: 'publish' }
|
||||
|
|
14
app/views/shared/dossiers/_expiration_banner.html.haml
Normal file
14
app/views/shared/dossiers/_expiration_banner.html.haml
Normal file
|
@ -0,0 +1,14 @@
|
|||
- if dossier.expirable? && dossier.close_to_expiration?
|
||||
.card.warning.mt-2.mb-3
|
||||
.card-title= t('shared.dossiers.header.banner.title')
|
||||
%p
|
||||
- if dossier.brouillon?
|
||||
= t('shared.dossiers.header.banner.states.brouillon')
|
||||
- elsif dossier.en_construction?
|
||||
= t('shared.dossiers.header.banner.states.en_construction')
|
||||
- elsif dossier.termine?
|
||||
= t('shared.dossiers.header.banner.states.termine')
|
||||
|
||||
- if dossier.expiration_can_be_extended?
|
||||
%br
|
||||
= button_to t('shared.dossiers.header.banner.button_delay_expiration'), users_dossier_repousser_expiration_path(dossier), class: 'button secondary mt-2'
|
|
@ -1,6 +1,14 @@
|
|||
%h1
|
||||
= procedure_libelle(dossier.procedure)
|
||||
= status_badge(dossier.state)
|
||||
.title-container
|
||||
%span.icon.folder
|
||||
%h1= procedure_libelle(dossier.procedure)
|
||||
%h2
|
||||
= t('views.users.dossiers.show.header.dossier_number', dossier_id: dossier.id)
|
||||
= t('views.users.dossiers.show.header.created_date', date_du_dossier: I18n.l(dossier.created_at))
|
||||
|
||||
= render(partial: 'shared/dossiers/short_expires_message', locals: {dossier: dossier})
|
||||
|
||||
.header-actions
|
||||
- if current_user.owns?(dossier)
|
||||
= render partial: 'invites/dropdown', locals: { dossier: dossier }
|
||||
|
||||
.dossier-form-actions
|
||||
- if current_user.owns?(dossier)
|
||||
= render partial: 'invites/dropdown', locals: { dossier: dossier }
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
- if dossier.expirable?
|
||||
%p.expires_at
|
||||
%small= t("shared.dossiers.header.expires_at.#{dossier.state}", date: safe_expiration_date(dossier))
|
||||
- else
|
||||
%p.expires_at_en_instruction
|
||||
%small= t("shared.dossiers.header.expires_at.en_instruction")
|
||||
|
||||
|
||||
= render(partial: 'shared/dossiers/expiration_banner', locals: {dossier: dossier})
|
|
@ -4,7 +4,7 @@
|
|||
- content_for :footer do
|
||||
= render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier }
|
||||
|
||||
#dossier-draft
|
||||
.dossier-container
|
||||
.dossier-header.sub-header
|
||||
.container
|
||||
= render partial: "shared/dossiers/header", locals: { dossier: @dossier, apercu: false }
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
- content_for :footer do
|
||||
= render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier }
|
||||
|
||||
#dossier-show
|
||||
.dossier-container.mb-4
|
||||
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
|
||||
|
||||
= render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'usager' }
|
||||
|
|
|
@ -39,6 +39,12 @@
|
|||
active: @statut == 'dossiers-transferes',
|
||||
badge: number_with_html_delimiter(@dossier_transfers.count))
|
||||
|
||||
- if @dossiers_close_to_expiration.count > 0
|
||||
= tab_item(t('pluralize.dossiers_close_to_expiration', count: @dossiers_close_to_expiration.count),
|
||||
dossiers_path(statut: 'dossiers-expirant'),
|
||||
active: @statut == 'dossiers-expirant',
|
||||
badge: number_with_html_delimiter(@dossiers_close_to_expiration.count))
|
||||
|
||||
.container
|
||||
- if @statut == "mes-dossiers"
|
||||
= render partial: "dossiers_list", locals: { dossiers: @user_dossiers }
|
||||
|
@ -48,6 +54,8 @@
|
|||
|
||||
- if @statut == "dossiers-supprimes"
|
||||
= render partial: "deleted_dossiers_list", locals: { deleted_dossiers: @dossiers_supprimes }
|
||||
|
||||
- if @statut == "dossiers-transferes"
|
||||
= render partial: "transfered_dossiers_list", locals: { dossier_transfers: @dossier_transfers }
|
||||
|
||||
- if @statut == "dossiers-expirant"
|
||||
= render partial: "dossiers_list", locals: { dossiers: @dossiers_close_to_expiration }
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
- content_for :footer do
|
||||
= render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier }
|
||||
|
||||
#dossier-show
|
||||
.dossier-container.mb-4
|
||||
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
|
||||
|
||||
.container
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
- content_for :footer do
|
||||
= render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier }
|
||||
|
||||
#dossier-show
|
||||
.dossier-container.mb-4
|
||||
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
|
||||
|
||||
.container
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
- content_for :footer do
|
||||
= render partial: "users/procedure_footer", locals: { procedure: @dossier.procedure, dossier: @dossier }
|
||||
|
||||
#dossier-show
|
||||
.dossier-container.mb-4
|
||||
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
|
||||
|
||||
.container
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
- if dossier.en_construction_at.present?
|
||||
= t('views.users.dossiers.show.header.submit_date', date_du_dossier: I18n.l(dossier.en_construction_at))
|
||||
|
||||
= render(partial: 'shared/dossiers/short_expires_message', locals: {dossier: dossier})
|
||||
|
||||
|
||||
- if current_user.owns?(dossier)
|
||||
.header-actions
|
||||
= render partial: 'invites/dropdown', locals: { dossier: dossier }
|
||||
|
@ -22,21 +25,6 @@
|
|||
%li
|
||||
= link_to t('views.users.dossiers.show.header.print_dossier'), dossier_path(dossier, format: :pdf), target: "_blank", rel: "noopener", class: "menu-item menu-link"
|
||||
|
||||
- if dossier.close_to_expiration?
|
||||
.card.warning
|
||||
.card-title Votre dossier va expirer
|
||||
- if dossier.brouillon?
|
||||
%p
|
||||
Votre dossier est en brouillon, mais va bientôt expirer. Cela signifie qu’il va bientôt être supprimé sans avoir été déposé.
|
||||
Si vous souhaitez le conserver afin de poursuivre la démarche, vous pouvez le conserver
|
||||
un mois de plus en cliquant sur le bouton ci-dessous.
|
||||
- else
|
||||
%p
|
||||
Votre dossier a été déposé, mais va bientôt expirer. Cela signifie qu’il va bientôt être supprimé sans avoir été traité par l’administration.
|
||||
Si vous souhaitez le conserver afin de poursuivre la démarche, vous pouvez le conserver
|
||||
un mois de plus en cliquant sur le bouton ci-dessous.
|
||||
%br
|
||||
= button_to 'Repousser sa suppression', users_dossier_repousser_expiration_path(dossier), class: 'button secondary'
|
||||
|
||||
%ul.tabs
|
||||
= dynamic_tab_item(t('views.users.dossiers.show.header.summary'), dossier_path(dossier))
|
||||
|
|
|
@ -23,7 +23,13 @@
|
|||
|
||||
= f.submit t('views.users.sessions.new.connection'), class: "button large primary expand"
|
||||
|
||||
%hr
|
||||
.france-connect-login-separator
|
||||
= t('views.shared.france_connect_login.separator')
|
||||
- if AgentConnectService.enabled?
|
||||
.center
|
||||
%p.mb-2= t('views.users.sessions.new.instructor_or_admin')
|
||||
= link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path
|
||||
%hr
|
||||
%p.center
|
||||
%span= t('views.users.sessions.new.are_you_new', app_name: APPLICATION_NAME.gsub("-","‑")).html_safe
|
||||
%br
|
||||
|
|
|
@ -45,6 +45,13 @@ FC_PARTICULIER_ID=""
|
|||
FC_PARTICULIER_SECRET=""
|
||||
FC_PARTICULIER_BASE_URL=""
|
||||
|
||||
# Service externe: authentification Agent Connect
|
||||
AGENT_CONNECT_ID=""
|
||||
AGENT_CONNECT_SECRET=""
|
||||
AGENT_CONNECT_BASE_URL=""
|
||||
AGENT_CONNECT_JWKS=""
|
||||
AGENT_CONNECT_REDIRECT=""
|
||||
|
||||
# Service externe: Support Utilisateur HelpScout | Spécifique démarches-simplifiées.fr
|
||||
HELPSCOUT_MAILBOX_ID=""
|
||||
HELPSCOUT_CLIENT_ID=""
|
||||
|
|
|
@ -11,6 +11,9 @@ DS_ENV="staging"
|
|||
# Utilisation de France Connect
|
||||
# FRANCE_CONNECT_ENABLED="disabled" # "enabled" par défaut
|
||||
|
||||
# Utilisation de Agent Connect
|
||||
# AGENT_CONNECT_ENABLED="disabled" # "enabled" par défaut
|
||||
|
||||
# Personnalisation d'instance - URLs des CGU et des mentions légales
|
||||
# CGU_URL=""
|
||||
# MENTIONS_LEGALES_URL=""
|
||||
|
|
|
@ -101,6 +101,7 @@ ignore_unused:
|
|||
- 'activerecord.errors.*'
|
||||
- 'errors.messages.blank'
|
||||
- 'errors.messages.content_type_invalid'
|
||||
- 'errors.messages.file_size_out_of_range'
|
||||
- 'pluralize.*'
|
||||
- 'views.pagination.*'
|
||||
- 'time.formats.default'
|
||||
|
|
1
config/initializers/agent_connect.rb
Normal file
1
config/initializers/agent_connect.rb
Normal file
|
@ -0,0 +1 @@
|
|||
AGENT_CONNECT = Rails.application.secrets.agent_connect
|
|
@ -3,3 +3,59 @@ OpenIDConnect.logger = Rails.logger
|
|||
Rack::OAuth2.logger = Rails.logger
|
||||
# Webfinger.logger = Rails.logger
|
||||
SWD.logger = Rails.logger
|
||||
|
||||
# the openid_connect gem does not support
|
||||
# jwt format in the userinfo call.
|
||||
# A PR is open to improve the situation
|
||||
# https://github.com/nov/openid_connect/pull/54
|
||||
module OpenIDConnect
|
||||
class AccessToken < Rack::OAuth2::AccessToken::Bearer
|
||||
private
|
||||
|
||||
def jwk_loader
|
||||
JSON.parse(URI.parse(ENV['AGENT_CONNECT_JWKS']).read).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def decode_jwt(requested_host, jwt)
|
||||
agent_connect_host = URI.parse(ENV['AGENT_CONNECT_BASE_URL']).host
|
||||
|
||||
if requested_host == agent_connect_host
|
||||
# rubocop:disable Lint/UselessAssignment
|
||||
JWT.decode(jwt, key = nil, verify = true, { algorithms: ['ES256'], jwks: jwk_loader })[0]
|
||||
# rubocop:enable Lint/UselessAssignment
|
||||
else
|
||||
raise "unknwon host : #{requested_host}"
|
||||
end
|
||||
end
|
||||
|
||||
def resource_request
|
||||
res = yield
|
||||
case res.status
|
||||
when 200
|
||||
hash = case parse_type_and_subtype(res.content_type)
|
||||
when 'application/jwt'
|
||||
requested_host = URI.parse(client.userinfo_endpoint).host
|
||||
decode_jwt(requested_host, res.body)
|
||||
when 'application/json'
|
||||
JSON.parse(res.body)
|
||||
end
|
||||
hash&.with_indifferent_access
|
||||
when 400
|
||||
raise BadRequest.new('API Access Faild', res)
|
||||
when 401
|
||||
raise Unauthorized.new('Access Token Invalid or Expired', res)
|
||||
when 403
|
||||
raise Forbidden.new('Insufficient Scope', res)
|
||||
else
|
||||
raise HttpError.new(res.status, 'Unknown HttpError', res)
|
||||
end
|
||||
end
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc2045#section-5.1
|
||||
# - type and subtype are the first member
|
||||
# they are case insensitive
|
||||
def parse_type_and_subtype(content_type)
|
||||
content_type.split(';')[0].strip.downcase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
en:
|
||||
errors:
|
||||
messages:
|
||||
content_type_invalid: "is not of an accepted type"
|
||||
file_size_out_of_range: "is too big. The file must be at most %{file_size_limit}."
|
||||
content_type_invalid: is not of an accepted type
|
||||
file_size_out_of_range: is too big. The file must be at most %{max_size}.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
fr:
|
||||
errors:
|
||||
messages:
|
||||
content_type_invalid: "n’est pas d’un type accepté"
|
||||
file_size_out_of_range: "est trop lourd(e). Le fichier doit faire au plus %{file_size_limit}."
|
||||
content_type_invalid: n’est pas d’un type accepté
|
||||
file_size_out_of_range: est trop lourd(e). Le fichier doit faire au plus %{max_size}.
|
||||
|
|
|
@ -160,6 +160,7 @@ en:
|
|||
request: "Request"
|
||||
mailbox: "Mailbox"
|
||||
dossier_number: "File n. %{dossier_id}"
|
||||
created_date: "- Draft on %{date_du_dossier}"
|
||||
submit_date: "- Submit on %{date_du_dossier}"
|
||||
print: "print"
|
||||
print_dossier: "All the file"
|
||||
|
@ -203,6 +204,8 @@ en:
|
|||
connection: Sign in
|
||||
are_you_new: First time on %{app_name}?
|
||||
find_procedure: Find your procedure
|
||||
instructor_or_admin: Instructor or Administrator ?
|
||||
connect_with_agent_connect: Connect with AgentConnect
|
||||
passwords:
|
||||
reset_link_sent:
|
||||
got_it: Got it!
|
||||
|
@ -339,6 +342,10 @@ en:
|
|||
zero: transfer request
|
||||
one: transfer request
|
||||
other: transfer requests
|
||||
dossiers_close_to_expiration:
|
||||
zero: expiring file
|
||||
one: expiring file
|
||||
other: expiring files
|
||||
dossier_trouve:
|
||||
zero: 0 file found
|
||||
one: 1 file found
|
||||
|
|
|
@ -156,6 +156,7 @@ fr:
|
|||
request: "Demande"
|
||||
mailbox: "Messagerie"
|
||||
dossier_number: "Dossier nº %{dossier_id}"
|
||||
created_date: "- En brouillon depuis le %{date_du_dossier}"
|
||||
submit_date: "- Déposé le %{date_du_dossier}"
|
||||
print: "imprimer"
|
||||
print_dossier: "Tout le dossier"
|
||||
|
@ -199,6 +200,8 @@ fr:
|
|||
connection: Se connecter
|
||||
are_you_new: Vous êtes nouveau sur %{app_name} ?
|
||||
find_procedure: Trouvez votre démarche
|
||||
instructor_or_admin: Vous êtes instructeur ou administrateur ?
|
||||
connect_with_agent_connect: Se connecter avec AgentConnect
|
||||
passwords:
|
||||
reset_link_sent:
|
||||
email_sent_html: "Nous vous avons envoyé un email à l’adresse <strong>%{email}</strong>."
|
||||
|
@ -347,6 +350,10 @@ fr:
|
|||
zero: demande de transfert
|
||||
one: demande de transfert
|
||||
other: demandes de transfert
|
||||
dossiers_close_to_expiration:
|
||||
zero: dossier expirant
|
||||
one: dossier expirant
|
||||
other: dossiers expirant
|
||||
dossier_trouve:
|
||||
zero: 0 dossier trouvé
|
||||
one: 1 dossier trouvé
|
||||
|
|
|
@ -7,6 +7,21 @@ en:
|
|||
numero_allocataire_notice: It is usually composed of 7 digits.
|
||||
code_postal_label: postal code
|
||||
code_postal_notice: It is usually composed of 5 digits.
|
||||
header:
|
||||
expires_at:
|
||||
brouillon: "Expires at %{date}"
|
||||
en_construction: "Expires at %{date}"
|
||||
en_instruction: "This file is being instructed, the administration will answer as soon as possible"
|
||||
accepte: "Expires at %{date}"
|
||||
refuse: "Expires at %{date}"
|
||||
sans_suite: "Expires at %{date}"
|
||||
banner:
|
||||
title: Your file will expire
|
||||
states:
|
||||
brouillon: Your file is still in draft and will soon expire. So it will be deleted soon without being instructed. If you want to pursue your procedure you can submit it now. Otherwise you are able to delay its expiration by clicking on the underneath button.
|
||||
en_construction: Your file is pending for instruction. The maximum delay is 6 months, but your can extend the duration by a month by clicking on the underneath button.
|
||||
termine: Your file had been processed and will soon expire.So it will be deleted soon. If you want to keep it, your can dowload a PDF file of it.
|
||||
button_delay_expiration: "Delay deletion"
|
||||
champs:
|
||||
cnaf:
|
||||
show:
|
||||
|
|
|
@ -7,6 +7,22 @@ fr:
|
|||
numero_allocataire_notice: Il est généralement composé de 7 chiffres.
|
||||
code_postal_label: Le code postal
|
||||
code_postal_notice: Il est généralement composé de 5 chiffres.
|
||||
header:
|
||||
expires_at:
|
||||
brouillon: "Expirera le %{date}"
|
||||
en_construction: "Expirera le %{date}"
|
||||
en_instruction: "Ce dossier est en instruction, il n'expirera pas"
|
||||
accepte: "Expirera le %{date}"
|
||||
refuse: "Expirera le %{date}"
|
||||
sans_suite: "Expirera le %{date}"
|
||||
banner:
|
||||
title: Votre dossier va expirer
|
||||
states:
|
||||
brouillon: Votre dossier est en brouillon, mais va bientôt expirer. Cela signifie qu’il va bientôt être supprimé sans avoir été déposé. Si vous souhaitez le conserver afin de poursuivre la démarche, vous pouvez le conserver un mois de plus en cliquant sur le bouton ci-dessous.
|
||||
en_construction: Votre dossier est en attente de prise en charge par l'administration. Le delais de prise en charge maximale est de 6 mois. Vous pouvez toutefois entendre cette durée d'un mois en cliquant sur le bouton suivant.
|
||||
termine: Le traitement de votre dossier est terminé, mais il va bientôt expirer. Cela signifie qu’il va bientôt être supprimé. Si vous souhaitez conserver une trace, vous pouvez le télécharger au format PDF.
|
||||
button_delay_expiration: "Repousser sa suppression"
|
||||
|
||||
champs:
|
||||
cnaf:
|
||||
show:
|
||||
|
|
11
config/locales/views/agent_connect/agent/en.yml
Normal file
11
config/locales/views/agent_connect/agent/en.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
en:
|
||||
agent_connect:
|
||||
agent:
|
||||
index:
|
||||
connect: Connect with AgentConnect
|
||||
intro_html: |
|
||||
AgentConnect allows <b class='bold'>instructors et administrators</b> to use their usual login credentials to connect to %{app_name}.
|
||||
<br />
|
||||
<br />
|
||||
Only agents of <b class='bold'>the Ministry of Ecological Transition</b> can currently benefit from it.
|
||||
cta: Connect with AgentConnect
|
11
config/locales/views/agent_connect/agent/fr.yml
Normal file
11
config/locales/views/agent_connect/agent/fr.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
fr:
|
||||
agent_connect:
|
||||
agent:
|
||||
index:
|
||||
connect: Connectez-vous avec AgentConnect
|
||||
intro_html: |
|
||||
AgentConnect permet aux <b class='bold'>instructeurs et administrateurs</b> d’utiliser leurs identifiants habituels pour se connecter à %{app_name}.
|
||||
<br />
|
||||
<br />
|
||||
Seul les agents du <b class='bold'>ministère de la Transition écologique</b> peuvent actuellement en bénéficier.
|
||||
cta: S’identifier avec AgentConnect
|
|
@ -129,6 +129,12 @@ Rails.application.routes.draw do
|
|||
post 'particulier/merge_with_new_account' => 'particulier#merge_with_new_account'
|
||||
end
|
||||
|
||||
namespace :agent_connect do
|
||||
get '' => 'agent#index'
|
||||
get 'login' => 'agent#login'
|
||||
get 'callback' => 'agent#callback'
|
||||
end
|
||||
|
||||
namespace :champs do
|
||||
get ':position/siret', to: 'siret#show', as: :siret
|
||||
get ':position/dossier_link', to: 'dossier_link#show', as: :dossier_link
|
||||
|
|
|
@ -25,6 +25,14 @@ defaults: &defaults
|
|||
token_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/token
|
||||
userinfo_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/userinfo
|
||||
logout_endpoint: <%= ENV['FC_PARTICULIER_BASE_URL'] %>/api/v1/logout
|
||||
agent_connect:
|
||||
identifier: <%= ENV['AGENT_CONNECT_ID'] %>
|
||||
secret: <%= ENV['AGENT_CONNECT_SECRET'] %>
|
||||
redirect_uri: <%= ENV['AGENT_CONNECT_REDIRECT'] %>
|
||||
authorization_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/authorize
|
||||
token_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/token
|
||||
userinfo_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/userinfo
|
||||
logout_endpoint: <%= ENV['AGENT_CONNECT_BASE_URL'] %>/api/v2/session/end
|
||||
mailjet:
|
||||
api_key: <%= ENV['MAILJET_API_KEY'] %>
|
||||
secret_key: <%= ENV['MAILJET_SECRET_KEY'] %>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
class AddAgentConnectSubColumnToInstructeursTable < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :instructeurs, :agent_connect_id, :string
|
||||
add_index :instructeurs, :agent_connect_id, unique: true
|
||||
end
|
||||
end
|
5
db/migrate/20211124111429_add_depose_at_to_dossiers.rb
Normal file
5
db/migrate/20211124111429_add_depose_at_to_dossiers.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddDeposeAtToDossiers < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :dossiers, :depose_at, :datetime
|
||||
end
|
||||
end
|
|
@ -10,7 +10,8 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_11_15_112933) do
|
||||
ActiveRecord::Schema.define(version: 2021_11_24_111429) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
enable_extension "unaccent"
|
||||
|
@ -322,6 +323,7 @@ ActiveRecord::Schema.define(version: 2021_11_15_112933) do
|
|||
t.index "to_tsvector('french'::regconfig, search_terms)", name: "index_dossiers_on_search_terms", using: :gin
|
||||
t.bigint "dossier_transfer_id"
|
||||
t.datetime "identity_updated_at"
|
||||
t.datetime "depose_at"
|
||||
t.index ["archived"], name: "index_dossiers_on_archived"
|
||||
t.index ["dossier_transfer_id"], name: "index_dossiers_on_dossier_transfer_id"
|
||||
t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id"
|
||||
|
@ -534,6 +536,8 @@ ActiveRecord::Schema.define(version: 2021_11_15_112933) do
|
|||
t.text "encrypted_login_token"
|
||||
t.datetime "login_token_created_at"
|
||||
t.boolean "bypass_email_login_token", default: false, null: false
|
||||
t.string "agent_connect_id"
|
||||
t.index ["agent_connect_id"], name: "index_instructeurs_on_agent_connect_id", unique: true
|
||||
end
|
||||
|
||||
create_table "invites", id: :serial, force: :cascade do |t|
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
namespace :after_party do
|
||||
desc 'Deployment task: add_depose_at_to_dossiers'
|
||||
task add_depose_at_to_dossiers: :environment do
|
||||
puts "Running deploy task 'add_depose_at_to_dossiers'"
|
||||
|
||||
dossiers = Dossier.includes(:traitements).where(depose_at: nil).where.not(en_construction_at: nil)
|
||||
progress = ProgressReport.new(dossiers.count)
|
||||
|
||||
dossiers.find_each do |dossier|
|
||||
traitement = dossier.traitements.find { |traitement| traitement.state == :en_construction }
|
||||
depose_at = traitement&.processed_at || dossier.en_construction_at
|
||||
dossier.update_column(:depose_at, depose_at)
|
||||
progress.inc
|
||||
end
|
||||
progress.finish
|
||||
|
||||
# Update task as completed. If you remove the line below, the task will
|
||||
# run with every deploy (or every time you call after_party:run).
|
||||
AfterParty::TaskRecord
|
||||
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
|
||||
end
|
||||
end
|
90
spec/controllers/agent_connect/agent_controller_spec.rb
Normal file
90
spec/controllers/agent_connect/agent_controller_spec.rb
Normal file
|
@ -0,0 +1,90 @@
|
|||
describe AgentConnect::AgentController, type: :controller do
|
||||
describe '#callback' do
|
||||
let(:email) { 'i@email.com' }
|
||||
subject { get :callback, params: { code: code } }
|
||||
|
||||
context 'when the callback code is correct' do
|
||||
let(:code) { 'correct' }
|
||||
let(:user_info) { { 'sub' => 'sub', 'email' => ' I@email.com' } }
|
||||
|
||||
context 'and user_info returns some info' do
|
||||
before do
|
||||
expect(AgentConnectService).to receive(:user_info).and_return(user_info)
|
||||
end
|
||||
|
||||
context 'and the instructeur does not have an account yet' do
|
||||
before do
|
||||
expect(controller).to receive(:sign_in)
|
||||
end
|
||||
|
||||
it 'creates the user, signs in and redirects to procedure_path' do
|
||||
expect { subject }.to change { User.count }.by(1).and change { Instructeur.count }.by(1)
|
||||
|
||||
last_user = User.last
|
||||
|
||||
expect(last_user.email).to eq(email)
|
||||
expect(last_user.confirmed_at).to be_present
|
||||
expect(last_user.instructeur.agent_connect_id).to eq('sub')
|
||||
expect(response).to redirect_to(instructeur_procedures_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'and the instructeur already has an account' do
|
||||
let!(:instructeur) { create(:instructeur, email: email) }
|
||||
|
||||
before do
|
||||
expect(controller).to receive(:sign_in)
|
||||
end
|
||||
|
||||
it 'reuses the account, signs in and redirects to procedure_path' do
|
||||
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
|
||||
|
||||
instructeur.reload
|
||||
|
||||
expect(instructeur.agent_connect_id).to eq('sub')
|
||||
expect(response).to redirect_to(instructeur_procedures_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'and the instructeur already has an account as a user' do
|
||||
let!(:user) { create(:user, email: email) }
|
||||
|
||||
before do
|
||||
expect(controller).to receive(:sign_in)
|
||||
end
|
||||
|
||||
it 'reuses the account, signs in and redirects to procedure_path' do
|
||||
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(1)
|
||||
|
||||
instructeur = user.reload.instructeur
|
||||
|
||||
expect(instructeur.agent_connect_id).to eq('sub')
|
||||
expect(response).to redirect_to(instructeur_procedures_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'but user_info raises and error' do
|
||||
before do
|
||||
expect(AgentConnectService).to receive(:user_info).and_raise(Rack::OAuth2::Client::Error.new(500, error: 'Unknown'))
|
||||
end
|
||||
|
||||
it 'aborts the processus' do
|
||||
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
|
||||
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the callback code is blank' do
|
||||
let(:code) { '' }
|
||||
|
||||
it 'aborts the processus' do
|
||||
expect { subject }.to change { User.count }.by(0).and change { Instructeur.count }.by(0)
|
||||
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
RSpec.describe GraphqlOperationLogConcern, type: :controller do
|
||||
class TestController < ActionController::Base
|
||||
include GraphqlOperationLogConcern
|
||||
end
|
||||
|
||||
controller TestController do
|
||||
end
|
||||
|
||||
describe '#operation_log' do
|
||||
let(:query) { nil }
|
||||
let(:variables) { nil }
|
||||
let(:operation_name) { nil }
|
||||
|
||||
subject { controller.operation_log(query, operation_name, variables) }
|
||||
|
||||
context 'with no query' do
|
||||
it { expect(subject).to eq('NoQuery') }
|
||||
end
|
||||
|
||||
context 'with invalid query' do
|
||||
let(:query) { 'query { demarche {} }' }
|
||||
|
||||
it { expect(subject).to eq('InvalidQuery') }
|
||||
end
|
||||
|
||||
context 'with two queries' do
|
||||
let(:query) { 'query demarche { demarche } query dossier { dossier }' }
|
||||
let(:operation_name) { 'dossier' }
|
||||
|
||||
it { expect(subject).to eq('query: dossier { dossier }') }
|
||||
end
|
||||
|
||||
context 'with arguments' do
|
||||
let(:query) { 'query demarche { demarche(number: 123) { id } }' }
|
||||
|
||||
it { expect(subject).to eq('query: demarche { demarche } number: "123"') }
|
||||
end
|
||||
|
||||
context 'with variables' do
|
||||
let(:query) { 'query { demarche(number: 123) { id } }' }
|
||||
let(:variables) { { number: 124 } }
|
||||
|
||||
it { expect(subject).to eq('query: { demarche } number: "124"') }
|
||||
end
|
||||
|
||||
context 'with mutation and arguments' do
|
||||
let(:query) { 'mutation { passerDossierEnInstruction(input: { number: 123 }) { id } }' }
|
||||
|
||||
it { expect(subject).to eq('mutation: { passerDossierEnInstruction } number: "123"') }
|
||||
end
|
||||
|
||||
context 'with mutation and variables' do
|
||||
let(:query) { 'mutation { passerDossierEnInstruction(input: { number: 123 }) { id } }' }
|
||||
let(:variables) { { input: { number: 124 } } }
|
||||
|
||||
it { expect(subject).to eq('mutation: { passerDossierEnInstruction } number: "124"') }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -131,7 +131,7 @@ describe NewAdministrateur::ServicesController, type: :controller do
|
|||
end
|
||||
|
||||
it { expect(service.reload).not_to be_nil }
|
||||
it { expect(flash.alert).to eq("la démarche #{procedure.libelle} utilise encore le service service. Veuillez l'affecter à un autre service avant de pouvoir le supprimer") }
|
||||
it { expect(flash.alert).to eq("la démarche #{procedure.libelle} utilise encore le service #{service.nom}. Veuillez l'affecter à un autre service avant de pouvoir le supprimer") }
|
||||
it { expect(flash.notice).to be_nil }
|
||||
it { expect(response).to redirect_to(admin_services_path(procedure_id: 12)) }
|
||||
end
|
||||
|
|
|
@ -1,15 +1,35 @@
|
|||
describe RechercheController, type: :controller do
|
||||
let(:dossier) { create(:dossier, :en_construction, :with_populated_annotations) }
|
||||
let(:dossier2) { create(:dossier, :en_construction, procedure: dossier.procedure) }
|
||||
let(:procedure) {
|
||||
create(:procedure,
|
||||
:published,
|
||||
:for_individual,
|
||||
:with_type_de_champ,
|
||||
:with_type_de_champ_private,
|
||||
types_de_champ_count: 2,
|
||||
types_de_champ_private_count: 2)
|
||||
}
|
||||
let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
||||
let(:instructeur) { create(:instructeur) }
|
||||
|
||||
let(:dossier_with_expert) { avis.dossier }
|
||||
let(:avis) { create(:avis, dossier: create(:dossier, :en_construction, :with_populated_annotations)) }
|
||||
let(:dossier_with_expert) { create(:dossier, :en_construction, :with_individual, procedure: procedure) }
|
||||
let(:avis) { create(:avis, dossier: dossier_with_expert) }
|
||||
|
||||
let(:user) { instructeur.user }
|
||||
|
||||
before do
|
||||
instructeur.assign_to_procedure(dossier.procedure)
|
||||
|
||||
dossier.champs[0].value = "Name of district A"
|
||||
dossier.champs[1].value = "Name of city A"
|
||||
dossier.champs_private[0].value = "Dossier A is complete"
|
||||
dossier.champs_private[1].value = "Dossier A is valid"
|
||||
dossier.save!
|
||||
|
||||
dossier_with_expert.champs[0].value = "Name of district B"
|
||||
dossier_with_expert.champs[1].value = "name of city B"
|
||||
dossier_with_expert.champs_private[0].value = "Dossier B is incomplete"
|
||||
dossier_with_expert.champs_private[1].value = "Dossier B is invalid"
|
||||
dossier_with_expert.save!
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
|
@ -46,8 +66,8 @@ describe RechercheController, type: :controller do
|
|||
end
|
||||
|
||||
context 'when instructeur do not own the dossier' do
|
||||
let(:dossier3) { create(:dossier, :en_construction) }
|
||||
let(:query) { dossier3.id }
|
||||
let(:dossier2) { create(:dossier, :en_construction) }
|
||||
let(:query) { dossier2.id }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
|
@ -69,29 +89,49 @@ describe RechercheController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'by private annotations' do
|
||||
context 'when instructeur search by private annotations' do
|
||||
let(:query) { dossier.private_search_terms }
|
||||
describe 'by champs' do
|
||||
let(:query) { 'district A' }
|
||||
|
||||
before { subject }
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier.id)
|
||||
end
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier.id)
|
||||
end
|
||||
|
||||
context 'when expert search by private annotations' do
|
||||
context 'as an expert' do
|
||||
let(:user) { avis.experts_procedure.expert.user }
|
||||
let(:query) { dossier_with_expert.private_search_terms }
|
||||
|
||||
before { subject }
|
||||
let(:query) { 'district' }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns 0 dossiers' do
|
||||
it 'returns only the dossier available to the expert' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier_with_expert.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'by private annotations' do
|
||||
let(:query) { 'invalid' }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'returns the expected dossier' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(1)
|
||||
expect(assigns(:projected_dossiers).first.dossier_id).to eq(dossier_with_expert.id)
|
||||
end
|
||||
|
||||
context 'as an expert' do
|
||||
let(:user) { avis.experts_procedure.expert.user }
|
||||
|
||||
it { is_expected.to have_http_status(200) }
|
||||
|
||||
it 'does not allow experts to search in private annotations' do
|
||||
expect(assigns(:projected_dossiers).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1125,4 +1125,14 @@ describe Users::DossiersController, type: :controller do
|
|||
it { is_expected.to be_falsy }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#index' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
it 'works' do
|
||||
get :index
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -190,7 +190,7 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
factory :champ_siret, class: 'Champs::SiretChamp' do
|
||||
association :type_de_champ, factory: [:type_de_champ_siret]
|
||||
type_de_champ { association :type_de_champ_siret, procedure: dossier.procedure }
|
||||
association :etablissement, factory: [:etablissement]
|
||||
value { '44011762001530' }
|
||||
end
|
||||
|
|
|
@ -2,27 +2,19 @@ FactoryBot.define do
|
|||
factory :dossier do
|
||||
autorisation_donnees { true }
|
||||
state { Dossier.states.fetch(:brouillon) }
|
||||
association :user
|
||||
|
||||
user { association :user }
|
||||
groupe_instructeur { procedure.routee? ? nil : procedure.defaut_groupe_instructeur }
|
||||
revision { procedure.active_revision }
|
||||
individual { association(:individual, :empty, dossier: instance, strategy: :build) if procedure.for_individual? }
|
||||
|
||||
transient do
|
||||
procedure { nil }
|
||||
end
|
||||
|
||||
after(:build) do |dossier, evaluator|
|
||||
if evaluator.procedure.present?
|
||||
procedure = evaluator.procedure
|
||||
else
|
||||
procedure = create(:procedure, :published, :with_type_de_champ, :with_type_de_champ_private)
|
||||
end
|
||||
|
||||
dossier.revision = procedure.active_revision
|
||||
|
||||
# Assign the procedure to the dossier through the groupe_instructeur
|
||||
if dossier.groupe_instructeur.nil?
|
||||
dossier.groupe_instructeur = procedure.routee? ? nil : procedure.defaut_groupe_instructeur
|
||||
end
|
||||
|
||||
dossier.build_default_individual
|
||||
for_individual? { false }
|
||||
# For now a dossier must use a `create`d procedure, even if the dossier is only built (and not created).
|
||||
# This is because saving the dossier fails when the procedure has not been saved beforehand
|
||||
# (due to some internal ActiveRecord error).
|
||||
# TODO: find a way to find the issue and just `build` the procedure.
|
||||
procedure { create(:procedure, :published, :with_type_de_champ, :with_type_de_champ_private, for_individual: for_individual?) }
|
||||
end
|
||||
|
||||
trait :with_entreprise do
|
||||
|
@ -42,12 +34,11 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :with_individual do
|
||||
after(:build) do |dossier, evaluator|
|
||||
# If the procedure was implicitely created by the factory,
|
||||
# mark it automatically as for_individual.
|
||||
if evaluator.procedure.nil?
|
||||
dossier.procedure.update(for_individual: true)
|
||||
end
|
||||
transient do
|
||||
for_individual? { true }
|
||||
end
|
||||
|
||||
after(:build) do |dossier, _evaluator|
|
||||
if !dossier.procedure.for_individual?
|
||||
raise 'Inconsistent factory: attempting to create a dossier :with_individual on a procedure that is not `for_individual?`'
|
||||
end
|
||||
|
@ -99,9 +90,7 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :with_commentaires do
|
||||
after(:create) do |dossier, _evaluator|
|
||||
dossier.commentaires += create_list(:commentaire, 2)
|
||||
end
|
||||
commentaires { [build(:commentaire), build(:commentaire)] }
|
||||
end
|
||||
|
||||
trait :followed do
|
||||
|
|
|
@ -4,5 +4,9 @@ FactoryBot.define do
|
|||
factory :groupe_instructeur do
|
||||
label { generate(:groupe_label) }
|
||||
association :procedure
|
||||
|
||||
trait :default do
|
||||
label { GroupeInstructeur::DEFAUT_LABEL }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,5 +5,12 @@ FactoryBot.define do
|
|||
prenom { 'Xavier' }
|
||||
birthdate { Date.new(1991, 11, 01) }
|
||||
association :dossier
|
||||
|
||||
trait :empty do
|
||||
gender { nil }
|
||||
nom { nil }
|
||||
prenom { nil }
|
||||
birthdate { nil }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
FactoryBot.define do
|
||||
sequence(:published_path) { |n| "fake_path#{n}" }
|
||||
|
||||
factory :procedure do
|
||||
sequence(:libelle) { |n| "Procedure #{n}" }
|
||||
description { "Demande de subvention à l'intention des associations" }
|
||||
|
@ -12,6 +13,9 @@ FactoryBot.define do
|
|||
lien_site_web { "https://mon-site.gouv" }
|
||||
path { SecureRandom.uuid }
|
||||
|
||||
groupe_instructeurs { [association(:groupe_instructeur, :default, procedure: instance, strategy: :build)] }
|
||||
administrateurs { administrateur.present? ? [administrateur] : [association(:administrateur)] }
|
||||
|
||||
transient do
|
||||
administrateur { }
|
||||
instructeurs { [] }
|
||||
|
@ -21,29 +25,16 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
after(:build) do |procedure, evaluator|
|
||||
if evaluator.administrateur
|
||||
procedure.administrateurs = [evaluator.administrateur]
|
||||
elsif procedure.administrateurs.empty?
|
||||
procedure.administrateurs = [build(:administrateur)]
|
||||
end
|
||||
procedure.draft_revision = build(:procedure_revision, procedure: procedure)
|
||||
initial_revision = build(:procedure_revision, procedure: procedure)
|
||||
add_types_de_champs(evaluator.types_de_champ, to: initial_revision, scope: :public)
|
||||
add_types_de_champs(evaluator.types_de_champ_private, to: initial_revision, scope: :private)
|
||||
|
||||
evaluator.types_de_champ.each do |type_de_champ|
|
||||
type_de_champ.revision = procedure.draft_revision
|
||||
type_de_champ.private = false
|
||||
type_de_champ.revision.revision_types_de_champ << build(:procedure_revision_type_de_champ,
|
||||
revision: procedure.draft_revision,
|
||||
position: type_de_champ.order_place,
|
||||
type_de_champ: type_de_champ)
|
||||
end
|
||||
|
||||
evaluator.types_de_champ_private.each do |type_de_champ|
|
||||
type_de_champ.revision = procedure.draft_revision
|
||||
type_de_champ.private = true
|
||||
type_de_champ.revision.revision_types_de_champ_private << build(:procedure_revision_type_de_champ,
|
||||
revision: procedure.draft_revision,
|
||||
position: type_de_champ.order_place,
|
||||
type_de_champ: type_de_champ)
|
||||
if procedure.brouillon?
|
||||
procedure.draft_revision = initial_revision
|
||||
else
|
||||
procedure.published_revision = initial_revision
|
||||
procedure.published_revision.published_at = Time.zone.now
|
||||
procedure.draft_revision = build(:procedure_revision, from_original: initial_revision)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -71,11 +62,12 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
factory :simple_procedure do
|
||||
published
|
||||
|
||||
for_individual { true }
|
||||
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.for_individual = true
|
||||
build(:type_de_champ, libelle: 'Texte obligatoire', mandatory: true, procedure: procedure)
|
||||
procedure.path = generate(:published_path)
|
||||
procedure.publish!
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -88,9 +80,7 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :with_service do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.service = create(:service)
|
||||
end
|
||||
service { association :service, administrateur: administrateurs.first }
|
||||
end
|
||||
|
||||
trait :with_instructeur do
|
||||
|
@ -106,9 +96,7 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :for_individual do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.for_individual = true
|
||||
end
|
||||
for_individual { true }
|
||||
end
|
||||
|
||||
trait :with_auto_archive do
|
||||
|
@ -218,26 +206,27 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :published do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.path = generate(:published_path)
|
||||
procedure.publish!
|
||||
end
|
||||
aasm_state { :publiee }
|
||||
path { generate(:published_path) }
|
||||
published_at { Time.zone.now }
|
||||
unpublished_at { nil }
|
||||
closed_at { nil }
|
||||
end
|
||||
|
||||
trait :closed do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.path = generate(:published_path)
|
||||
procedure.publish!
|
||||
procedure.close!
|
||||
end
|
||||
published
|
||||
|
||||
aasm_state { :close }
|
||||
published_at { Time.zone.now - 1.second }
|
||||
closed_at { Time.zone.now }
|
||||
end
|
||||
|
||||
trait :unpublished do
|
||||
after(:build) do |procedure, _evaluator|
|
||||
procedure.path = generate(:published_path)
|
||||
procedure.publish!
|
||||
procedure.unpublish!
|
||||
end
|
||||
published
|
||||
|
||||
aasm_state { :depubliee }
|
||||
published_at { Time.zone.now - 1.second }
|
||||
unpublished_at { Time.zone.now }
|
||||
end
|
||||
|
||||
trait :discarded do
|
||||
|
@ -308,3 +297,17 @@ FactoryBot.define do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_types_de_champs(types_de_champ, to: nil, scope: :public)
|
||||
revision = to
|
||||
association_name = scope == :private ? :revision_types_de_champ_private : :revision_types_de_champ
|
||||
|
||||
types_de_champ.each do |type_de_champ|
|
||||
type_de_champ.revision = revision
|
||||
type_de_champ.private = (scope == :private)
|
||||
type_de_champ.revision.public_send(association_name) << build(:procedure_revision_type_de_champ,
|
||||
revision: revision,
|
||||
position: type_de_champ.order_place,
|
||||
type_de_champ: type_de_champ)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,21 @@
|
|||
FactoryBot.define do
|
||||
factory :procedure_revision do
|
||||
transient do
|
||||
from_original { nil }
|
||||
end
|
||||
|
||||
after(:build) do |revision, evaluator|
|
||||
if evaluator.from_original
|
||||
original = evaluator.from_original
|
||||
|
||||
revision.procedure = original.procedure
|
||||
original.revision_types_de_champ.each do |r_tdc|
|
||||
revision.revision_types_de_champ << build(:procedure_revision_type_de_champ, from_original: r_tdc)
|
||||
end
|
||||
original.revision_types_de_champ_private.each do |r_tdc|
|
||||
revision.revision_types_de_champ_private << build(:procedure_revision_type_de_champ, from_original: r_tdc)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,16 @@
|
|||
FactoryBot.define do
|
||||
factory :procedure_revision_type_de_champ do
|
||||
transient do
|
||||
from_original { nil }
|
||||
end
|
||||
|
||||
after(:build) do |revision_type_de_champ, evaluator|
|
||||
if evaluator.from_original
|
||||
original = evaluator.from_original
|
||||
|
||||
revision_type_de_champ.type_de_champ = original.type_de_champ
|
||||
revision_type_de_champ.position = original.position
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :service do
|
||||
nom { 'service' }
|
||||
sequence(:nom) { |n| "Service #{n}" }
|
||||
organisme { 'organisme' }
|
||||
type_organisme { Service.type_organismes.fetch(:association) }
|
||||
email { 'email@toto.com' }
|
||||
|
|
8
spec/factories/traitement.rb
Normal file
8
spec/factories/traitement.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
FactoryBot.define do
|
||||
factory :traitement do
|
||||
trait :accepte do
|
||||
process_expired { true }
|
||||
state { :accepte }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -49,7 +49,7 @@ RSpec.describe DossierHelper, type: :helper do
|
|||
let(:procedure) { create(:simple_procedure, :for_individual) }
|
||||
|
||||
context "when the individual is not provided" do
|
||||
let(:individual) { nil }
|
||||
let(:individual) { build(:individual, :empty) }
|
||||
it { is_expected.to be_blank }
|
||||
end
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ RSpec.describe APIEntreprise::Job, type: :job do
|
|||
describe '#perform' do
|
||||
let(:dossier) { create(:dossier, :with_entreprise) }
|
||||
|
||||
context 'when a un retryable error is raised' do
|
||||
context 'when an un-retriable error is raised' do
|
||||
let(:errors) { [:standard_error] }
|
||||
|
||||
it 'does not retry' do
|
||||
|
@ -14,7 +14,7 @@ RSpec.describe APIEntreprise::Job, type: :job do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when a retryable error is raised' do
|
||||
context 'when a retriable error is raised' do
|
||||
let(:errors) { [:service_unavaible, :bad_gateway, :timed_out] }
|
||||
|
||||
it 'retries 5 times' do
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
RSpec.describe ApplicationMailer, type: :mailer do
|
||||
describe 'dealing with invalid emails' do
|
||||
let(:dossier) { create(:dossier, procedure: build(:simple_procedure)) }
|
||||
let(:dossier) { create(:dossier, procedure: create(:simple_procedure)) }
|
||||
subject { DossierMailer.notify_new_draft(dossier) }
|
||||
|
||||
describe 'invalid emails are not sent' do
|
||||
|
|
|
@ -12,7 +12,7 @@ RSpec.describe DossierMailer, type: :mailer do
|
|||
end
|
||||
|
||||
describe '.notify_new_draft' do
|
||||
let(:dossier) { create(:dossier, procedure: build(:simple_procedure, :with_auto_archive)) }
|
||||
let(:dossier) { create(:dossier, procedure: create(:simple_procedure, :with_auto_archive)) }
|
||||
|
||||
subject { described_class.notify_new_draft(dossier) }
|
||||
|
||||
|
@ -27,7 +27,7 @@ RSpec.describe DossierMailer, type: :mailer do
|
|||
end
|
||||
|
||||
describe '.notify_new_answer with dossier brouillon' do
|
||||
let(:dossier) { create(:dossier, procedure: build(:simple_procedure)) }
|
||||
let(:dossier) { create(:dossier, procedure: create(:simple_procedure)) }
|
||||
let(:commentaire) { create(:commentaire, dossier: dossier) }
|
||||
subject { described_class.with(commentaire: commentaire).notify_new_answer }
|
||||
|
||||
|
@ -39,8 +39,9 @@ RSpec.describe DossierMailer, type: :mailer do
|
|||
end
|
||||
|
||||
describe '.notify_new_answer with dossier en construction' do
|
||||
let(:dossier) { create(:dossier, state: "en_construction", procedure: build(:simple_procedure)) }
|
||||
let(:dossier) { create(:dossier, :en_construction, procedure: create(:simple_procedure)) }
|
||||
let(:commentaire) { create(:commentaire, dossier: dossier) }
|
||||
|
||||
subject { described_class.with(commentaire: commentaire).notify_new_answer }
|
||||
|
||||
it { expect(subject.subject).to include("Nouveau message") }
|
||||
|
@ -51,7 +52,7 @@ RSpec.describe DossierMailer, type: :mailer do
|
|||
end
|
||||
|
||||
describe '.notify_new_answer with commentaire discarded' do
|
||||
let(:dossier) { create(:dossier, procedure: build(:simple_procedure)) }
|
||||
let(:dossier) { create(:dossier, procedure: create(:simple_procedure)) }
|
||||
let(:commentaire) { create(:commentaire, dossier: dossier, discarded_at: 2.minutes.ago) }
|
||||
|
||||
subject { described_class.with(commentaire: commentaire).notify_new_answer }
|
||||
|
@ -83,7 +84,7 @@ RSpec.describe DossierMailer, type: :mailer do
|
|||
end
|
||||
|
||||
describe '.notify_revert_to_instruction' do
|
||||
let(:dossier) { create(:dossier, procedure: build(:simple_procedure)) }
|
||||
let(:dossier) { create(:dossier, procedure: create(:simple_procedure)) }
|
||||
|
||||
subject { described_class.notify_revert_to_instruction(dossier) }
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
describe Champs::IbanChamp do
|
||||
describe '#valid?' do
|
||||
it do
|
||||
|
|
|
@ -61,6 +61,16 @@ describe Dossier do
|
|||
|
||||
it { is_expected.not_to include(expiring_dossier) }
|
||||
end
|
||||
|
||||
context 'when .close_to_expiration' do
|
||||
subject { Dossier.close_to_expiration }
|
||||
it do
|
||||
is_expected.not_to include(young_dossier)
|
||||
is_expected.to include(expiring_dossier)
|
||||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'en_construction_close_to_expiration' do
|
||||
|
@ -87,6 +97,16 @@ describe Dossier do
|
|||
|
||||
it { is_expected.not_to include(expiring_dossier) }
|
||||
end
|
||||
|
||||
context 'when .close_to_expiration' do
|
||||
subject { Dossier.close_to_expiration }
|
||||
it do
|
||||
is_expected.not_to include(young_dossier)
|
||||
is_expected.to include(expiring_dossier)
|
||||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'en_instruction_close_to_expiration' do
|
||||
|
@ -104,6 +124,43 @@ describe Dossier do
|
|||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
|
||||
context 'when .close_to_expiration' do
|
||||
subject { Dossier.close_to_expiration }
|
||||
it do
|
||||
is_expected.not_to include(young_dossier)
|
||||
is_expected.to include(expiring_dossier)
|
||||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'termine_close_to_expiration' do
|
||||
let(:procedure) { create(:procedure, :published, duree_conservation_dossiers_dans_ds: 6) }
|
||||
let!(:young_dossier) { create(:dossier, :accepte, procedure: procedure, traitements: [build(:traitement, :accepte)]) }
|
||||
let!(:expiring_dossier) { create(:dossier, :accepte, procedure: procedure, traitements: [build(:traitement, :accepte, processed_at: 175.days.ago)]) }
|
||||
let!(:just_expired_dossier) { create(:dossier, :accepte, procedure: procedure, traitements: [build(:traitement, :accepte, processed_at: (6.months + 1.hour + 10.seconds).ago)]) }
|
||||
let!(:long_expired_dossier) { create(:dossier, :accepte, procedure: procedure, traitements: [build(:traitement, :accepte, processed_at: 1.year.ago)]) }
|
||||
|
||||
subject { Dossier.termine_close_to_expiration }
|
||||
|
||||
it do
|
||||
is_expected.not_to include(young_dossier)
|
||||
is_expected.to include(expiring_dossier)
|
||||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
|
||||
context 'when .close_to_expiration' do
|
||||
subject { Dossier.close_to_expiration }
|
||||
it do
|
||||
is_expected.not_to include(young_dossier)
|
||||
is_expected.to include(expiring_dossier)
|
||||
is_expected.to include(just_expired_dossier)
|
||||
is_expected.to include(long_expired_dossier)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with_notifications' do
|
||||
|
@ -193,11 +250,21 @@ describe Dossier do
|
|||
expect(dossier.champs.count).to eq(1)
|
||||
expect(dossier.champs_private.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_default_individual' do
|
||||
let(:dossier) { build(:dossier, procedure: procedure, user: user) }
|
||||
|
||||
subject do
|
||||
dossier.individual = nil
|
||||
dossier.build_default_individual
|
||||
end
|
||||
|
||||
context 'when the dossier belongs to a procedure for individuals' do
|
||||
let(:procedure) { create(:procedure, :with_type_de_champ, for_individual: true) }
|
||||
let(:procedure) { create(:procedure, for_individual: true) }
|
||||
|
||||
it 'creates a default individual' do
|
||||
subject
|
||||
expect(dossier.individual).to be_present
|
||||
expect(dossier.individual.nom).to be_nil
|
||||
expect(dossier.individual.prenom).to be_nil
|
||||
|
@ -209,6 +276,7 @@ describe Dossier do
|
|||
let(:user) { build(:user, france_connect_information: france_connect_information) }
|
||||
|
||||
it 'fills the individual with the informations from France Connect' do
|
||||
subject
|
||||
expect(dossier.individual.nom).to eq('DUBOIS')
|
||||
expect(dossier.individual.prenom).to eq('Angela Claire Louise')
|
||||
expect(dossier.individual.gender).to eq(Individual::GENDER_FEMALE)
|
||||
|
@ -217,9 +285,10 @@ describe Dossier do
|
|||
end
|
||||
|
||||
context 'when the dossier belongs to a procedure for moral personas' do
|
||||
let(:procedure) { create(:procedure, :with_type_de_champ, for_individual: false) }
|
||||
let(:procedure) { create(:procedure, for_individual: false) }
|
||||
|
||||
it 'doesn’t create a individual' do
|
||||
subject
|
||||
expect(dossier.individual).to be_nil
|
||||
end
|
||||
end
|
||||
|
@ -387,6 +456,7 @@ describe Dossier do
|
|||
|
||||
it { expect(dossier.state).to eq(Dossier.states.fetch(:en_construction)) }
|
||||
it { expect(dossier.en_construction_at).to eq(beginning_of_day) }
|
||||
it { expect(dossier.depose_at).to eq(beginning_of_day) }
|
||||
it { expect(dossier.traitement.state).to eq(Dossier.states.fetch(:en_construction)) }
|
||||
it { expect(dossier.traitement.processed_at).to eq(beginning_of_day) }
|
||||
|
||||
|
@ -398,6 +468,7 @@ describe Dossier do
|
|||
expect(dossier.traitements.size).to eq(3)
|
||||
expect(dossier.traitements.first.processed_at).to eq(beginning_of_day)
|
||||
expect(dossier.traitement.processed_at.round).to eq(dossier.en_construction_at.round)
|
||||
expect(dossier.depose_at).to eq(beginning_of_day)
|
||||
expect(dossier.en_construction_at).to be > beginning_of_day
|
||||
end
|
||||
end
|
||||
|
@ -421,8 +492,9 @@ describe Dossier do
|
|||
dossier.repasser_en_construction!(instructeur)
|
||||
dossier.passer_en_instruction!(instructeur)
|
||||
|
||||
expect(dossier.traitements.size).to eq(3)
|
||||
expect(dossier.traitements.first.processed_at).to eq(beginning_of_day)
|
||||
expect(dossier.traitements.size).to eq(4)
|
||||
expect(dossier.traitements.en_construction.first.processed_at).to eq(dossier.depose_at)
|
||||
expect(dossier.traitements.en_instruction.first.processed_at).to eq(beginning_of_day)
|
||||
expect(dossier.traitement.processed_at.round).to eq(dossier.en_instruction_at.round)
|
||||
expect(dossier.en_instruction_at).to be > beginning_of_day
|
||||
end
|
||||
|
|
|
@ -262,14 +262,14 @@ describe Instructeur, type: :model do
|
|||
end
|
||||
|
||||
describe '#notifications_for_groupe_instructeurs' do
|
||||
# one procedure, one group, 2 instructeurs
|
||||
# a procedure, one group, 2 instructeurs
|
||||
let(:procedure) { create(:simple_procedure, :routee, :with_type_de_champ_private, :for_individual) }
|
||||
let(:gi_p1) { procedure.groupe_instructeurs.last }
|
||||
let!(:dossier) { create(:dossier, :with_individual, :followed, groupe_instructeur: gi_p1, state: Dossier.states.fetch(:en_construction)) }
|
||||
let!(:dossier) { create(:dossier, :with_individual, :followed, procedure: procedure, groupe_instructeur: gi_p1, state: Dossier.states.fetch(:en_construction)) }
|
||||
let(:instructeur) { dossier.follows.first.instructeur }
|
||||
let!(:instructeur_2) { create(:instructeur, groupe_instructeurs: [gi_p1]) }
|
||||
|
||||
# one other procedure, dossier followed by a third instructeur
|
||||
# another procedure, dossier followed by a third instructeur
|
||||
let!(:dossier_on_procedure_2) { create(:dossier, :followed, state: Dossier.states.fetch(:en_construction)) }
|
||||
let!(:instructeur_on_procedure_2) { dossier_on_procedure_2.follows.first.instructeur }
|
||||
let(:gi_p2) { dossier.groupe_instructeur }
|
||||
|
|
|
@ -16,7 +16,7 @@ describe ProcedurePresentation do
|
|||
|
||||
context 'for a published procedure' do
|
||||
let(:procedure) { create(:procedure, :published) }
|
||||
let!(:tdc) { { type_champ: :number, libelle: 'libelle 1' } }
|
||||
let(:tdc) { { type_champ: :number, libelle: 'libelle 1' } }
|
||||
|
||||
before do
|
||||
procedure.draft_revision.add_type_de_champ(tdc)
|
||||
|
@ -26,7 +26,7 @@ describe ProcedurePresentation do
|
|||
it { is_expected.to match(['libelle 1']) }
|
||||
|
||||
context 'when there is another published revision with an added tdc' do
|
||||
let!(:added_tdc) { { type_champ: :number, libelle: 'libelle 2' } }
|
||||
let(:added_tdc) { { type_champ: :number, libelle: 'libelle 2' } }
|
||||
|
||||
before do
|
||||
procedure.draft_revision.add_type_de_champ(added_tdc)
|
||||
|
@ -37,7 +37,7 @@ describe ProcedurePresentation do
|
|||
end
|
||||
|
||||
context 'add one tdc above the first one' do
|
||||
let!(:tdc2) { { type_champ: :number, libelle: 'libelle 2' } }
|
||||
let(:tdc2) { { type_champ: :number, libelle: 'libelle 2' } }
|
||||
|
||||
before do
|
||||
created_tdc2 = procedure.draft_revision.add_type_de_champ(tdc2)
|
||||
|
@ -47,7 +47,7 @@ describe ProcedurePresentation do
|
|||
|
||||
it { is_expected.to match(['libelle 2', 'libelle 1']) }
|
||||
|
||||
context 'and finaly, when this tdc is removed' do
|
||||
context 'and finally, when this tdc is removed' do
|
||||
let!(:previous_tdc2) { procedure.published_revision.types_de_champ.find_by(libelle: 'libelle 2') }
|
||||
|
||||
before do
|
||||
|
|
|
@ -320,8 +320,8 @@ describe ProcedurePresentation do
|
|||
|
||||
let(:procedure) { create(:procedure, :for_individual) }
|
||||
|
||||
let!(:first_dossier) { create(:dossier, procedure: procedure, individual: create(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) }
|
||||
let!(:last_dossier) { create(:dossier, procedure: procedure, individual: create(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) }
|
||||
let!(:first_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) }
|
||||
let!(:last_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) }
|
||||
|
||||
context 'for gender column' do
|
||||
let(:column) { 'gender' }
|
||||
|
@ -617,8 +617,8 @@ describe ProcedurePresentation do
|
|||
|
||||
context 'for individual table' do
|
||||
let(:procedure) { create(:procedure, :for_individual) }
|
||||
let!(:kept_dossier) { create(:dossier, procedure: procedure, individual: create(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) }
|
||||
let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: create(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) }
|
||||
let!(:kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) }
|
||||
let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) }
|
||||
|
||||
context 'for gender column' do
|
||||
let(:filter) { [{ 'table' => 'individual', 'column' => 'gender', 'value' => 'Mme' }] }
|
||||
|
@ -646,7 +646,7 @@ describe ProcedurePresentation do
|
|||
]
|
||||
end
|
||||
|
||||
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: create(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) }
|
||||
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) }
|
||||
|
||||
it 'returns every dossier that matches any of the search criteria for a given column' do
|
||||
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
|
||||
|
|
|
@ -695,6 +695,50 @@ describe Procedure do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#publish_revision!" do
|
||||
let(:procedure) { create(:procedure, :published) }
|
||||
let(:tdc_attributes) { { type_champ: :number, libelle: 'libelle 1' } }
|
||||
let(:publication_date) { Time.zone.local(2021, 1, 1, 12, 00, 00) }
|
||||
|
||||
before do
|
||||
procedure.draft_revision.add_type_de_champ(tdc_attributes)
|
||||
end
|
||||
|
||||
subject do
|
||||
Timecop.freeze(publication_date) do
|
||||
procedure.publish_revision!
|
||||
end
|
||||
end
|
||||
|
||||
it 'publishes the new revision' do
|
||||
subject
|
||||
expect(procedure.published_revision).to be_present
|
||||
expect(procedure.published_revision.published_at).to eq(publication_date)
|
||||
expect(procedure.published_revision.types_de_champ.first.libelle).to eq('libelle 1')
|
||||
end
|
||||
|
||||
it 'creates a new draft revision' do
|
||||
expect { subject }.to change(ProcedureRevision, :count).by(1)
|
||||
expect(procedure.draft_revision).to be_present
|
||||
expect(procedure.draft_revision.revision_types_de_champ).to be_present
|
||||
expect(procedure.draft_revision.types_de_champ).to be_present
|
||||
expect(procedure.draft_revision.types_de_champ.first.libelle).to eq('libelle 1')
|
||||
end
|
||||
|
||||
context 'when the procedure has dossiers' do
|
||||
let(:dossier_draft) { create(:dossier, :brouillon, procedure: procedure) }
|
||||
let(:dossier_submitted) { create(:dossier, :en_construction, procedure: procedure) }
|
||||
|
||||
before { [dossier_draft, dossier_submitted] }
|
||||
|
||||
it 'enqueues rebase jobs for draft dossiers' do
|
||||
subject
|
||||
expect(DossierRebaseJob).to have_been_enqueued.with(dossier_draft)
|
||||
expect(DossierRebaseJob).not_to have_been_enqueued.with(dossier_submitted)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#unpublish!" do
|
||||
let(:procedure) { create(:procedure, :published) }
|
||||
let(:now) { Time.zone.now.beginning_of_minute }
|
||||
|
|
|
@ -21,11 +21,11 @@ describe DossierSerializer do
|
|||
let(:dossier) { create(:dossier, :en_construction, procedure: create(:procedure, :published, :with_type_de_champ)) }
|
||||
|
||||
before do
|
||||
dossier.champs << build(:champ_carte)
|
||||
dossier.champs << build(:champ_siret)
|
||||
dossier.champs << build(:champ_integer_number)
|
||||
dossier.champs << build(:champ_decimal_number)
|
||||
dossier.champs << build(:champ_linked_drop_down_list)
|
||||
dossier.champs << build(:champ_carte, dossier: dossier)
|
||||
dossier.champs << build(:champ_siret, dossier: dossier)
|
||||
dossier.champs << build(:champ_integer_number, dossier: dossier)
|
||||
dossier.champs << build(:champ_decimal_number, dossier: dossier)
|
||||
dossier.champs << build(:champ_linked_drop_down_list, dossier: dossier)
|
||||
end
|
||||
|
||||
it {
|
||||
|
|
|
@ -92,7 +92,7 @@ describe DossierProjectionService do
|
|||
context 'for individual table' do
|
||||
let(:table) { 'individual' }
|
||||
let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) }
|
||||
let(:dossier) { create(:dossier, procedure: procedure, individual: create(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) }
|
||||
let(:dossier) { create(:dossier, procedure: procedure, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) }
|
||||
|
||||
context 'for prenom column' do
|
||||
let(:column) { 'prenom' }
|
||||
|
|
|
@ -9,7 +9,7 @@ end
|
|||
|
||||
Capybara.register_driver :headless_chrome do |app|
|
||||
options = Selenium::WebDriver::Chrome::Options.new
|
||||
options.add_argument('--headless')
|
||||
options.add_argument('--headless') unless ENV['NO_HEADLESS']
|
||||
options.add_argument('--window-size=1440,900')
|
||||
|
||||
capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
describe 'wcag rules for usager', js: true do
|
||||
let(:procedure) { create(:procedure, :with_type_de_champ, :with_all_champs, :with_service, :for_individual, :published) }
|
||||
let(:procedure) { create(:procedure, :published, :with_all_champs, :with_service, :for_individual) }
|
||||
let(:password) { 'a very complicated password' }
|
||||
let(:litteraire_user) { create(:user, password: password) }
|
||||
|
||||
|
|
|
@ -165,6 +165,27 @@ describe 'The user' do
|
|||
expect(page).to have_current_path(merci_dossier_path(user_dossier))
|
||||
end
|
||||
|
||||
scenario 'extends dossier experation date more than one time, ', js: true do
|
||||
user_old_dossier = create(:dossier,
|
||||
procedure: simple_procedure,
|
||||
created_at: simple_procedure.duree_conservation_dossiers_dans_ds.month.ago,
|
||||
user: user)
|
||||
login_as(user, scope: :user)
|
||||
visit brouillon_dossier_path(user_old_dossier)
|
||||
|
||||
expect(page).to have_css('.card-title', text: 'Votre dossier va expirer', visible: true)
|
||||
click_on "Repousser sa suppression"
|
||||
expect(page).not_to have_button("Repousser sa suppression")
|
||||
|
||||
Timecop.freeze(1.month.from_now) do
|
||||
visit brouillon_dossier_path(user_old_dossier)
|
||||
|
||||
expect(page).to have_css('.card-title', text: 'Votre dossier va expirer', visible: true)
|
||||
click_on "Repousser sa suppression"
|
||||
expect(page).not_to have_button("Repousser sa suppression")
|
||||
end
|
||||
end
|
||||
|
||||
let(:procedure_with_pj) do
|
||||
tdcs = [build(:type_de_champ_piece_justificative, mandatory: true, libelle: 'Pièce justificative')]
|
||||
create(:procedure, :published, :for_individual, types_de_champ: tdcs)
|
||||
|
|
|
@ -32,7 +32,7 @@ describe 'shared/dossiers/demande.html.haml', type: :view do
|
|||
end
|
||||
|
||||
context 'when dossier was created by an individual' do
|
||||
let(:individual) { create(:individual) }
|
||||
let(:individual) { build(:individual) }
|
||||
|
||||
it 'renders the individual identity infos' do
|
||||
expect(subject).to include(individual.gender)
|
||||
|
|
56
spec/views/shared/dossiers/_header.html.haml_spec.rb
Normal file
56
spec/views/shared/dossiers/_header.html.haml_spec.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
describe 'shared/dossiers/short_expires_message.html.haml', type: :view do
|
||||
include DossierHelper
|
||||
let(:dossier) do
|
||||
build(:dossier, state, attributes.merge(id: 1, state: state))
|
||||
end
|
||||
let(:i18n_key_state) { state }
|
||||
subject do
|
||||
render('shared/dossiers/short_expires_message.html.haml',
|
||||
dossier: dossier,
|
||||
current_user: build(:user))
|
||||
end
|
||||
|
||||
context 'with dossier.brouillon?' do
|
||||
let(:attributes) { { created_at: 6.months.ago } }
|
||||
let(:state) { :brouillon }
|
||||
|
||||
it 'render estimated expiration date' do
|
||||
expect(subject).to have_selector('.expires_at',
|
||||
text: I18n.t("shared.dossiers.header.expires_at.#{i18n_key_state}",
|
||||
date: safe_expiration_date(dossier)))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with dossier.en_construction?' do
|
||||
let(:attributes) { { en_construction_at: 6.months.ago } }
|
||||
let(:state) { :en_construction }
|
||||
|
||||
it 'render estimated expiration date' do
|
||||
expect(subject).to have_selector('.expires_at',
|
||||
text: I18n.t("shared.dossiers.header.expires_at.#{i18n_key_state}",
|
||||
date: safe_expiration_date(dossier)))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with dossier.en_instruction?' do
|
||||
let(:state) { :en_instruction }
|
||||
let(:attributes) { {} }
|
||||
|
||||
it 'render estimated expiration date' do
|
||||
expect(subject).to have_selector('p.expires_at_en_instruction',
|
||||
text: I18n.t("shared.dossiers.header.expires_at.#{i18n_key_state}"))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with dossier.en_processed_at?' do
|
||||
let(:state) { :accepte }
|
||||
let(:attributes) { {} }
|
||||
|
||||
it 'render estimated expiration date' do
|
||||
allow(dossier).to receive(:processed_at).and_return(6.months.ago)
|
||||
expect(subject).to have_selector('.expires_at',
|
||||
text: I18n.t("shared.dossiers.header.expires_at.#{i18n_key_state}",
|
||||
date: safe_expiration_date(dossier)))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -13,6 +13,7 @@ describe 'users/dossiers/index.html.haml', type: :view do
|
|||
assign(:dossiers_invites, Kaminari.paginate_array(dossiers_invites).page(1))
|
||||
assign(:dossiers_supprimes, Kaminari.paginate_array(user_dossiers).page(1))
|
||||
assign(:dossier_transfers, Kaminari.paginate_array([]).page(1))
|
||||
assign(:dossiers_close_to_expiration, Kaminari.paginate_array([]).page(1))
|
||||
assign(:statut, statut)
|
||||
render
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue