diff --git a/app/controllers/api/settings_controller.rb b/app/controllers/api/settings_controller.rb index 56ad1b596..4bf7a5155 100644 --- a/app/controllers/api/settings_controller.rb +++ b/app/controllers/api/settings_controller.rb @@ -29,7 +29,7 @@ class API::SettingsController < API::ApiController updated_settings = [] may_transaction params[:transactional] do params[:settings].each do |setting| - next if !setting[:name] || !setting[:value] + next if !setting[:name] || !setting[:value] || setting[:value].blank? db_setting = Setting.find_or_initialize_by(name: setting[:name]) if !SettingService.update_allowed?(db_setting) diff --git a/app/controllers/api/trainings_controller.rb b/app/controllers/api/trainings_controller.rb index 88c3c95a6..e3c09af31 100644 --- a/app/controllers/api/trainings_controller.rb +++ b/app/controllers/api/trainings_controller.rb @@ -77,7 +77,8 @@ class API::TrainingsController < API::ApiController def training_params params.require(:training) .permit(:id, :name, :description, :machine_ids, :plan_ids, :nb_total_places, :public_page, :disabled, - :auto_cancel, :auto_cancel_threshold, :auto_cancel_deadline, + :auto_cancel, :auto_cancel_threshold, :auto_cancel_deadline, :authorization, :authorization_period, + :invalidation, :invalidation_period, training_image_attributes: %i[id attachment], machine_ids: [], plan_ids: [], advanced_accounting_attributes: %i[code analytical_section]) end diff --git a/app/frontend/src/javascript/components/trainings/training-form.tsx b/app/frontend/src/javascript/components/trainings/training-form.tsx index 933466962..acaf4fa6e 100644 --- a/app/frontend/src/javascript/components/trainings/training-form.tsx +++ b/app/frontend/src/javascript/components/trainings/training-form.tsx @@ -39,12 +39,11 @@ export const TrainingForm: React.FC = ({ action, training, on const [machineModule, setMachineModule] = useState(null); const [isActiveAccounting, setIsActiveAccounting] = useState(false); - const [isActiveAuthorizationValidity, setIsActiveAuthorizationValidity] = useState(false); - const [isActiveValidationRule, setIsActiveValidationRule] = useState(false); - const { handleSubmit, register, control, setValue, formState } = useForm({ defaultValues: { ...training } }); const output = useWatch({ control }); const isActiveCancellation = useWatch({ control, name: 'auto_cancel' }) as boolean; + const isActiveAuthorizationValidity = useWatch({ control, name: 'authorization' }) as boolean; + const isActiveValidationRule = useWatch({ control, name: 'invalidation' }); useEffect(() => { SettingAPI.get('machines_module').then(setMachineModule).catch(onError); @@ -79,20 +78,6 @@ export const TrainingForm: React.FC = ({ action, training, on }).catch(error => onError(error)); }; - /** - * Callback triggered when the authorisation validity switch has changed. - */ - const toggleAuthorizationValidity = (value: boolean) => { - setIsActiveAuthorizationValidity(value); - }; - - /** - * Callback triggered when the authorisation validity switch has changed. - */ - const toggleValidationRule = (value: boolean) => { - setIsActiveValidationRule(value); - }; - return (
@@ -203,12 +188,12 @@ export const TrainingForm: React.FC = ({ action, training, on

{t('app.admin.training_form.authorization_validity_info')}

- {isActiveAuthorizationValidity && <> - = ({ action, training, on

{t('app.admin.training_form.validation_rule_info')}

- {isActiveValidationRule && <> - = ({ onError, o const { register, control, formState, handleSubmit, reset } = useForm>(); const isActiveAutoCancellation = useWatch({ control, name: 'trainings_auto_cancel' }) as boolean; - const [isActiveAuthorizationValidity, setIsActiveAuthorizationValidity] = useState(false); - const [isActiveValidationRule, setIsActiveValidationRule] = useState(false); + const isActiveAuthorizationValidity = useWatch({ control, name: 'trainings_authorization_validity' }) as boolean; + const isActiveInvalidationRule = useWatch({ control, name: 'trainings_invalidation_rule' }) as boolean; useEffect(() => { SettingAPI.query(trainingSettings) @@ -41,20 +41,6 @@ export const TrainingsSettings: React.FC = ({ onError, o .catch(onError); }, []); - /** - * Callback triggered when the authorisation validity switch has changed. - */ - const toggleAuthorizationValidity = (value: boolean) => { - setIsActiveAuthorizationValidity(value); - }; - - /** - * Callback triggered when the authorisation validity switch has changed. - */ - const toggleValidationRule = (value: boolean) => { - setIsActiveValidationRule(value); - }; - /** * Callback triggered when the form is submitted: save the settings */ @@ -119,12 +105,12 @@ export const TrainingsSettings: React.FC = ({ onError, o

{t('app.admin.trainings_settings.authorization_validity_info')}

- {isActiveAuthorizationValidity && <> - = ({ onError, o

{t('app.admin.trainings_settings.validation_rule_info')}

- - {isActiveValidationRule && <> - + diff --git a/app/frontend/src/javascript/components/trainings/trainings.tsx b/app/frontend/src/javascript/components/trainings/trainings.tsx index c887716d4..242a83811 100644 --- a/app/frontend/src/javascript/components/trainings/trainings.tsx +++ b/app/frontend/src/javascript/components/trainings/trainings.tsx @@ -159,16 +159,16 @@ export const Trainings: React.FC = ({ onError, onSuccess }) => {
{t('app.admin.trainings.authorisation')} -

+ {(training.authorization &&

{t('app.admin.trainings.active_true')} - |{t('app.admin.trainings.period_MONTH', { MONTH: 48 })} -

+ |{t('app.admin.trainings.period_MONTH', { MONTH: training.authorization_period })} +

) ||

---

}
{t('app.admin.trainings.validation_rule')}

- {t('app.admin.trainings.active_false')} + {training.invalidation ? t('app.admin.trainings.active_true') : t('app.admin.trainings.active_false')}

diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 8f0af92b4..ef96aefbb 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -236,7 +236,11 @@ export const storeSettings = [ export const trainingSettings = [ 'trainings_auto_cancel', 'trainings_auto_cancel_threshold', - 'trainings_auto_cancel_deadline' + 'trainings_auto_cancel_deadline', + 'trainings_authorization_validity', + 'trainings_authorization_validity_duration', + 'trainings_invalidation_rule', + 'trainings_invalidation_rule_period' ] as const; export const allSettings = [ diff --git a/app/frontend/src/javascript/models/training.ts b/app/frontend/src/javascript/models/training.ts index 6a73a75e2..4ad7a0a00 100644 --- a/app/frontend/src/javascript/models/training.ts +++ b/app/frontend/src/javascript/models/training.ts @@ -17,6 +17,10 @@ export interface Training { auto_cancel: boolean, auto_cancel_threshold: number, auto_cancel_deadline: number, + authorization: boolean, + authorization_period: number, + invalidation: boolean, + invalidation_period: number, availabilities?: Array<{ id: number, start_at: TDateISO, @@ -33,5 +37,5 @@ export interface Training { export interface TrainingIndexFilter extends ApiFilter { disabled?: boolean, public_page?: boolean, - requested_attributes?: ['availabillities'], + requested_attributes?: ['availabilities'], } diff --git a/app/models/concerns/user_ressources_concern.rb b/app/models/concerns/user_ressources_concern.rb index b7bcde8ba..84a7c7ed2 100644 --- a/app/models/concerns/user_ressources_concern.rb +++ b/app/models/concerns/user_ressources_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Add resources-related functionalities to the user model (eg. Reservation, Subscrtion, Project, etc.) +# Add resources-related functionalities to the user model (eg. Reservation, Subscription, Project, etc.) module UserRessourcesConcern extend ActiveSupport::Concern diff --git a/app/models/notification_type.rb b/app/models/notification_type.rb index 8eeb3d300..6ff2be7f6 100644 --- a/app/models/notification_type.rb +++ b/app/models/notification_type.rb @@ -75,6 +75,8 @@ class NotificationType notify_admin_low_stock_threshold notify_admin_training_auto_cancelled notify_member_training_auto_cancelled + notify_member_training_authorization_expired + notify_member_training_invalidated ] # deprecated: # - notify_member_subscribed_plan_is_changed diff --git a/app/models/setting.rb b/app/models/setting.rb index 56f8b80ab..37aa1a46d 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -168,7 +168,11 @@ class Setting < ApplicationRecord invoice_VAT-name trainings_auto_cancel trainings_auto_cancel_threshold - trainings_auto_cancel_deadline] } + trainings_auto_cancel_deadline + trainings_authorization_validity + trainings_authorization_validity_duration + trainings_invalidation_rule + trainings_invalidation_rule_period] } # WARNING: when adding a new key, you may also want to add it in: # - config/locales/en.yml#settings # - app/frontend/src/javascript/models/setting.ts#SettingName @@ -204,13 +208,17 @@ class Setting < ApplicationRecord end def previous_value - previous_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(2).last - previous_value&.value + last_two = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(2) + return nil if last_two.count < 2 + + last_two.last&.value end def previous_update - previous_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(2).last - previous_value&.created_at + last_two = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(2) + return nil if last_two.count < 2 + + last_two.last&.created_at end # @deprecated, prefer Setting.set() instead diff --git a/app/services/setting_service.rb b/app/services/setting_service.rb index 31f521722..f904d9213 100644 --- a/app/services/setting_service.rb +++ b/app/services/setting_service.rb @@ -23,6 +23,8 @@ class SettingService validate_admins(settings) update_accounting_line(settings) update_trainings_auto_cancel(settings) + update_trainings_authorization(settings) + update_trainings_invalidation(settings) end private @@ -108,5 +110,31 @@ class SettingService Trainings::AutoCancelService.update_auto_cancel(t, tac, threshold, deadline) end end + + # update trainings authorization parameters + # @param settings [Array] + def update_trainings_authorization(settings) + return unless settings.any? { |s| s.name.match(/^trainings_authorization_validity/) } + + authorization = settings.find { |s| s.name == 'trainings_authorization_validity' } + duration = settings.find { |s| s.name == 'trainings_authorization_validity_duration' } + + Training.find_each do |t| + Trainings::AuthorizationService.update_authorization(t, authorization, duration) + end + end + + # update trainings invalidation parameters + # @param settings [Array] + def update_trainings_invalidation(settings) + return unless settings.any? { |s| s.name.match(/^trainings_invalidation_rule/) } + + invalidation = settings.find { |s| s.name == 'trainings_invalidation_rule' } + duration = settings.find { |s| s.name == 'trainings_invalidation_rule_period' } + + Training.find_each do |t| + Trainings::InvalidationService.update_invalidation(t, invalidation, duration) + end + end end end diff --git a/app/services/trainings/authorization_service.rb b/app/services/trainings/authorization_service.rb new file mode 100644 index 000000000..718879e2d --- /dev/null +++ b/app/services/trainings/authorization_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Business logic around trainings +module Trainings; end + +# Automatically cancel trainings authorizations when the configured period has expired +class Trainings::AuthorizationService + class << self + # @param training [Training] + def auto_cancel_authorizations(training) + return unless training.authorization + + training.statistic_profile_trainings + .where('created_at < ?', DateTime.current - training.authorization_period.months) + .find_each do |spt| + NotificationCenter.call type: 'notify_member_training_authorization_expired', + receiver: spt.statistic_profile.user, + attached_object: spt.training + spt.destroy! + end + end + + # update the given training, depending on the provided settings + # @param training [Training] + # @param authorization [Setting,NilClass] + # @param duration [Setting,NilClass] + def update_authorization(training, authorization, duration) + previous_authorization = if authorization.nil? + Setting.find_by(name: 'trainings_authorization_validity').value + else + authorization.previous_value + end + previous_duration = duration.nil? ? Setting.find_by(name: 'trainings_authorization_validity_duration').value : duration.previous_value + is_default = training.authorization.to_s == previous_authorization.to_s && + training.authorization_period.to_s == previous_duration.to_s + + return unless is_default + + # update parameters if the given training is default + params = {} + params[:authorization] = authorization.value unless authorization.nil? + params[:authorization_period] = duration.value unless duration.nil? + training.update(params) + end + end +end diff --git a/app/services/trainings/auto_cancel_service.rb b/app/services/trainings/auto_cancel_service.rb index 83848c166..af3352f3e 100644 --- a/app/services/trainings/auto_cancel_service.rb +++ b/app/services/trainings/auto_cancel_service.rb @@ -23,6 +23,7 @@ class Trainings::AutoCancelService attached_object: availability, meta_data: { auto_refund: auto_refund } + availability.update(lock: true) availability.slots_reservations.find_each do |sr| NotificationCenter.call type: 'notify_member_training_auto_cancelled', receiver: sr.reservation.user, @@ -44,9 +45,9 @@ class Trainings::AutoCancelService previous_auto_cancel = auto_cancel.nil? ? Setting.find_by(name: 'trainings_auto_cancel').value : auto_cancel.previous_value previous_threshold = threshold.nil? ? Setting.find_by(name: 'trainings_auto_cancel_threshold').value : threshold.previous_value previous_deadline = deadline.nil? ? Setting.find_by(name: 'trainings_auto_cancel_deadline').value : deadline.previous_value - is_default = training.auto_cancel.to_s == previous_auto_cancel && - [nil, previous_threshold].include?(training.auto_cancel_threshold.to_s) && - [nil, previous_deadline].include?(training.auto_cancel_deadline.to_s) + is_default = training.auto_cancel.to_s == previous_auto_cancel.to_s && + training.auto_cancel_threshold.to_s == previous_threshold.to_s && + training.auto_cancel_deadline.to_s == previous_deadline.to_s return unless is_default diff --git a/app/services/trainings/invalidation_service.rb b/app/services/trainings/invalidation_service.rb new file mode 100644 index 000000000..5da89810d --- /dev/null +++ b/app/services/trainings/invalidation_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Business logic around trainings +module Trainings; end + +# Automatically cancel trainings authorizations if no machines reservations were made during +# the configured period +class Trainings::InvalidationService + class << self + # @param training [Training] + def auto_invalidate(training) + return unless training.invalidation + + training.statistic_profile_trainings + .where('created_at < ?', DateTime.current - training.invalidation_period.months) + .find_each do |spt| + reservations_since = spt.statistic_profile + .reservations + .where(reservable_type: 'Machine', reservable_id: spt.training.machines) + .where('created_at > ?', spt.created_at) + .count + + next if reservations_since.positive? + + NotificationCenter.call type: 'notify_member_training_invalidated', + receiver: spt.statistic_profile.user, + attached_object: spt.training + spt.destroy! + end + end + + # update the given training, depending on the provided settings + # @param training [Training] + # @param invalidation [Setting,NilClass] + # @param duration [Setting,NilClass] + def update_invalidation(training, invalidation, duration) + previous_invalidation = invalidation.nil? ? Setting.find_by(name: 'trainings_invalidation_rule').value : invalidation.previous_value + previous_duration = duration.nil? ? Setting.find_by(name: 'trainings_invalidation_rule_period').value : duration.previous_value + is_default = training.invalidation.to_s == previous_invalidation.to_s && + training.invalidation_period.to_s == previous_duration.to_s + + return unless is_default + + # update parameters if the given training is default + params = {} + params[:invalidation] = invalidation.value unless invalidation.nil? + params[:invalidation_period] = duration.value unless duration.nil? + training.update(params) + end + end +end diff --git a/app/views/api/notifications/_notify_member_training_authorization_expired.json.jbuilder b/app/views/api/notifications/_notify_member_training_authorization_expired.json.jbuilder new file mode 100644 index 000000000..5cd6d3b89 --- /dev/null +++ b/app/views/api/notifications/_notify_member_training_authorization_expired.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.training_authorization_revoked', { MACHINES: notification.attached_object.machines.map(&:name).join(', ') }) diff --git a/app/views/api/notifications/notify_member_training_auto_cancelled.json.jbuilder b/app/views/api/notifications/_notify_member_training_auto_cancelled.json.jbuilder similarity index 100% rename from app/views/api/notifications/notify_member_training_auto_cancelled.json.jbuilder rename to app/views/api/notifications/_notify_member_training_auto_cancelled.json.jbuilder diff --git a/app/views/api/notifications/_notify_member_training_invalidated.json.jbuilder b/app/views/api/notifications/_notify_member_training_invalidated.json.jbuilder new file mode 100644 index 000000000..438cc548e --- /dev/null +++ b/app/views/api/notifications/_notify_member_training_invalidated.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.invalidated', { MACHINES: notification.attached_object.machines.map(&:name).join(', ') }) diff --git a/app/views/api/trainings/_training.json.jbuilder b/app/views/api/trainings/_training.json.jbuilder index 2708049e4..eb4c8b598 100644 --- a/app/views/api/trainings/_training.json.jbuilder +++ b/app/views/api/trainings/_training.json.jbuilder @@ -1,7 +1,8 @@ # frozen_string_literal: true json.extract! training, :id, :name, :description, :machine_ids, :nb_total_places, :public_page, :disabled, :slug, - :auto_cancel, :auto_cancel_threshold, :auto_cancel_deadline + :auto_cancel, :auto_cancel_threshold, :auto_cancel_deadline, :authorization, :authorization_period, :invalidation, + :invalidation_period if training.training_image json.training_image_attributes do json.id training.training_image.id diff --git a/app/views/notifications_mailer/notify_member_training_authorization_expired.html.erb b/app/views/notifications_mailer/notify_member_training_authorization_expired.html.erb new file mode 100644 index 000000000..f4fc020ed --- /dev/null +++ b/app/views/notifications_mailer/notify_member_training_authorization_expired.html.erb @@ -0,0 +1,8 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +<%= t('.body.training_expired_html', { + TRAINING: @attached_object.name, + MACHINES: @attached_object.machines.map(&:name).join(', '), + DATE: I18n.l((DateTime.current - @attached_object.authorization_period.months).to_date), + PERIOD: @attached_object.authorization_period +}) %> diff --git a/app/views/notifications_mailer/notify_member_training_invalidated.html.erb b/app/views/notifications_mailer/notify_member_training_invalidated.html.erb new file mode 100644 index 000000000..fd6ae0e5f --- /dev/null +++ b/app/views/notifications_mailer/notify_member_training_invalidated.html.erb @@ -0,0 +1,8 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +<%= t('.body.training_invalidated_html', { + TRAINING: @attached_object.name, + MACHINES: @attached_object.machines.map(&:name).join(', '), + DATE: I18n.l((DateTime.current - @attached_object.authorization_period.months).to_date), + PERIOD: @attached_object.authorization_period +}) %> diff --git a/app/workers/training_authorization_worker.rb b/app/workers/training_authorization_worker.rb new file mode 100644 index 000000000..b431ecb0c --- /dev/null +++ b/app/workers/training_authorization_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# This training will periodically check for trainings authorizations to revoke +class TrainingAuthorizationWorker + include Sidekiq::Worker + + def perform + Training.find_each do |t| + Trainings::AuthorizationService.auto_cancel_authorizations(t) + Trainings::InvalidationService.auto_invalidate(t) + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1e2d5a992..23944812b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -331,9 +331,13 @@ en: your_subscription_has_expired: "Your subscription has expired." notify_member_subscription_will_expire_in_7_days: your_subscription_will_expire_in_7_days: "Your subscription will expire in 7 days." + notify_member_training_authorization_expired: + training_authorization_revoked: "Your authorization to use %{MACHINES} has been revoked because it has expired." notify_member_training_auto_cancelled: auto_cancelled_training: "The %{TRAINING} training session scheduled for %{DATE}, has been canceled due to an insufficient number of participants." auto_refund: "You were refunded on your wallet." + notify_member_training_invalidated: + invalidated: "Your authorization to use %{MACHINES} has been invalidated due to a lack of reservations." notify_partner_subscribed_plan: subscription_partner_PLAN_has_been_subscribed_by_USER_html: "Partner subscription %{PLAN} has been subscribed by %{USER}." notify_project_author_when_collaborator_valid: @@ -675,6 +679,10 @@ en: trainings_auto_cancel: "Trainings automatic cancellation" trainings_auto_cancel_threshold: "Minimum participants for automatic cancellation" trainings_auto_cancel_deadline: "Automatic cancellation deadline" + trainings_authorization_validity: "Trainings validity period" + trainings_authorization_validity_duration: "Trainings validity period duration" + trainings_invalidation_rule: "Trainings automatic invalidation" + trainings_invalidation_rule_period: "Grace period before invalidating a training" #statuses of projects statuses: new: "New" diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 2251ad5a7..13ccc0ba1 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -131,11 +131,19 @@ en: your_plan: "you plan" expires_in_7_days: "will expire in 7 days." to_renew_your_plan_follow_the_link: "Please, follow this link to renew your plan" + notify_member_training_authorization_expired: + subject: "Your authorization was revoked" + body: + training_expired_html: "

You took the %{TRAINING} training, on %{DATE}.

Your authorization for this training, valid for %{PERIOD} months, has expired.

Please validate it again in order to be able to reserve the %{MACHINES}

." notify_member_training_auto_cancelled: subject: "Your training session was cancelled" body: cancelled_training: "The %{TRAINING} training session scheduled for %{DATE}, from %{START} to %{END} has been canceled due to an insufficient number of participants." auto_refund: "You were refunded on your wallet and a credit note should be available." + notify_member_training_invalidated: + subject: "Your authorization was invalidated" + body: + training_invalidated_html: "

You took the %{TRAINING} training, on %{DATE} giving you access to the %{MACHINES}.

Due to the lack of reservations for one of these machines during the last %{PERIOD} months, your authorization has been invalidated.

Please validate the training again in order to continue reserving these machines.

." notify_member_subscription_is_expired: subject: "Your subscription has expired" body: diff --git a/config/schedule.yml b/config/schedule.yml index 844bda55b..2f7b4768f 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -57,4 +57,9 @@ auto_cancel_tranings: class: TrainingAutoCancelWorker queue: default +auto_cancel_authorizations: + cron: "0 0 * * *" # every day, at midnight + class: TrainingAuthorizationWorker + queue: default + <%= PluginRegistry.insert_code('yml.schedule') %> diff --git a/db/migrate/20230124094255_add_auto_cancel_to_trainings.rb b/db/migrate/20230124094255_add_auto_cancel_to_trainings.rb index e4b26ffe6..8938cdcb9 100644 --- a/db/migrate/20230124094255_add_auto_cancel_to_trainings.rb +++ b/db/migrate/20230124094255_add_auto_cancel_to_trainings.rb @@ -6,7 +6,7 @@ class AddAutoCancelToTrainings < ActiveRecord::Migration[5.2] def change change_table :trainings, bulk: true do |t| - t.boolean :auto_cancel, default: false + t.boolean :auto_cancel t.integer :auto_cancel_threshold t.integer :auto_cancel_deadline end diff --git a/db/migrate/20230127091337_add_authorization_to_trainings.rb b/db/migrate/20230127091337_add_authorization_to_trainings.rb new file mode 100644 index 000000000..8a678ffa0 --- /dev/null +++ b/db/migrate/20230127091337_add_authorization_to_trainings.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# From this migration, we allows trainings to be valid for a maximum duration, after +# the configured period, the member must validate a new training session. +# Moreover, we allows to configure automatic cancellation of the training validity +# if the member has not used the associated machines for a configurable duration +class AddAuthorizationToTrainings < ActiveRecord::Migration[5.2] + def change + change_table :trainings, bulk: true do |t| + t.boolean :authorization + t.integer :authorization_period + t.boolean :invalidation + t.integer :invalidation_period + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ed4819f2..4ba30590d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.string "signaled_type" t.integer "signaled_id" + t.string "signaled_type" t.string "first_name" t.string "last_name" t.string "email" @@ -68,8 +68,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.string "locality" t.string "country" t.string "postal_code" - t.string "placeable_type" t.integer "placeable_id" + t.string "placeable_type" t.datetime "created_at" t.datetime "updated_at" end @@ -93,8 +93,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "assets", id: :serial, force: :cascade do |t| - t.string "viewable_type" t.integer "viewable_id" + t.string "viewable_type" t.string "attachment" t.string "type" t.datetime "created_at" @@ -281,8 +281,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "credits", id: :serial, force: :cascade do |t| - t.string "creditable_type" t.integer "creditable_id" + t.string "creditable_type" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -524,15 +524,15 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.string "attached_object_type" t.integer "attached_object_id" + t.string "attached_object_type" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: "{}" + t.jsonb "meta_data", default: {} t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -772,8 +772,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.string "priceable_type" t.integer "priceable_id" + t.string "priceable_type" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -976,8 +976,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.string "reservable_type" t.integer "reservable_id" + t.string "reservable_type" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -986,8 +986,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.string "resource_type" t.integer "resource_id" + t.string "resource_type" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" diff --git a/test/fixtures/trainings_machines.yml b/test/fixtures/trainings_machines.yml index 797e6cbcb..3e60a1c7d 100644 --- a/test/fixtures/trainings_machines.yml +++ b/test/fixtures/trainings_machines.yml @@ -24,27 +24,3 @@ trainings_machine_5: training_id: 3 machine_id: 5 -trainings_machine_1: - id: 1 - training_id: 2 - machine_id: 1 - -trainings_machine_2: - id: 2 - training_id: 2 - machine_id: 2 - -trainings_machine_3: - id: 3 - training_id: 4 - machine_id: 3 - -trainings_machine_4: - id: 4 - training_id: 1 - machine_id: 4 - -trainings_machine_5: - id: 5 - training_id: 3 - machine_id: 5 diff --git a/test/integration/trainings_test.rb b/test/integration/trainings_test.rb index 9b1390d44..0b627649c 100644 --- a/test/integration/trainings_test.rb +++ b/test/integration/trainings_test.rb @@ -72,6 +72,20 @@ class TrainingsTest < ActionDispatch::IntegrationTest assert_not training[:public_page] end + test 'user validates a training' do + training = Training.find(3) + user = User.find(9) + put "/api/trainings/#{training.id}", params: { training: { + users: [user.id] + } }.to_json, headers: default_headers + + # Check response status + assert_equal 204, response.status, response.body + + # Check user is authorized + assert user.training_machine?(Machine.find(5)) + end + test 'delete a training' do delete '/api/trainings/4', headers: default_headers assert_response :success diff --git a/test/services/trainings/authorization_service_test.rb b/test/services/trainings/authorization_service_test.rb new file mode 100644 index 000000000..0536d8891 --- /dev/null +++ b/test/services/trainings/authorization_service_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Trainings::AuthorizationServiceTest < ActiveSupport::TestCase + setup do + @training = Training.find(4) + @user = User.find(9) + end + + test 'training authorization is revoked after 6 month' do + # Mark training to auto-revoke after 6 month + @training.update( + authorization: true, + authorization_period: 6 + ) + # User validates a training + StatisticProfileTraining.create!( + statistic_profile_id: @user.statistic_profile.id, + training_id: @training.id + ) + + # jump to the future and proceed with auto revocations + travel_to(DateTime.current + 6.months + 1.day) + Trainings::AuthorizationService.auto_cancel_authorizations(@training) + + # Check authorization was revoked + assert_nil StatisticProfileTraining.find_by(statistic_profile_id: @user.statistic_profile.id, training_id: @training.id) + assert_not @user.training_machine?(Machine.find(3)) + + # Check notification was sent + notification = Notification.find_by( + notification_type_id: NotificationType.find_by_name('notify_member_training_authorization_expired'), # rubocop:disable Rails/DynamicFindBy + attached_object_type: 'Training', + attached_object_id: @training.id + ) + assert_not_nil notification, 'user notification was not created' + end +end diff --git a/test/services/trainings/auto_cancel_service_test.rb b/test/services/trainings/auto_cancel_service_test.rb index 3ced5c592..c15177d15 100644 --- a/test/services/trainings/auto_cancel_service_test.rb +++ b/test/services/trainings/auto_cancel_service_test.rb @@ -21,6 +21,10 @@ class Trainings::AutoCancelServiceTest < ActiveSupport::TestCase ) Trainings::AutoCancelService.auto_cancel_reservations(@training) + # Check availability was locked + @availability.reload + assert @availability.lock + # Check reservation was cancelled r.reload assert_not_nil r.slots_reservations.first&.canceled_at @@ -77,6 +81,10 @@ class Trainings::AutoCancelServiceTest < ActiveSupport::TestCase Trainings::AutoCancelService.auto_cancel_reservations(@training) + # Check availability was not locked + @availability.reload + assert_not @availability.lock + # Check nothing was cancelled r1.reload assert_nil r1.slots_reservations.first&.canceled_at diff --git a/test/services/trainings/invalidation_service_test.rb b/test/services/trainings/invalidation_service_test.rb new file mode 100644 index 000000000..b062a3400 --- /dev/null +++ b/test/services/trainings/invalidation_service_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Trainings::InvalidationServiceTest < ActiveSupport::TestCase + setup do + @training = Training.find(4) + @user = User.find(9) + end + + test 'training authorization is invalidated after 6 month without reservations' do + # Mark training to invalidable after 6 month + @training.update( + invalidation: true, + invalidation_period: 6 + ) + # User validates a training + StatisticProfileTraining.create!( + statistic_profile_id: @user.statistic_profile.id, + training_id: @training.id + ) + + # jump to the future and proceed with auto invalidations + travel_to(DateTime.current + 6.months + 1.day) + Trainings::InvalidationService.auto_invalidate(@training) + + # Check authorization was revoked + assert_nil StatisticProfileTraining.find_by(statistic_profile_id: @user.statistic_profile.id, training_id: @training.id) + assert_not @user.training_machine?(Machine.find(3)) + + # Check notification was sent + notification = Notification.find_by( + notification_type_id: NotificationType.find_by_name('notify_member_training_invalidated'), # rubocop:disable Rails/DynamicFindBy + attached_object_type: 'Training', + attached_object_id: @training.id + ) + assert_not_nil notification, 'user notification was not created' + end + + test 'training authorization is not invalidated after 6 month with some reservations' do + # Mark training to invalidable after 6 month + @training.update( + invalidation: true, + invalidation_period: 6 + ) + # User validates a training + StatisticProfileTraining.create!( + statistic_profile_id: @user.statistic_profile.id, + training_id: @training.id + ) + + # User reserves a machine authorized by this training + machine = @training.machines.first + slot = machine.availabilities.where('start_at > ?', DateTime.current).first&.slots&.first + Reservation.create!( + reservable_id: machine.id, + reservable_type: Machine.name, + slots_reservations_attributes: [{ slot_id: slot&.id }], + statistic_profile_id: @user.statistic_profile.id + ) + + # jump to the future and proceed with auto invalidations + travel_to(DateTime.current + 6.months + 1.day) + Trainings::InvalidationService.auto_invalidate(@training) + + # Check authorization was not revoked + assert_not_nil StatisticProfileTraining.find_by(statistic_profile_id: @user.statistic_profile.id, training_id: @training.id) + assert @user.training_machine?(machine) + + # Check notification was not sent + notification = Notification.find_by( + notification_type_id: NotificationType.find_by_name('notify_member_training_invalidated'), # rubocop:disable Rails/DynamicFindBy + attached_object_type: 'Training', + attached_object_id: @training.id + ) + assert_nil notification + end +end