From bdc572914120617df71a5976cdce1fee24164b15 Mon Sep 17 00:00:00 2001 From: Nicolas Florentin Date: Fri, 28 Jul 2023 16:16:44 +0200 Subject: [PATCH] (feat) space associate machines --- Gemfile | 2 + Gemfile.lock | 3 + app/controllers/api/spaces_controller.rb | 16 ++- app/frontend/images/icons.svg | 2 +- app/frontend/images/social-icons.svg | 73 ------------ .../javascript/components/base/fab-badge.tsx | 36 ++++++ .../components/machines/machine-card.tsx | 2 + .../components/socials/edit-socials.tsx | 2 +- .../components/socials/fab-socials.tsx | 2 +- .../components/spaces/space-form.tsx | 62 ++++++++++ app/frontend/src/javascript/models/machine.ts | 5 +- app/frontend/src/javascript/models/slot.ts | 1 + app/frontend/src/stylesheets/application.scss | 1 + .../stylesheets/modules/base/fab-badge.scss | 15 +++ .../stylesheets/modules/base/fab-button.scss | 4 +- .../stylesheets/modules/spaces/spaces.scss | 90 ++++++++++++++- app/frontend/templates/spaces/index.html | 1 + app/frontend/templates/spaces/show.html | 17 +++ app/helpers/availability_helper.rb | 3 + app/models/cart_item/reservation.rb | 20 ++++ app/models/machine.rb | 2 + app/models/slot.rb | 2 + app/models/space.rb | 3 + .../availabilities/availabilities_service.rb | 21 +++- app/services/machine_service.rb | 4 +- app/services/slots/interblocking_service.rb | 60 ++++++++++ app/services/slots/title_service.rb | 14 ++- .../api/availabilities/_slot.json.jbuilder | 1 + app/views/api/machines/_machine.json.jbuilder | 6 + app/views/api/spaces/_space.json.jbuilder | 4 + app/views/api/spaces/index.json.jbuilder | 11 ++ app/views/api/spaces/show.json.jbuilder | 11 +- config/initializers/ancestry.rb | 1 + config/locales/app.admin.en.yml | 4 + config/locales/app.admin.fr.yml | 4 + config/locales/en.yml | 2 + config/locales/fr.yml | 2 + ...0610153123_add_stp_customer_id_to_users.rb | 1 + .../20230728072726_add_ancestry_to_spaces.rb | 9 ++ ...20230728090257_add_space_id_to_machines.rb | 5 + db/structure.sql | 33 +++++- test/fixtures/spaces.yml | 1 + test/models/space_test.rb | 39 +++++++ .../availabilities_service_test.rb | 88 ++++++++++++++ .../slots/interblocking_service_test.rb | 108 ++++++++++++++++++ 45 files changed, 699 insertions(+), 94 deletions(-) delete mode 100644 app/frontend/images/social-icons.svg create mode 100644 app/frontend/src/javascript/components/base/fab-badge.tsx create mode 100644 app/frontend/src/stylesheets/modules/base/fab-badge.scss create mode 100644 app/services/slots/interblocking_service.rb create mode 100644 config/initializers/ancestry.rb create mode 100644 db/migrate/20230728072726_add_ancestry_to_spaces.rb create mode 100644 db/migrate/20230728090257_add_space_id_to_machines.rb create mode 100644 test/services/slots/interblocking_service_test.rb diff --git a/Gemfile b/Gemfile index bbe94468b..f7198a90f 100644 --- a/Gemfile +++ b/Gemfile @@ -151,3 +151,5 @@ gem 'sentry-rails' gem 'sentry-ruby' gem "reverse_markdown" + +gem "ancestry" diff --git a/Gemfile.lock b/Gemfile.lock index 6c6c012ae..1d41c1961 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,6 +76,8 @@ GEM public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) afm (0.2.2) + ancestry (4.3.3) + activerecord (>= 5.2.6) ansi (1.5.0) api-pagination (4.8.2) apipie-rails (0.5.17) @@ -536,6 +538,7 @@ DEPENDENCIES aasm active_record_query_trace acts_as_list + ancestry api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/spaces_controller.rb b/app/controllers/api/spaces_controller.rb index 5690b399d..d6a2ea8ae 100644 --- a/app/controllers/api/spaces_controller.rb +++ b/app/controllers/api/spaces_controller.rb @@ -7,7 +7,9 @@ class API::SpacesController < API::APIController respond_to :json def index - @spaces = Space.includes(:space_image).where(deleted_at: nil) + @spaces = Space.includes(:space_image, :machines).where(deleted_at: nil) + @spaces_indexed_with_parent = @spaces.index_with { |space| @spaces.find { |s| s.id == space.parent_id } } + @spaces_grouped_by_parent_id = @spaces.group_by(&:parent_id) end def show @@ -20,6 +22,7 @@ class API::SpacesController < API::APIController authorize Space @space = Space.new(space_params) if @space.save + update_space_children(@space, params[:space][:child_ids]) render :show, status: :created, location: @space else render json: @space.errors, status: :unprocessable_entity @@ -29,6 +32,7 @@ class API::SpacesController < API::APIController def update authorize @space if @space.update(space_params) + update_space_children(@space, params[:space][:child_ids]) render :show, status: :ok, location: @space else render json: @space.errors, status: :unprocessable_entity @@ -50,8 +54,18 @@ class API::SpacesController < API::APIController def space_params params.require(:space).permit(:name, :description, :characteristics, :default_places, :disabled, + machine_ids: [], space_image_attributes: %i[id attachment], space_files_attributes: %i[id attachment _destroy], advanced_accounting_attributes: %i[code analytical_section]) end + + def update_space_children(parent_space, child_ids) + Space.transaction do + parent_space.children.each { |child| child.update!(parent: nil) } + child_ids.to_a.select(&:present?).each do |child_id| + Space.find(child_id).update!(parent: parent_space) + end + end + end end diff --git a/app/frontend/images/icons.svg b/app/frontend/images/icons.svg index 2b18d95bb..819737ece 100644 --- a/app/frontend/images/icons.svg +++ b/app/frontend/images/icons.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/frontend/images/social-icons.svg b/app/frontend/images/social-icons.svg deleted file mode 100644 index ae6a259f7..000000000 --- a/app/frontend/images/social-icons.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/frontend/src/javascript/components/base/fab-badge.tsx b/app/frontend/src/javascript/components/base/fab-badge.tsx new file mode 100644 index 000000000..4c931584b --- /dev/null +++ b/app/frontend/src/javascript/components/base/fab-badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { IApplication } from '../../models/application'; +import { Loader } from '../base/loader'; +import { react2angular } from 'react2angular'; +import Icons from '../../../../images/icons.svg'; + +declare const Application: IApplication; + +interface FabBadgeProps { + icon: string, + iconWidth: string, + className?: string, +} + +/** + * Renders a badge (parent needs to be position: relative) + */ +export const FabBadge: React.FC = ({ icon, iconWidth, className }) => { + return ( +
+ + + +
+ ); +}; + +const FabBadgeWrapper: React.FC = ({ icon, iconWidth, className }) => { + return ( + + + + ); +}; + +Application.Components.component('fabBadge', react2angular(FabBadgeWrapper, ['icon', 'iconWidth', 'className'])); diff --git a/app/frontend/src/javascript/components/machines/machine-card.tsx b/app/frontend/src/javascript/components/machines/machine-card.tsx index 5a005c367..dd7bb372d 100644 --- a/app/frontend/src/javascript/components/machines/machine-card.tsx +++ b/app/frontend/src/javascript/components/machines/machine-card.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Loader } from '../base/loader'; import { ReserveButton } from './reserve-button'; import { User } from '../../models/user'; +import { FabBadge } from '../base/fab-badge'; interface MachineCardProps { user?: User, @@ -57,6 +58,7 @@ const MachineCard: React.FC = ({ user, machine, onShowMachine, return (
{machinePicture()} + {machine.space && user.role === 'admin' && }
{machine.name}
diff --git a/app/frontend/src/javascript/components/socials/edit-socials.tsx b/app/frontend/src/javascript/components/socials/edit-socials.tsx index 032d6b861..8a37afb85 100644 --- a/app/frontend/src/javascript/components/socials/edit-socials.tsx +++ b/app/frontend/src/javascript/components/socials/edit-socials.tsx @@ -3,7 +3,7 @@ import { FormState, UseFormRegister, UseFormSetValue } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { User } from '../../models/user'; import { SocialNetwork } from '../../models/social-network'; -import Icons from '../../../../images/social-icons.svg'; +import Icons from '../../../../images/icons.svg'; import { FormInput } from '../form/form-input'; import { Trash } from 'phosphor-react'; import { useTranslation } from 'react-i18next'; diff --git a/app/frontend/src/javascript/components/socials/fab-socials.tsx b/app/frontend/src/javascript/components/socials/fab-socials.tsx index e1bd046e1..010fa8cfa 100644 --- a/app/frontend/src/javascript/components/socials/fab-socials.tsx +++ b/app/frontend/src/javascript/components/socials/fab-socials.tsx @@ -8,7 +8,7 @@ import { IApplication } from '../../models/application'; import { Loader } from '../base/loader'; import { react2angular } from 'react2angular'; import { SettingName } from '../../models/setting'; -import Icons from '../../../../images/social-icons.svg'; +import Icons from '../../../../images/icons.svg'; import { Trash } from 'phosphor-react'; import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; diff --git a/app/frontend/src/javascript/components/spaces/space-form.tsx b/app/frontend/src/javascript/components/spaces/space-form.tsx index 89a61e58a..9da75107d 100644 --- a/app/frontend/src/javascript/components/spaces/space-form.tsx +++ b/app/frontend/src/javascript/components/spaces/space-form.tsx @@ -14,9 +14,13 @@ import { FormSwitch } from '../form/form-switch'; import { FormMultiFileUpload } from '../form/form-multi-file-upload'; import { FabButton } from '../base/fab-button'; import { Space } from '../../models/space'; +import { Machine } from '../../models/machine'; import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form'; import SettingAPI from '../../api/setting'; import { FabAlert } from '../base/fab-alert'; +import MachineAPI from '../../api/machine'; +import { FormMultiSelect } from '../form/form-multi-select'; +import { SelectOption } from '../../models/select'; declare const Application: IApplication; @@ -41,6 +45,41 @@ export const SpaceForm: React.FC = ({ action, space, onError, on SettingAPI.get('advanced_accounting').then(res => setIsActiveAccounting(res.value === 'true')).catch(onError); }, []); + /** + * Asynchronously load the full list of machines to display in the drop-down select field + */ + const loadMachines = (inputValue: string, callback: (options: Array>) => void): void => { + MachineAPI.index().then(data => { + callback(data.map(m => machineToOption(m))); + }).catch(error => onError(error)); + }; + + /** + * Convert a machine to an option usable by react-select + */ + const machineToOption = (machine: Machine): SelectOption => { + return { value: machine.id, label: machine.name }; + }; + + /** + * Asynchronously load the full list of spaces to display in the drop-down select field + */ + const loadSpaces = (inputValue: string, callback: (options: Array>) => void): void => { + SpaceAPI.index().then(data => { + if (space) { + data = data.filter((d) => d.id !== space.id); + } + callback(data.map(m => spaceToOption(m))); + }).catch(error => onError(error)); + }; + + /** + * Convert a space to an option usable by react-select + */ + const spaceToOption = (space: Space): SelectOption => { + return { value: space.id, label: space.name }; + }; + /** * Callback triggered when the user validates the machine form: handle create or update */ @@ -106,6 +145,29 @@ export const SpaceForm: React.FC = ({ action, space, onError, on
+
+
+

+ {t('app.admin.space_form.associated_objects')} +

+

+ {t('app.admin.space_form.associated_objects_warning')} +

+
+
+ + +
+
+

{t('app.admin.space_form.attachments')}

diff --git a/app/frontend/src/javascript/models/machine.ts b/app/frontend/src/javascript/models/machine.ts index 74cad1fcd..e62014beb 100644 --- a/app/frontend/src/javascript/models/machine.ts +++ b/app/frontend/src/javascript/models/machine.ts @@ -33,5 +33,8 @@ export interface Machine { slug: string, }>, advanced_accounting_attributes?: AdvancedAccounting, - machine_category_id?: number + machine_category_id?: number, + space: { + name: string + } } diff --git a/app/frontend/src/javascript/models/slot.ts b/app/frontend/src/javascript/models/slot.ts index e62129898..8b3d33470 100644 --- a/app/frontend/src/javascript/models/slot.ts +++ b/app/frontend/src/javascript/models/slot.ts @@ -8,6 +8,7 @@ export interface Slot { end: TDateISO, is_reserved: boolean, is_completed: boolean, + is_blocked?: boolean, backgroundColor: 'white', availability_id: number, diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index b70e769c6..759d95f9f 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -27,6 +27,7 @@ @import "modules/base/edit-destroy-buttons"; @import "modules/base/editorial-block"; @import "modules/base/fab-alert"; +@import "modules/base/fab-badge"; @import "modules/base/fab-button"; @import "modules/base/fab-input"; @import "modules/base/fab-modal"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-badge.scss b/app/frontend/src/stylesheets/modules/base/fab-badge.scss new file mode 100644 index 000000000..b01124528 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/base/fab-badge.scss @@ -0,0 +1,15 @@ +.fab-badge { + position: absolute; + top: 0; + right: 1.5rem; + padding: 0.8rem; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--secondary); + color: var(--secondary-text-color); + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + z-index: 1; + pointer-events: none; +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 1a6e06508..a447f64f8 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -67,12 +67,12 @@ @include colorVariant(var(--information), var(--gray-soft-lightest)); } &.is-secondary { - @include colorVariant(var(--secondary), var(--gray-hard-darkest)); + @include colorVariant(var(--secondary), var(--secondary-text-color)); } &.is-black { @include colorVariant(var(--gray-hard-darkest), var(--gray-soft-lightest)); } &.is-main { - @include colorVariant(var(--main), var(--gray-soft-lightest)); + @include colorVariant(var(--main), var(--main-text-color)); } } diff --git a/app/frontend/src/stylesheets/modules/spaces/spaces.scss b/app/frontend/src/stylesheets/modules/spaces/spaces.scss index a171dad97..d2ed7f81c 100644 --- a/app/frontend/src/stylesheets/modules/spaces/spaces.scss +++ b/app/frontend/src/stylesheets/modules/spaces/spaces.scss @@ -11,6 +11,94 @@ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 3.2rem; - .panel { margin-bottom: 0; } + .panel { + position: relative; + margin-bottom: 0; + } + } + + &-relations { + padding: 1.6rem; + display: flex; + flex-direction: column; + align-items: flex-start; + row-gap: 1.6rem; + background-color: var(--gray-soft-light); + border-radius: var(--border-radius); + + .space-parent { + @include text-lg(500); + color: var(--gray-hard-light); + margin: 0; + } + + .space-current { + display: flex; + align-items: center; + gap: 0.8rem; + + &.has-parent::before { + content: ""; + display: block; + width: 1rem; + height: 1rem; + border-bottom: 1px solid var(--gray-hard-lightest); + border-left: 1px solid var(--gray-hard-lightest); + } + + &-name { + padding: 0.8rem; + display: flex; + align-items: center; + gap: 0.8rem; + @include text-lg(600); + color: var(--main); + background-color: var(--gray-soft-lightest); + border-radius: var(--border-radius-sm); + svg { color: var(--gray-hard-darkest); } + } + } + + .related-machines, + .related-spaces { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.8rem; + list-style-type: none; + } + .related-spaces { + position: relative; + padding-inline-start: 2.6rem; + @include text-lg(500); + color: var(--gray-hard-light); + + &::before { + position: absolute; + top: 0.5rem; + left: 0.8rem; + content: ""; + display: block; + width: 1rem; + height: 1rem; + border-bottom: 1px solid var(--gray-hard-lightest); + border-left: 1px solid var(--gray-hard-lightest); + } + } + .related-machines { + position: relative; + + &::before { + position: absolute; + top: 0.4rem; + left: 1.6rem; + content: ""; + display: block; + width: 0.6rem; + height: 0.6rem; + border-bottom: 1px solid var(--gray-hard-lightest); + border-left: 1px solid var(--gray-hard-lightest); + } + } } } \ No newline at end of file diff --git a/app/frontend/templates/spaces/index.html b/app/frontend/templates/spaces/index.html index a483033ac..5d41285a5 100644 --- a/app/frontend/templates/spaces/index.html +++ b/app/frontend/templates/spaces/index.html @@ -48,6 +48,7 @@
+
diff --git a/app/frontend/templates/spaces/show.html b/app/frontend/templates/spaces/show.html index ed36a57aa..e8ef623eb 100644 --- a/app/frontend/templates/spaces/show.html +++ b/app/frontend/templates/spaces/show.html @@ -41,6 +41,23 @@
+
+

