diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4679a37..ac4d5c8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog Fab-manager +- Ability to configure reservation slot restricted for plan subscribers - Ability to configure the policy (allow or prevent) for members booking a machine/formation/event slot, if they already have a reservation the same day at the same time - Ability to create and delete periodic calendar availabilities (recurrence) - Ability to fully customize the home page diff --git a/app/assets/javascripts/controllers/admin/calendar.js.erb b/app/assets/javascripts/controllers/admin/calendar.js.erb index 45a32e6ee..45f0fed92 100644 --- a/app/assets/javascripts/controllers/admin/calendar.js.erb +++ b/app/assets/javascripts/controllers/admin/calendar.js.erb @@ -18,8 +18,8 @@ * Controller used in the calendar management page */ -Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', - function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) { +Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', + function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) { /* PRIVATE STATIC CONSTANTS */ // The calendar is divided in slots of 30 minutes @@ -164,6 +164,44 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state } }; + /** + * Open a confirmation modal to remove a plan for the currently selected availability, + * @param plan {Object} must contain the machine ID and name + */ + $scope.removePlan = function (plan) { + // open a confirmation dialog + return dialogs.confirm({ + resolve: { + object () { + return { + title: _t('app.admin.calendar.confirmation_required'), + msg: _t('app.admin.calendar.do_you_really_want_to_remove_PLAN_from_this_slot', { GENDER: getGender($scope.currentUser), PLAN: plan.name }) + ' ' + + _t('app.admin.calendar.this_will_prevent_any_new_reservation_on_this_slot_but_wont_cancel_those_existing') + '
' + + _t('app.admin.calendar.beware_this_cannot_be_reverted') + '' + }; + } + } + } + , function () { + // the admin has confirmed, remove the plan + const plans = _.drop($scope.availability.plan_ids, plan.id); + + return Availability.update({ id: $scope.availability.id }, { availability: { plans_attributes: [{ id: plan.id, _destroy: true }] } } + , function (data, status) { // success + // update the plan_ids attribute + $scope.availability.plan_ids = data.plan_ids; + $scope.availability.plans = availabilityPlans(); + uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents'); + // notify the admin + return growl.success(_t('app.admin.calendar.the_plan_was_successfully_removed_from_the_slot')); + } + , function (data, status) { // failed + growl.error(_t('app.admin.calendar.deletion_failed')); + } + ); + }); + }; + /** * Callback to alert the admin that the export request was acknowledged and is * processing right now. @@ -340,6 +378,26 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state } else { return 'other'; } }; + /** + * Return a list of plans classified by group + * + * @returns {array} + */ + var availabilityPlans = function() { + const plansClassifiedByGroup = []; + const _plans = _.filter(plansPromise, function (p) { return _.include($scope.availability.plan_ids, p.id) }); + for (let group of Array.from(groupsPromise)) { + const groupObj = { id: group.id, name: group.name, plans: [] }; + for (let plan of Array.from(_plans)) { + if (plan.group_id === group.id) { groupObj.plans.push(plan); } + } + if (groupObj.plans.length > 0) { + plansClassifiedByGroup.push(groupObj); + } + } + return plansClassifiedByGroup; + }; + // Triggered when the admin drag on the agenda to create a new reservable slot. // @see http://fullcalendar.io/docs/selection/select_callback/ // @@ -372,7 +430,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }], spacesPromise: ['Space', function (Space) { return Space.query().$promise; }], - tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }] + tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }], + plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }], + groupsPromise: ['Group', function (Group) { return Group.query().$promise; }] } }); // when the modal is closed, we send the slot to the server for saving modalInstance.result.then( @@ -389,7 +449,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state borderColor: availability.borderColor, tag_ids: availability.tag_ids, tags: availability.tags, - machine_ids: availability.machine_ids + machine_ids: availability.machine_ids, + plan_ids: availability.plan_ids }, true ); @@ -406,6 +467,7 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state */ const calendarEventClickCb = function (event, jsEvent, view) { $scope.availability = event; + $scope.availability.plans = availabilityPlans(); if ($scope.availabilityDom) { $scope.availabilityDom.classList.remove("fc-selected") @@ -484,8 +546,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state /** * Controller used in the slot creation modal window */ -Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'growl', '_t', - function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, growl, _t) { +Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'plansPromise', 'groupsPromise', 'growl', '_t', + function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, plansPromise, groupsPromise, growl, _t) { // $uibModal parameter $scope.start = start; @@ -504,6 +566,21 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui // all tags list $scope.tags = tagsPromise; + $scope.isOnlySubscriptions = false; + $scope.selectedPlans = []; + $scope.selectedPlansBinding = {}; + // list of plans, classified by group + $scope.plansClassifiedByGroup = []; + for (let group of Array.from(groupsPromise)) { + const groupObj = { id: group.id, name: group.name, plans: [] }; + for (let plan of Array.from(plansPromise)) { + if (plan.group_id === group.id) { groupObj.plans.push(plan); } + } + if (groupObj.plans.length > 0) { + $scope.plansClassifiedByGroup.push(groupObj); + } + } + // machines associated with the created slot $scope.selectedMachines = []; $scope.selectedMachinesBinding = {}; @@ -553,6 +630,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui // localized name(s) of the selected tag(s) $scope.tagsName = ''; + // localized name(s) of the selected plan(s) + $scope.plansName = ''; + // make the duration available for display $scope.slotDuration = Fablab.slotDuration; @@ -584,6 +664,34 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui } } + /** + * Adds or removes the provided plan from the current slot + * @param plan {Object} + */ + $scope.toggleSelectPlan = function (plan) { + const index = $scope.selectedPlans.indexOf(plan); + if (index > -1) { + return $scope.selectedPlans.splice(index, 1); + } else { + return $scope.selectedPlans.push(plan); + } + }; + + /** + * Select/unselect all the plans + */ + $scope.toggleAllPlans = function() { + const count = $scope.selectedPlans.length; + $scope.selectedPlans = []; + $scope.selectedPlansBinding = {}; + if (count == 0) { + plansPromise.forEach(function (plan) { + $scope.selectedPlans.push(plan); + $scope.selectedPlansBinding[plan.id] = true; + }) + } + }; + /** * Callback for the modal window validation: save the slot and closes the modal */ @@ -603,6 +711,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui if ($scope.availability.is_recurrent) { $scope.availability.occurrences = $scope.occurrences; } + if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) { + $scope.availability.plan_ids = $scope.selectedPlans.map(function (p) { return p.id; }); + } return Availability.save( { availability: $scope.availability }, function (availability) { $uibModalInstance.close(availability); } @@ -653,6 +764,14 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui $scope.selectedSpace = $scope.spaces[0]; } + // when disable is only subscriptions option, reset all selected plans + $scope.$watch('isOnlySubscriptions', function(value) { + if (!value) { + $scope.selectedPlans = []; + $scope.selectedPlansBinding = {}; + } + }); + // When we configure a machine/space availability, do not let the user change the end time, as the total // time must be dividable by Fablab.slotDuration minutes (base slot duration). For training availabilities, the user // can configure any duration as it does not matters. @@ -778,6 +897,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui return $scope.availability.tag_ids.indexOf(t.id) > -1; }) $scope.tagsName = localizedList(tags); + if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) { + $scope.plansName = localizedList($scope.selectedPlans); + } } const localizedList = function (items) { diff --git a/app/assets/javascripts/controllers/machines.js.erb b/app/assets/javascripts/controllers/machines.js.erb index 2f761df11..f21f106ce 100644 --- a/app/assets/javascripts/controllers/machines.js.erb +++ b/app/assets/javascripts/controllers/machines.js.erb @@ -450,6 +450,8 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat $scope.settings = settingsPromise; // list of plans, classified by group + $scope.groups = groupsPromise; + $scope.plans = plansPromise; $scope.plansClassifiedByGroup = []; for (let group of Array.from(groupsPromise)) { const groupObj = { id: group.id, name: group.name, plans: [] }; diff --git a/app/assets/javascripts/controllers/spaces.js.erb b/app/assets/javascripts/controllers/spaces.js.erb index aa7550a15..bc0fba8ab 100644 --- a/app/assets/javascripts/controllers/spaces.js.erb +++ b/app/assets/javascripts/controllers/spaces.js.erb @@ -328,6 +328,8 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP // list of plans, classified by group $scope.plansClassifiedByGroup = []; + $scope.groups = groupsPromise; + $scope.plans = plansPromise; for (let group of Array.from(groupsPromise)) { const groupObj = { id: group.id, name: group.name, plans: [] }; for (let plan of Array.from(plansPromise)) { diff --git a/app/assets/javascripts/controllers/trainings.js.erb b/app/assets/javascripts/controllers/trainings.js.erb index 22ed4e5dd..dfecc48af 100644 --- a/app/assets/javascripts/controllers/trainings.js.erb +++ b/app/assets/javascripts/controllers/trainings.js.erb @@ -111,6 +111,8 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta { member: {} }; // list of plans, classified by group + $scope.groups = groupsPromise; + $scope.plans = plansPromise; $scope.plansClassifiedByGroup = []; for (let group of Array.from(groupsPromise)) { const groupObj = { id: group.id, name: group.name, plans: [] }; diff --git a/app/assets/javascripts/directives/cart.js.erb b/app/assets/javascripts/directives/cart.js.erb index efafc2763..35546e187 100644 --- a/app/assets/javascripts/directives/cart.js.erb +++ b/app/assets/javascripts/directives/cart.js.erb @@ -23,6 +23,8 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', plan: '=', planSelectionTime: '=', settings: '=', + plans: '=', + groups: '=', onSlotAddedToCart: '=', onSlotRemovedFromCart: '=', onSlotStartToModify: '=', @@ -164,22 +166,55 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', $scope.payCart = function () { // first, we check that a user was selected if (Object.keys($scope.user).length > 0) { - const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan); - return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) { - const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); - if (!$scope.isAdmin() && (amountToPay > 0)) { - if ($rootScope.fablabWithoutOnlinePayment) { - growl.error(_t('app.shared.cart.online_payment_disabled')); + // check user was selected a plan if slot is restricted for subscriptions + const slotValidations = []; + let slotNotValid; + let slotNotValidError; + $scope.events.reserved.forEach(function (slot) { + if (slot.plan_ids.length > 0) { + if ( + ($scope.selectedPlan && _.include(slot.plan_ids, $scope.selectedPlan.id)) || + ($scope.user.subscribed_plan && _.include(slot.plan_ids, $scope.user.subscribed_plan.id)) + ) { + slotValidations.push(true); } else { - return payByStripe(reservation); - } - } else { - if ($scope.isAdmin() || (amountToPay === 0)) { - return payOnSite(reservation); + slotNotValid = slot; + if ($scope.selectedPlan && !_.include(slot.plan_ids, $scope.selectedPlan.id)) { + slotNotValidError = 'selectedPlanError'; + } + if ($scope.user.subscribed_plan && !_.include(slot.plan_ids, $scope.user.subscribed_plan.id)) { + slotNotValidError = 'userPlanError'; + } + if (!$scope.selectedPlan || !$scope.user.subscribed_plan) { + slotNotValidError = 'noPlanError'; + } + slotValidations.push(false); } } }); + const hasPlanForSlot = slotValidations.every(function (a) { return a; }); + if (!hasPlanForSlot) { + if (!$scope.isAdmin()) { + return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan')); + } else { + const modalInstance = $uibModal.open({ + animation: true, + templateUrl: '<%= asset_path "shared/_reserve_slot_without_plan.html" %>', + size: 'md', + controller: 'ReserveSlotWithoutPlanController', + resolve: { + slot: function() { return slotNotValid; }, + slotNotValidError: function() { return slotNotValidError; }, + } + }); + modalInstance.result.then(function(res) { + return paySlots(); + }); + } + } else { + return paySlots(); + } } else { // otherwise we alert, this error musn't occur when the current user is not admin return growl.error(_t('app.shared.cart.please_select_a_member_first')); @@ -292,6 +327,27 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', */ var slotSelectionChanged = function () { if ($scope.slot) { + // build a list of plans if this slot is restricted for subscriptions + if ($scope.slot.plan_ids.length > 0) { + const _plans = _.filter($scope.plans, function (p) { return _.include($scope.slot.plan_ids, p.id) }); + $scope.slot.plansGrouped = []; + $scope.slot.group_ids = []; + for (let group of Array.from($scope.groups)) { + const groupObj = { id: group.id, name: group.name, plans: [] }; + for (let plan of Array.from(_plans)) { + if (plan.group_id === group.id) { groupObj.plans.push(plan); } + } + if (groupObj.plans.length > 0) { + if ($scope.isAdmin()) { + $scope.slot.plansGrouped.push(groupObj); + } else if ($scope.user.group_id === groupObj.id) { + $scope.slot.plansGrouped.push(groupObj); + } + } + } + $scope.slot.group_ids = $scope.slot.plansGrouped.map(function(g) { return g.id; }); + } + if (!$scope.slot.is_reserved && !$scope.events.modifiable && !$scope.slot.is_completed) { // slot is not reserved and we are not currently modifying a slot // -> can be added to cart or removed if already present @@ -638,6 +694,28 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs', return $scope.selectedPlan = null; }; + /** + * Actions to pay slots + */ + var paySlots = function() { + const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan); + + return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) { + const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); + if (!$scope.isAdmin() && (amountToPay > 0)) { + if ($rootScope.fablabWithoutOnlinePayment) { + growl.error(_t('app.shared.cart.online_payment_disabled')); + } else { + return payByStripe(reservation); + } + } else { + if ($scope.isAdmin() || (amountToPay === 0)) { + return payOnSite(reservation); + } + } + }); + }; + // !!! MUST BE CALLED AT THE END of the directive return initialize(); } @@ -666,3 +744,25 @@ Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', ' } } ]); + +/** + * Controller used to alert admin reserve slot without plan + */ +Application.Controllers.controller('ReserveSlotWithoutPlanController', ['$scope', '$uibModalInstance', 'slot', 'slotNotValidError', 'growl', '_t', + function ($scope, $uibModalInstance, slot, slotNotValidError, growl, _t) { + $scope.slot = slot; + $scope.slotNotValidError = slotNotValidError; + /** + * Confirmation callback + */ + $scope.ok = function () { + $uibModalInstance.close({}); + } + /** + * Cancellation callback + */ + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + } + } +]); diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb index fab0277a1..cb3935ea2 100644 --- a/app/assets/javascripts/router.js.erb +++ b/app/assets/javascripts/router.js.erb @@ -584,7 +584,9 @@ angular.module('application.router', ['ui.router']) resolve: { bookingWindowStart: ['Setting', function (Setting) { return Setting.get({ name: 'booking_window_start' }).$promise; }], bookingWindowEnd: ['Setting', function (Setting) { return Setting.get({ name: 'booking_window_end' }).$promise; }], - machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }] + machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], + plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }], + groupsPromise: ['Group', function (Group) { return Group.query().$promise; }] } }) .state('app.admin.calendar.icalendar', { diff --git a/app/assets/templates/admin/calendar/calendar.html.erb b/app/assets/templates/admin/calendar/calendar.html.erb index aac772311..fe97354e8 100644 --- a/app/assets/templates/admin/calendar/calendar.html.erb +++ b/app/assets/templates/admin/calendar/calendar.html.erb @@ -90,6 +90,25 @@ +
+
+

