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:
parent
0dc564a294
commit
e2edbb419a
@ -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); }
|
||||
})}
|
||||
|
@ -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')} />
|
||||
|
@ -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]);
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
12
app/workers/training_auto_cancel_worker.rb
Normal file
12
app/workers/training_auto_cancel_worker.rb
Normal 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
|
@ -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') %>
|
||||
|
14
db/migrate/20230124094255_add_auto_cancel_to_trainings.rb
Normal file
14
db/migrate/20230124094255_add_auto_cancel_to_trainings.rb
Normal 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
|
10
test/fixtures/availabilities.yml
vendored
10
test/fixtures/availabilities.yml
vendored
@ -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
|
||||
|
8
test/fixtures/slots.yml
vendored
8
test/fixtures/slots.yml
vendored
@ -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
|
||||
|
8
test/fixtures/statuses.yml
vendored
8
test/fixtures/statuses.yml
vendored
@ -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
|
||||
|
7
test/fixtures/trainings_availabilities.yml
vendored
7
test/fixtures/trainings_availabilities.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
65
test/services/training_service_test.rb
Normal file
65
test/services/training_service_test.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user