1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

(feat) auto cancel trainings under the threshold

This commit is contained in:
Sylvain 2023-01-24 14:03:01 +01:00 committed by Sylvain
parent 0dc564a294
commit e2edbb419a
14 changed files with 215 additions and 40 deletions

View File

@ -50,6 +50,24 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
}
};
/**
* Parse the inputted value before saving it in the RHF state
*/
const parseValue = (value: string) => {
if ([null, ''].includes(value) && nullable) {
return null;
} else {
if (type === 'number') {
const num: number = parseFloat(value);
if (Number.isNaN(num) && nullable) {
return null;
}
return num;
}
return value;
}
};
// Compose classnames from props
const classNames = [
`${className || ''}`,
@ -66,7 +84,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsDate: type === 'date',
setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v),
setValueAs: parseValue,
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}

View File

@ -98,12 +98,14 @@ export const TrainingsSettings: React.FC<TrainingsSettingsProps> = ({ onError, o
register={register}
rules={{ required: isActiveAutoCancellation, min: 0 }}
step={1}
nullable
formState={formState}
label={t('app.admin.trainings_settings.automatic_cancellation_threshold')} />
<FormInput id="trainings_auto_cancel_deadline"
type="number"
register={register}
rules={{ required: isActiveAutoCancellation, min: 1 }}
nullable
step={1}
formState={formState}
label={t('app.admin.trainings_settings.automatic_cancellation_deadline')} />

View File

@ -52,7 +52,7 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
}, []);
useEffect(() => {
TrainingAPI.index({ disabled: filter })
TrainingAPI.index(typeof filter === 'boolean' ? { disabled: filter } : {})
.then(setTrainings)
.catch(onError);
}, [filter]);

View File

@ -19,11 +19,11 @@ class Availability < ApplicationRecord
has_many :spaces_availabilities, dependent: :destroy
has_many :spaces, through: :spaces_availabilities
has_many :slots
has_many :slots, dependent: :destroy
has_many :slots_reservations, through: :slots
has_many :reservations, through: :slots
has_one :event
has_one :event, dependent: :destroy
has_many :availability_tags, dependent: :destroy
has_many :tags, through: :availability_tags
@ -58,34 +58,34 @@ class Availability < ApplicationRecord
def safe_destroy
case available_type
when 'machines'
reservations = Reservation.where(reservable_type: 'Machine', reservable_id: machine_ids)
.joins(:slots)
.where('slots.availability_id = ?', id)
reservations = find_reservations('Machine', machine_ids)
when 'training'
reservations = Reservation.where(reservable_type: 'Training', reservable_id: training_ids)
.joins(:slots)
.where('slots.availability_id = ?', id)
reservations = find_reservations('Training', training_ids)
when 'space'
reservations = Reservation.where(reservable_type: 'Space', reservable_id: space_ids)
.joins(:slots)
.where('slots.availability_id = ?', id)
reservations = find_reservations('Space', space_ids)
when 'event'
reservations = Reservation.where(reservable_type: 'Event', reservable_id: event&.id)
.joins(:slots)
.where('slots.availability_id = ?', id)
reservations = find_reservations('Event', [event&.id])
else
Rails.logger.warn "[safe_destroy] Availability with unknown type #{available_type}"
reservations = []
end
if reservations.size.zero?
# this update may not call any rails callbacks, that's why we use direct SQL update
update_column(:destroying, true)
update_column(:destroying, true) # rubocop:disable Rails/SkipsModelValidations
destroy
else
false
end
end
# @param reservable_type [String]
# @param reservable_ids [Array<Integer>]
def find_reservations(reservable_type, reservable_ids)
Reservation.where(reservable_type: reservable_type, reservable_id: reservable_ids)
.joins(:slots)
.where(slots: { availability_id: id })
end
## compute the total number of places over the whole space availability
def available_space_places
return unless available_type == 'space'
@ -97,7 +97,7 @@ class Availability < ApplicationRecord
def title(filter = {})
case available_type
when 'machines'
return machines.to_ary.delete_if { |m| !filter[:machine_ids].include?(m.id) }.map(&:name).join(' - ') if filter[:machine_ids]
return machines.to_ary.delete_if { |m| filter[:machine_ids].exclude?(m.id) }.map(&:name).join(' - ') if filter[:machine_ids]
machines.map(&:name).join(' - ')
when 'event'

