diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b0e71bf..f0f667cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - Fix a security issue: updated rack to 2.2.6.2 to fix [CVE-2022-44571](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-44571) - Fix a security issue: updated globalid to 1.0.1 to fix [CVE-2023-22799](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-22799) - [TODO DEPLOY] `rails fablab:fix:invoice_items_in_error` THEN `rails fablab:fix_invoice_items` THEN `rails db:migrate` +- [TODO DEPLOY] `rails fablab:fix_availabilities` THEN `rails fablab:setup:build_places_cache` ## v5.6.5 2023 January 9 diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index aa9a56d1b..c1b037829 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -116,7 +116,7 @@ const StoreCart: React.FC = ({ onSuccess, onError, currentUser, }; /** - * Change cart's customer by admin/manger + * Change cart's customer by admin/manager */ const handleChangeMember = (user: User): void => { CartAPI.setCustomer(cart, user.id).then(setCart).catch(onError); diff --git a/app/frontend/src/javascript/components/reservations/ongoing-reservation-panel.tsx b/app/frontend/src/javascript/components/reservations/ongoing-reservation-panel.tsx new file mode 100644 index 000000000..742d826d0 --- /dev/null +++ b/app/frontend/src/javascript/components/reservations/ongoing-reservation-panel.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from 'react'; +import { IApplication } from '../../models/application'; +import { Loader } from '../base/loader'; +import { react2angular } from 'react2angular'; +import { Slot } from '../../models/slot'; +import { ReservableType } from '../../models/reservation'; +import { User } from '../../models/user'; +import { MemberSelect } from '../user/member-select'; +import CartAPI from '../../api/cart'; +import useCart from '../../hooks/use-cart'; +import { ReservationsSummary } from './reservations-summary'; +import UserLib from '../../lib/user'; + +declare const Application: IApplication; + +interface OngoingReservationPanelProps { + selectedSlot: Slot, + reservableId: number, + reservableType: ReservableType, + onError: (message: string) => void, + operator: User, + onSlotAdded: (slot: Slot) => void, + onSlotRemoved: (slot: Slot) => void, +} + +/** + * Panel to manage the ongoing reservation (select the member, show the price, etc) + */ +export const OngoingReservationPanel: React.FC = ({ selectedSlot, reservableId, reservableType, onError, operator, onSlotAdded, onSlotRemoved }) => { + const { cart, setCart } = useCart(operator); + + const [noMemberError, setNoMemberError] = useState(false); + + useEffect(() => { + if (selectedSlot && !cart.user) { + setNoMemberError(true); + onError('please select a member first'); + } + }, [selectedSlot]); + + /** + * The admin/manager can change the cart's customer + */ + const handleChangeMember = (user: User): void => { + CartAPI.setCustomer(cart, user.id).then(setCart).catch(onError); + }; + + return ( +
+ {new UserLib(operator).hasPrivilegedRole() && + + } + +
+ ); +}; + +const OngoingReservationPanelWrapper: React.FC = (props) => ( + + + +); + +Application.Components.component('ongoingReservationPanel', react2angular(OngoingReservationPanelWrapper, ['selectedSlot', 'reservableId', 'reservableType', 'onError', 'operator', 'onSlotRemoved', 'onSlotAdded'])); diff --git a/app/frontend/src/javascript/components/reservations/reservations-summary.tsx b/app/frontend/src/javascript/components/reservations/reservations-summary.tsx index 8d9db7685..839fe40a1 100644 --- a/app/frontend/src/javascript/components/reservations/reservations-summary.tsx +++ b/app/frontend/src/javascript/components/reservations/reservations-summary.tsx @@ -1,20 +1,18 @@ -import { IApplication } from '../../models/application'; -import React, { useEffect } from 'react'; -import { Loader } from '../base/loader'; -import { react2angular } from 'react2angular'; +import React, { ReactNode, useEffect, useState } from 'react'; import type { Slot } from '../../models/slot'; import { useImmer } from 'use-immer'; import FormatLib from '../../lib/format'; import { FabButton } from '../base/fab-button'; import { ShoppingCart } from 'phosphor-react'; import CartAPI from '../../api/cart'; -import useCart from '../../hooks/use-cart'; import type { User } from '../../models/user'; import Switch from 'react-switch'; import { CartItemReservation } from '../../models/cart_item'; import { ReservableType } from '../../models/reservation'; - -declare const Application: IApplication; +import { Order } from '../../models/order'; +import PriceAPI from '../../api/price'; +import { PaymentMethod } from '../../models/payment'; +import { ComputePriceResult } from '../../models/price'; interface ReservationsSummaryProps { slot: Slot, @@ -22,15 +20,19 @@ interface ReservationsSummaryProps { reservableId: number, reservableType: ReservableType, onError: (error: string) => void, + cart: Order, + setCart: (cart: Order) => void, + onSlotAdded: (slot: Slot) => void, + onSlotRemoved: (slot: Slot) => void, } /** * Display a summary of the selected slots, and ask for confirmation before adding them to the cart */ -const ReservationsSummary: React.FC = ({ slot, customer, reservableId, reservableType, onError }) => { - const { cart, setCart } = useCart(customer); +export const ReservationsSummary: React.FC = ({ slot, customer, reservableId, reservableType, onError, cart, setCart, onSlotAdded, onSlotRemoved }) => { const [pendingSlots, setPendingSlots] = useImmer>([]); const [offeredSlots, setOfferedSlots] = useImmer>(new Map()); + const [price, setPrice] = useState(null); const [reservation, setReservation] = useImmer({ reservation: { reservable_id: reservableId, @@ -42,18 +44,30 @@ const ReservationsSummary: React.FC = ({ slot, custome useEffect(() => { if (slot) { if (pendingSlots.find(s => s.slot_id === slot.slot_id)) { - setPendingSlots(draft => draft.filter(s => s.slot_id !== slot.slot_id)); + removeSlot(slot)(); } else { - setPendingSlots(draft => { draft.push(slot); }); + addSlot(slot); } } }, [slot]); useEffect(() => { - if (customer && cart) { - CartAPI.setCustomer(cart, customer.id).then(setCart).catch(onError); - } - }, [customer]); + if (!customer) return; + + PriceAPI.compute({ + customer_id: customer.id, + items: [reservation], + payment_method: PaymentMethod.Other + }).then(setPrice).catch(onError); + }, [reservation]); + + /** + * Add a new slot to the pending list + */ + const addSlot = (slot: Slot) => { + setPendingSlots(draft => { draft.push(slot); }); + if (typeof onSlotAdded === 'function') onSlotAdded(slot); + }; /** * Add the product to cart @@ -69,6 +83,33 @@ const ReservationsSummary: React.FC = ({ slot, custome }; }; + /** + * Check if the reservation contains the given slot + */ + const isSlotInReservation = (slot: Slot): boolean => { + return reservation.reservation.slots_reservations_attributes.filter(s => s.slot_id === slot.slot_id).length > 0; + }; + + /** + * Removes the given slot from the reservation (if applicable) and trigger the onSlotRemoved callback + */ + const removeSlot = (slot: Slot) => { + return () => { + if (isSlotInReservation(slot)) { + setReservation(draft => { + return { + reservation: { + ...draft.reservation, + slots_reservations_attributes: draft.reservation.slots_reservations_attributes.filter(sr => sr.slot_id !== slot.slot_id) + } + }; + }); + } + setPendingSlots(draft => draft.filter(s => s.slot_id !== slot.slot_id)); + if (typeof onSlotRemoved === 'function') onSlotRemoved(slot); + }; + }; + /** * Build / validate the reservation at server-side, then add it to the cart. */ @@ -93,26 +134,42 @@ const ReservationsSummary: React.FC = ({ slot, custome }; }; + /** + * Return the price of the given slot, if known + */ + const slotPrice = (slot: Slot): string => { + if (!price) return ''; + + const slotPrice = price.details.slots.find(s => s.slot_id === slot.slot_id); + if (!slotPrice) return ''; + + return FormatLib.price(slotPrice.price); + }; + + /** + * Return the total price for the current reservation + */ + const total = (): ReactNode => { + if (!price || reservation.reservation.slots_reservations_attributes.length === 0) return ''; + + return TOTAL: {FormatLib.price(price?.price)}; + }; + return (
    {pendingSlots.map(slot => (
  • {FormatLib.date(slot.start)} {FormatLib.time(slot.start)} - {FormatLib.time(slot.end)} - - validate this slot + + {slotPrice(slot)} + {!isSlotInReservation(slot) && Ajouter à la réservation} + Enlever
  • ))}
+ {total()} {reservation.reservation.slots_reservations_attributes.length > 0 &&
Ajouter au panier
}
); }; - -const ReservationsSummaryWrapper: React.FC = (props) => ( - - - -); - -Application.Components.component('reservationsSummary', react2angular(ReservationsSummaryWrapper, ['slot', 'customer', 'reservableId', 'reservableType', 'onError'])); diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx index db6581f13..85c311edb 100644 --- a/app/frontend/src/javascript/components/user/member-select.tsx +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -15,7 +15,7 @@ interface MemberSelectProps { } /** - * This component renders the member select for manager. + * This component allows privileged users (managers/admins) to select a user on whose behalf to act. */ export const MemberSelect: React.FC = ({ defaultUser, value, onSelected, noHeader, hasError }) => { const { t } = useTranslation('public'); diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index 0a43b0661..c6fa655c9 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -469,21 +469,23 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran /** * Change the last selected slot's appearance to looks like 'added to cart' */ - $scope.markSlotAsAdded = function () { - $scope.selectedEvent.backgroundColor = FREE_SLOT_BORDER_COLOR; - $scope.selectedEvent.oldTitle = $scope.selectedEvent.title; - $scope.selectedEvent.title = _t('app.logged.machines_reserve.i_reserve'); - updateEvents($scope.selectedEvent); + $scope.markSlotAsAdded = function (slot) { + let calSlot = { ...slot }; + calSlot.backgroundColor = FREE_SLOT_BORDER_COLOR; + calSlot.oldTitle = $scope.selectedEvent.title; + calSlot.title = _t('app.logged.machines_reserve.i_reserve'); + updateEvents(calSlot); }; /** * Change the last selected slot's appearance to looks like 'never added to cart' */ $scope.markSlotAsRemoved = function (slot) { - slot.backgroundColor = 'white'; - slot.borderColor = FREE_SLOT_BORDER_COLOR; - slot.title = slot.oldTitle; - updateEvents(slot); + let calSlot = { ...slot }; + calSlot.backgroundColor = 'white'; + calSlot.borderColor = FREE_SLOT_BORDER_COLOR; + calSlot.title = calSlot.oldTitle; + updateEvents(calSlot); }; /** diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index 56bc893ab..4ad4de2de 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -529,7 +529,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', } // slot is not in the cart, so we add it $scope.events.reserved.push($scope.slot); - if (typeof $scope.onSlotAddedToCart === 'function') { $scope.onSlotAddedToCart(); } + if (typeof $scope.onSlotAddedToCart === 'function') { $scope.onSlotAddedToCart($scope.slot); } } else { // slot is in the cart, remove it $scope.removeSlot($scope.slot, index); diff --git a/app/frontend/src/javascript/lib/user.ts b/app/frontend/src/javascript/lib/user.ts index a7fd30f69..736fbbd95 100644 --- a/app/frontend/src/javascript/lib/user.ts +++ b/app/frontend/src/javascript/lib/user.ts @@ -20,6 +20,13 @@ export default class UserLib { return false; }; + /** + * Check if the current user has a privileged role + */ + hasPrivilegedRole = (): boolean => { + return (this.user?.role === 'admin' || this.user?.role === 'manager'); + }; + /** * Filter social networks from the user's profile */ diff --git a/app/frontend/src/javascript/models/price.ts b/app/frontend/src/javascript/models/price.ts index 9f696ceb3..2060743cb 100644 --- a/app/frontend/src/javascript/models/price.ts +++ b/app/frontend/src/javascript/models/price.ts @@ -23,6 +23,7 @@ export interface ComputePriceResult { price_without_coupon: number, details?: { slots: Array<{ + slot_id: number, start_at: TDateISO, price: number, promo: boolean diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 9484f8cc6..8e4c1806a 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -1,5 +1,6 @@ @import "variables/animations"; @import "variables/colors"; +@import "variables/component"; @import "variables/decoration"; @import "variables/layout"; @import "variables/typography"; diff --git a/app/frontend/src/stylesheets/modules/cart/store-cart.scss b/app/frontend/src/stylesheets/modules/cart/store-cart.scss index f53369009..d2c2144de 100644 --- a/app/frontend/src/stylesheets/modules/cart/store-cart.scss +++ b/app/frontend/src/stylesheets/modules/cart/store-cart.scss @@ -174,18 +174,9 @@ aside { & > div { - margin-bottom: 3.2rem; - padding: 1.6rem; - background-color: var(--gray-soft-lightest); - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); - h3, - .member-select-title { - margin: 0 0 2.4rem; - padding-bottom: 1.2rem; - border-bottom: 1px solid var(--gray-hard); - @include title-base; - color: var(--gray-hard-dark) !important; + @include component-border; + h3 { + @include component-title; } } .checkout { diff --git a/app/frontend/src/stylesheets/modules/user/member-select.scss b/app/frontend/src/stylesheets/modules/user/member-select.scss index 469198a39..fdea9487d 100644 --- a/app/frontend/src/stylesheets/modules/user/member-select.scss +++ b/app/frontend/src/stylesheets/modules/user/member-select.scss @@ -1,4 +1,8 @@ .member-select { + @include component-border; + &-title { + @include component-title; + } &.error { .select-input > div:first-of-type { border-color: var(--alert); diff --git a/app/frontend/src/stylesheets/variables/component.scss b/app/frontend/src/stylesheets/variables/component.scss new file mode 100644 index 000000000..dea239091 --- /dev/null +++ b/app/frontend/src/stylesheets/variables/component.scss @@ -0,0 +1,15 @@ +@mixin component-border { + margin-bottom: 3.2rem; + padding: 1.6rem; + background-color: var(--gray-soft-lightest); + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); +} + +@mixin component-title { + margin: 0 0 2.4rem; + padding-bottom: 1.2rem; + border-bottom: 1px solid var(--gray-hard); + @include title-base; + color: var(--gray-hard-dark) !important; +} diff --git a/app/frontend/templates/machines/reserve.html b/app/frontend/templates/machines/reserve.html index 9dc36e5a1..31bbaef0f 100644 --- a/app/frontend/templates/machines/reserve.html +++ b/app/frontend/templates/machines/reserve.html @@ -31,9 +31,14 @@
-
- -
+ + - +
+ +
Array} def grouped_slots return { all: cart_item_reservation_slots } unless Setting.get('extended_prices_in_same_day') cart_item_reservation_slots.group_by { |slot| slot.slot[:start_at].to_date } end - ## # Compute the price of a single slot, according to the list of applicable prices. - # @param prices {{ prices: Array<{price: Price, duration: number}> }} list of prices to use with the current reservation - # @see get_slot_price - ## + # @param prices [Hash{Symbol => Array Number,Price}>}] list of prices to use with the current reservation, + # as returned by #get_slot_price + # @option prices [Array Number,Price}>}] :prices => {price: Price, duration: number} + # @param slot_reservation [CartItem::ReservationSlot] + # @param is_privileged [Boolean] + # @param options [Hash] optional parameters, allowing the following options: + # @option options [Array] :elements if provided the resulting price will be append into elements.slots + # @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided + # @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default) + # @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer + # @return [Float] def get_slot_price_from_prices(prices, slot_reservation, is_privileged, options = {}) options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options) @@ -134,19 +142,17 @@ class CartItem::Reservation < CartItem::BaseItem real_price end - ## # Compute the price of a single slot, according to the base price and the ability for an admin # to offer the slot. - # @param hourly_rate {Number} base price of a slot - # @param slot_reservation {CartItem::ReservationSlot} - # @param is_privileged {Boolean} true if the current user has a privileged role (admin or manager) - # @param [options] {Hash} optional parameters, allowing the following options: - # - elements {Array} if provided the resulting price will be append into elements.slots - # - has_credits {Boolean} true if the user still has credits for the given slot, false if not provided - # - is_division {boolean} false if the slot covers a full availability, true if it is a subdivision (default) - # - prepaid_minutes {Number} number of remaining prepaid minutes for the customer - # @return {Number} price of the slot - ## + # @param hourly_rate [Float] base price of a slot + # @param slot_reservation [CartItem::ReservationSlot] + # @param is_privileged [Boolean] true if the current user has a privileged role (admin or manager) + # @param options [Hash] optional parameters, allowing the following options: + # @option options [Array] :elements if provided the resulting price will be append into elements.slots + # @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided + # @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default) + # @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer + # @return [Float] price of the slot def get_slot_price(hourly_rate, slot_reservation, is_privileged, options = {}) options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options) @@ -158,16 +164,12 @@ class CartItem::Reservation < CartItem::BaseItem else slot_rate end - # subtract free minutes from prepaid packs - if real_price.positive? && options[:prepaid][:minutes]&.positive? - consumed = slot_minutes - consumed = options[:prepaid][:minutes] if slot_minutes > options[:prepaid][:minutes] - real_price = (Rational(slot_minutes - consumed) * (Rational(slot_rate) / Rational(MINUTES_PER_HOUR))).to_f - options[:prepaid][:minutes] -= consumed - end + + real_price = handle_prepaid_pack_price(real_price, slot_minutes, slot_rate, options) unless options[:elements].nil? options[:elements][:slots].push( + slot_id: slot_reservation.slot_id, start_at: slot_reservation.slot[:start_at], price: real_price, promo: (slot_rate != hourly_rate) @@ -176,15 +178,35 @@ class CartItem::Reservation < CartItem::BaseItem real_price end + # @param price [Float, Integer] + # @param slot_minutes [Number] + # @param options [Hash] optional parameters, allowing the following options: + # @option options [Array] :elements if provided the resulting price will be append into elements.slots + # @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided + # @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default) + # @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer + # @return [Float, Integer] + def handle_prepaid_pack_price(price, slot_minutes, slot_rate, options) + return price unless price.positive? && options[:prepaid][:minutes]&.positive? + + # subtract free minutes from prepaid packs + consumed = slot_minutes + consumed = options[:prepaid][:minutes] if slot_minutes > options[:prepaid][:minutes] + options[:prepaid][:minutes] -= consumed + (Rational(slot_minutes - consumed) * (Rational(slot_rate) / Rational(MINUTES_PER_HOUR))).to_f + end + # We determine the list of prices applicable to current reservation # The longest available price is always used in priority. # Eg. If the reservation is for 12 hours, and there are prices for 3 hours, 7 hours, # and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12). # All these prices are returned to be applied to the reservation. + # @param slots_reservations [Array] + # @return [Hash{Symbol => Array Number, Price}>}] def applicable_prices(slots_reservations) total_duration = slots_reservations.map do |slot| (slot.slot[:end_at].to_time - slot.slot[:start_at].to_time) / SECONDS_PER_MINUTE - end.reduce(:+) + end.reduce(:+) || 0 rates = { prices: [] } remaining_duration = total_duration diff --git a/app/models/cart_item/reservation_slot.rb b/app/models/cart_item/reservation_slot.rb index 363daac32..b1aa6a91d 100644 --- a/app/models/cart_item/reservation_slot.rb +++ b/app/models/cart_item/reservation_slot.rb @@ -18,4 +18,39 @@ class CartItem::ReservationSlot < ApplicationRecord belongs_to :slot belongs_to :slots_reservation + + after_create :add_to_places_cache + after_update :remove_from_places_cache, if: :canceled? + + before_destroy :remove_from_places_cache + + private + + def add_to_places_cache + update_places_cache(:+) + end + + def remove_from_places_cache + update_places_cache(:-) + end + + # @param operation [Symbol] :+ or :- + def update_places_cache(operation) + user_method = operation == :+ ? :add_users : :remove_users + if cart_item_type == 'CartItem::EventReservation' + Slots::PlacesCacheService.change_places(slot, 'Event', cart_item.event_id, cart_item.total_tickets, operation) + Slots::PlacesCacheService.send(user_method, + slot, + 'Event', + cart_item.event_id, + [cart_item.customer_profile.user_id]) + else + Slots::PlacesCacheService.change_places(slot, cart_item.reservable_type, cart_item.reservable_id, 1, operation) + Slots::PlacesCacheService.send(user_method, + slot, + cart_item.reservable_type, + cart_item.reservable_id, + [cart_item.customer_profile.user_id]) + end + end end diff --git a/app/models/shopping_cart.rb b/app/models/shopping_cart.rb index ba3f6378d..01b2c6046 100644 --- a/app/models/shopping_cart.rb +++ b/app/models/shopping_cart.rb @@ -22,6 +22,7 @@ class ShoppingCart end # compute the price details of the current shopping cart + # @return [Hash] def total total_amount = 0 all_elements = { slots: [] } diff --git a/app/models/slot.rb b/app/models/slot.rb index 7db754c95..4284ef6c7 100644 --- a/app/models/slot.rb +++ b/app/models/slot.rb @@ -12,17 +12,25 @@ class Slot < ApplicationRecord has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy + after_create_commit :create_places_cache + attr_accessor :is_reserved, :machine, :space, :title, :can_modify, :current_user_slots_reservations_ids, :current_user_pending_reservations_ids + # @param reservable [Machine, Space, Training, Event, NilClass] def full?(reservable = nil) availability_places = availability.available_places_per_slot(reservable) return false if availability_places.nil? - if reservable.nil? - slots_reservations.where(canceled_at: nil).count >= availability_places - else - slots_reservations.includes(:reservation).where(canceled_at: nil).where('reservations.reservable': reservable).count >= availability_places - end + reserved_places = if reservable.nil? + places.pluck('reserved_places').reduce(:+) + else + rp = places.detect do |p| + p['reservable_type'] == reservable.class.name && p['reservable_id'] == reservable&.id + end + rp['reserved_places'] + end + + reserved_places >= availability_places end def empty?(reservable = nil) @@ -36,4 +44,10 @@ class Slot < ApplicationRecord def duration (end_at - start_at).seconds end + + private + + def create_places_cache + Slots::PlacesCacheService.refresh(self) + end end diff --git a/app/models/slots_reservation.rb b/app/models/slots_reservation.rb index ba31cff2e..8e8d6f7e5 100644 --- a/app/models/slots_reservation.rb +++ b/app/models/slots_reservation.rb @@ -7,11 +7,15 @@ class SlotsReservation < ApplicationRecord belongs_to :reservation has_one :cart_item_reservation_slot, class_name: 'CartItem::ReservationSlot', dependent: :nullify + after_create :add_to_places_cache after_update :set_ex_start_end_dates_attrs, if: :slot_changed? after_update :notify_member_and_admin_slot_is_modified, if: :slot_changed? after_update :notify_member_and_admin_slot_is_canceled, if: :canceled? after_update :update_event_nb_free_places, if: :canceled? + after_update :remove_from_places_cache, if: :canceled? + + before_destroy :remove_from_places_cache def set_ex_start_end_dates_attrs update_columns(ex_start_at: previous_slot.start_at, ex_end_at: previous_slot.end_at) # rubocop:disable Rails/SkipsModelValidations @@ -37,6 +41,29 @@ class SlotsReservation < ApplicationRecord reservation.update_event_nb_free_places end + def add_to_places_cache + update_places_cache(:+) + Slots::PlacesCacheService.add_users(slot, reservation.reservable_type, reservation.reservable_id, [reservation.statistic_profile.user_id]) + end + + def remove_from_places_cache + update_places_cache(:-) + Slots::PlacesCacheService.remove_users(slot, reservation.reservable_type, reservation.reservable_id, [reservation.statistic_profile.user_id]) + end + + # @param operation [Symbol] :+ or :- + def update_places_cache(operation) + if reservation.reservable_type == 'Event' + Slots::PlacesCacheService.change_places(slot, + reservation.reservable_type, + reservation.reservable_id, + reservation.total_booked_seats, + operation) + else + Slots::PlacesCacheService.change_places(slot, reservation.reservable_type, reservation.reservable_id, 1, operation) + end + end + def notify_member_and_admin_slot_is_modified NotificationCenter.call type: 'notify_member_slot_is_modified', receiver: reservation.user, diff --git a/app/services/availabilities/status_service.rb b/app/services/availabilities/status_service.rb index bfa38e3e8..5b72b2cff 100644 --- a/app/services/availabilities/status_service.rb +++ b/app/services/availabilities/status_service.rb @@ -2,6 +2,7 @@ # Provides helper methods checking reservation status of any availabilities class Availabilities::StatusService + # @param current_user_role [String] def initialize(current_user_role) @current_user_role = current_user_role @show_name = (%w[admin manager].include?(@current_user_role) || (current_user_role && Setting.get('display_name_enable'))) @@ -10,7 +11,7 @@ class Availabilities::StatusService # check that the provided slot is reserved for the given reservable (machine, training or space). # Mark it accordingly for display in the calendar # @param slot [Slot] - # @param user [User] + # @param user [User] the customer # @param reservables [Array] # @return [Slot] def slot_reserved_status(slot, user, reservables) @@ -19,24 +20,24 @@ class Availabilities::StatusService "#{reservables.map(&:class).map(&:name).uniq} , with slot #{slot.id}") end - slots_reservations, user_slots_reservations = slots_reservations(slot.slots_reservations, reservables, user) - - pending_reserv_slot_ids = slot.cart_item_reservation_slots.select('id').map(&:id) - pending_reservations, user_pending_reservations = pending_reservations(pending_reserv_slot_ids, reservables, user) - - is_reserved = slots_reservations.count.positive? || pending_reservations.count.positive? + places = places(slot, reservables) + is_reserved = places.any? { |p| p['reserved_places'].positive? } + is_reserved_by_user = is_reserved && places.select { |p| p['user_ids'].include?(user.id) }.length.positive? slot.is_reserved = is_reserved - slot.title = slot_title(slots_reservations, user_slots_reservations, user_pending_reservations, reservables) + slot.title = slot_title(slot, is_reserved, is_reserved_by_user, reservables) slot.can_modify = true if %w[admin manager].include?(@current_user_role) || is_reserved - slot.current_user_slots_reservations_ids = user_slots_reservations.select('id').map(&:id) - slot.current_user_pending_reservations_ids = user_pending_reservations.select('id').map(&:id) + if is_reserved_by_user + user_reservations = Slots::ReservationsService.user_reservations(slot, user, reservables.first.class.name) + slot.current_user_slots_reservations_ids = user_reservations[:reservations].select('id').map(&:id) + slot.current_user_pending_reservations_ids = user_reservations[:pending].select('id').map(&:id) + end slot end # check that the provided ability is reserved by the given user # @param availability [Availability] - # @param user [User] + # @param user [User] the customer # @param reservables [Array] # @return [Availability] def availability_reserved_status(availability, user, reservables) @@ -45,85 +46,54 @@ class Availabilities::StatusService "#{reservables.map(&:class).map(&:name).uniq}, with availability #{availability.id}") end - slots_reservations, user_slots_reservations = slots_reservations(availability.slots_reservations, reservables, user) + slots = availability.slots.map do |slot| + slot_reserved_status(slot, user, reservables) + end - pending_reserv_slot_ids = availability.joins(slots: :cart_item_reservation_slots) - .select('cart_item_reservation_slots.id as cirs_id') - pending_reservations, user_pending_reservations = pending_reservations(pending_reserv_slot_ids, reservables, user) - - availability.is_reserved = slots_reservations.count.positive? || pending_reservations.count.positive? - availability.current_user_slots_reservations_ids = user_slots_reservations.select('id').map(&:id) - availability.current_user_pending_reservations_ids = user_pending_reservations.select('id').map(&:id) + availability.is_reserved = slots.any?(&:is_reserved) + availability.current_user_slots_reservations_ids = slots.map(&:current_user_slots_reservations_ids).flatten + availability.current_user_pending_reservations_ids = slots.map(&:current_user_pending_reservations_ids).flatten availability end private - # @param slots_reservations [ActiveRecord::Relation] - # @param user_slots_reservations [ActiveRecord::Relation] same as slots_reservations but filtered by the current user - # @param user_pending_reservations [ActiveRecord::Relation] + # @param slot [Slot] # @param reservables [Array] - def slot_title(slots_reservations, user_slots_reservations, user_pending_reservations, reservables) + # @return [Array] + def places(slot, reservables) + places = [] + reservables.each do |reservable| + places.push(slot.places.detect { |p| p['reservable_type'] == reservable.class.name && p['reservable_id'] == reservable.id }) + end + places + end + + # @param slot [Slot] + # @param is_reserved [Boolean] + # @param is_reserved_by_user [Boolean] + # @param reservables [Array] + def slot_title(slot, is_reserved, is_reserved_by_user, reservables) name = reservables.map(&:name).join(', ') - if user_slots_reservations.count.zero? && slots_reservations.count.zero? + if !is_reserved && !is_reserved_by_user name - elsif user_slots_reservations.count.zero? && slots_reservations.count.positive? - "#{name} #{@show_name ? "- #{slot_users_names(slots_reservations)}" : ''}" - elsif user_pending_reservations.count.positive? - "#{name} - #{I18n.t('availabilities.reserving')}" + elsif is_reserved && !is_reserved_by_user + "#{name} #{@show_name ? "- #{slot_users_names(slot, reservables)}" : ''}" else "#{name} - #{I18n.t('availabilities.i_ve_reserved')}" end end - # @param slots_reservations [ActiveRecord::Relation] + # @param slot [Slot] + # @param reservables [Array] # @return [String] - def slot_users_names(slots_reservations) - slots_reservations.map(&:reservation) - .map(&:user) - .map { |u| u&.profile&.full_name || I18n.t('availabilities.deleted_user') } - .join(', ') - end - - # @param slot_ids [Array] - # @param reservables [Array] - # @param user [User] - # @return [Array>] - def pending_reservations(slot_ids, reservables, user) - reservable_types = reservables.map(&:class).map(&:name).uniq - if reservable_types.size > 1 - raise TypeError("[Availabilities::StatusService#pending_reservations] reservables have differents types: #{reservable_types}") - end - - relation = "cart_item_#{reservable_types.first&.downcase}_reservation" - table = reservable_types.first == 'Event' ? 'cart_item_event_reservations' : 'cart_item_reservations' - pending_reservations = CartItem::ReservationSlot.where(id: slot_ids) - .includes(relation.to_sym) - .where(table => { reservable_type: reservable_types }) - .where(table => { reservable_id: reservables.map(&:id) }) - - user_pending_reservations = pending_reservations.where(table => { customer_profile_id: user&.invoicing_profile&.id }) - - [pending_reservations, user_pending_reservations] - end - - # @param slots_reservations [ActiveRecord::Relation] - # @param reservables [Array] - # @param user [User] - # @return [Array>] - def slots_reservations(slots_reservations, reservables, user) - reservable_types = reservables.map(&:class).map(&:name).uniq - if reservable_types.size > 1 - raise TypeError("[Availabilities::StatusService#slot_reservations] reservables have differents types: #{reservable_types}") - end - - reservations = slots_reservations.includes(:reservation) - .where('reservations.reservable_type': reservable_types) - .where('reservations.reservable_id': reservables.map(&:id)) - .where('slots_reservations.canceled_at': nil) - - user_slots_reservations = reservations.where('reservations.statistic_profile_id': user&.statistic_profile&.id) - - [reservations, user_slots_reservations] + def slot_users_names(slot, reservables) + user_ids = slot.places + .select { |p| p['reservable_type'] == reservables.first.class.name && reservables.map(&:id).includes?(p['reservable_id']) } + .pluck('user_ids') + .flatten + User.where(id: user_ids).includes(:profile) + .map { |u| u&.profile&.full_name || I18n.t('availabilities.deleted_user') } + .join(', ') end end diff --git a/app/services/slots/places_cache_service.rb b/app/services/slots/places_cache_service.rb new file mode 100644 index 000000000..57ba953b7 --- /dev/null +++ b/app/services/slots/places_cache_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Services around slots +module Slots; end + +# Maintain the cache of reserved places for a slot +class Slots::PlacesCacheService + class << self + # @param slot [Slot] + def refresh(slot) + return if slot.nil? + + reservables = case slot.availability.available_type + when 'machines' + slot.availability.machines + when 'training' + slot.availability.trainings + when 'space' + slot.availability.spaces + when 'event' + Event.where(id: slot.availability.event.id) + else + Rails.logger.warn "[Slots::PlacesCacheService#update] Availability #{slot.availability_id} with unknown " \ + "type #{slot.availability.available_type}" + [nil] + end + browser = reservables.respond_to?(:find_each) ? :find_each : :each + places = [] + reservables.try(browser) do |reservable| + reservations = Slots::ReservationsService.reservations(slot.slots_reservations, [reservable]) + pending = Slots::ReservationsService.pending_reservations(slot.cart_item_reservation_slots.map(&:id), [reservable]) + + places.push({ + reservable_type: reservable.class.name, + reservable_id: reservable.try(&:id), + reserved_places: (reservations[:reservations].count || 0) + (pending[:reservations].count || 0), + user_ids: reservations[:user_ids] + pending[:user_ids] + }) + end + slot.update(places: places) + end + + # @param slot [Slot] + # @param reservable_type [String] + # @param reservable_id [Number] + # @param places [Number] + # @param operation [Symbol] :+ OR :- + def change_places(slot, reservable_type, reservable_id, places, operation = :+) + return if slot.nil? + + ActiveRecord::Base.connection.execute <<-SQL.squish + with reservable_places as ( + select ('{'||index-1||',reserved_places}')::text[] as path + ,(place->>'reserved_places')::decimal as reserved_places + from slots + ,jsonb_array_elements(places) with ordinality arr(place, index) + where place->>'reservable_type' = '#{reservable_type}' + and place->>'reservable_id' = '#{reservable_id}' + and id = #{slot.id} + ) + update slots + set places = jsonb_set(places, reservable_places.path, (reservable_places.reserved_places #{operation} #{places})::varchar::jsonb, true) + from reservable_places + where id = #{slot.id}; + SQL + end + + # @param slot [Slot] + # @param reservable_type [String] + # @param reservable_id [Number] + # @param user_ids [Array] + def remove_users(slot, reservable_type, reservable_id, user_ids) + return if slot.nil? + + ActiveRecord::Base.connection.execute <<-SQL.squish + with users as ( + select ('{'||index-1||',user_ids}')::text[] as path + ,place->>'user_ids' as user_ids + from slots + ,jsonb_array_elements(places) with ordinality arr(place, index) + where place->>'reservable_type' = '#{reservable_type}' + and place->>'reservable_id' = '#{reservable_id}' + and id = #{slot.id} + ), + all_users as ( + select (ids.id)::text::int as all_ids + from users + ,jsonb_array_elements(users.user_ids) with ordinality ids(id, index) + ), + remaining_users as ( + SELECT array_to_json(array(SELECT unnest(array_agg(all_users.all_ids)) EXCEPT SELECT unnest('{#{user_ids.to_s.gsub(/\]| |\[|/, '')}}'::int[])))::jsonb as ids + from all_users + ) + update slots + set places = jsonb_set(places, users.path, remaining_users.ids, false) + from users, remaining_users + where id = #{slot.id}; + SQL + end + + # @param slot [Slot] + # @param reservable_type [String] + # @param reservable_id [Number] + # @param user_ids [Array] + def add_users(slot, reservable_type, reservable_id, user_ids) + return if slot.nil? + + ActiveRecord::Base.connection.execute <<-SQL.squish + with users as ( + select ('{'||index-1||',user_ids}')::text[] as path + ,place->>'user_ids' as user_ids + from slots + ,jsonb_array_elements(places) with ordinality arr(place, index) + where place->>'reservable_type' = '#{reservable_type}' + and place->>'reservable_id' = '#{reservable_id}' + and id = #{slot.id} + ), + all_users as ( + select (ids.id)::text::int as all_ids + from users + ,jsonb_array_elements(users.user_ids::jsonb) with ordinality ids(id, index) + ), + new_users as ( + SELECT array_to_json(array_cat(array_agg(all_users.all_ids), '{#{user_ids.to_s.gsub(/\]| |\[|/, '')}}'::int[]))::jsonb as ids + from all_users + ) + update slots + set places = jsonb_set(places, users.path, new_users.ids, false) + from users, new_users + where id = #{slot.id}; + SQL + end + end +end diff --git a/app/services/slots/reservations_service.rb b/app/services/slots/reservations_service.rb new file mode 100644 index 000000000..97b2328a0 --- /dev/null +++ b/app/services/slots/reservations_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Services around slots +module Slots; end + +# Check the reservation status of a slot +class Slots::ReservationsService + class << self + # @param slots_reservations [ActiveRecord::Relation] + # @param reservables [Array] + # @return [Hash{Symbol=>ActiveRecord::Relation,Array}] + def reservations(slots_reservations, reservables) + reservable_types = reservables.map(&:class).map(&:name).uniq + if reservable_types.size > 1 + raise TypeError("[Availabilities::StatusService#slot_reservations] reservables have differents types: #{reservable_types}") + end + + reservations = slots_reservations.includes(:reservation) + .where('reservations.reservable_type': reservable_types) + .where('reservations.reservable_id': reservables.map { |r| r.try(:id) }) + .where('slots_reservations.canceled_at': nil) + + user_ids = reservations.includes(reservation: :statistic_profile) + .map(&:reservation) + .map(&:statistic_profile) + .map(&:user_id) + .filter { |id| !id.nil? } + + { + reservations: reservations, + user_ids: user_ids + } + end + + # @param cart_item_reservation_slot_ids [Array] + # @param reservables [Array] + # @return [Hash{Symbol=>ActiveRecord::Relation,Array}] + def pending_reservations(cart_item_reservation_slot_ids, reservables) + reservable_types = reservables.map(&:class).map(&:name).uniq + if reservable_types.size > 1 + raise TypeError("[Slots::StatusService#pending_reservations] reservables have differents types: #{reservable_types}") + end + + relation = "cart_item_#{reservable_types.first&.downcase}_reservation".to_sym + pending_reservations = case reservable_types.first + when 'Event' + CartItem::ReservationSlot.where(id: cart_item_reservation_slot_ids) + .includes(relation) + .where(cart_item_event_reservations: { event_id: reservables.map(&:id) }) + when 'NilClass' + [] + else + CartItem::ReservationSlot.where(id: cart_item_reservation_slot_ids) + .includes(relation) + .where(cart_item_reservations: { reservable_type: reservable_types }) + .where(cart_item_reservations: { reservable_id: reservables.map { |r| r.try(:id) } }) + end + + user_ids = if reservable_types.first == 'NilClass' + [] + else + pending_reservations.includes(relation => :customer_profile) + .map(&:cart_item) + .map(&:customer_profile) + .map(&:user_id) + .filter { |id| !id.nil? } + end + + { + reservations: pending_reservations, + user_ids: user_ids + } + end + + # @param slot [Slot] + # @param user [User] + # @param reservable_type [String] 'Machine' | 'Space' | 'Training' | 'Event' + # @return [Hash{Symbol=>ActiveRecord::Relation,ActiveRecord::Relation}] + def user_reservations(slot, user, reservable_type) + reservations = SlotsReservation.includes(:reservation) + .where(slot_id: slot.id, reservations: { statistic_profile_id: user.statistic_profile.id }) + relation = "cart_item_#{reservable_type&.downcase}_reservation".to_sym + table = (reservable_type == 'Event' ? 'cart_item_event_reservations' : 'cart_item_reservations').to_sym + pending = CartItem::ReservationSlot.includes(relation) + .where(slot_id: slot.id, table => { customer_profile_id: user.invoicing_profile.id }) + + { + reservations: reservations, + pending: pending + } + end + end +end diff --git a/app/views/api/prices/compute.json.jbuilder b/app/views/api/prices/compute.json.jbuilder index 10e3cc8d8..1548ea1d0 100644 --- a/app/views/api/prices/compute.json.jbuilder +++ b/app/views/api/prices/compute.json.jbuilder @@ -5,6 +5,7 @@ json.price_without_coupon @amount[:before_coupon] / 100.00 if @amount[:elements] json.details do json.slots @amount[:elements][:slots] do |slot| + json.slot_id slot[:slot_id] json.start_at slot[:start_at] json.price slot[:price] / 100.00 json.promo slot[:promo] diff --git a/db/migrate/20230106081943_add_not_null_to_invoice_items_object.rb b/db/migrate/20230106081943_add_not_null_to_invoice_items_object.rb index 4ec48db09..e4357fe4a 100644 --- a/db/migrate/20230106081943_add_not_null_to_invoice_items_object.rb +++ b/db/migrate/20230106081943_add_not_null_to_invoice_items_object.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# From this migration, ths object_type and object_id columns in InvoiceItem won't be able to be null anymore +# From this migration, the object_type and object_id columns in InvoiceItem won't be able to be null anymore # This will prevent issues while building the accounting data, and ensure data integrity class AddNotNullToInvoiceItemsObject < ActiveRecord::Migration[5.2] def change diff --git a/db/migrate/20230112151631_add_places_cache_to_slot.rb b/db/migrate/20230112151631_add_places_cache_to_slot.rb new file mode 100644 index 000000000..930c12297 --- /dev/null +++ b/db/migrate/20230112151631_add_places_cache_to_slot.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# From this migration, we will save the number of reserved places for each slots, for each reservable +# This will improved performance because of computing the number of reserved seats on each request in very resource demanding +# +# The places field is a jsonb object, with the following structure: +# {reservable_type: string, reservable_id: number, reserved_places: number, user_ids: number[]} +class AddPlacesCacheToSlot < ActiveRecord::Migration[5.2] + def change + add_column :slots, :places, :jsonb, null: false, default: [] + add_index :slots, :places, using: :gin + end +end diff --git a/db/migrate/20230113145632_add_not_null_to_slots_reservation.rb b/db/migrate/20230113145632_add_not_null_to_slots_reservation.rb new file mode 100644 index 000000000..ae7719f89 --- /dev/null +++ b/db/migrate/20230113145632_add_not_null_to_slots_reservation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# From this migration, the slot_id and reservation_id of the SlotsReservation won't be allowed to be null, +# otherwise this could result in error +class AddNotNullToSlotsReservation < ActiveRecord::Migration[5.2] + def change + change_column_null :slots_reservations, :slot_id, false + change_column_null :slots_reservations, :reservation_id, false + change_column_null :slots, :availability_id, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 4ba30590d..4ed4819f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.integer "signaled_id" t.string "signaled_type" + t.integer "signaled_id" t.string "first_name" t.string "last_name" t.string "email" @@ -68,8 +68,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.string "locality" t.string "country" t.string "postal_code" - t.integer "placeable_id" t.string "placeable_type" + t.integer "placeable_id" t.datetime "created_at" t.datetime "updated_at" end @@ -93,8 +93,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "assets", id: :serial, force: :cascade do |t| - t.integer "viewable_id" t.string "viewable_type" + t.integer "viewable_id" t.string "attachment" t.string "type" t.datetime "created_at" @@ -281,8 +281,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "credits", id: :serial, force: :cascade do |t| - t.integer "creditable_id" t.string "creditable_type" + t.integer "creditable_id" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -524,15 +524,15 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.integer "attached_object_id" t.string "attached_object_type" + t.integer "attached_object_id" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: {} + t.jsonb "meta_data", default: "{}" t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -772,8 +772,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.integer "priceable_id" t.string "priceable_type" + t.integer "priceable_id" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -976,8 +976,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.integer "reservable_id" t.string "reservable_type" + t.integer "reservable_id" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -986,8 +986,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.integer "resource_id" t.string "resource_type" + t.integer "resource_id" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" diff --git a/doc/postgresql_readme.md b/doc/postgresql_readme.md index 8de0d458e..fbc8741a3 100644 --- a/doc/postgresql_readme.md +++ b/doc/postgresql_readme.md @@ -82,3 +82,4 @@ This is currently not supported, because of some PostgreSQL specific instruction - `db/migrate/20200623141305_update_search_vector_of_projects.rb` defines a PL/pgSQL function (`fill_search_vector_for_project()`) and create an SQL trigger for this function; - `db/migrate/20200629123011_update_pg_trgm.rb` is using [ALTER EXTENSION](https://www.postgresql.org/docs/10/sql-alterextension.html); - `db/migrate/20201027101809_create_payment_schedule_items.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html); + - `db/migrate/20230112151631_add_places_cache_to_slot.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html); diff --git a/lib/tasks/fablab/fix_availabilities.rake b/lib/tasks/fablab/fix_availabilities.rake new file mode 100644 index 000000000..f5ecaef60 --- /dev/null +++ b/lib/tasks/fablab/fix_availabilities.rake @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# This take will ensure data integrity for availbilities. +# For an unknown reason, some slots are associated with unexisting availabilities. This script will try to re-create them +namespace :fablab do + desc 'regenerate missing availabilities' + task fix_availabilities: :environment do |_task, _args| + ActiveRecord::Base.transaction do + Slot.find_each do |slot| + next unless slot.availability.nil? + + other_slots = Slot.where(availability_id: slot.availability_id) + reservations = SlotsReservation.where(slot_id: other_slots.map(&:id)) + + a = Availability.new( + id: slot.availability_id, + start_at: other_slots.group('id').select('min(start_at) as min').first[:min], + end_at: other_slots.group('id').select('max(end_at) as max').first[:max], + available_type: available_type(reservations), + machine_ids: machines_ids(reservations, slot.availability_id), + space_ids: space_ids(reservations, slot.availability_id), + training_ids: training_ids(reservations, slot.availability_id) + ) + raise StandardError, "unable to save availability for slot #{slot.id}: #{a.errors.full_messages}" unless a.save(validate: false) + end + end + end + + private + + # @param reservations [ActiveRecord::Relation] + def available_type(reservations) + return 'unknown' if reservations.count.zero? + + type = reservations.first&.reservation&.reservable_type + case type + when 'Training', 'Space', 'Event' + type&.downcase + else + 'machines' + end + end + + # @param reservations [ActiveRecord::Relation] + # @param availability_id [Number] + def machines_ids(reservations, availability_id) + type = reservations.first&.reservation&.reservable_type + return [] unless type == 'Machine' + + ma = MachinesAvailability.where(availability_id: availability_id).map(&:machine_id) + return ma unless ma.empty? + + rv = reservations.map(&:reservation).map(&:reservable_id) + return rv unless rv.empty? + + [] + end + + # @param reservations [ActiveRecord::Relation] + # @param availability_id [Number] + def space_ids(reservations, availability_id) + type = reservations.first&.reservation&.reservable_type + return [] unless type == 'Space' + + sa = SpacesAvailability.where(availability_id: availability_id).map(&:machine_id) + return sa unless sa.empty? + + rv = reservations.map(&:reservation).map(&:reservable_id) + return rv unless rv.empty? + + [] + end + + # @param reservations [ActiveRecord::Relation] + # @param availability_id [Number] + def training_ids(reservations, availability_id) + type = reservations.first&.reservation&.reservable_type + return [] unless type == 'Training' + + ta = TrainingsAvailability.where(availability_id: availability_id).map(&:machine_id) + return ta unless ta.empty? + + rv = reservations.map(&:reservation).map(&:reservable_id) + return rv unless rv.empty? + + [] + end +end diff --git a/lib/tasks/fablab/setup.rake b/lib/tasks/fablab/setup.rake index e9c613bfc..aac3c23e9 100644 --- a/lib/tasks/fablab/setup.rake +++ b/lib/tasks/fablab/setup.rake @@ -134,6 +134,17 @@ namespace :fablab do puts '-> Done' end + desc 'build the reserved places cache for all slots' + task build_places_cache: :environment do + puts 'Builing the places cache. This may take some time...' + total = Slot.maximum(:id) + Slot.order(id: :asc).find_each do |slot| + puts "#{slot.id} / #{total}" + Slots::PlacesCacheService.refresh(slot) + end + puts '-> Done' + end + def select_group(groups) groups.each do |g| print "#{g.id}) #{g.name}\n" diff --git a/test/fixtures/reservations.yml b/test/fixtures/reservations.yml index bf7800cee..5b44f7d82 100644 --- a/test/fixtures/reservations.yml +++ b/test/fixtures/reservations.yml @@ -5,7 +5,7 @@ reservation_1: message: created_at: 2012-03-12 11:03:31.651441000 Z updated_at: 2012-03-12 11:03:31.651441000 Z - reservable_id: 2 + reservable_id: 3 reservable_type: Training nb_reserve_places: @@ -15,6 +15,6 @@ reservation_2: message: created_at: 2015-06-10 11:20:01.341130000 Z updated_at: 2015-06-10 11:20:01.341130000 Z - reservable_id: 4 + reservable_id: 2 reservable_type: Machine nb_reserve_places: diff --git a/test/fixtures/slots.yml b/test/fixtures/slots.yml index d52610ebb..f47bf70cc 100644 --- a/test/fixtures/slots.yml +++ b/test/fixtures/slots.yml @@ -6,6 +6,7 @@ slot_1: created_at: '2012-03-12 13:40:22.342717' updated_at: '2012-03-12 13:40:22.342717' availability_id: 12 + places: [{"user_ids": [7], "reservable_id": 3, "reservable_type": "Training", "reserved_places": 1}] slot_2: id: 2 @@ -14,6 +15,7 @@ slot_2: created_at: '2015-06-10 11:20:01.341130' updated_at: '2015-06-10 11:20:01.341130' availability_id: 13 + places: [{"user_ids": [3], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 1}] slot_9: id: 9 @@ -22,6 +24,7 @@ slot_9: created_at: '2022-07-12 15:18:43.880751' updated_at: '2022-07-12 15:18:43.880751' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_10: id: 10 @@ -30,6 +33,7 @@ slot_10: created_at: '2022-07-12 15:18:43.882957' updated_at: '2022-07-12 15:18:43.882957' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_11: id: 11 @@ -38,6 +42,7 @@ slot_11: created_at: '2022-07-12 15:18:43.884691' updated_at: '2022-07-12 15:18:43.884691' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_12: id: 12 @@ -46,6 +51,7 @@ slot_12: created_at: '2022-07-12 15:18:43.886431' updated_at: '2022-07-12 15:18:43.886431' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_13: id: 13 @@ -54,6 +60,7 @@ slot_13: created_at: '2022-07-12 15:18:43.888074' updated_at: '2022-07-12 15:18:43.888074' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_14: id: 14 @@ -62,6 +69,7 @@ slot_14: created_at: '2022-07-12 15:18:43.889691' updated_at: '2022-07-12 15:18:43.889691' availability_id: 3 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_15: id: 15 @@ -70,6 +78,7 @@ slot_15: created_at: '2022-07-12 15:18:43.893096' updated_at: '2022-07-12 15:18:43.893096' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_16: id: 16 @@ -78,6 +87,7 @@ slot_16: created_at: '2022-07-12 15:18:43.894777' updated_at: '2022-07-12 15:18:43.894777' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_17: id: 17 @@ -86,6 +96,7 @@ slot_17: created_at: '2022-07-12 15:18:43.896423' updated_at: '2022-07-12 15:18:43.896423' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_18: id: 18 @@ -94,6 +105,7 @@ slot_18: created_at: '2022-07-12 15:18:43.898021' updated_at: '2022-07-12 15:18:43.898021' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_19: id: 19 @@ -102,6 +114,7 @@ slot_19: created_at: '2022-07-12 15:18:43.899592' updated_at: '2022-07-12 15:18:43.899592' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_20: id: 20 @@ -110,6 +123,7 @@ slot_20: created_at: '2022-07-12 15:18:43.900938' updated_at: '2022-07-12 15:18:43.900938' availability_id: 4 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_21: id: 21 @@ -118,6 +132,7 @@ slot_21: created_at: '2022-07-12 15:18:43.904013' updated_at: '2022-07-12 15:18:43.904013' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_22: id: 22 @@ -126,6 +141,7 @@ slot_22: created_at: '2022-07-12 15:18:43.905470' updated_at: '2022-07-12 15:18:43.905470' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_23: id: 23 @@ -134,6 +150,7 @@ slot_23: created_at: '2022-07-12 15:18:43.907030' updated_at: '2022-07-12 15:18:43.907030' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_24: id: 24 @@ -142,6 +159,7 @@ slot_24: created_at: '2022-07-12 15:18:43.908585' updated_at: '2022-07-12 15:18:43.908585' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_25: id: 25 @@ -150,6 +168,7 @@ slot_25: created_at: '2022-07-12 15:18:43.910138' updated_at: '2022-07-12 15:18:43.910138' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_26: id: 26 @@ -158,6 +177,7 @@ slot_26: created_at: '2022-07-12 15:18:43.911643' updated_at: '2022-07-12 15:18:43.911643' availability_id: 5 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_27: id: 27 @@ -166,6 +186,7 @@ slot_27: created_at: '2022-07-12 15:18:43.914664' updated_at: '2022-07-12 15:18:43.914664' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_28: id: 28 @@ -174,6 +195,7 @@ slot_28: created_at: '2022-07-12 15:18:43.916047' updated_at: '2022-07-12 15:18:43.916047' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_29: id: 29 @@ -182,6 +204,7 @@ slot_29: created_at: '2022-07-12 15:18:43.917304' updated_at: '2022-07-12 15:18:43.917304' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_30: id: 30 @@ -190,6 +213,7 @@ slot_30: created_at: '2022-07-12 15:18:43.918798' updated_at: '2022-07-12 15:18:43.918798' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_31: id: 31 @@ -198,6 +222,7 @@ slot_31: created_at: '2022-07-12 15:18:43.920194' updated_at: '2022-07-12 15:18:43.920194' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_32: id: 32 @@ -206,6 +231,7 @@ slot_32: created_at: '2022-07-12 15:18:43.921662' updated_at: '2022-07-12 15:18:43.921662' availability_id: 6 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Machine", "reserved_places": 0}] slot_33: id: 33 @@ -214,6 +240,7 @@ slot_33: created_at: '2022-07-12 15:18:43.924285' updated_at: '2022-07-12 15:18:43.924285' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_34: id: 34 @@ -222,6 +249,7 @@ slot_34: created_at: '2022-07-12 15:18:43.925669' updated_at: '2022-07-12 15:18:43.925669' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_35: id: 35 @@ -230,6 +258,7 @@ slot_35: created_at: '2022-07-12 15:18:43.927038' updated_at: '2022-07-12 15:18:43.927038' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_36: id: 36 @@ -238,6 +267,7 @@ slot_36: created_at: '2022-07-12 15:18:43.928407' updated_at: '2022-07-12 15:18:43.928407' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_37: id: 37 @@ -246,6 +276,7 @@ slot_37: created_at: '2022-07-12 15:18:43.929907' updated_at: '2022-07-12 15:18:43.929907' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_38: id: 38 @@ -254,6 +285,7 @@ slot_38: created_at: '2022-07-12 15:18:43.931295' updated_at: '2022-07-12 15:18:43.931295' availability_id: 7 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 4, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 5, "reservable_type": "Machine", "reserved_places": 0}, {"user_ids": [], "reservable_id": 6, "reservable_type": "Machine", "reserved_places": 0}] slot_39: id: 39 @@ -262,6 +294,7 @@ slot_39: created_at: '2022-07-12 15:18:43.934465' updated_at: '2022-07-12 15:18:43.934465' availability_id: 13 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_40: id: 40 @@ -270,6 +303,7 @@ slot_40: created_at: '2022-07-12 15:18:43.935716' updated_at: '2022-07-12 15:18:43.935716' availability_id: 13 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_41: id: 41 @@ -278,6 +312,7 @@ slot_41: created_at: '2022-07-12 15:18:43.937025' updated_at: '2022-07-12 15:18:43.937025' availability_id: 13 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_42: id: 42 @@ -286,6 +321,7 @@ slot_42: created_at: '2022-07-12 15:18:43.938379' updated_at: '2022-07-12 15:18:43.938379' availability_id: 13 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_43: id: 43 @@ -294,6 +330,7 @@ slot_43: created_at: '2022-07-12 15:18:43.939737' updated_at: '2022-07-12 15:18:43.939737' availability_id: 13 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_44: id: 44 @@ -302,6 +339,7 @@ slot_44: created_at: '2022-07-12 15:18:43.942392' updated_at: '2022-07-12 15:18:43.942392' availability_id: 14 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_45: id: 45 @@ -310,6 +348,7 @@ slot_45: created_at: '2022-07-12 15:18:43.943779' updated_at: '2022-07-12 15:18:43.943779' availability_id: 14 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_46: id: 46 @@ -318,6 +357,7 @@ slot_46: created_at: '2022-07-12 15:18:43.945154' updated_at: '2022-07-12 15:18:43.945154' availability_id: 14 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_47: id: 47 @@ -326,6 +366,7 @@ slot_47: created_at: '2022-07-12 15:18:43.946515' updated_at: '2022-07-12 15:18:43.946515' availability_id: 14 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_48: id: 48 @@ -334,6 +375,7 @@ slot_48: created_at: '2022-07-12 15:18:43.949178' updated_at: '2022-07-12 15:18:43.949178' availability_id: 15 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_49: id: 49 @@ -342,6 +384,7 @@ slot_49: created_at: '2022-07-12 15:18:43.950348' updated_at: '2022-07-12 15:18:43.950348' availability_id: 15 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_50: id: 50 @@ -350,6 +393,7 @@ slot_50: created_at: '2022-07-12 15:18:43.951535' updated_at: '2022-07-12 15:18:43.951535' availability_id: 15 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_51: id: 51 @@ -358,6 +402,7 @@ slot_51: created_at: '2022-07-12 15:18:43.952864' updated_at: '2022-07-12 15:18:43.952864' availability_id: 15 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_52: id: 52 @@ -366,6 +411,7 @@ slot_52: created_at: '2022-07-12 15:18:43.955443' updated_at: '2022-07-12 15:18:43.955443' availability_id: 16 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_53: id: 53 @@ -374,6 +420,7 @@ slot_53: created_at: '2022-07-12 15:18:43.956657' updated_at: '2022-07-12 15:18:43.956657' availability_id: 16 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_54: id: 54 @@ -382,6 +429,7 @@ slot_54: created_at: '2022-07-12 15:18:43.957811' updated_at: '2022-07-12 15:18:43.957811' availability_id: 16 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_55: id: 55 @@ -390,6 +438,7 @@ slot_55: created_at: '2022-07-12 15:18:43.959063' updated_at: '2022-07-12 15:18:43.959063' availability_id: 16 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Machine", "reserved_places": 0}] slot_56: id: 56 @@ -398,6 +447,7 @@ slot_56: created_at: '2022-07-12 15:18:43.961319' updated_at: '2022-07-12 15:18:43.961319' availability_id: 17 + places: [{"user_ids": [], "reservable_id": 4, "reservable_type": "Event", "reserved_places": 0}] slot_112: id: 112 @@ -406,6 +456,7 @@ slot_112: created_at: '2022-07-12 15:18:44.038089' updated_at: '2022-07-12 15:18:44.038089' availability_id: 18 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Space", "reserved_places": 0}] slot_113: id: 113 @@ -414,6 +465,7 @@ slot_113: created_at: '2022-07-12 15:18:44.039392' updated_at: '2022-07-12 15:18:44.039392' availability_id: 18 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Space", "reserved_places": 0}] slot_114: id: 114 @@ -422,6 +474,7 @@ slot_114: created_at: '2022-07-12 15:18:44.040522' updated_at: '2022-07-12 15:18:44.040522' availability_id: 18 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Space", "reserved_places": 0}] slot_115: id: 115 @@ -430,6 +483,7 @@ slot_115: created_at: '2022-07-12 15:18:44.041937' updated_at: '2022-07-12 15:18:44.041937' availability_id: 18 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Space", "reserved_places": 0}] slot_116: id: 116 @@ -438,6 +492,7 @@ slot_116: created_at: '2022-07-12 15:18:44.044421' updated_at: '2022-07-12 15:18:44.044421' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_117: id: 117 @@ -446,6 +501,7 @@ slot_117: created_at: '2022-07-12 15:18:44.045689' updated_at: '2022-07-12 15:18:44.045689' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_118: id: 118 @@ -454,6 +510,7 @@ slot_118: created_at: '2022-07-12 15:18:44.047009' updated_at: '2022-07-12 15:18:44.047009' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_119: id: 119 @@ -462,6 +519,7 @@ slot_119: created_at: '2022-07-12 15:18:44.048272' updated_at: '2022-07-12 15:18:44.048272' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_120: id: 120 @@ -470,6 +528,7 @@ slot_120: created_at: '2022-07-12 15:18:44.049599' updated_at: '2022-07-12 15:18:44.049599' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_121: id: 121 @@ -478,6 +537,7 @@ slot_121: created_at: '2022-07-12 15:18:44.050947' updated_at: '2022-07-12 15:18:44.050947' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_122: id: 122 @@ -486,6 +546,7 @@ slot_122: created_at: '2022-07-12 15:18:44.052817' updated_at: '2022-07-12 15:18:44.052817' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_123: id: 123 @@ -494,6 +555,7 @@ slot_123: created_at: '2022-07-12 15:18:44.054966' updated_at: '2022-07-12 15:18:44.054966' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_124: id: 124 @@ -502,6 +564,7 @@ slot_124: created_at: '2022-07-12 15:18:44.057217' updated_at: '2022-07-12 15:18:44.057217' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_125: id: 125 @@ -510,6 +573,7 @@ slot_125: created_at: '2022-07-12 15:18:44.059135' updated_at: '2022-07-12 15:18:44.059135' availability_id: 19 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Machine", "reserved_places": 0}] slot_126: id: 126 @@ -518,6 +582,7 @@ slot_126: created_at: '2022-07-12 15:18:44.061887' updated_at: '2022-07-12 15:18:44.061887' availability_id: 1 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Training", "reserved_places": 0}] slot_127: id: 127 @@ -526,6 +591,7 @@ slot_127: created_at: '2022-07-12 15:18:44.063528' updated_at: '2022-07-12 15:18:44.063528' availability_id: 2 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Training", "reserved_places": 0}] slot_128: id: 128 @@ -534,6 +600,7 @@ slot_128: created_at: '2022-07-12 15:18:44.065114' updated_at: '2022-07-12 15:18:44.065114' availability_id: 8 + places: [{"user_ids": [], "reservable_id": 4, "reservable_type": "Training", "reserved_places": 0}] slot_129: id: 129 @@ -542,6 +609,7 @@ slot_129: created_at: '2022-07-12 15:18:44.066837' updated_at: '2022-07-12 15:18:44.066837' availability_id: 9 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Event", "reserved_places": 0}] slot_130: id: 130 @@ -550,6 +618,7 @@ slot_130: created_at: '2022-07-12 15:18:44.068259' updated_at: '2022-07-12 15:18:44.068259' availability_id: 10 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Event", "reserved_places": 0}] slot_131: id: 131 @@ -558,6 +627,7 @@ slot_131: created_at: '2022-07-12 15:18:44.069870' updated_at: '2022-07-12 15:18:44.069870' availability_id: 11 + places: [{"user_ids": [], "reservable_id": 3, "reservable_type": "Event", "reserved_places": 0}] slot_132: id: 132 @@ -566,6 +636,7 @@ slot_132: created_at: '2022-07-18 12:38:21.616510' updated_at: '2022-07-18 12:38:21.616510' availability_id: 20 + places: [{"user_ids": [], "reservable_id": 2, "reservable_type": "Training", "reserved_places": 0}] slot_133: id: 133 @@ -574,6 +645,7 @@ slot_133: created_at: '2022-12-14 12:01:26.165110' updated_at: '2022-12-14 12:01:26.165110' availability_id: 21 + places: [{"user_ids": [], "reservable_id": 1, "reservable_type": "Space", "reserved_places": 0}] slot_134: id: 134 @@ -582,3 +654,4 @@ slot_134: created_at: 2023-01-24 13:34:43.841240000 Z updated_at: 2023-01-24 13:34:43.841240000 Z availability_id: 22 + places: [{"user_ids": [], "reservable_id": 4, "reservable_type": "Training", "reserved_places": 0}] diff --git a/test/integration/machines_test.rb b/test/integration/machines_test.rb index ea7d5ba62..1c9f73b32 100644 --- a/test/integration/machines_test.rb +++ b/test/integration/machines_test.rb @@ -79,9 +79,9 @@ class MachinesTest < ActionDispatch::IntegrationTest end test 'soft delete a machine' do - machine = Machine.find(4) + machine = Machine.find(2) assert_not machine.destroyable? - delete '/api/machines/4', headers: default_headers + delete "/api/machines/#{machine.id}", headers: default_headers assert_response :success assert_empty response.body diff --git a/test/integration/open_api/machines_test.rb b/test/integration/open_api/machines_test.rb index 50ac4c755..73f090293 100644 --- a/test/integration/open_api/machines_test.rb +++ b/test/integration/open_api/machines_test.rb @@ -53,13 +53,13 @@ class OpenApi::MachinesTest < ActionDispatch::IntegrationTest end test 'soft delete a machine' do - assert_not Machine.find(4).destroyable? - delete '/open_api/v1/machines/4', headers: open_api_headers(@token) + assert_not Machine.find(2).destroyable? + delete '/open_api/v1/machines/2', headers: open_api_headers(@token) assert_response :success - get '/open_api/v1/machines/4', headers: open_api_headers(@token) + get '/open_api/v1/machines/2', headers: open_api_headers(@token) assert_response :not_found get '/open_api/v1/machines', headers: open_api_headers(@token) machines = json_response(response.body) - assert_not(machines[:machines].any? { |m| m[:id] == 4 }) + assert_not(machines[:machines].any? { |m| m[:id] == 2 }) end end diff --git a/test/integration/open_api/reservations_test.rb b/test/integration/open_api/reservations_test.rb index 478934836..ed316bb5b 100644 --- a/test/integration/open_api/reservations_test.rb +++ b/test/integration/open_api/reservations_test.rb @@ -57,13 +57,13 @@ class OpenApi::ReservationsTest < ActionDispatch::IntegrationTest assert_equal ['Machine'], reservations[:reservations].pluck(:reservable_type).uniq end - test 'list all machine 4 reservations' do - get '/open_api/v1/reservations?reservable_type=Machine&reservable_id=4', headers: open_api_headers(@token) + test 'list all machine 2 reservations' do + get '/open_api/v1/reservations?reservable_type=Machine&reservable_id=2', headers: open_api_headers(@token) assert_response :success assert_equal Mime[:json], response.content_type reservations = json_response(response.body) assert_not_empty reservations[:reservations] - assert_equal [4], reservations[:reservations].pluck(:reservable_id).uniq + assert_equal [2], reservations[:reservations].pluck(:reservable_id).uniq end end