1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-22 11:52:21 +01:00

Merge branch 'reservation-context' into staging

This commit is contained in:
Nicolas Florentin 2023-07-21 13:10:00 +02:00
commit c0df547293
60 changed files with 821 additions and 77 deletions

View File

@ -1,8 +1,15 @@
# Changelog Fab-manager
- adds reservation context feature (for machine, training, space)
- [TODO DEPLOY] `rails fablab:es:build_stats`
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2014,1]`
## v6.0.10 2023 July 13
- Fix a bug: unable to confirm payment of store for admin
- Fix a bug: unable to update payment schedule item
- Fix a bug: event reserved places compute error
- [TODO DEPLOY] `rails fablab:setup:build_places_cache`
## v6.0.9 2023 July 07

View File

@ -4,6 +4,7 @@
class API::ProjectCategoriesController < ApplicationController
before_action :set_project_category, only: %i[update destroy]
before_action :authenticate_user!, only: %i[create update destroy]
def index
@project_categories = ProjectCategory.all
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
# API Controller for resources of type ReservationContext
class API::ReservationContextsController < API::APIController
before_action :authenticate_user!, except: [:index]
before_action :set_reservation_context, only: %i[show update destroy]
def index
@reservation_contexts = ReservationContext.all
@reservation_contexts = @reservation_contexts.applicable_on(params[:applicable_on]) if params[:applicable_on].present?
@reservation_contexts = @reservation_contexts.order(:created_at)
end
def show; end
def create
authorize ReservationContext
@reservation_context = ReservationContext.new(reservation_context_params)
if @reservation_context.save
render :show, status: :created, location: @reservation_context
else
render json: @reservation_context.errors, status: :unprocessable_entity
end
end
def update
authorize ReservationContext
if @reservation_context.update(reservation_context_params)
render :show, status: :ok, location: @reservation_context
else
render json: @reservation_context.errors, status: :unprocessable_entity
end
end
def destroy
authorize ReservationContext
if @reservation_context.safe_destroy
head :no_content
else
render json: @reservation_context.errors, status: :unprocessable_entity
end
end
def applicable_on_values
authorize ReservationContext
render json: ReservationContext::APPLICABLE_ON, status: :ok
end
private
def set_reservation_context
@reservation_context = ReservationContext.find(params[:id])
end
def reservation_context_params
params.require(:reservation_context).permit(:name, applicable_on: [])
end
end

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ReservationContext } from '../models/reservation';
export default class ReservationContextAPI {
static async index (): Promise<Array<ReservationContext>> {
const res: AxiosResponse<Array<ReservationContext>> = await apiClient.get('/api/reservation_contexts');
return res?.data;
}
}

View File

@ -12,8 +12,8 @@
*/
'use strict';
Application.Controllers.controller('SettingsController', ['$scope', '$rootScope', '$filter', '$uibModal', 'dialogs', 'Setting', 'growl', 'settingsPromise', 'privacyDraftsPromise', 'cgvFile', 'cguFile', 'logoFile', 'logoBlackFile', 'faviconFile', 'profileImageFile', 'CSRF', '_t', 'Member', 'uiTourService',
function ($scope, $rootScope, $filter, $uibModal, dialogs, Setting, growl, settingsPromise, privacyDraftsPromise, cgvFile, cguFile, logoFile, logoBlackFile, faviconFile, profileImageFile, CSRF, _t, Member, uiTourService) {
Application.Controllers.controller('SettingsController', ['$scope', '$rootScope', '$filter', '$uibModal', 'dialogs', 'Setting', 'growl', 'settingsPromise', 'privacyDraftsPromise', 'cgvFile', 'cguFile', 'logoFile', 'logoBlackFile', 'faviconFile', 'profileImageFile', 'reservationContextsPromise', 'reservationContextApplicableOnValuesPromise', 'CSRF', '_t', 'Member', 'uiTourService', 'ReservationContext',
function ($scope, $rootScope, $filter, $uibModal, dialogs, Setting, growl, settingsPromise, privacyDraftsPromise, cgvFile, cguFile, logoFile, logoBlackFile, faviconFile, profileImageFile, reservationContextsPromise, reservationContextApplicableOnValuesPromise, CSRF, _t, Member, uiTourService, ReservationContext) {
/* PUBLIC SCOPE */
// timepickers steps configuration
@ -76,6 +76,7 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
$scope.mainColorSetting = { name: 'main_color', value: settingsPromise.main_color };
$scope.secondColorSetting = { name: 'secondary_color', value: settingsPromise.secondary_color };
$scope.nameGenre = { name: 'name_genre', value: settingsPromise.name_genre };
$scope.reservationContextFeature = { name: 'reservation_context_feature', value: settingsPromise.reservation_context_feature };
$scope.cguFile = cguFile.custom_asset;
$scope.cgvFile = cgvFile.custom_asset;
$scope.customLogo = logoFile.custom_asset;
@ -83,6 +84,11 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
$scope.customFavicon = faviconFile.custom_asset;
$scope.profileImage = profileImageFile.custom_asset;
// necessary initialisation for reservation context
$scope.reservationContexts = reservationContextsPromise;
$scope.reservationContextApplicableOnValues = reservationContextApplicableOnValuesPromise;
$scope.newReservationContext = null;
// By default, we display the currently published privacy policy
$scope.privacyPolicy = {
version: null,
@ -314,21 +320,6 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
$scope.codeMirrorEditor = editor;
};
/**
* Shows a success message forwarded from a child react component
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
console.error(message);
growl.error(message);
};
/**
* Options for allow/prevent book overlapping slots: which kind of slots are used in the overlapping computation
*/
@ -476,6 +467,63 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
growl.error(message);
};
// Functions for reservation context feature
$scope.onReservationContextFeatureChange = function (value) {
$scope.reservationContextFeature.value = value;
};
$scope.saveReservationContext = function (data, id) {
if (id != null) {
return ReservationContext.update({ id }, data);
} else {
return ReservationContext.save(data, function (resp) { $scope.reservationContexts[$scope.reservationContexts.length - 1].id = resp.id; });
}
};
$scope.removeReservationContext = function (index) {
if ($scope.reservationContexts[index].related_to > 0) {
growl.error(_t('app.admin.settings.unable_to_delete_reservation_context_already_related_to_reservations'));
return false;
}
return dialogs.confirm({
resolve: {
object () {
return {
title: _t('app.admin.settings.confirmation_required'),
msg: _t('app.admin.settings.do_you_really_want_to_delete_this_reservation_context')
};
}
}
}
, function () { // delete confirmed
ReservationContext.delete($scope.reservationContexts[index], null, function () { $scope.reservationContexts.splice(index, 1); }
, function () { growl.error(_t('app.admin.settings.unable_to_delete_reservation_context_an_error_occured')); });
});
};
$scope.addReservationContext = function () {
$scope.newReservationContext = {
name: '',
related_to: 0
};
return $scope.reservationContexts.push($scope.newReservationContext);
};
$scope.cancelReservationContext = function (rowform, index) {
if ($scope.reservationContexts[index].id != null) {
return rowform.$cancel();
} else {
return $scope.reservationContexts.splice(index, 1);
}
};
$scope.translateApplicableOnValue = function (value) {
if (!value) { return; }
if (angular.isArray(value)) { return value.map(v => _t(`app.admin.reservation_contexts.${v}`)).join(', '); }
return _t(`app.admin.reservation_contexts.${value}`);
};
/* PRIVATE SCOPE */
/**

View File

@ -15,8 +15,8 @@
*/
'use strict';
Application.Controllers.controller('StatisticsController', ['$scope', '$state', '$transitions', '$rootScope', '$uibModal', 'es', 'Member', '_t', 'membersPromise', 'statisticsPromise', 'uiTourService', 'settingsPromise',
function ($scope, $state, $transitions, $rootScope, $uibModal, es, Member, _t, membersPromise, statisticsPromise, uiTourService, settingsPromise) {
Application.Controllers.controller('StatisticsController', ['$scope', '$state', '$transitions', '$rootScope', '$uibModal', 'es', 'Member', '_t', 'membersPromise', 'statisticsPromise', 'uiTourService', 'settingsPromise', 'reservationContextsPromise', 'reservationContextApplicableOnValuesPromise',
function ($scope, $state, $transitions, $rootScope, $uibModal, es, Member, _t, membersPromise, statisticsPromise, uiTourService, settingsPromise, reservationContextsPromise, reservationContextApplicableOnValuesPromise) {
/* PRIVATE STATIC CONSTANTS */
// search window size
@ -133,6 +133,18 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state',
}
};
$scope.reservationContextFeatureEnabled = settingsPromise.reservation_context_feature === 'true';
$scope.reservationContexts = reservationContextsPromise;
$scope.reservationContexts = reservationContextsPromise.filter(rc => rc.applicable_on.length > 0);
$scope.reservationContextApplicableOnValues = reservationContextApplicableOnValuesPromise;
$scope.reservationContextIsApplicable = function (esTypeKey) {
return $scope.reservationContextApplicableOnValues.includes(esTypeKey);
};
/**
* Return a localized name for the given field
*/
@ -220,17 +232,9 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state',
*/
$scope.formatDate = function (date) { return moment(date).format('LL'); };
/**
* Parse the sex and return a user-friendly string
* @param sex {string} 'male' | 'female'
*/
$scope.formatSex = function (sex) {
if (sex === 'male') {
return _t('app.admin.statistics.man');
}
if (sex === 'female') {
return _t('app.admin.statistics.woman');
}
$scope.formatReservationContext = function (id) {
if (id === null || id === undefined) { return; }
return $scope.reservationContexts.find(rc => rc.id === id).name;
};
/**
@ -645,6 +649,15 @@ Application.Controllers.controller('StatisticsController', ['$scope', '$state',
{ key: 'ca', label: _t('app.admin.statistics.revenue'), values: ['input_number'] }
];
if ($scope.reservationContextFeatureEnabled && $scope.reservationContextIsApplicable($scope.selectedIndex.es_type_key)) {
const reservationContextValues = $scope.reservationContexts.map((rc) => {
return { key: rc.id, label: rc.name };
});
$scope.filters.push({
key: 'reservationContextId', label: _t('app.admin.statistics.reservation_context'), values: reservationContextValues
});
}
// if no plans were created, there's no types for statisticIndex=subscriptions
if ($scope.type.active) {
$scope.filters.splice(4, 0, { key: 'subType', label: _t('app.admin.statistics.type'), values: $scope.type.active.subtypes });

View File

@ -390,8 +390,8 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
* This controller workflow is pretty similar to the trainings reservation controller.
*/
Application.Controllers.controller('ReserveMachineController', ['$scope', '$transition$', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'growl', 'helpers', 'AuthService',
function ($scope, $transition$, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation, growl, helpers, AuthService) {
Application.Controllers.controller('ReserveMachineController', ['$scope', '$transition$', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'reservationContextsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'growl', 'helpers', 'AuthService',
function ($scope, $transition$, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, reservationContextsPromise, uiCalendarConfig, CalendarConfig, Reservation, growl, helpers, AuthService) {
/* PRIVATE STATIC CONSTANTS */
// Slot free to be booked
@ -445,6 +445,13 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
// will be set to a Promise and resolved after the payment is sone
$scope.afterPaymentPromise = null;
// reservation contexts stuff
if (settingsPromise.reservation_context_feature === 'true') {
$scope.reservationContexts = reservationContextsPromise;
} else {
$scope.reservationContexts = [];
}
// fullCalendar (v2) configuration
$scope.calendarConfig = CalendarConfig({
minTime: moment.duration(moment.utc(settingsPromise.booking_window_start.match(/\d{4}-\d{2}-\d{2}(?: |T)\d{2}:\d{2}:\d{2}/)[0]).format('HH:mm:ss')),

View File

@ -328,8 +328,8 @@ Application.Controllers.controller('ShowSpaceController', ['$scope', '$state', '
* per slots.
*/
Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transition$', 'Auth', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'spacePromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'helpers', 'AuthService',
function ($scope, $transition$, Auth, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, spacePromise, _t, uiCalendarConfig, CalendarConfig, Reservation, helpers, AuthService) {
Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transition$', 'Auth', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'spacePromise', 'reservationContextsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'helpers', 'AuthService',
function ($scope, $transition$, Auth, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, spacePromise, reservationContextsPromise, _t, uiCalendarConfig, CalendarConfig, Reservation, helpers, AuthService) {
/* PRIVATE STATIC CONSTANTS */
// Color of the selected event backgound
@ -407,6 +407,13 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
// Global config: is the user validation required ?
$scope.enableUserValidationRequired = settingsPromise.user_validation_required === 'true';
// reservation contexts stuff
if (settingsPromise.reservation_context_feature === 'true') {
$scope.reservationContexts = reservationContextsPromise;
} else {
$scope.reservationContexts = [];
}
/**
* Change the last selected slot's appearance to looks like 'added to cart'
*/

View File

@ -98,8 +98,8 @@ Application.Controllers.controller('ShowTrainingController', ['$scope', '$state'
* training can be reserved during the reservation process (the shopping cart may contain only one training and a subscription).
*/
Application.Controllers.controller('ReserveTrainingController', ['$scope', '$transition$', 'Auth', 'AuthService', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'trainingPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'helpers',
function ($scope, $transition$, Auth, AuthService, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, trainingPromise, _t, uiCalendarConfig, CalendarConfig, Reservation, helpers) {
Application.Controllers.controller('ReserveTrainingController', ['$scope', '$transition$', 'Auth', 'AuthService', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'trainingPromise', 'reservationContextsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation', 'helpers',
function ($scope, $transition$, Auth, AuthService, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, trainingPromise, reservationContextsPromise, _t, uiCalendarConfig, CalendarConfig, Reservation, helpers) {
/* PRIVATE STATIC CONSTANTS */
// Color of the selected event backgound
@ -180,6 +180,13 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
// Global config: is the user validation required ?
$scope.enableUserValidationRequired = settingsPromise.user_validation_required === 'true';
// reservation contexts stuff
if (settingsPromise.reservation_context_feature === 'true') {
$scope.reservationContexts = reservationContextsPromise;
} else {
$scope.reservationContexts = [];
}
/**
* Change the last selected slot's appearance to looks like 'added to cart'
*/

View File

@ -36,7 +36,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
reservableId: '@',
reservableType: '@',
reservableName: '@',
limitToOneSlot: '@'
limitToOneSlot: '@',
reservationContexts: '='
},
templateUrl: '/shared/_cart.html',
link ($scope, element, attributes) {
@ -88,6 +89,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// currently logged-in user
$scope.currentUser = $rootScope.currentUser;
$scope.reservationContextsData = {
options: $scope.reservationContexts || [],
selected: null
};
/**
* Add the provided slot to the shopping cart (state transition from free to 'about to be reserved')
* and increment the total amount of the cart if needed.
@ -162,6 +168,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
* Validates the shopping chart and redirect the user to the payment step
*/
$scope.payCart = function () {
if ($scope.reservationContextsData.options.length && angular.isUndefinedOrNull($scope.reservationContextsData.selected)) {
return growl.error(_t('app.shared.cart.please_select_a_reservation_context'));
}
// first, we check that a user was selected
if (Object.keys($scope.user).length > 0) {
// check selected user has a subscription, if any slot is restricted for subscriptions
@ -725,6 +734,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
reservable_type: $scope.reservableType,
slots_reservations_attributes: []
};
if (!angular.isUndefinedOrNull($scope.reservationContextsData.selected)) {
reservation.reservation_context_id = $scope.reservationContextsData.selected.id;
}
angular.forEach(slots, function (slot) {
reservation.slots_reservations_attributes.push({
offered: slot.offered || false,

View File

@ -80,3 +80,9 @@ export interface ReservationIndexFilter extends ApiFilter {
reservable_type?: ReservableType | Array<ReservableType>,
user_id?: number
}
export interface ReservationContext {
id?: number,
name: string,
related_to?: number
}

View File

@ -88,7 +88,8 @@ export const bookingSettings = [
'display_name_enable',
'book_overlapping_slots',
'slot_duration',
'overlapping_categories'
'overlapping_categories',
'reservation_context_feature'
] as const;
export const themeSettings = [

View File

@ -429,9 +429,11 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['machine_explications_alert', 'booking_window_start', 'booking_window_end', 'booking_move_enable', " +
"'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'online_payment_module', 'payment_gateway', 'overlapping_categories', 'user_validation_required', 'user_validation_required_list']"
"'online_payment_module', 'payment_gateway', 'overlapping_categories', 'user_validation_required', 'user_validation_required_list', " +
"'reservation_context_feature']"
}).$promise;
}]
}],
reservationContextsPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.query({ applicable_on: 'machine' }).$promise; }]
}
})
.state('app.admin.machines_edit', {
@ -518,9 +520,10 @@ angular.module('application.router', ['ui.router'])
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'space_explications_alert', 'online_payment_module', 'payment_gateway', 'overlapping_categories', " +
"'user_validation_required', 'user_validation_required_list']"
"'user_validation_required', 'user_validation_required_list', 'reservation_context_feature']"
}).$promise;
}]
}],
reservationContextsPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.query({ applicable_on: 'space' }).$promise; }]
}
})
@ -572,9 +575,11 @@ angular.module('application.router', ['ui.router'])
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'training_explications_alert', 'training_information_message', 'online_payment_module', " +
"'payment_gateway', 'overlapping_categories', 'user_validation_required', 'user_validation_required_list']"
"'payment_gateway', 'overlapping_categories', 'user_validation_required', 'user_validation_required_list', " +
"'reservation_context_feature']"
}).$promise;
}]
}],
reservationContextsPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.query({ applicable_on: 'training' }).$promise; }]
}
})
// notifications
@ -1152,7 +1157,9 @@ angular.module('application.router', ['ui.router'])
resolve: {
membersPromise: ['Member', function (Member) { return Member.mapping().$promise; }],
statisticsPromise: ['Statistics', function (Statistics) { return Statistics.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display', 'reservation_context_feature']" }).$promise; }],
reservationContextsPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.query().$promise; }],
reservationContextApplicableOnValuesPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.applicableOnValues().$promise; }]
}
})
.state('app.admin.stats_graphs', {
@ -1193,14 +1200,16 @@ angular.module('application.router', ['ui.router'])
"'extended_prices_in_same_day', 'recaptcha_site_key', 'recaptcha_secret_key', 'user_validation_required', " +
"'user_validation_required_list', 'machines_module', 'user_change_group', 'show_username_in_admin_list', " +
"'store_module', 'machine_reservation_deadline', 'training_reservation_deadline', 'event_reservation_deadline', " +
"'space_reservation_deadline']"
"'space_reservation_deadline', 'reservation_context_feature']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],
cguFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'cgu-file' }).$promise; }],
cgvFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'cgv-file' }).$promise; }],
faviconFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'favicon-file' }).$promise; }],
profileImageFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'profile-image-file' }).$promise; }]
profileImageFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'profile-image-file' }).$promise; }],
reservationContextsPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.query().$promise; }],
reservationContextApplicableOnValuesPromise: ['ReservationContext', function (ReservationContext) { return ReservationContext.applicableOnValues().$promise; }]
}
})

View File

@ -0,0 +1,16 @@
'use strict';
Application.Services.factory('ReservationContext', ['$resource', function ($resource) {
return $resource('/api/reservation_contexts/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
},
applicableOnValues: {
method: 'GET',
url: '/api/reservation_contexts/applicable_on_values',
isArray: true
}
}
);
}]);

View File

@ -250,3 +250,74 @@
</div>
</div>
</div>
<div class="panel panel-default m-t-lg">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.reservation_context_feature_title' }}</span>
</div>
<div class="panel-body">
<div class="row">
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.reservation_context_feature_info' | translate"></p>
<boolean-setting name="'reservation_context_feature'"
label="'app.admin.settings.reservation_context_feature' | translate"
class-name="'m-l'"
on-success="onSuccess"
on-error="onError"
on-change="onReservationContextFeatureChange">
</boolean-setting>
</div>
<div class="row p-l p-r" ng-if="reservationContextFeature.value === 'true'">
<h3 class="" translate>{{ 'app.admin.settings.reservation_context_options' }}</h3>
<button type="button" class="btn btn-warning m-b m-t" ng-click="addReservationContext()" translate>{{ 'app.admin.settings.add_a_reservation_context' }}</button>
<table class="table">
<thead>
<tr>
<th style="width:40%" translate>{{ 'app.admin.reservation_contexts.name' }}</th>
<th style="width:40%" translate>{{ 'app.admin.reservation_contexts.applicable_on' }}</th>
<th style="width:20%"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="reservationContext in reservationContexts">
<td>
<span editable-text="reservationContext.name" e-cols="100" e-name="name" e-form="rowform" e-required>
{{ reservationContext.name }}
</span>
</td>
<td>
<div editable-ui-select="reservationContext.applicable_on" e-multiple e-form="rowform" e-name="applicable_on" e-ng-model="reservationContext.applicable_on" e-style="min-width: 200px">
{{ translateApplicableOnValue(reservationContext.applicable_on) }}
<editable-ui-select-match placeholder="">
{{ translateApplicableOnValue($item) }}
</editable-ui-select-match>
<editable-ui-select-choices repeat="ao in reservationContextApplicableOnValues | filter: $select.search track by $index">
{{ translateApplicableOnValue(ao) }}
</editable-ui-select-choices>
</div>
</td>
<td>
<!-- form -->
<form editable-form name="rowform" onbeforesave="saveReservationContext($data, reservationContext.id)" ng-show="rowform.$visible" class="form-buttons form-inline" shown="newReservationContext == reservationContext">
<button type="submit" ng-disabled="rowform.$waiting" class="btn btn-warning">
<i class="fa fa-check"></i>
</button>
<button type="button" ng-disabled="rowform.$waiting" ng-click="cancelReservationContext(rowform, $index)" class="btn btn-default">
<i class="fa fa-times"></i>
</button>
</form>
<div class="buttons" ng-show="!rowform.$visible">
<button class="btn btn-default" ng-click="rowform.$show()">
<i class="fa fa-edit"></i> <span class="hidden-xs hidden-sm" translate>{{ 'app.shared.buttons.edit' }}</span>
</button>
<button class="btn btn-danger" ng-click="removeReservationContext($index)">
<i class="fa fa-trash-o"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -257,7 +257,9 @@
<th ng-if="['booking', 'hour'].includes(type.active.key)" translate>{{ 'app.admin.statistics.reservation_date' }}</th>
<th ng-if="!['booking', 'hour'].includes(type.active.key)" translate>{{ 'app.admin.statistics.date' }}</th>
<th translate>{{ 'app.admin.statistics.user' }}</th>
<th translate>{{ 'app.admin.statistics.gender' }}</th>
<th ng-if="reservationContextFeatureEnabled && reservationContextIsApplicable(selectedIndex.es_type_key)">
{{ 'app.admin.statistics.reservation_context' | translate }}
</th>
<th translate>{{ 'app.admin.statistics.age' }}</th>
<th translate>{{ 'app.admin.statistics.type' }}</th>
<th ng-if="!type.active.simple">{{type.active.label}}</th>
@ -280,7 +282,9 @@
<a ng-show="datum._source.userId" ui-sref="app.admin.members_edit({id:datum._source.userId})">{{getUserNameFromId(datum._source.userId)}}</a>
<span class="text-gray text-italic" ng-hide="datum._source.userId" translate>{{ 'app.admin.statistics.deleted_user' }}</span>
</td>
<td>{{formatSex(datum._source.gender)}}</td>
<td ng-if="reservationContextFeatureEnabled && reservationContextIsApplicable(selectedIndex.es_type_key)">
{{ formatReservationContext(datum._source.reservationContextId) }}
</td>
<td>
<span ng-if="datum._source.age">{{datum._source.age}} {{ 'app.admin.statistics.years_old' | translate }}</span>
<span ng-if="!datum._source.age" translate>{{ 'app.admin.statistics.unknown' }}</span>

View File

@ -75,7 +75,8 @@
after-payment="afterPayment"
reservable-id="{{machine.id}}"
reservable-type="Machine"
reservable-name="{{machine.name}}"></cart>
reservable-name="{{machine.name}}"
reservation-contexts="reservationContexts"></cart>
<uib-alert type="warning m">
<p class="text-sm">

View File

@ -18,6 +18,10 @@
</div>
<div class="widget-content no-bg auto wrapper" ng-if="events.reserved.length > 0">
<div ng-if="reservationContextsData.options.length > 0" class="m-b">
<div class="font-sbold m-b-sm " translate>{{ 'app.shared.cart.select_the_reservation_context' }}</div>
<select ng-model="reservationContextsData.selected" class="form-control m-t-sm" ng-options="o.name for o in reservationContextsData.options"></select>
</div>
<div class="font-sbold m-b-sm " translate>{{ 'app.shared.cart.you_ve_just_selected_the_slot' }}</div>

View File

@ -47,7 +47,8 @@
after-payment="afterPayment"
reservable-id="{{space.id}}"
reservable-type="Space"
reservable-name="{{space.name}}"></cart>
reservable-name="{{space.name}}"
reservation-contexts="reservationContexts"></cart>
<uib-alert type="warning m" ng-show="spaceExplicationsAlert">

View File

@ -60,7 +60,8 @@
reservable-id="{{training.id}}"
reservable-type="Training"
reservable-name="{{training.name}}"
limit-to-one-slot="true"></cart>
limit-to-one-slot="true"
reservation-contexts="reservationContexts"></cart>
<uib-alert type="info m">

View File

@ -47,6 +47,20 @@ module ExcelHelper
[data, styles, types]
end
def add_hardcoded_cells(index, hit, data, styles, types)
if index.concerned_by_reservation_context?
add_reservation_context_cell(hit, data, styles, types)
end
end
def add_reservation_context_cell(hit, data, styles, types)
reservation_contexts = ReservationContext.pluck(:id, :name).to_h
data.push reservation_contexts[hit['_source']['reservationContextId']]
styles.push nil
types.push :text
end
# append a cell containing the CA amount
def add_ca_cell(index, hit, data, styles, types)
return unless index.ca

View File

@ -202,6 +202,7 @@ module SettingsHelper
projects_list_date_filters_presence
project_categories_filter_placeholder
project_categories_wording
reservation_context_feature
].freeze
end
# rubocop:enable Metrics/ModuleLength

View File

@ -13,6 +13,8 @@ class CartItem::MachineReservation < CartItem::Reservation
belongs_to :plan
belongs_to :reservation_context
def type
'machine'
end

View File

@ -83,6 +83,7 @@ class CartItem::Reservation < CartItem::BaseItem
::Reservation.new(
reservable_id: reservable_id,
reservable_type: reservable_type,
reservation_context_id: reservation_context_id,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)

View File

@ -13,6 +13,8 @@ class CartItem::SpaceReservation < CartItem::Reservation
belongs_to :plan
belongs_to :reservation_context
def type
'space'
end

View File

@ -13,6 +13,8 @@ class CartItem::TrainingReservation < CartItem::Reservation
belongs_to :plan
belongs_to :reservation_context
def price
base_amount = reservable&.amount_by_group(customer.group_id)&.amount
is_privileged = operator.admin? || (operator.manager? && operator.id != customer.id)

View File

@ -6,6 +6,7 @@ module StatReservationConcern
included do
attribute :reservationId, Integer
attribute :reservationContextId, Integer
attribute :ca, Float
attribute :name, String
end

View File

@ -26,6 +26,8 @@ class Reservation < ApplicationRecord
has_many :booking_users, dependent: :destroy
accepts_nested_attributes_for :booking_users, allow_destroy: true
belongs_to :reservation_context
validates :reservable_id, :reservable_type, presence: true
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }
validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) }

View File

@ -0,0 +1,27 @@
class ReservationContext < ApplicationRecord
has_many :reservations
has_many :cart_item_reservations, dependent: :nullify, class_name: "CartItem::Reservation"
APPLICABLE_ON = %w[machine space training]
scope :applicable_on, ->(applicable_on) { where("applicable_on @> ?", "{#{applicable_on.presence_in(APPLICABLE_ON)}}")}
validates :name, presence: true
validate :validate_applicable_on
def safe_destroy
if reservations.count.zero?
destroy
else
false
end
end
private
def validate_applicable_on
return if applicable_on.all? { |applicable_on| applicable_on.in? APPLICABLE_ON }
errors.add(:applicable_on, :invalid)
end
end

View File

@ -2,4 +2,11 @@ class StatisticIndex < ApplicationRecord
has_many :statistic_types
has_many :statistic_fields
has_one :statistic_graph
def concerned_by_reservation_context?
return false unless es_type_key.in? ReservationContext::APPLICABLE_ON
return false unless Setting.get('reservation_context_feature')
true
end
end

View File

@ -0,0 +1,7 @@
class ReservationContextPolicy < ApplicationPolicy
%w(create update destroy applicable_on_values).each do |action|
define_method "#{action}?" do
user.admin?
end
end
end

View File

@ -47,7 +47,7 @@ class SettingPolicy < ApplicationPolicy
machines_banner_cta_url trainings_banner_active trainings_banner_text trainings_banner_cta_active trainings_banner_cta_label
trainings_banner_cta_url events_banner_active events_banner_text events_banner_cta_active events_banner_cta_label
events_banner_cta_url projects_list_member_filter_presence projects_list_date_filters_presence
project_categories_filter_placeholder project_categories_wording family_account child_validation_required]
project_categories_filter_placeholder project_categories_wording family_account child_validation_required reservation_context_feature]
end
##

View File

@ -157,14 +157,16 @@ class CartService
reservable: reservable,
cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
new_subscription: plan_info[:new_subscription],
reservation_context_id: cart_item[:reservation_context_id])
when Training
CartItem::TrainingReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
reservable: reservable,
cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
new_subscription: plan_info[:new_subscription],
reservation_context_id: cart_item[:reservation_context_id])
when Event
CartItem::EventReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
@ -179,7 +181,8 @@ class CartService
reservable: reservable,
cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
new_subscription: plan_info[:new_subscription],
reservation_context_id: cart_item[:reservation_context_id])
else
Rails.logger.warn "the reservable #{reservable} is not implemented"
raise NotImplementedError

View File

@ -17,7 +17,9 @@ class Statistics::Builders::ReservationsBuilderService
subType: r["#{category}_type".to_sym],
ca: r[:ca],
name: r["#{category}_name".to_sym],
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
reservationId: r[:reservation_id],
reservationContextId: r[:reservation_context_id]
}.merge(user_info_stat(r)))
stat[:stat] = (type == 'booking' ? 1 : r[:nb_hours])
stat["#{category}Id".to_sym] = r["#{category}_id".to_sym]

View File

@ -46,7 +46,7 @@ class Statistics::FetcherService
Reservation
.where("reservable_type = 'Machine' AND slots_reservations.canceled_at IS NULL AND " \
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
.eager_load(:slots, :slots_reservations, :invoice_items, :reservation_context, statistic_profile: [:group])
.find_each do |r|
next unless r.reservable
@ -58,7 +58,9 @@ class Statistics::FetcherService
machine_name: r.reservable.name,
slot_dates: r.slots.map(&:start_at).map(&:to_date),
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_f,
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))
ca: calcul_ca(r.original_invoice),
reservation_context_id: r.reservation_context_id
}.merge(user_info(profile))
yield result
end
end
@ -70,7 +72,7 @@ class Statistics::FetcherService
Reservation
.where("reservable_type = 'Space' AND slots_reservations.canceled_at IS NULL AND " \
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
.eager_load(:slots, :slots_reservations, :invoice_items, :reservation_context, statistic_profile: [:group])
.find_each do |r|
next unless r.reservable
@ -82,7 +84,9 @@ class Statistics::FetcherService
space_type: r.reservable.slug,
slot_dates: r.slots.map(&:start_at).map(&:to_date),
nb_hours: (r.slots.map(&:duration).map(&:to_i).reduce(:+) / 3600.0).to_f,
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))
ca: calcul_ca(r.original_invoice),
reservation_context_id: r.reservation_context_id
}.merge(user_info(profile))
yield result
end
end
@ -94,7 +98,7 @@ class Statistics::FetcherService
Reservation
.where("reservable_type = 'Training' AND slots_reservations.canceled_at IS NULL AND " \
'reservations.created_at >= :start_date AND reservations.created_at <= :end_date', options)
.eager_load(:slots, :slots_reservations, :invoice_items, statistic_profile: [:group])
.eager_load(:slots, :slots_reservations, :invoice_items, :reservation_context, statistic_profile: [:group])
.find_each do |r|
next unless r.reservable
@ -107,7 +111,9 @@ class Statistics::FetcherService
training_name: r.reservable.name,
training_date: slot.start_at.to_date,
nb_hours: difference_in_hours(slot.start_at, slot.end_at),
ca: calcul_ca(r.original_invoice) }.merge(user_info(profile))
ca: calcul_ca(r.original_invoice),
reservation_context_id: r.reservation_context_id
}.merge(user_info(profile))
yield result
end
end

View File

@ -0,0 +1,6 @@
user_is_admin = (current_user and current_user.admin?)
json.array!(@reservation_contexts) do |reservation_context|
json.extract! reservation_context, :id, :name, :applicable_on
json.related_to reservation_context.reservations.count if user_is_admin
end

View File

@ -0,0 +1 @@
json.extract! @reservation_context, :id, :name, :applicable_on

View File

@ -26,7 +26,9 @@ wb.add_worksheet(name: ExcelService.name_safe(index.label)) do |sheet|
fields.each do |f|
columns.push f.label
end
columns.push t('export.reservation_context') if index.concerned_by_reservation_context?
columns.push t('export.revenue') if index.ca
sheet.add_row columns, style: header
# data rows
@ -37,6 +39,7 @@ wb.add_worksheet(name: ExcelService.name_safe(index.label)) do |sheet|
fields.each do |f|
format_xlsx_cell(hit['_source'][f.key], data, styles, types, source_data_type: f.data_type, date_format: date)
end
add_hardcoded_cells(index, hit, data, styles, types)
add_ca_cell(index, hit, data, styles, types)
sheet.add_row data, style: styles, types: types

View File

@ -35,6 +35,7 @@ indices.each do |index|
index.statistic_fields.each do |f|
format_xlsx_cell(hit['_source'][f.key], data, styles, types, source_data_type: f.data_type, date_format: date)
end
add_hardcoded_cells(index, hit, data, styles, types)
# proceed the 'ca' field if requested
add_ca_cell(index, hit, data, styles, types)

View File

@ -1596,6 +1596,7 @@ en:
create_plans_to_start: "Start by creating new subscription plans."
click_here: "Click here to create your first one."
average_cart: "Average cart:"
reservation_context: Reservation context
#statistics graphs
stats_graphs:
statistics: "Statistics"
@ -1850,6 +1851,15 @@ en:
enable_family_account: "Enable the Family Account option"
child_validation_required: "the account validation option for children"
child_validation_required_label: "Activate the account validation option for children"
reservation_context_feature_title: Reservation context
reservation_context_feature_info: "If you enable this feature, members will have to enter the context of their reservation when reserving."
reservation_context_feature: "Enable the feature \"Reservation context\""
reservation_context_options: Reservation context options
add_a_reservation_context: Add a new context
confirmation_required: Confirmation required
do_you_really_want_to_delete_this_reservation_context: "Do you really want to delete this context?"
unable_to_delete_reservation_context_already_related_to_reservations: "Unable to delete this context because it is already associated to a reservation"
unable_to_delete_reservation_context_an_error_occured: "Unable to delete: an error occurred"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
@ -2524,3 +2534,9 @@ en:
cta_switch: "Display a button"
cta_label: "Button label"
cta_url: "Button link"
reservation_contexts:
name: "Name"
applicable_on: "Applicable on"
machine: Machine
training: Training
space: Space

View File

@ -1588,6 +1588,7 @@ fr:
create_plans_to_start: "Pour commencer, créez de nouvelles formules d'abonnement."
click_here: "Cliquez ici pour créer votre première formule."
average_cart: "Panier moyen :"
reservation_context: Nature de la réservation
#statistics graphs
stats_graphs:
statistics: "Statistiques"
@ -1842,6 +1843,15 @@ fr:
enable_family_account: "Activer l'option Compte Famille"
child_validation_required: "l'option de validation des comptes enfants"
child_validation_required_label: "Activer l'option de validation des comptes enfants"
reservation_context_feature_title: Natures des réservations
reservation_context_feature_info: "Si vous activez cette fonctionnalité, le membre devra obligatoirement saisir la nature de sa réservation au moment de réserver."
reservation_context_feature: Activer la saisie obligatoire de la nature des réservations
reservation_context_options: Natures des réservations possibles
add_a_reservation_context: Ajouter une nouvelle nature
confirmation_required: Confirmation requise
do_you_really_want_to_delete_this_reservation_context: "Êtes-vous sûr de vouloir supprimer cette nature de réservation ?"
unable_to_delete_reservation_context_already_related_to_reservations: "Impossible de supprimer cette nature de réservation car elle est actuellement associée à des réservations."
unable_to_delete_reservation_context_an_error_occured: "Impossible de supprimer : une erreur est survenue."
overlapping_options:
training_reservations: "Formations"
machine_reservations: "Machines"
@ -2516,3 +2526,9 @@ fr:
cta_switch: "Afficher un bouton"
cta_label: "Libellé du bouton"
cta_url: "Lien du bouton"
reservation_contexts:
name: "Nom"
applicable_on: "Applicable à"
machine: Machine
training: Formation
space: Espace

View File

@ -375,6 +375,9 @@ en:
user_validation_required_alert: "Your administrator must validate your account. Then, you'll then be able to access all the booking features."
child_validation_required_alert: "Your administrator must validate your child account. Then, you'll then be able to book the event."
child_birthday_must_be_under_18_years_ago_alert: "Your child must be under 18. Then, you'll then be able to book the event."
user_validation_required_alert: "Warning!<br>Your administrator must validate your account. Then, you'll then be able to access all the booking features."
select_the_reservation_context: "Select the context of the reservation"
please_select_a_reservation_context: "Please select the context of the reservation first"
#feature-tour modal
tour:
previous: "Previous"

View File

@ -375,6 +375,8 @@ fr:
user_validation_required_alert: "Attention !<br>Votre administrateur doit valider votre compte. Vous pourrez alors accéder à l'ensemble des fonctionnalités de réservation."
child_validation_required_alert: "Attention !<br>Votre administrateur doit valider votre compte enfant. Vous pourrez alors réserver l'événement."
child_birthday_must_be_under_18_years_ago_alert: "Attention !<br>La date de naissance de l'enfant doit être inférieure à 18 ans. Vous pourrez alors réserver l'événement."
select_the_reservation_context: Sélectionnez la nature de la réservation
please_select_a_reservation_context: "Veuillez tout d'abord sélectionner la nature de la réservation"
#feature-tour modal
tour:
previous: "Précédent"

View File

@ -534,6 +534,7 @@ en:
male: "Man"
female: "Woman"
deleted_user: "Deleted user"
reservation_context: "Reservation context"
#initial price's category for events, created to replace the old "reduced amount" property
price_category:
reduced_fare: "Reduced fare"
@ -731,6 +732,7 @@ en:
project_categories_filter_placeholder: "Placeholder for categories filter in project gallery"
project_categories_wording: "Wording used to replace \"Categories\" on public pages"
family_account: "Family account"
reservation_context_feature: "Force member to select the nature of his reservation when reserving"
#statuses of projects
statuses:
new: "New"

View File

@ -534,6 +534,7 @@ fr:
male: "Homme"
female: "Femme"
deleted_user: "Utilisateur supprimé"
reservation_context: "Nature de la réservation"
#initial price's category for events, created to replace the old "reduced amount" property
price_category:
reduced_fare: "Tarif réduit"
@ -730,6 +731,7 @@ fr:
projects_list_date_filters_presence: "Filtre de présence de dates sur la liste des projets"
project_categories_filter_placeholder: "Dans la galerie de projets, renommer le filtre \"Toutes les catégories\""
project_categories_wording: "Dans la fiche projet, renommer l'intitulé de l'encart Catégories"
reservation_context_feature: "Obligation pour le membre de saisir la nature de sa réservation au moment de réserver"
#statuses of projects
statuses:
new: "Nouveau"

View File

@ -225,6 +225,9 @@ Rails.application.routes.draw do
end
# export accounting data to csv or equivalent
post 'accounting/export' => 'accounting_exports#export'
resources :reservation_contexts do
get :applicable_on_values, on: :collection
end
# i18n
# regex allows using dots in URL for 'state'

View File

@ -0,0 +1,10 @@
class CreateReservationContexts < ActiveRecord::Migration[7.0]
def change
create_table :reservation_contexts do |t|
t.string :name
t.string :applicable_on, array: true, default: []
t.timestamps
end
end
end

View File

@ -0,0 +1,5 @@
class AddReservationContextIdToReservation < ActiveRecord::Migration[7.0]
def change
add_reference :reservations, :reservation_context, index: true, foreign_key: true
end
end

View File

@ -0,0 +1,5 @@
class AddReservationContextIdToCartItemReservations < ActiveRecord::Migration[7.0]
def change
add_reference :cart_item_reservations, :reservation_context, foreign_key: true
end
end

View File

@ -735,3 +735,5 @@ Setting.set('project_categories_filter_placeholder', 'Toutes les catégories') u
Setting.set('project_categories_wording', 'Catégories') unless Setting.find_by(name: 'project_categories_wording').try(:value)
Setting.set('family_account', false) unless Setting.find_by(name: 'family_account').try(:value)
Setting.set('child_validation_required', false) unless Setting.find_by(name: 'child_validation_required').try(:value)
Setting.set('reservation_context_feature', false) unless Setting.find_by(name: 'reservation_context_feature')

View File

@ -839,7 +839,8 @@ CREATE TABLE public.cart_item_reservations (
operator_profile_id bigint,
type character varying,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
updated_at timestamp without time zone NOT NULL,
reservation_context_id bigint
);
@ -3126,6 +3127,38 @@ CREATE SEQUENCE public.projects_themes_id_seq
ALTER SEQUENCE public.projects_themes_id_seq OWNED BY public.projects_themes.id;
--
-- Name: reservation_contexts; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.reservation_contexts (
id bigint NOT NULL,
name character varying,
applicable_on character varying[] DEFAULT '{}'::character varying[],
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: reservation_contexts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.reservation_contexts_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: reservation_contexts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.reservation_contexts_id_seq OWNED BY public.reservation_contexts.id;
--
-- Name: reservations; Type: TABLE; Schema: public; Owner: -
--
@ -3138,7 +3171,8 @@ CREATE TABLE public.reservations (
reservable_type character varying,
reservable_id integer,
nb_reserve_places integer,
statistic_profile_id integer
statistic_profile_id integer,
reservation_context_id bigint
);
@ -5007,6 +5041,13 @@ ALTER TABLE ONLY public.projects_spaces ALTER COLUMN id SET DEFAULT nextval('pub
ALTER TABLE ONLY public.projects_themes ALTER COLUMN id SET DEFAULT nextval('public.projects_themes_id_seq'::regclass);
--
-- Name: reservation_contexts id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservation_contexts ALTER COLUMN id SET DEFAULT nextval('public.reservation_contexts_id_seq'::regclass);
--
-- Name: reservations id; Type: DEFAULT; Schema: public; Owner: -
--
@ -5954,6 +5995,14 @@ ALTER TABLE ONLY public.projects_themes
ADD CONSTRAINT projects_themes_pkey PRIMARY KEY (id);
--
-- Name: reservation_contexts reservation_contexts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservation_contexts
ADD CONSTRAINT reservation_contexts_pkey PRIMARY KEY (id);
--
-- Name: reservations reservations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -6503,6 +6552,13 @@ CREATE INDEX index_cart_item_reservations_on_plan_id ON public.cart_item_reserva
CREATE INDEX index_cart_item_reservations_on_reservable ON public.cart_item_reservations USING btree (reservable_type, reservable_id);
--
-- Name: index_cart_item_reservations_on_reservation_context_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_cart_item_reservations_on_reservation_context_id ON public.cart_item_reservations USING btree (reservation_context_id);
--
-- Name: index_cart_item_slots_on_cart_item; Type: INDEX; Schema: public; Owner: -
--
@ -7259,6 +7315,13 @@ CREATE INDEX index_projects_themes_on_theme_id ON public.projects_themes USING b
CREATE INDEX index_reservations_on_reservable_type_and_reservable_id ON public.reservations USING btree (reservable_type, reservable_id);
--
-- Name: index_reservations_on_reservation_context_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_reservations_on_reservation_context_id ON public.reservations USING btree (reservation_context_id);
--
-- Name: index_reservations_on_statistic_profile_id; Type: INDEX; Schema: public; Owner: -
--
@ -8500,6 +8563,14 @@ ALTER TABLE ONLY public.accounting_periods
ADD CONSTRAINT fk_rails_cc9abff81f FOREIGN KEY (closed_by) REFERENCES public.users(id);
--
-- Name: reservations fk_rails_cfaaf202e7; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations
ADD CONSTRAINT fk_rails_cfaaf202e7 FOREIGN KEY (reservation_context_id) REFERENCES public.reservation_contexts(id);
--
-- Name: wallet_transactions fk_rails_d07bc24ce3; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -8548,6 +8619,14 @@ ALTER TABLE ONLY public.payment_schedule_items
ADD CONSTRAINT fk_rails_d6030dd0e7 FOREIGN KEY (payment_schedule_id) REFERENCES public.payment_schedules(id);
--
-- Name: cart_item_reservations fk_rails_daa5b1b8b9; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.cart_item_reservations
ADD CONSTRAINT fk_rails_daa5b1b8b9 FOREIGN KEY (reservation_context_id) REFERENCES public.reservation_contexts(id);
--
-- Name: product_stock_movements fk_rails_dc802d5f48; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -9076,5 +9155,6 @@ INSERT INTO "schema_migrations" (version) VALUES
('20230626122844'),
('20230626122947'),
('20230710072403');
('20230718133636'),
('20230718134350'),
('20230720085857');

View File

@ -1,6 +1,6 @@
{
"name": "fab-manager",
"version": "6.0.9",
"version": "6.0.10",
"description": "Fab-manager is the FabLab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.",
"keywords": [
"fablab",

14
test/fixtures/reservation_contexts.yml vendored Normal file
View File

@ -0,0 +1,14 @@
reservation_context_1:
id: 1
name: Pédagogique
applicable_on: [machine, training, space]
created_at: 2023-07-20 15:39:08.259759000 Z
updated_at: 2023-07-20 15:39:08.259759000 Z
reservation_context_2:
id: 2
name: Recherche
applicable_on: [machine, training, space]
created_at: 2023-07-20 15:39:08.265840000 Z
updated_at: 2023-07-20 15:39:08.265840000 Z

View File

@ -622,3 +622,9 @@ setting_105:
name: child_validation_required
created_at: 2023-03-31 14:38:40.000421500 Z
updated_at: 2023-03-31 14:38:40.000421500 Z
setting_106:
id: 106
name: reservation_context_feature
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z

View File

@ -0,0 +1,8 @@
import { ReservationContext } from 'models/reservation';
const reservationContexts: Array<ReservationContext> = [
{ id: 1, name: 'Research' },
{ id: 2, name: 'Pedagogical' }
];
export default reservationContexts;

View File

@ -855,6 +855,12 @@ export const settings: Array<Setting> = [
value: 'false',
last_update: '2023-03-31T14:39:12+0100',
localized: 'Family account'
},
{
name: 'reservation_context_feature',
value: 'false',
last_update: '2022-12-23T14:39:12+0100',
localized: 'Reservation context feature'
}
];

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'test_helper'
class ReservationContextsTest < ActionDispatch::IntegrationTest
def setup
@admin = User.find_by(username: 'admin')
login_as(@admin, scope: :user)
end
test 'create a reservation_context' do
applicable_on = ["machine", "space", "training"]
post '/api/reservation_contexts',
params: {
name: 'Enseignant',
applicable_on: applicable_on
}.to_json,
headers: default_headers
# Check response format & reservation_context
assert_equal 201, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the correct reservation_context was created
res = json_response(response.body)
reservation_context = ReservationContext.where(id: res[:id]).first
assert_not_nil reservation_context, 'reservation_context was not created in database'
assert_equal 'Enseignant', res[:name]
assert_equal applicable_on, res[:applicable_on]
end
test 'update a reservation_context' do
applicable_on = ["machine"]
patch '/api/reservation_contexts/1',
params: {
name: 'Nouveau nom',
applicable_on: applicable_on
}.to_json,
headers: default_headers
# Check response format & reservation_context
assert_equal 200, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the reservation_context was updated
res = json_response(response.body)
assert_equal 1, res[:id]
assert_equal 'Nouveau nom', res[:name]
assert_equal applicable_on, res[:applicable_on]
end
test 'list all reservation_contexts' do
logout @admin
get '/api/reservation_contexts'
# Check response format & reservation_context
assert_equal 200, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the list items are ok
reservation_contexts = json_response(response.body)
assert_equal ReservationContext.count, reservation_contexts.count
assert_equal reservation_contexts(:reservation_context_1).name, reservation_contexts[0][:name]
end
test "list all applicable_on possible values" do
get '/api/reservation_contexts/applicable_on_values'
# Check response format & reservation_context
assert_equal 200, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the list items are ok
applicable_on_values = json_response(response.body)
assert_equal %w[machine space training], applicable_on_values
end
test 'delete a reservation_context' do
reservation_context = ReservationContext.create!(name: 'Gone too soon')
delete "/api/reservation_contexts/#{reservation_context.id}"
assert_response :success
assert_empty response.body
assert_raise ActiveRecord::RecordNotFound do
reservation_context.reload
end
end
end

44
test/meta/i18n_test.rb Normal file
View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'test_helper'
class I18nTest < ActiveSupport::TestCase
# Do not use I18n.default_locale, reference locale can be different
REFERENCE_LOCALE = :fr
LOCALES_TO_CHECK = [:en]
SKIP_FILES = %w[
devise
rails
].freeze
FILE_IDS = Rails.root.glob("config/locales/*.#{REFERENCE_LOCALE}.yml")
.map { File.basename(_1) }
.map { _1.split(".")[0..-3].join(".") } # view.admin.fr.yml -> view.admin
.reject { SKIP_FILES.include?(_1) }
LOCALES_TO_CHECK.each do |locale|
FILE_IDS.each do |file_id|
test "#{file_id}.#{locale}.yml have same keys as #{file_id}.#{REFERENCE_LOCALE}.yml" do
reference_keys = read_keys(REFERENCE_LOCALE, file_id)
locale_keys = read_keys(locale, file_id)
reference_keys.each_with_index do |reference_key, index|
next if index.zero?
unless reference_key[0] == "#"
assert_equal reference_key, locale_keys[index], "invalid key at line #{index + 1}"
end
end
end
end
end
def read_keys(locale, file_id)
file = Rails.root.join("config/locales/#{file_id}.#{locale}.yml")
File.read(file)
.split("\n")
.map { _1.split(":", 2).first.to_s.strip }
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'test_helper'
class ReservationContextTest < ActiveSupport::TestCase
test 'fixtures are valid' do
ReservationContext.find_each do |reservation_context|
assert reservation_context.valid?
end
end
test "applicable_on validation" do
reservation_context = reservation_contexts(:reservation_context_1)
reservation_context.applicable_on << "wrong"
assert reservation_context.invalid?
assert_equal reservation_context.errors.details, { applicable_on: [{ error: :invalid }] }
end
test "name validation" do
reservation_context = reservation_contexts(:reservation_context_1)
reservation_context.name = nil
assert reservation_context.invalid?
assert_equal reservation_context.errors.details, { name: [{ error: :blank }] }
end
test "#safe_destroy" do
reservation_context = reservation_contexts(:reservation_context_1)
reservation = reservations(:reservation_1).tap { |r| r.update!(reservation_context: reservation_context) }
assert_not reservation_context.safe_destroy
reservation.update!(reservation_context_id: nil)
assert reservation_context.safe_destroy
assert reservation_context.destroyed?
end
test "scope applicable_on" do
assert_equal reservation_contexts(:reservation_context_1, :reservation_context_2), ReservationContext.applicable_on("space")
reservation_context = reservation_contexts(:reservation_context_1)
reservation_context.applicable_on.delete("space")
reservation_context.save!
assert_equal [reservation_contexts(:reservation_context_2)], ReservationContext.applicable_on("space")
end
test "foreign key on reservations" do
reservation_context = reservation_contexts(:reservation_context_1)
reservation = reservations(:reservation_1).tap { |r| r.update!(reservation_context: reservation_context) }
assert_raise ActiveRecord::InvalidForeignKey do
reservation_context.destroy!
end
end
end