View File

@ -2,22 +2,56 @@
# Provides methods for Trainings
class TrainingService
def self.list(filters)
trainings = Training.includes(:training_image, :plans, :machines)
class << self
# @param filters [ActionController::Parameters]
def list(filters)
trainings = Training.includes(:training_image, :plans, :machines)
trainings = filter_by_disabled(trainings, filters)
trainings = filter_by_public_page(trainings, filters)
if filters[:requested_attributes].try(:include?, 'availabilities')
trainings = trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
.order('availabilities.start_at DESC')
end
trainings
end
# @param training [Training]
def auto_cancel_reservation(training)
return unless training.auto_cancel
training.availabilities
.includes(slots: :slots_reservations)
.where('availabilities.start_at >= ?', DateTime.current - training.auto_cancel_deadline.hours)
.find_each do |a|
next if a.reservations.count >= training.auto_cancel_threshold
a.slots_reservations.find_each do |sr|
sr.update(canceled_at: DateTime.current)
end
end
end
private
# @param trainings [ActiveRecord::Relation<Training>]
# @param filters [ActionController::Parameters]
def filter_by_disabled(trainings, filters)
return trainings if filters[:disabled].blank?
if filters[:disabled].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
trainings = trainings.where(disabled: state)
end
if filters[:public_page].present?
state = filters[:public_page] == 'false' ? [nil, false] : true
trainings = trainings.where(public_page: state)
end
if filters[:requested_attributes].try(:include?, 'availabilities')
trainings = trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
.order('availabilities.start_at DESC')
trainings.where(disabled: state)
end
trainings
# @param trainings [ActiveRecord::Relation<Training>]
# @param filters [ActionController::Parameters]
def filter_by_public_page(trainings, filters)
return trainings if filters[:public_page].blank?
state = filters[:public_page] == 'false' ? [nil, false] : true
trainings.where(public_page: state)
end
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# This training will periodically check for trainings reservations to auto-cancel
class TrainingAutoCancelWorker
include Sidekiq::Worker
def perform
Training.find_each do |t|
TrainingService.auto_cancel_reservation(t)
end
end
end

View File

@ -52,4 +52,9 @@ accounting_data:
class: AccountingWorker
queue: default
auto_cancel_tranings:
cron: "0 * * * *" # every day, every hour
class: TrainingAutoCancelWorker
queue: default
<%= PluginRegistry.insert_code('yml.schedule') %>

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# From this migration, we allows auto-cancellation of trainings
# if a minimum number of user are not registred, X hours before the
# beginning of the training
class AddAutoCancelToTrainings < ActiveRecord::Migration[5.2]
def change
change_table :trainings, bulk: true do |t|
t.boolean :auto_cancel, default: false
t.integer :auto_cancel_threshold
t.integer :auto_cancel_deadline
end
end
end

View File

@ -209,3 +209,13 @@ availability_21:
updated_at: 2022-12-14 12:01:26.165110000 Z
nb_total_places: 2
destroying: false
availability_22:
id: 22
start_at: <%= 1.hour.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
end_at: <%= 2.hours.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
available_type: training
created_at: 2023-01-24 13:34:43.841240000 Z
updated_at: 2023-01-24 13:34:43.841240000 Z
nb_total_places: 5
destroying: false

View File

@ -574,3 +574,11 @@ slot_133:
created_at: '2022-12-14 12:01:26.165110'
updated_at: '2022-12-14 12:01:26.165110'
availability_id: 21
slot_134:
id: 134
start_at: <%= 1.hour.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
end_at: <%= 2.hours.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
created_at: 2023-01-24 13:34:43.841240000 Z
updated_at: 2023-01-24 13:34:43.841240000 Z
availability_id: 22

View File

