1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +01:00
This commit is contained in:
Nicolas Florentin 2023-07-20 16:55:22 +02:00
parent 8cd87f94d5
commit 043ff6d47e
58 changed files with 812 additions and 74 deletions

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,7 +168,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
* Validates the shopping chart and redirect the user to the payment step
*/
$scope.payCart = function () {
// first, we check that a user was selected
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
const slotValidations = [];
@ -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

@ -52,3 +52,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

@ -417,9 +417,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', {
@ -506,9 +508,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; }]
}
})
@ -560,9 +563,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
@ -1140,7 +1145,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', {
@ -1181,14 +1188,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

@ -200,6 +200,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

@ -23,6 +23,8 @@ class Reservation < ApplicationRecord
has_many :prepaid_pack_reservations, dependent: :destroy
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]
project_categories_filter_placeholder project_categories_wording 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,
@ -178,7 +180,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

@ -1068,7 +1068,7 @@ en:
currency_error: "The inputted value is not a valid currency"
error_while_saving: "An error occurred while saving the currency: "
currency_updated: "The PayZen currency was successfully updated to {CURRENCY}."
# select a payment gateway
#select a payment gateway
select_gateway_modal:
select_gateway_title: "Select a payment gateway"
gateway_info: "To securely collect and process payments online, Fab-manager needs to use an third-party service authorized by the financial institutions, called a payment gateway."
@ -1316,7 +1316,7 @@ en:
invalidate_member_error: "An error occurred: impossible to invalidate from this member."
supporting_documents: "Supporting documents"
change_role: "Change role"
# extend a subscription for free
#extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
@ -1534,6 +1534,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"
@ -1783,6 +1784,15 @@ en:
projects_list_date_filters_presence: "Presence of date filters on projects list"
project_categories_filter_placeholder: "Placeholder for categories filter in project gallery"
project_categories_wording: "Wording used to replace \"Categories\" on public pages"
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"
@ -2457,3 +2467,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

@ -386,7 +386,7 @@ fr:
deleted_user: "Utilisateur supprimé"
select_type: "Veuillez sélectionner un type pour continuer"
no_modules_available: "Aucun module réservable n'est disponible. Veuillez activer au moins un module (machines, espaces ou formations) dans la section Personnalisation."
#import external iCal calendar
# import external iCal calendar
icalendar:
icalendar_import: "Import iCalendar"
intro: "Fab-manager vous permet d'importer automatiquement des événements de calendrier, au format iCalendar RFC 5545, depuis des URL externes. Ces URL seront synchronisée toutes les heures et les événements seront affichés dans le calendrier publique. Vous pouvez aussi déclencher une synchronisation en cliquant sur le bouton correspondant, en face de chaque import."
@ -1534,6 +1534,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"
@ -1783,6 +1784,15 @@ fr:
projects_list_date_filters_presence: "Permettre la recherche de projets par dates"
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_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"
@ -2457,3 +2467,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

@ -372,12 +372,14 @@ en:
user_tags: "User tags"
no_tags: "No tags"
user_validation_required_alert: "Warning!<br>Your administrator must validate your account. Then, you'll then be able to access all the booking features."
# feature-tour modal
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"
next: "Next"
end: "End the tour"
# help modal
#help modal
help:
title: "Help"
what_to_do: "What do you want to do?"
@ -386,7 +388,7 @@ en:
stripe_confirm_modal:
resolve_action: "Resolve the action"
ok_button: "OK"
# 2nd factor authentication for card payments
#2nd factor authentication for card payments
stripe_confirm:
pending: "Pending for action..."
success: "Thank you, your card setup is complete. The payment will be proceeded shortly."

View File

@ -372,6 +372,8 @@ fr:
user_tags: "Étiquettes de l'utilisateur"
no_tags: "Aucune étiquette"
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."
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

@ -1,5 +1,5 @@
en:
time:
formats:
# See http://apidock.com/ruby/DateTime/strftime for a list of available directives
#See http://apidock.com/ruby/DateTime/strftime for a list of available directives
hour_minute: "%I:%M %p"

View File

@ -505,6 +505,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"
@ -701,6 +702,7 @@ en:
projects_list_date_filters_presence: "Presence of dates filter on projects list"
project_categories_filter_placeholder: "Placeholder for categories filter in project gallery"
project_categories_wording: "Wording used to replace \"Categories\" on public pages"
reservation_context_feature: "Force member to select the nature of his reservation when reserving"
#statuses of projects
statuses:
new: "New"

View File

@ -505,6 +505,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"
@ -701,6 +702,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

@ -217,6 +217,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

@ -733,3 +733,5 @@ Setting.set('projects_list_member_filter_presence', false) unless Setting.find_b
Setting.set('projects_list_date_filters_presence', false) unless Setting.find_by(name: 'projects_list_date_filters_presence')
Setting.set('project_categories_filter_placeholder', 'Toutes les catégories') unless Setting.find_by(name: 'project_categories_filter_placeholder').try(:value)
Setting.set('project_categories_wording', 'Catégories') unless Setting.find_by(name: 'project_categories_wording').try(:value)
Setting.set('reservation_context_feature', false) unless Setting.find_by(name: 'reservation_context_feature')

View File

@ -762,7 +762,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
);
@ -3009,6 +3010,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: -
--
@ -3021,7 +3054,8 @@ CREATE TABLE public.reservations (
reservable_id integer,
reservable_type character varying,
nb_reserve_places integer,
statistic_profile_id integer
statistic_profile_id integer,
reservation_context_id bigint
);
@ -4865,6 +4899,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: -
--
@ -5788,6 +5829,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: -
--
@ -6287,6 +6336,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: -
--
@ -7036,6 +7092,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: -
--
@ -8260,6 +8323,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: -
--
@ -8308,6 +8379,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: -
--
@ -8818,6 +8897,9 @@ INSERT INTO "schema_migrations" (version) VALUES
('20230328094808'),
('20230328094809'),
('20230626122844'),
('20230626122947');
('20230626122947'),
('20230718133636'),
('20230718134350'),
('20230720085857');

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

@ -610,3 +610,9 @@ setting_103:
name: projects_list_date_filters_presence
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
setting_104:
id: 104
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

@ -849,6 +849,12 @@ export const settings: Array<Setting> = [
value: 'Catégories',
last_update: '2022-12-23T14:39:12+0100',
localized: 'Project categories overridden name'
},
{
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