{{ space.parent.name }}

+
+ + + + + {{ space.name }} + +
+ + +
diff --git a/app/helpers/availability_helper.rb b/app/helpers/availability_helper.rb index eb817f1c6..4163c5a50 100644 --- a/app/helpers/availability_helper.rb +++ b/app/helpers/availability_helper.rb @@ -8,6 +8,7 @@ module AvailabilityHelper EVENT_COLOR = '#dd7e6b' IS_RESERVED_BY_CURRENT_USER = '#b2e774' IS_FULL = '#eeeeee' + IS_BLOCKED = '#b2e774' # same color as IS_RESERVED_BY_CURRENT_USER for simplicity def availability_border_color(availability) case availability.available_type @@ -38,6 +39,8 @@ module AvailabilityHelper IS_RESERVED_BY_CURRENT_USER elsif slot.full? IS_FULL + elsif slot.is_blocked + IS_BLOCKED else SPACE_COLOR end diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index c70f67918..92743a75b 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -282,6 +282,26 @@ class CartItem::Reservation < CartItem::BaseItem return false end + unless operator.privileged? + if reservable_type == "Space" + space = reservable + slot = reservation_slot.slot + if Slots::InterblockingService.new.blocked_slots_for_spaces([space], [slot]).any? + errors.add(:slot, I18n.t('cart_item_validation.blocked_by_another_reservation')) + return false + end + end + + if reservable_type == "Machine" + machine = reservable + slot = reservation_slot.slot + if Slots::InterblockingService.new.blocked_slots_for_machines([machine], [slot]).any? + errors.add(:slot, I18n.t('cart_item_validation.blocked_by_another_reservation')) + return false + end + end + end + true end end diff --git a/app/models/machine.rb b/app/models/machine.rb index 029ebfdfe..6e7d58ac7 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -44,6 +44,8 @@ class Machine < ApplicationRecord has_many :plan_limitations, dependent: :destroy, inverse_of: :machine, foreign_type: 'limitable_type', as: :limitable + belongs_to :space + after_create :create_statistic_subtype after_create :create_machine_prices after_create :update_gateway_product diff --git a/app/models/slot.rb b/app/models/slot.rb index 673b6a306..c4e83e038 100644 --- a/app/models/slot.rb +++ b/app/models/slot.rb @@ -14,6 +14,8 @@ class Slot < ApplicationRecord after_create_commit :create_places_cache + attr_accessor :is_blocked + # @param reservable [Machine,Space,Training,Event,NilClass] # @return [Integer] the total number of reserved places def reserved_places(reservable = nil) diff --git a/app/models/space.rb b/app/models/space.rb index 765eef8a0..d6bd1ca2f 100644 --- a/app/models/space.rb +++ b/app/models/space.rb @@ -6,6 +6,7 @@ class Space < ApplicationRecord extend FriendlyId friendly_id :name, use: :slugged + has_ancestry cache_depth: true validates :name, :default_places, presence: true @@ -34,6 +35,8 @@ class Space < ApplicationRecord has_many :cart_item_space_reservations, class_name: 'CartItem::SpaceReservation', dependent: :destroy, inverse_of: :reservable, foreign_type: 'reservable_type', as: :reservable + has_many :machines, dependent: :nullify + after_create :create_statistic_subtype after_create :create_space_prices after_create :update_gateway_product diff --git a/app/services/availabilities/availabilities_service.rb b/app/services/availabilities/availabilities_service.rb index d2fcd3f96..8df177504 100644 --- a/app/services/availabilities/availabilities_service.rb +++ b/app/services/availabilities/availabilities_service.rb @@ -39,7 +39,10 @@ class Availabilities::AvailabilitiesService availabilities = availabilities(ma_availabilities, 'machines', user, window[:start], window[:end]) if @level == 'slot' - availabilities.map(&:slots).flatten + slots = availabilities.map(&:slots).flatten + + blocked_slots = Slots::InterblockingService.new.blocked_slots_for_machines(machines, slots) + flag_or_remove_blocked_slots(slots, blocked_slots, @current_user) else availabilities end @@ -57,7 +60,10 @@ class Availabilities::AvailabilitiesService availabilities = availabilities(sp_availabilities, 'space', user, window[:start], window[:end]) if @level == 'slot' - availabilities.map(&:slots).flatten + slots = availabilities.map(&:slots).flatten + + blocked_slots = Slots::InterblockingService.new.blocked_slots_for_spaces(spaces, slots) + flag_or_remove_blocked_slots(slots, blocked_slots, @current_user) else availabilities end @@ -133,4 +139,15 @@ class Availabilities::AvailabilitiesService qry end + + def flag_or_remove_blocked_slots(slots, blocked_slots, user) + if user.admin? || user.manager? + blocked_slots.each do |slot| + slot.is_blocked = true + end + else + slots -= blocked_slots + end + slots + end end diff --git a/app/services/machine_service.rb b/app/services/machine_service.rb index 61d2e563c..49827abbd 100644 --- a/app/services/machine_service.rb +++ b/app/services/machine_service.rb @@ -9,9 +9,9 @@ class MachineService def list(filters) sort_by = Setting.get('machines_sort_by') || 'default' machines = if sort_by == 'default' - Machine.includes(:machine_image, :plans) + Machine.includes(:machine_image, :plans, :space) else - Machine.includes(:machine_image, :plans).order(sort_by) + Machine.includes(:machine_image, :plans, :space).order(sort_by) end # do not include soft destroyed machines = machines.where(deleted_at: nil) diff --git a/app/services/slots/interblocking_service.rb b/app/services/slots/interblocking_service.rb new file mode 100644 index 000000000..1026371cf --- /dev/null +++ b/app/services/slots/interblocking_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Services around slots +module Slots; end + +# Check the reservation status of a slot +class Slots::InterblockingService + # returns an array of slots + # @param spaces [ActiveRecord::Relation] + # @param slots [ActiveRecord::Relation] + def blocked_slots_for_spaces(spaces, slots) + blocking_slots_start_at_end_at = [] + spaces.each do |space| + parent_and_child_space_ids = [space.parent_id, space.child_ids].flatten.compact + blocking_slots_start_at_end_at << Slot.joins(slots_reservations: :reservation) + .where(slots_reservations: { canceled_at: nil }, + reservations: { reservable_type: 'Space', + reservable_id: parent_and_child_space_ids }) + .pluck(:start_at, :end_at) + .map { |d| %i[start_at end_at].zip(d).to_h } + child_machine_ids = Machine.where(space_id: [space.id, parent_and_child_space_ids].flatten) + blocking_slots_start_at_end_at << Slot.joins(slots_reservations: :reservation) + .where(slots_reservations: { canceled_at: nil }, + reservations: { reservable_type: 'Machine', + reservable_id: child_machine_ids }) + .pluck(:start_at, :end_at) + .map { |d| %i[start_at end_at].zip(d).to_h } + end + blocking_slots_start_at_end_at = blocking_slots_start_at_end_at.flatten&.uniq || [] + + blocked_slots(slots, blocking_slots_start_at_end_at) + end + + def blocked_slots_for_machines(machines, slots) + blocking_slots_start_at_end_at = [] + machines.each do |machine| + parent_space_ids = machine.space&.path_ids + next unless parent_space_ids&.any? + + blocking_slots_start_at_end_at << Slot.joins(slots_reservations: :reservation) + .where(slots_reservations: { canceled_at: nil }, reservations: { reservable_type: 'Space', + reservable_id: parent_space_ids }) + .pluck(:start_at, :end_at) + .map { |d| %i[start_at end_at].zip(d).to_h } + end + blocking_slots_start_at_end_at = blocking_slots_start_at_end_at.flatten&.uniq || [] + + blocked_slots(slots, blocking_slots_start_at_end_at) + end + + private + + def blocked_slots(slots, blocking_slots) + slots.select do |slot| + blocking_slots.find do |blocking_slot| + blocking_slot[:start_at] < slot.end_at && slot.start_at < blocking_slot[:end_at] + end + end + end +end diff --git a/app/services/slots/title_service.rb b/app/services/slots/title_service.rb index 33a453f48..f37b9dc82 100644 --- a/app/services/slots/title_service.rb +++ b/app/services/slots/title_service.rb @@ -15,12 +15,16 @@ class Slots::TitleService is_reserved_by_user = slot.reserved_by?(@user&.id, reservables) name = reservables.map(&:name).join(', ') - if !is_reserved && !is_reserved_by_user - name - elsif is_reserved && !is_reserved_by_user - "#{name} #{@show_name ? "- #{slot_users_names(slot, reservables)}" : ''}" + if !slot.is_blocked + if !is_reserved && !is_reserved_by_user + name + elsif is_reserved && !is_reserved_by_user + "#{name} #{@show_name ? "- #{slot_users_names(slot, reservables)}" : ''}" + else + "#{name} - #{I18n.t('availabilities.i_ve_reserved')}" + end else - "#{name} - #{I18n.t('availabilities.i_ve_reserved')}" + "#{name} - #{I18n.t('availabilities.blocked')}" end end diff --git a/app/views/api/availabilities/_slot.json.jbuilder b/app/views/api/availabilities/_slot.json.jbuilder index b9afa9923..eed3ddc68 100644 --- a/app/views/api/availabilities/_slot.json.jbuilder +++ b/app/views/api/availabilities/_slot.json.jbuilder @@ -7,6 +7,7 @@ json.start slot.start_at.iso8601 json.end slot.end_at.iso8601 json.is_reserved slot.reserved?(reservable) json.is_completed slot.full?(reservable) +json.is_blocked slot.is_blocked json.backgroundColor 'white' json.availability_id slot.availability_id diff --git a/app/views/api/machines/_machine.json.jbuilder b/app/views/api/machines/_machine.json.jbuilder index 7969b3a7a..75c9de678 100644 --- a/app/views/api/machines/_machine.json.jbuilder +++ b/app/views/api/machines/_machine.json.jbuilder @@ -15,3 +15,9 @@ if machine.advanced_accounting json.partial! 'api/advanced_accounting/advanced_accounting', advanced_accounting: machine.advanced_accounting end end + +if machine.space_id + json.space do + json.name machine.space.name + end +end \ No newline at end of file diff --git a/app/views/api/spaces/_space.json.jbuilder b/app/views/api/spaces/_space.json.jbuilder index 6daf5c976..82a1a8e9e 100644 --- a/app/views/api/spaces/_space.json.jbuilder +++ b/app/views/api/spaces/_space.json.jbuilder @@ -14,3 +14,7 @@ if space.advanced_accounting json.partial! 'api/advanced_accounting/advanced_accounting', advanced_accounting: space.advanced_accounting end end + +json.machines space.machines do |machine| + json.name machine.name +end diff --git a/app/views/api/spaces/index.json.jbuilder b/app/views/api/spaces/index.json.jbuilder index cb524287f..6fdc2f7e6 100644 --- a/app/views/api/spaces/index.json.jbuilder +++ b/app/views/api/spaces/index.json.jbuilder @@ -2,4 +2,15 @@ json.array!(@spaces) do |space| json.partial! 'api/spaces/space', space: space + + parent = @spaces_indexed_with_parent[space] + if parent + json.parent do + json.name parent.name + end + end + + json.children @spaces_grouped_by_parent_id[space.id] do |child| + json.name child.name + end end diff --git a/app/views/api/spaces/show.json.jbuilder b/app/views/api/spaces/show.json.jbuilder index 7127304e0..ffffcccf4 100644 --- a/app/views/api/spaces/show.json.jbuilder +++ b/app/views/api/spaces/show.json.jbuilder @@ -1,9 +1,18 @@ # frozen_string_literal: true json.partial! 'api/spaces/space', space: @space -json.extract! @space, :characteristics, :created_at, :updated_at +json.extract! @space, :characteristics, :machine_ids, :child_ids, :created_at, :updated_at json.space_files_attributes @space.space_files do |f| json.id f.id json.attachment_name f.attachment_identifier json.attachment_url f.attachment_url end + +if @space.parent + json.parent do + json.name @space.parent.name + end +end +json.children @space.children do |child| + json.name child.name +end diff --git a/config/initializers/ancestry.rb b/config/initializers/ancestry.rb new file mode 100644 index 000000000..47f56693c --- /dev/null +++ b/config/initializers/ancestry.rb @@ -0,0 +1 @@ +Ancestry.default_ancestry_format = :materialized_path2 \ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 39c9399aa..790d342bb 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -111,6 +111,10 @@ en: save: "Save" create_success: "The space was created successfully" update_success: "The space was updated successfully" + associated_machines: "Included machines" + children_spaces: "Included spaces" + associated_objects: "Associated objects" + associated_objects_warning: "Only use these fields if you want interblocking reservation between spaces, child spaces and machines. If you want machine and space reservations to remain independent, please leave the following fields blank." event_form: ACTION_title: "{ACTION, select, create{New} other{Update the}} event" title: "Title" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 8c8f15647..a6659a759 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -111,6 +111,10 @@ fr: save: "Enregistrer" create_success: "L'espace a bien été créé" update_success: "L'espace a bien été mis à jour" + associated_machines: "Machines" + children_spaces: "Espaces" + associated_objects: "Machines et sous-espaces" + associated_objects_warning: "Utilisez ces champs uniquement si vous souhaitez que la réservation de l'espace bloque la réservation des machines associées et des sous-espaces (et vice-versa). Si vous souhaitez que les réservations restent indépendantes, veuillez laisser les champs suivants vides." event_form: ACTION_title: "{ACTION, select, create{Nouvel } other{Mettre à jour l''}}événement" title: "Titre" diff --git a/config/locales/en.yml b/config/locales/en.yml index 248a18e50..49f54a66a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -65,6 +65,7 @@ en: not_available: "Not available" reserving: "I'm reserving" i_ve_reserved: "I've reserved" + blocked: "Blocked" length_must_be_slot_multiple: "must be at least %{MIN} minutes after the start date" must_be_associated_with_at_least_1_machine: "must be associated with at least 1 machine" deleted_user: "Deleted user" @@ -532,6 +533,7 @@ en: space: "This space is disabled" machine: "This machine is disabled" reservable: "This machine is not reservable" + blocked_by_another_reservation: "This slot is blocked by another reservation" cart_validation: select_user: "Please select a user before continuing" settings: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 5fcc25b29..1dcadc4fd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -65,6 +65,7 @@ fr: not_available: "Non disponible" reserving: "Je réserve" i_ve_reserved: "J'ai réservé" + blocked: "Bloquée" length_must_be_slot_multiple: "doit être au moins %{MIN} minutes après la date de début" must_be_associated_with_at_least_1_machine: "doit être associé avec au moins 1 machine" deleted_user: "Utilisateur supprimé" @@ -532,6 +533,7 @@ fr: space: "Cet espace est désactivé" machine: "Cette machine est désactivée" reservable: "Cette machine n'est pas réservable" + blocked_by_another_reservation: "Ce créneau est bloqué par une autre réservation" cart_validation: select_user: "Veuillez sélectionner un utilisateur avant de continuer" settings: diff --git a/db/migrate/20140610153123_add_stp_customer_id_to_users.rb b/db/migrate/20140610153123_add_stp_customer_id_to_users.rb index f06fcaa44..42787d54a 100644 --- a/db/migrate/20140610153123_add_stp_customer_id_to_users.rb +++ b/db/migrate/20140610153123_add_stp_customer_id_to_users.rb @@ -3,6 +3,7 @@ class AddStpCustomerIdToUsers < ActiveRecord::Migration[4.2] def up add_column :users, :stp_customer_id, :string + User.reset_column_information User.all.each do |user| if user.stp_customer_id.blank? user.send(:create_stripe_customer) diff --git a/db/migrate/20230728072726_add_ancestry_to_spaces.rb b/db/migrate/20230728072726_add_ancestry_to_spaces.rb new file mode 100644 index 000000000..c816fdd89 --- /dev/null +++ b/db/migrate/20230728072726_add_ancestry_to_spaces.rb @@ -0,0 +1,9 @@ +class AddAncestryToSpaces < ActiveRecord::Migration[7.0] + def change + add_column :spaces, :ancestry, :string, collation: 'C' + Space.update_all(ancestry: '/') + change_column_null(:spaces, :ancestry, false) + add_column :spaces, :ancestry_depth, :integer, default: 0 + add_index :spaces, :ancestry + end +end diff --git a/db/migrate/20230728090257_add_space_id_to_machines.rb b/db/migrate/20230728090257_add_space_id_to_machines.rb new file mode 100644 index 000000000..4a9a88239 --- /dev/null +++ b/db/migrate/20230728090257_add_space_id_to_machines.rb @@ -0,0 +1,5 @@ +class AddSpaceIdToMachines < ActiveRecord::Migration[7.0] + def change + add_reference :machines, :space, foreign_key: true, index: true + end +end diff --git a/db/structure.sql b/db/structure.sql index a9aa1f96d..2e80f5d6c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1618,7 +1618,8 @@ CREATE TABLE public.machines ( disabled boolean, deleted_at timestamp without time zone, machine_category_id bigint, - reservable boolean DEFAULT true + reservable boolean DEFAULT true, + space_id bigint ); @@ -3199,7 +3200,9 @@ CREATE TABLE public.spaces ( updated_at timestamp without time zone NOT NULL, characteristics text, disabled boolean, - deleted_at timestamp without time zone + deleted_at timestamp without time zone, + ancestry character varying NOT NULL COLLATE pg_catalog."C", + ancestry_depth integer DEFAULT 0 ); @@ -6574,6 +6577,13 @@ CREATE INDEX index_machines_on_machine_category_id ON public.machines USING btre CREATE UNIQUE INDEX index_machines_on_slug ON public.machines USING btree (slug); +-- +-- Name: index_machines_on_space_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_machines_on_space_id ON public.machines USING btree (space_id); + + -- -- Name: index_notification_preferences_on_notification_type_id; Type: INDEX; Schema: public; Owner: - -- @@ -7106,6 +7116,13 @@ CREATE INDEX index_spaces_availabilities_on_availability_id ON public.spaces_ava CREATE INDEX index_spaces_availabilities_on_space_id ON public.spaces_availabilities USING btree (space_id); +-- +-- Name: index_spaces_on_ancestry; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_spaces_on_ancestry ON public.spaces USING btree (ancestry); + + -- -- Name: index_spaces_on_deleted_at; Type: INDEX; Schema: public; Owner: - -- @@ -8148,6 +8165,14 @@ ALTER TABLE ONLY public.statistic_profile_prepaid_packs ADD CONSTRAINT fk_rails_b0251cdfcf FOREIGN KEY (prepaid_pack_id) REFERENCES public.prepaid_packs(id); +-- +-- Name: machines fk_rails_b2e37688bb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.machines + ADD CONSTRAINT fk_rails_b2e37688bb FOREIGN KEY (space_id) REFERENCES public.spaces(id); + + -- -- Name: orders fk_rails_b33ed6c672; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -8818,6 +8843,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230328094808'), ('20230328094809'), ('20230626122844'), -('20230626122947'); +('20230626122947'), +('20230728072726'), +('20230728090257'); diff --git a/test/fixtures/spaces.yml b/test/fixtures/spaces.yml index 242041286..7dcb23747 100644 --- a/test/fixtures/spaces.yml +++ b/test/fixtures/spaces.yml @@ -7,3 +7,4 @@ space_1: created_at: 2017-02-15 15:55:04.123928000 Z updated_at: 2017-02-15 15:55:04.123928000 Z characteristics: Scie à chantourner, rabot, dégauchisseuse, chanfreineuse et pyrograveur + ancestry: '/' diff --git a/test/models/space_test.rb b/test/models/space_test.rb index 008de7e84..92e54663a 100644 --- a/test/models/space_test.rb +++ b/test/models/space_test.rb @@ -31,4 +31,43 @@ class SpaceTest < ActiveSupport::TestCase assert_nil Space.find_by(slug: slug) assert_nil StatisticSubType.find_by(key: slug) end + + test "space can be associated with spaces in a tree-like structure" do + space_1 = Space.create!(name: "space 1", default_places: 2) + space_1_1 = Space.create!(name: "space 1_1", default_places: 2, parent: space_1) + space_1_2 = Space.create!(name: "space 1_2", default_places: 2, parent: space_1) + space_1_2_1 = Space.create!(name: "space 1_2_1", default_places: 2, parent: space_1_2) + space_other = Space.create!(name: "space other", default_places: 2) + + assert_equal [space_1_1, space_1_2], space_1.children + assert_equal [], space_1_1.children + assert_equal [space_1_2_1], space_1_2.children + + assert_equal [space_1, space_1_2], space_1_2_1.ancestors + assert_equal [space_1], space_1_2.ancestors + assert_equal [space_1], space_1_1.ancestors + assert_equal [], space_1.ancestors + + assert_equal [space_1_1, space_1_2, space_1_2_1], space_1.descendants + assert_equal [], space_1_1.descendants + assert_equal [space_1_2_1], space_1_2.descendants + assert_equal [], space_1_2_1.descendants + + assert_equal [], space_other.descendants + assert_equal [], space_other.ancestors + end + + test "space can be associated with machines" do + space = spaces(:space_1) + machine_1 = machines(:machine_1) + machine_2 = machines(:machine_2) + + space.machines << machine_1 + space.machines << machine_2 + + assert_equal 2, space.machines.count + + assert_equal space, machine_1.space + assert_equal space, machine_2.space + end end diff --git a/test/services/availabilities/availabilities_service_test.rb b/test/services/availabilities/availabilities_service_test.rb index cb3827029..06a15d469 100644 --- a/test/services/availabilities/availabilities_service_test.rb +++ b/test/services/availabilities/availabilities_service_test.rb @@ -66,6 +66,50 @@ class Availabilities::AvailabilitiesServiceTest < ActiveSupport::TestCase assert_equal availability.end_at, slots.max_by(&:end_at).end_at end + test '[member] machines availabilities with blocked slots' do + space = Space.find(1) + machine = Machine.find(1).tap { |m| m.update!(space: space) } + reservation = Reservation.create!(reservable: space, statistic_profile: statistic_profiles(:jdupont)) + machine_availability = Availability.create!(availabilities(:availability_7).slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'machines', machine_ids: [machine.id])) + + machine_slot = availabilities(:availability_7).slots.first + slot = Slot.create!(availability: machine_availability, start_at: machine_slot.start_at, end_at: machine_slot.end_at) + + opts = { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day } + service = Availabilities::AvailabilitiesService.new(@no_subscription) + slots = service.machines([machine], @no_subscription, opts) + assert_equal 7, slots.count + + SlotsReservation.create!(reservation: reservation, slot: slot) + + slots = service.machines([machine], @no_subscription, opts) + assert_equal 5, slots.count + end + + test '[admin] machines availabilities with blocked slots' do + space = Space.find(1) + machine = Machine.find(1).tap { |m| m.update!(space: space) } + reservation = Reservation.create!(reservable: space, statistic_profile: statistic_profiles(:jdupont)) + machine_availability = Availability.create!(availabilities(:availability_7).slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'machines', machine_ids: [machine.id])) + + machine_slot = availabilities(:availability_7).slots.first + slot = Slot.create!(availability: machine_availability, start_at: machine_slot.start_at, end_at: machine_slot.end_at) + + opts = { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day } + service = Availabilities::AvailabilitiesService.new(@admin) + slots = service.machines([machine], @admin, opts) + assert_equal 7, slots.count + + SlotsReservation.create!(reservation: reservation, slot: slot) + + slots = service.machines([machine], @admin, opts) + assert_equal 7, slots.count + + assert_equal 2, slots.count(&:is_blocked) + end + test 'spaces availabilities' do service = Availabilities::AvailabilitiesService.new(@no_subscription) slots = service.spaces([Space.find(1)], @no_subscription, { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day }) @@ -77,6 +121,50 @@ class Availabilities::AvailabilitiesServiceTest < ActiveSupport::TestCase assert_equal availability.end_at, slots.max_by(&:end_at).end_at end + test '[member] spaces availabilities with blocked slots' do + space = Space.find(1) + machine = machines(:machine_1).tap { |m| m.update!(space: space) } + reservation = Reservation.create!(reservable: machine, statistic_profile: statistic_profiles(:jdupont)) + machine_availability = Availability.create!(availabilities(:availability_18).slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'machines', machine_ids: [machine.id])) + + space_slot = availabilities(:availability_18).slots.first + slot = Slot.create!(availability: machine_availability, start_at: space_slot.start_at, end_at: space_slot.end_at) + + opts = { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day } + service = Availabilities::AvailabilitiesService.new(@no_subscription) + slots = service.spaces([space], @no_subscription, opts) + assert_equal 4, slots.count + + SlotsReservation.create!(reservation: reservation, slot: slot) + + slots = service.spaces([space], @no_subscription, opts) + assert_equal 3, slots.count + end + + test '[admin] spaces availabilities with blocked slots' do + space = Space.find(1) + machine = machines(:machine_1).tap { |m| m.update!(space: space) } + reservation = Reservation.create!(reservable: machine, statistic_profile: statistic_profiles(:jdupont)) + machine_availability = Availability.create!(availabilities(:availability_18).slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'machines', machine_ids: [machine.id])) + + space_slot = availabilities(:availability_18).slots.first + slot = Slot.create!(availability: machine_availability, start_at: space_slot.start_at, end_at: space_slot.end_at) + + opts = { start: 2.days.from_now.beginning_of_day, end: 4.days.from_now.end_of_day } + service = Availabilities::AvailabilitiesService.new(@admin) + slots = service.spaces([space], @admin, opts) + assert_equal 4, slots.count + + SlotsReservation.create!(reservation: reservation, slot: slot) + + slots = service.spaces([space], @admin, opts) + assert_equal 4, slots.count + + assert_equal 1, slots.count(&:is_blocked) + end + test 'trainings availabilities' do service = Availabilities::AvailabilitiesService.new(@no_subscription) trainings = [Training.find(1), Training.find(2)] diff --git a/test/services/slots/interblocking_service_test.rb b/test/services/slots/interblocking_service_test.rb new file mode 100644 index 000000000..1415a66c2 --- /dev/null +++ b/test/services/slots/interblocking_service_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Slots::InterblockingServiceTest < ActiveSupport::TestCase + setup do + @parent_space = spaces(:space_1) + @child_space = Space.create!(name: 'space 1-1', default_places: 2, parent: @parent_space) + @space_availability = availabilities(:availability_18) + @space_slots = @space_availability.slots + @machine_availability = availabilities(:availability_7) + @machine_slots = @machine_availability.slots + @machine = machines(:machine_1).tap { |m| m.update!(space: @child_space) } + end + + test '#blocked_slots_for_spaces : no reservation' do + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + end + + test '#blocked_slots_for_spaces : reservation on parent space' do + reservation = Reservation.create!(reservable: @parent_space, statistic_profile: statistic_profiles(:jdupont)) + SlotsReservation.create!(reservation: reservation, slot: @space_slots.first) + + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + end + + test '#blocked_slots_for_spaces : reservation on child space' do + reservation = Reservation.create!(reservable: @child_space, statistic_profile: statistic_profiles(:jdupont)) + SlotsReservation.create!(reservation: reservation, slot: @space_slots.first) + + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + end + + test '#blocked_slots_for_spaces : reservation on child machine' do + reservation = Reservation.create!(reservable: @machine, statistic_profile: statistic_profiles(:jdupont)) + machine_availability = Availability.create!(@space_availability.slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'machines', machine_ids: [@machine.id])) + + slot = Slot.create!(availability: machine_availability, start_at: @space_slots.first.start_at, end_at: @space_slots.first.end_at) + SlotsReservation.create!(reservation: reservation, slot: slot) + + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + + slot.update!(start_at: slot.start_at - 15.minutes, end_at: slot.end_at - 15.minutes) + + # still match when overlapping + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + assert_equal [@space_slots.first], Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + + slot.update!(start_at: slot.start_at - 45.minutes, end_at: slot.end_at - 45.minutes) + + # not overlapping anymore + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@parent_space], @space_slots) + assert_empty Slots::InterblockingService.new.blocked_slots_for_spaces([@child_space], @space_slots) + end + + test '#blocked_slots_for_machines : no reservation' do + assert_empty Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + end + + test '#blocked_slots_for_machines : reservation on parent space' do + reservation = Reservation.create!(reservable: @parent_space, statistic_profile: statistic_profiles(:jdupont)) + + space_availability = Availability.create!(@machine_availability.slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'space', space_ids: [@parent_space.id])) + + slot = Slot.create!(availability: space_availability, start_at: @machine_slots.first.start_at, end_at: @machine_slots.first.end_at) + SlotsReservation.create!(reservation: reservation, slot: slot) + + assert_equal [@machine_slots.first], Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + + slot.update!(start_at: slot.start_at - 15.minutes, end_at: slot.end_at - 15.minutes) + + # still match when overlapping + assert_equal [@machine_slots.first], Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + + slot.update!(start_at: slot.start_at - 45.minutes, end_at: slot.end_at - 45.minutes) + + # not overlapping anymore + assert_empty Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + end + + test '#blocked_slots_for_machines : reservation on child space' do + reservation = Reservation.create!(reservable: @child_space, statistic_profile: statistic_profiles(:jdupont)) + + space_availability = Availability.create!(@machine_availability.slice(:start_at, :end_at, :slot_duration) + .merge(available_type: 'space', space_ids: [@child_space.id])) + + slot = Slot.create!(availability: space_availability, start_at: @machine_slots.first.start_at, end_at: @machine_slots.first.end_at) + SlotsReservation.create!(reservation: reservation, slot: slot) + + assert_equal [@machine_slots.first], Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + + slot.update!(start_at: slot.start_at - 15.minutes, end_at: slot.end_at - 15.minutes) + + # still match when overlapping + assert_equal [@machine_slots.first], Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + + slot.update!(start_at: slot.start_at - 45.minutes, end_at: slot.end_at - 45.minutes) + + # not overlapping anymore + assert_empty Slots::InterblockingService.new.blocked_slots_for_machines([@machine], @machine_slots) + end +end