@ -1,23 +1,23 @@
status_1:
id: 1
label: new
name: new
created_at: 2023-01-16 15:25:13.744539000 Z
updated_at: 2023-01-16 15:25:13.744539000 Z
status_2:
id: 2
label: pending
name: pending
created_at: 2023-01-16 15:25:13.744539000 Z
updated_at: 2023-01-16 15:25:13.744539000 Z
status_3:
id: 3
label: done
name: done
created_at: 2023-01-16 15:25:13.744539000 Z
updated_at: 2023-01-16 15:25:13.744539000 Z
status_4:
id: 4
label: disused
name: disused
created_at: 2023-01-16 15:25:13.744539000 Z
updated_at: 2023-01-16 15:25:13.744539000 Z

View File

@ -33,3 +33,10 @@ trainings_availability_5:
availability_id: 20
created_at: 2020-07-22 10:09:41.841162000 Z
updated_at: 2020-07-22 10:09:41.841162000 Z
trainings_availability_6:
id: 6
training_id: 4
availability_id: 22
created_at: 2023-01-24 13:34:43.841240000 Z
updated_at: 2023-01-24 13:34:43.841240000 Z

View File

@ -11,7 +11,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
test 'create a status' do
post '/api/statuses',
params: {
label: 'Open'
name: 'Open'
}.to_json,
headers: default_headers
@ -24,13 +24,13 @@ class StatusesTest < ActionDispatch::IntegrationTest
status = Status.where(id: res[:id]).first
assert_not_nil status, 'status was not created in database'
assert_equal 'Open', res[:label]
assert_equal 'Open', res[:name]
end
test 'update a status' do
patch '/api/statuses/1',
params: {
label: 'Done'
name: 'Done'
}.to_json,
headers: default_headers
@ -41,7 +41,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
# Check the status was updated
res = json_response(response.body)
assert_equal 1, res[:id]
assert_equal 'Done', res[:label]
assert_equal 'Done', res[:name]
end
test 'list all statuses' do
@ -57,7 +57,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
end
test 'delete a status' do
status = Status.create!(label: 'Gone too soon')
status = Status.create!(name: 'Gone too soon')
delete "/api/statuses/#{status.id}"
assert_response :success
assert_empty response.body

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
require 'test_helper'
class TrainingServiceTest < ActiveSupport::TestCase
setup do
@training = Training.find(4)
@availability = Availability.find(22)
end
test 'auto cancel reservation with less reservations than the deadline' do
@training.update(auto_cancel: true, auto_cancel_threshold: 3, auto_cancel_deadline: 24)
customer = User.find(3)
slot = @availability.slots.first
r = Reservation.create!(
reservable_id: @training.id,
reservable_type: Training.name,
slots_reservations_attributes: [{ slot_id: slot.id }],
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)
TrainingService.auto_cancel_reservation(@training)
r.reload
assert_not_nil r.slots_reservations.first&.canceled_at
end
test 'do not auto cancel reservation with more reservations than the deadline' do
@training.update(auto_cancel: true, auto_cancel_threshold: 3, auto_cancel_deadline: 24)
slot = @availability.slots.first
# first reservation
c1 = User.find(2)
r1 = Reservation.create!(
reservable_id: @training.id,
reservable_type: Training.name,
slots_reservations_attributes: [{ slot_id: slot.id }],
statistic_profile_id: StatisticProfile.find_by(user: c1).id
)
# second reservation
c2 = User.find(3)
r2 = Reservation.create!(
reservable_id: @training.id,
reservable_type: Training.name,
slots_reservations_attributes: [{ slot_id: slot.id }],
statistic_profile_id: StatisticProfile.find_by(user: c2).id
)
# third reservation
c3 = User.find(3)
r3 = Reservation.create!(
reservable_id: @training.id,
reservable_type: Training.name,
slots_reservations_attributes: [{ slot_id: slot.id }],
statistic_profile_id: StatisticProfile.find_by(user: c3).id
)
TrainingService.auto_cancel_reservation(@training)
r1.reload
assert_nil r1.slots_reservations.first&.canceled_at
r2.reload
assert_nil r2.slots_reservations.first&.canceled_at
r3.reload
assert_nil r3.slots_reservations.first&.canceled_at
end
end