{{ 'app.admin.calendar.plans' }}

+
+
+ +
+
+

{{ 'app.admin.calendar.actions' }}

diff --git a/app/assets/templates/admin/calendar/eventModal.html.erb b/app/assets/templates/admin/calendar/eventModal.html.erb index a0d172e4b..371c68c51 100644 --- a/app/assets/templates/admin/calendar/eventModal.html.erb +++ b/app/assets/templates/admin/calendar/eventModal.html.erb @@ -102,12 +102,41 @@
+
+

{{ 'app.admin.calendar.restrict_this_slot_for_subscriptions_optional' }}

+
+
+ + +
+
+

{{ 'app.admin.calendar.select_some_plans' | translate }}

+ +
+ +
+
{{::group.name}}
+ +
+
+
+
+
-

{{ 'app.public.plans.subcriptions' }}

+

{{ 'app.public.plans.subscriptions' }}

diff --git a/app/assets/templates/shared/_cart.html.erb b/app/assets/templates/shared/_cart.html.erb index e60c057aa..ae6dc7365 100644 --- a/app/assets/templates/shared/_cart.html.erb +++ b/app/assets/templates/shared/_cart.html.erb @@ -28,8 +28,22 @@ switch-animate="true" switch-readonly="{{slot.isValid}}"/> +
+
+
{{ 'app.shared.cart.slot_restrict_plans' }}
+
+
{{::group.name}}
+
    +
  • {{::plan.name}}
  • +
+
+
+
+
{{ 'app.shared.cart.slot_restrict_plans_of_others_groups' }}
+
+
-
+
diff --git a/app/assets/templates/shared/_reserve_slot_without_plan.html b/app/assets/templates/shared/_reserve_slot_without_plan.html new file mode 100644 index 000000000..671c26fb7 --- /dev/null +++ b/app/assets/templates/shared/_reserve_slot_without_plan.html @@ -0,0 +1,22 @@ + + + diff --git a/app/assets/templates/spaces/reserve.html.erb b/app/assets/templates/spaces/reserve.html.erb index 145c74a5c..81a57c380 100644 --- a/app/assets/templates/spaces/reserve.html.erb +++ b/app/assets/templates/spaces/reserve.html.erb @@ -35,6 +35,8 @@ plan="selectedPlan" plan-selection-time="planSelectionTime" settings="settings" + plans="plans" + groups="groups" on-slot-added-to-cart="markSlotAsAdded" on-slot-removed-from-cart="markSlotAsRemoved" on-slot-start-to-modify="markSlotAsModifying" diff --git a/app/assets/templates/trainings/reserve.html.erb b/app/assets/templates/trainings/reserve.html.erb index 4d1994bba..c30037fa3 100644 --- a/app/assets/templates/trainings/reserve.html.erb +++ b/app/assets/templates/trainings/reserve.html.erb @@ -47,6 +47,8 @@ plan="selectedPlan" plan-selection-time="planSelectionTime" settings="settings" + plans="plans" + groups="groups" on-slot-added-to-cart="markSlotAsAdded" on-slot-removed-from-cart="markSlotAsRemoved" on-slot-start-to-modify="markSlotAsModifying" diff --git a/app/controllers/api/availabilities_controller.rb b/app/controllers/api/availabilities_controller.rb index 405f8e443..cec87315d 100644 --- a/app/controllers/api/availabilities_controller.rb +++ b/app/controllers/api/availabilities_controller.rb @@ -148,8 +148,8 @@ class API::AvailabilitiesController < API::ApiController def availability_params params.require(:availability).permit(:start_at, :end_at, :available_type, :machine_ids, :training_ids, :nb_total_places, :is_recurrent, :period, :nb_periods, :end_date, - machine_ids: [], training_ids: [], space_ids: [], tag_ids: [], - machines_attributes: %i[id _destroy]) + machine_ids: [], training_ids: [], space_ids: [], tag_ids: [], plan_ids: [], + machines_attributes: %i[id _destroy], plans_attributes: %i[id _destroy]) end def lock_params diff --git a/app/models/availability.rb b/app/models/availability.rb index 13d48d253..0a01d0da7 100644 --- a/app/models/availability.rb +++ b/app/models/availability.rb @@ -4,7 +4,6 @@ # Eg. a 3D printer will be reservable on thursday from 9 to 11 pm # Availabilities may be subdivided into Slots (of 1h), for some types of reservables (eg. Machine) class Availability < ActiveRecord::Base - # elastic initialisations include Elasticsearch::Model index_name 'fablab' @@ -29,6 +28,10 @@ class Availability < ActiveRecord::Base has_many :tags, through: :availability_tags accepts_nested_attributes_for :tags, allow_destroy: true + has_many :plans_availabilities, dependent: :destroy + has_many :plans, through: :plans_availabilities + accepts_nested_attributes_for :plans, allow_destroy: true + scope :machines, -> { where(available_type: 'machines') } scope :trainings, -> { includes(:trainings).where(available_type: 'training') } scope :spaces, -> { includes(:spaces).where(available_type: 'space') } @@ -86,7 +89,7 @@ class Availability < ActiveRecord::Base def available_space_places return unless available_type == 'space' - ((end_at - start_at)/ApplicationHelper::SLOT_DURATION.minutes).to_i * nb_total_places + ((end_at - start_at) / ApplicationHelper::SLOT_DURATION.minutes).to_i * nb_total_places end def title(filter = {}) @@ -166,5 +169,4 @@ class Availability < ActiveRecord::Base errors.add(:machine_ids, I18n.t('availabilities.must_be_associated_with_at_least_1_machine')) end - end diff --git a/app/models/plan.rb b/app/models/plan.rb index 94d760994..a4d1b0d7a 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -25,7 +25,6 @@ class Plan < ActiveRecord::Base after_create :create_statistic_type after_create :set_name - validates :amount, :group, :base_name, presence: true validates :interval_count, numericality: { only_integer: true, greater_than_or_equal_to: 1 } validates :interval_count, numericality: { less_than: 13 }, if: proc { |plan| plan.interval == 'month' } diff --git a/app/models/plans_availability.rb b/app/models/plans_availability.rb new file mode 100644 index 000000000..baa30c111 --- /dev/null +++ b/app/models/plans_availability.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class PlansAvailability < ActiveRecord::Base + belongs_to :plan + belongs_to :availability +end diff --git a/app/models/reservation.rb b/app/models/reservation.rb index 1536531be..d95c8060e 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Reservation < ActiveRecord::Base include NotifyWith::NotificationAttachedObject @@ -17,6 +19,7 @@ class Reservation < ActiveRecord::Base validates_presence_of :reservable_id, :reservable_type validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) } validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) } + validates_with ReservationSlotSubscriptionValidator attr_accessor :plan_id, :subscription diff --git a/app/validators/reservation_slot_subscription_validator.rb b/app/validators/reservation_slot_subscription_validator.rb new file mode 100644 index 000000000..8815bae12 --- /dev/null +++ b/app/validators/reservation_slot_subscription_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ReservationSlotSubscriptionValidator < ActiveModel::Validator + def validate(record) + record.slots.each do |s| + unless s.availability.plan_ids.empty? + if record.user.subscribed_plan && s.availability.plan_ids.include?(record.user.subscribed_plan.id) + elsif s.availability.plan_ids.include?(record.plan_id) + else + record.errors[:slots] << 'slot is restrict for subscriptions' + end + end + end + end +end diff --git a/app/views/api/availabilities/index.json.jbuilder b/app/views/api/availabilities/index.json.jbuilder index e6e86f563..e98ad286c 100644 --- a/app/views/api/availabilities/index.json.jbuilder +++ b/app/views/api/availabilities/index.json.jbuilder @@ -1,3 +1,5 @@ +# frozen_string_literal: true + json.array!(@availabilities) do |availability| json.id availability.id json.title availability.title @@ -15,4 +17,5 @@ json.array!(@availabilities) do |availability| json.name t.name end json.lock availability.lock + json.plan_ids availability.plan_ids end diff --git a/app/views/api/availabilities/machine.json.jbuilder b/app/views/api/availabilities/machine.json.jbuilder index 255b9153b..7c62843a9 100644 --- a/app/views/api/availabilities/machine.json.jbuilder +++ b/app/views/api/availabilities/machine.json.jbuilder @@ -1,3 +1,5 @@ +# frozen_string_literal: true + json.array!(@slots) do |slot| json.id slot.id if slot.id json.can_modify slot.can_modify @@ -14,13 +16,16 @@ json.array!(@slots) do |slot| json.name slot.machine.name end # the user who booked the slot ... - json.user do - json.id slot.reservation.user.id - json.name slot.reservation.user.profile.full_name - end if @current_user_role == 'admin' and slot.reservation # ... if the slot was reserved + if (@current_user_role == 'admin') && slot.reservation + json.user do + json.id slot.reservation.user.id + json.name slot.reservation.user.profile.full_name + end + end # ... if the slot was reserved json.tag_ids slot.availability.tag_ids json.tags slot.availability.tags do |t| json.id t.id json.name t.name end + json.plan_ids slot.availability.plan_ids end diff --git a/app/views/api/availabilities/show.json.jbuilder b/app/views/api/availabilities/show.json.jbuilder index df5b2d734..a3788e23b 100644 --- a/app/views/api/availabilities/show.json.jbuilder +++ b/app/views/api/availabilities/show.json.jbuilder @@ -5,10 +5,11 @@ json.start_at @availability.start_at.iso8601 json.end_at @availability.end_at.iso8601 json.available_type @availability.available_type json.machine_ids @availability.machine_ids +json.plan_ids @availability.plan_ids json.backgroundColor 'white' json.borderColor availability_border_color(@availability) json.tag_ids @availability.tag_ids json.tags @availability.tags do |t| json.id t.id json.name t.name -end \ No newline at end of file +end diff --git a/app/views/api/availabilities/spaces.json.jbuilder b/app/views/api/availabilities/spaces.json.jbuilder index f8a0b417c..0528284fb 100644 --- a/app/views/api/availabilities/spaces.json.jbuilder +++ b/app/views/api/availabilities/spaces.json.jbuilder @@ -1,3 +1,5 @@ +# frozen_string_literal: true + json.array!(@slots) do |slot| json.id slot.id if slot.id json.can_modify slot.can_modify @@ -15,13 +17,16 @@ json.array!(@slots) do |slot| json.name slot.space.name end # the user who booked the slot ... - json.user do - json.id slot.reservation.user.id - json.name slot.reservation.user.profile.full_name - end if @current_user_role == 'admin' and slot.reservation # ... if the slot was reserved + if (@current_user_role == 'admin') && slot.reservation + json.user do + json.id slot.reservation.user.id + json.name slot.reservation.user.profile.full_name + end + end # ... if the slot was reserved json.tag_ids slot.availability.tag_ids json.tags slot.availability.tags do |t| json.id t.id json.name t.name end + json.plan_ids slot.availability.plan_ids end diff --git a/app/views/api/availabilities/trainings.json.jbuilder b/app/views/api/availabilities/trainings.json.jbuilder index cf76193cd..42bcd2b35 100644 --- a/app/views/api/availabilities/trainings.json.jbuilder +++ b/app/views/api/availabilities/trainings.json.jbuilder @@ -1,3 +1,5 @@ +# frozen_string_literal: true + json.array!(@availabilities) do |a| json.id a.slot_id if a.slot_id if a.is_reserved @@ -31,4 +33,5 @@ json.array!(@availabilities) do |a| json.id t.id json.name t.name end + json.plan_ids a.plan_ids end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 67373cc26..6ba268158 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -32,6 +32,8 @@ en: beware_this_cannot_be_reverted: "Beware: this cannot be reverted." the_machine_was_successfully_removed_from_the_slot: "The machine was successfully removed from the slot." deletion_failed: "Deletion failed." + do_you_really_want_to_remove_PLAN_from_this_slot: "Do you really want to remove \"{PLAN}\" from this slot?" + the_plan_was_successfully_removed_from_the_slot: "The plan was successfully removed from the slot." DATE_slot: "{DATE} slot:" what_kind_of_slot_do_you_want_to_create: "What kind of slot do you want to create?" training: "Training" @@ -49,6 +51,9 @@ en: adjust_the_opening_hours: "Adjust the opening hours" to_time: "to" # eg. from 18:00 to 21:00 restrict_this_slot_with_labels_optional: "Restrict this slot with labels (optional)" + restrict_this_slot_for_subscriptions_optional: "Restrict this slot for subscription users (optional)" + select_some_plans: "Select some plans" + plans: "Plan(s):" recurrence: "Recurrence" enabled: "Enabled" period: "Period" diff --git a/config/locales/app.admin.es.yml b/config/locales/app.admin.es.yml index a5766d786..376f7166e 100644 --- a/config/locales/app.admin.es.yml +++ b/config/locales/app.admin.es.yml @@ -32,6 +32,8 @@ es: beware_this_cannot_be_reverted: "Beware: esto no puede ser revertido." the_machine_was_successfully_removed_from_the_slot: "La máquina se eliminó correctamente de la ranura." deletion_failed: "Fallo al borrar." + do_you_really_want_to_remove_PLAN_from_this_slot: "Do you really want to remove \"{PLAN}\" from this slot?" + the_plan_was_successfully_removed_from_the_slot: "The plan was successfully removed from the slot." DATE_slot: "{DATE} espacio:" what_kind_of_slot_do_you_want_to_create: "¿Qué tipo de horario desea crear?" training: "Formación" @@ -49,6 +51,9 @@ es: adjust_the_opening_hours: "Ajustar el horario de apertura" to_time: a restrict_this_slot_with_labels_optional: "Restringir este horario con etiquetas (opcional)" + restrict_this_slot_for_subscriptions_optional: "Restrict this slot for subscription users (optional)" + select_some_plans: "Select some plans" + plans: "Plan(s):" recurrence: "Recurrencia" enabled: "Activa" period: "Período" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index f5d87d45e..1cae10b92 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -32,6 +32,8 @@ fr: beware_this_cannot_be_reverted: "Attention : ceci n'est pas réversible." the_machine_was_successfully_removed_from_the_slot: "La machine a bien été supprimée du créneau." deletion_failed: "La suppression a échouée." + do_you_really_want_to_remove_PLAN_from_this_slot: "Êtes-vous {GENDER, select, female{sûre} other{sûr}} de vouloir retirer \"{PLAN}\" de ce créneau ?" + the_plan_was_successfully_removed_from_the_slot: "Le formule d'abonnement a bien été supprimée du créneau." DATE_slot: "Créneau du {DATE} :" what_kind_of_slot_do_you_want_to_create: "Quel type de créneau voulez-vous créer ?" training: "Formation" @@ -49,6 +51,9 @@ fr: adjust_the_opening_hours: "Ajuster l'horaire" to_time: "à" # eg. from 18:00 to 21:00 restrict_this_slot_with_labels_optional: "Restreindre ce créneau avec des étiquettes (optionnel)" + restrict_this_slot_for_subscriptions_optional: "Restreindre ce créneau pour les abonnements (optionnel)" + select_some_plans: "Sélectionnez des formules d'abonnement" + plans: "Abonnement(s):" recurrence: "Récurrence" enabled: "Activée" period: "Période" diff --git a/config/locales/app.admin.pt.yml b/config/locales/app.admin.pt.yml index 6d6444138..0430bb1ce 100755 --- a/config/locales/app.admin.pt.yml +++ b/config/locales/app.admin.pt.yml @@ -32,6 +32,8 @@ pt: beware_this_cannot_be_reverted: "Cuidado: isso não pode ser revertido." the_machine_was_successfully_removed_from_the_slot: "A máquina foi removida com sucesso desse slot." deletion_failed: "Falha ao deletar." + do_you_really_want_to_remove_PLAN_from_this_slot: "Do you really want to remove \"{PLAN}\" from this slot?" + the_plan_was_successfully_removed_from_the_slot: "The plan was successfully removed from the slot." DATE_slot: "{DATE} slot:" what_kind_of_slot_do_you_want_to_create: "Qual tipo de slot você deseja criar?" training: "Treinamento" @@ -49,6 +51,9 @@ pt: adjust_the_opening_hours: "Ajustar o horário de funcionamento" to_time: "ás" # eg. from 18:00 to 21:00 restrict_this_slot_with_labels_optional: "Restrinja este slot com etiquetas (opcional)" + restrict_this_slot_for_subscriptions_optional: "Restrict this slot for subscription users (optional)" + select_some_plans: "Select some plans" + plans: "Plan(s):" recurrence: "Recurrence" enabled: "Enabled" period: "Period" diff --git a/config/locales/app.public.ach.yml b/config/locales/app.public.ach.yml index 6e8290c80..bc623609c 100644 --- a/config/locales/app.public.ach.yml +++ b/config/locales/app.public.ach.yml @@ -229,7 +229,7 @@ ach: the_training_cant_be_deleted_because_it_is_already_reserved_by_some_users: "crwdns9167:0crwdne9167:0" plans: #summary of the subscriptions - subcriptions: "crwdns9169:0crwdne9169:0" + subscriptions: "crwdns9169:0crwdne9169:0" i_choose_that_plan: "crwdns9171:0crwdne9171:0" i_subscribe_online: "crwdns9173:0crwdne9173:0" i_already_subscribed: "crwdns9175:0crwdne9175:0" diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index 42219bf77..a88539dc9 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -257,7 +257,7 @@ en: plans: # summary of the subscriptions - subcriptions: "Subscriptions" + subscriptions: "Subscriptions" i_choose_that_plan: "I choose that plan" i_subscribe_online: "I subscribe online" i_already_subscribed: "I already subscribed" diff --git a/config/locales/app.public.es.yml b/config/locales/app.public.es.yml index 2ebdb96b7..bac19cf25 100644 --- a/config/locales/app.public.es.yml +++ b/config/locales/app.public.es.yml @@ -257,7 +257,7 @@ es: plans: # summary of the subscriptions - subcriptions: "Suscripciones" + subscriptions: "Suscripciones" i_choose_that_plan: "Elijo este plan" i_subscribe_online: "Suscribirme online" i_already_subscribed: "Ya me he suscrito" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index a071e30ee..04b3499ad 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -257,7 +257,7 @@ fr: plans: # page récapitulative des abonnements - subcriptions: "Les abonnements" + subscriptions: "Les abonnements" i_choose_that_plan: "Je choisis cette formule" i_subscribe_online: "Je m'abonne en ligne" i_already_subscribed: "Je suis déjà abonné" diff --git a/config/locales/app.public.pt.yml b/config/locales/app.public.pt.yml index 50008d626..b2c93ee12 100755 --- a/config/locales/app.public.pt.yml +++ b/config/locales/app.public.pt.yml @@ -257,7 +257,7 @@ pt: plans: # summary of the subscriptions - subcriptions: "Assinaturas" + subscriptions: "Assinaturas" i_choose_that_plan: "Eu escolho esse plano" i_subscribe_online: "Me inscrever online" i_already_subscribed: "Eu já estou inscrito" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 46100458e..3c0bb0afb 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -439,6 +439,12 @@ en: a_problem_occurred_during_the_payment_process_please_try_again_later: "A problem occurred during the payment process. Please try again later." none: "None" online_payment_disabled: "Online payment is not available. Please contact the Fablab reception directly." + slot_restrict_plans: "This slot is restricted for the plans below:" + slot_restrict_subscriptions_must_select_plan: "The slot is restricted for the subscribers. Please select a plan first." + slot_restrict_plans_of_others_groups: "The slot is restricted for the subscribers of others groups." + selected_plan_dont_match_slot: "Selected plan dont match this slot" + user_plan_dont_match_slot: "User subscribed plan dont match this slot" + no_plan_match_slot: "You dont have any plan to match this slot" slot_at_same_time: "Conflict with others reservations" do_you_really_want_to_book_slot_at_same_time: "Do you really want to book this slot? Other bookings take place at the same time" unable_to_book_slot_because_really_have_reservation_at_same_time: "Unable to book this slot because the following reservation occurs at the same time." diff --git a/config/locales/app.shared.es.yml b/config/locales/app.shared.es.yml index 3c8f146a6..7ecea4de1 100644 --- a/config/locales/app.shared.es.yml +++ b/config/locales/app.shared.es.yml @@ -439,6 +439,12 @@ es: a_problem_occurred_during_the_payment_process_please_try_again_later: "A problem occurred during the payment process. Please try again later." none: "Ninguno" online_payment_disabled: "El pago en línea no está disponible. Póngase en contacto directamente con la recepción de Fablab." + slot_restrict_plans: "This slot is restricted for the plans below:" + slot_restrict_subscriptions_must_select_plan: "The slot is restricted for the subscribers. Please select a plan first." + slot_restrict_plans_of_others_groups: "The slot is restricted for the subscribers of others groups." + selected_plan_dont_match_slot: "Selected plan dont match this slot" + user_plan_dont_match_slot: "User subscribed plan dont match this slot" + no_plan_match_slot: "You dont have any plan to match this slot" slot_at_same_time: "Conflict with others reservations" do_you_really_want_to_book_slot_at_same_time: "Do you really want to book this slot? Other bookings take place at the same time" unable_to_book_slot_because_really_have_reservation_at_same_time: "Unable to book this slot because the following reservation occurs at the same time." diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 317d5c670..ae442845d 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -439,6 +439,12 @@ fr: a_problem_occurred_during_the_payment_process_please_try_again_later: "Il y a eu un problème lors de la procédure de paiement. Veuillez réessayer plus tard." none: "Aucune" online_payment_disabled: "Le payment par carte bancaire n'est pas disponible. Merci de contacter directement l'accueil du Fablab." + slot_restrict_plans: "Ce créneau est restreint pour les formules d'abonnement ci-desous:" + slot_restrict_subscriptions_must_select_plan: "Le créneau est restreint pour les abonnés. Veuillez tout d'abord sélectionner une formule d'abonnement" + slot_restrict_plans_of_others_groups: "Ce créneau est restreint pour les abonnés d'autres groupes." + selected_plan_dont_match_slot: "L'abonnement sélectionné ne correspondent pas ce créneau" + user_plan_dont_match_slot: "L'abonnement du membre ne correspondent pas ce créneau" + no_plan_match_slot: "Aucun abonnement correspondent ce créneau" slot_at_same_time: "Conflit avec d'autres réservations" do_you_really_want_to_book_slot_at_same_time: "Êtes-vous sûr de réserver ce créneau ? D'autres réservations ont lieu en même temps" unable_to_book_slot_because_really_have_reservation_at_same_time: "Impossible de réserver ce créneau car les réservations ci-dessous ont lieu en même temps." diff --git a/config/locales/app.shared.pt.yml b/config/locales/app.shared.pt.yml index 7703edb43..50413e721 100755 --- a/config/locales/app.shared.pt.yml +++ b/config/locales/app.shared.pt.yml @@ -439,6 +439,12 @@ pt: a_problem_occurred_during_the_payment_process_please_try_again_later: "Um problema ocorreu durante o processo de pagamento. Por favor tente novamente mais tarde." none: "Vazio" online_payment_disabled: "O pagamento online não está disponível. Entre em contato diretamente com a recepção do Fablab." + slot_restrict_plans: "This slot is restricted for the plans below:" + slot_restrict_subscriptions_must_select_plan: "The slot is restricted for the subscribers. Please select a plan first." + slot_restrict_plans_of_others_groups: "The slot is restricted for the subscribers of others groups." + selected_plan_dont_match_slot: "Selected plan dont match this slot" + user_plan_dont_match_slot: "User subscribed plan dont match this slot" + no_plan_match_slot: "You dont have any plan to match this slot" slot_at_same_time: "Conflict with others reservations" do_you_really_want_to_book_slot_at_same_time: "Do you really want to book this slot? Other bookings take place at the same time" unable_to_book_slot_because_really_have_reservation_at_same_time: "Unable to book this slot because the following reservation occurs at the same time." diff --git a/db/migrate/20200206132857_create_plans_availabilities.rb b/db/migrate/20200206132857_create_plans_availabilities.rb new file mode 100644 index 000000000..740d5d98e --- /dev/null +++ b/db/migrate/20200206132857_create_plans_availabilities.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreatePlansAvailabilities < ActiveRecord::Migration + def change + create_table :plans_availabilities do |t| + t.belongs_to :plan, index: true + t.belongs_to :availability, index: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 027694065..25f55854f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -465,6 +465,14 @@ ActiveRecord::Schema.define(version: 20200218092221) do add_index "plans", ["group_id"], name: "index_plans_on_group_id", using: :btree + create_table "plans_availabilities", force: :cascade do |t| + t.integer "plan_id" + t.integer "availability_id" + end + + add_index "plans_availabilities", ["availability_id"], name: "index_plans_availabilities_on_availability_id", using: :btree + add_index "plans_availabilities", ["plan_id"], name: "index_plans_availabilities_on_plan_id", using: :btree + create_table "price_categories", force: :cascade do |t| t.string "name" t.text "conditions"