1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

Merge branch 'dev' for release 5.4.14

This commit is contained in:
Du Peng 2022-08-01 18:25:06 +02:00
commit 089ab09dc4
284 changed files with 35515 additions and 27364 deletions

5
.codeclimate.yml Normal file
View File

@ -0,0 +1,5 @@
plugins:
rubocop:
enabled: true
config:
file: .rubocop.yml

9
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,9 @@
stages:
- test
include:
- template: Code-Quality.gitlab-ci.yml
code_quality:
artifacts:
paths: [ gl-code-quality-report.json ]

View File

@ -1,4 +1,8 @@
Metrics/LineLength:
require: rubocop-rails
AllCops:
NewCops: enable
Layout/LineLength:
Max: 140
Metrics/MethodLength:
Max: 35
@ -19,8 +23,6 @@ Metrics/BlockLength:
- 'test/**/*.rb'
Metrics/ParameterLists:
CountKeywordArgs: false
Style/BracesAroundHashParameters:
EnforcedStyle: context_dependent
Style/RegexpLiteral:
EnforcedStyle: slashes
Style/EmptyElse:

View File

@ -1,6 +1,61 @@
# Changelog Fab-manager
## next deploy
## next release
## v5.4.14 2022 August 1
- Added a test for multiple reservations on the same space slot
- Display the tag and theme field on the detail view of a project
- Improved file validation, validation is now based on content of the file in addition of the validation of the extension
- Fix a security issue: xss vulnerability in projects
- Fix a security issue: updated moment to 2.29.4 to fix [CVE-2022-31129](https://cve.mitre.org/cgi-bin/cvename.cgi?CVE-2022-31129)
## v5.4.13 2022 July 27
- Improved calendars loading time
- Refactored and documented the availability-slot-reservation data model
- Display bookers names to connected users now apply to all resources
- Updated rails locales files
- Usage of the rails logger instead of printing to standard output
- Optionnaly add a username column to the member list (#375)
- Improved attached ICS file texts, in reservations emails
- Fix a bug: user validation required alert is displayed and disappears instantly
- Fix a bug: canceled trainings are still shown on the public profile page
- Fix a bug: prevent same slot booking feature ignores canceled reservations
- Fix a bug: wrong currency on invoices files
- Fix a bug: unable to reserve if user's subscription plan is disabled
- Fix a bug: for admins and managers, the current password is not requested before changing their own password
- Fix a bug: missing translations
- Fix a bug: unable to book a space's slot with an existing reservation
- Fix a bug: Unable to import accounts from SSO when the transformation modal was opened but leaved empty
- Fix a bug: Unable to change the group of a user
- Fix a bug: As admin, unable to create a new member (#374)
- Fix a bug: profile completion form is not shown is T&C were not filled
- Fix a bug: Erroneous "cancelation failed" message if elasticsearch was disabled
- Fix a bug: admin group being replaced in SSO authentication (#376)
- Fix a bug: SSO data being overridden when it is empty and the user can change it (#377)
- Fix a security issue: updated terser to 4.8.1 to fix [CVE-2022-25858](https://cve.mitre.org/cgi-bin/cvename.cgi?CVE-2022-25858)
- Fix a security issue: updated tzinfo to 1.2.10 to fix [CVE-2022-31163](https://cve.mitre.org/cgi-bin/cvename.cgi?CVE-2022-31163)
- Fix a security issue: updated rails to 5.2.8.1 to fix [CVE-2022-32224](https://cve.mitre.org/cgi-bin/cvename.cgi?CVE-2022-32224)
## v5.4.12 2022 July 06
- Fix a bug: Gender, Address and Birthday are not mapped properly from SSO (#365)
- Fix a bug: unable to import a new account from an SSO provider
- Fix a security issue: updated rails-html-sanitizer to 1.4.3 to fix [CVE-2022-32209](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-32209)
## v5.4.11 2022 July 06
- Fix a bug: social networks icons not shown in firefox
- Fix a bug: Gender, Address and Birthday are not mapped properly from SSO (#365)
- Fix a bug: OIDC scopes are not shown in the configuration form select
- Fix a bug: OIDC scopes are not saved
## v5.4.10 2022 July 05
- Increased About page title's size
- Fix a bug: script mount-proof-of-identity-files creates a volume with an invalid path
- Fix a bug: unable to access the new OIDC provider form
## v5.4.9 2022 June 29

View File

@ -3,7 +3,7 @@
source 'https://rubygems.org'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.2.7'
gem 'rails', '~> 5.2.8'
# Used by rails 5.2 to reduce the app boot time by over 50%
gem 'bootsnap'
# Use Puma as web server
@ -39,7 +39,8 @@ group :development do
gem 'rb-readline'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'railroady'
gem 'rubocop', '~> 0.61.1', require: false
gem 'rubocop', '~> 1.31', require: false
gem 'rubocop-rails', require: false
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end

View File

@ -4,46 +4,46 @@ GEM
Ascii85 (1.0.3)
aasm (5.0.8)
concurrent-ruby (~> 1.0)
actioncable (5.2.7.1)
actionpack (= 5.2.7.1)
actioncable (5.2.8.1)
actionpack (= 5.2.8.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.7.1)
actionpack (= 5.2.7.1)
actionview (= 5.2.7.1)
activejob (= 5.2.7.1)
actionmailer (5.2.8.1)
actionpack (= 5.2.8.1)
actionview (= 5.2.8.1)
activejob (= 5.2.8.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.7.1)
actionview (= 5.2.7.1)
activesupport (= 5.2.7.1)
actionpack (5.2.8.1)
actionview (= 5.2.8.1)
activesupport (= 5.2.8.1)
rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionpack-page_caching (1.2.2)
actionpack (>= 5.0.0)
actionview (5.2.7.1)
activesupport (= 5.2.7.1)
actionview (5.2.8.1)
activesupport (= 5.2.8.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_record_query_trace (1.7)
activejob (5.2.7.1)
activesupport (= 5.2.7.1)
activejob (5.2.8.1)
activesupport (= 5.2.8.1)
globalid (>= 0.3.6)
activemodel (5.2.7.1)
activesupport (= 5.2.7.1)
activerecord (5.2.7.1)
activemodel (= 5.2.7.1)
activesupport (= 5.2.7.1)
activemodel (5.2.8.1)
activesupport (= 5.2.8.1)
activerecord (5.2.8.1)
activemodel (= 5.2.8.1)
activesupport (= 5.2.8.1)
arel (>= 9.0)
activestorage (5.2.7.1)
actionpack (= 5.2.7.1)
activerecord (= 5.2.7.1)
activestorage (5.2.8.1)
actionpack (= 5.2.8.1)
activerecord (= 5.2.8.1)
marcel (~> 1.0.0)
activesupport (5.2.7.1)
activesupport (5.2.8.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -57,7 +57,7 @@ GEM
apipie-rails (0.5.17)
rails (>= 4.1)
arel (9.0.0)
ast (2.4.0)
ast (2.4.2)
attr_required (1.0.1)
awesome_print (1.8.0)
axiom-types (0.1.1)
@ -167,7 +167,7 @@ GEM
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.10.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
icalendar (2.7.1)
ice_cube (~> 0.16)
@ -176,7 +176,6 @@ GEM
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
jaro_winkler (1.5.4)
jbuilder (2.10.0)
activesupport (>= 5.0.0)
jbuilder_cache_multi (0.1.0)
@ -204,7 +203,7 @@ GEM
listen (3.0.8)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
loofah (2.17.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -222,7 +221,7 @@ GEM
mini_magick (4.10.1)
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.15.0)
minitest (5.16.2)
minitest-reporters (1.4.2)
ansi
builder
@ -233,7 +232,7 @@ GEM
multi_xml (0.6.0)
multipart-post (2.1.1)
nio4r (2.5.8)
nokogiri (1.13.6)
nokogiri (1.13.8)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
notify_with (0.0.2)
@ -274,8 +273,8 @@ GEM
httparty (~> 0.20)
orm_adapter (0.5.0)
parallel (1.19.1)
parser (2.7.0.4)
ast (~> 2.4.0)
parser (3.1.2.0)
ast (~> 2.4.1)
pdf-core (0.7.0)
pdf-reader (2.4.0)
Ascii85 (~> 1.0.0)
@ -287,7 +286,6 @@ GEM
pg_search (2.3.2)
activerecord (>= 5.2)
activesupport (>= 5.2)
powerpack (0.1.2)
prawn (2.2.2)
pdf-core (~> 0.7.0)
ttfunk (~> 1.5)
@ -300,7 +298,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
rack (2.2.3.1)
rack (2.2.4)
rack-oauth2 (1.19.0)
activesupport
attr_required
@ -309,26 +307,26 @@ GEM
rack (>= 2.1.0)
rack-proxy (0.7.2)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-test (2.0.2)
rack (>= 1.3)
railroady (1.5.3)
rails (5.2.7.1)
actioncable (= 5.2.7.1)
actionmailer (= 5.2.7.1)
actionpack (= 5.2.7.1)
actionview (= 5.2.7.1)
activejob (= 5.2.7.1)
activemodel (= 5.2.7.1)
activerecord (= 5.2.7.1)
activestorage (= 5.2.7.1)
activesupport (= 5.2.7.1)
rails (5.2.8.1)
actioncable (= 5.2.8.1)
actionmailer (= 5.2.8.1)
actionpack (= 5.2.8.1)
actionview (= 5.2.8.1)
activejob (= 5.2.8.1)
activemodel (= 5.2.8.1)
activerecord (= 5.2.8.1)
activestorage (= 5.2.8.1)
activesupport (= 5.2.8.1)
bundler (>= 1.3.0)
railties (= 5.2.7.1)
railties (= 5.2.8.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
rails-observers (0.1.5)
activemodel (>= 4.0)
@ -337,9 +335,9 @@ GEM
rails_stdout_logging
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.2.7.1)
actionpack (= 5.2.7.1)
activesupport (= 5.2.7.1)
railties (5.2.8.1)
actionpack (= 5.2.8.1)
activesupport (= 5.2.8.1)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
@ -353,19 +351,29 @@ GEM
activesupport
i18n
redis (4.6.0)
regexp_parser (2.5.0)
repost (0.3.2)
responders (2.4.1)
actionpack (>= 4.2.0, < 6.0)
railties (>= 4.2.0, < 6.0)
rexml (3.2.5)
rolify (5.2.0)
rubocop (0.61.1)
jaro_winkler (~> 1.5.1)
rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.19.1)
parser (>= 3.1.1.0)
rubocop-rails (2.15.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
ruby-progressbar (1.10.1)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4)
@ -412,7 +420,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
sprockets (4.0.3)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
@ -440,7 +448,7 @@ GEM
camertron-eprun
cldr-plurals-runtime-rb (~> 1.0)
tzinfo
tzinfo (1.2.9)
tzinfo (1.2.10)
thread_safe (~> 0.1)
tzinfo-data (1.2020.4)
tzinfo (>= 1.0.0)
@ -529,7 +537,7 @@ DEPENDENCIES
puma (= 4.3.12)
pundit
railroady
rails (~> 5.2.7)
rails (~> 5.2.8)
rails-observers
rails_12factor
rb-readline
@ -537,7 +545,8 @@ DEPENDENCIES
repost
responders (~> 2.0)
rolify
rubocop (~> 0.61.1)
rubocop (~> 1.31)
rubocop-rails
rubyXL
rubyzip (>= 1.3.0)
sassc (= 2.1.0)

View File

@ -85,10 +85,10 @@ class API::AuthProvidersController < API::ApiController
def provider_params
if params['auth_provider']['providable_type'] == DatabaseProvider.name
params.require(:auth_provider).permit(:name, :providable_type, providable_attributes: [:id])
params.require(:auth_provider).permit(:id, :name, :providable_type, providable_attributes: [:id])
elsif params['auth_provider']['providable_type'] == OAuth2Provider.name
params.require(:auth_provider)
.permit(:name, :providable_type,
.permit(:id, :name, :providable_type,
providable_attributes: %i[id base_url token_endpoint authorization_endpoint
profile_url client_id client_secret scopes],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
@ -96,10 +96,11 @@ class API::AuthProvidersController < API::ApiController
mapping: %i[from to]]])
elsif params['auth_provider']['providable_type'] == OpenIdConnectProvider.name
params.require(:auth_provider)
.permit(:name, :providable_type,
providable_attributes: %i[id issuer discovery client_auth_method scope prompt send_scope_to_token_endpoint
client__identifier client__secret client__authorization_endpoint client__token_endpoint
client__userinfo_endpoint client__jwks_uri client__end_session_endpoint profile_url],
.permit(:id, :name, :providable_type,
providable_attributes: [:id, :issuer, :discovery, :client_auth_method, :prompt, :send_scope_to_token_endpoint,
:client__identifier, :client__secret, :client__authorization_endpoint, :client__token_endpoint,
:client__userinfo_endpoint, :client__jwks_uri, :client__end_session_endpoint, :profile_url,
scope: []],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
:_destroy, transformation: [:type, :format, :true_value, :false_value,
mapping: %i[from to]]])

View File

@ -4,15 +4,15 @@
class API::AvailabilitiesController < API::ApiController
before_action :authenticate_user!, except: [:public]
before_action :set_availability, only: %i[show update reservations lock]
before_action :define_max_visibility, only: %i[machine trainings spaces]
before_action :set_operator_role, only: %i[machine spaces trainings]
before_action :set_customer, only: %i[machine spaces trainings]
respond_to :json
def index
authorize Availability
start_date = ActiveSupport::TimeZone[params[:timezone]].parse(params[:start])
end_date = ActiveSupport::TimeZone[params[:timezone]].parse(params[:end]).end_of_day
display_window = window
@availabilities = Availability.includes(:machines, :tags, :trainings, :spaces)
.where('start_at >= ? AND end_at <= ?', start_date, end_date)
.where('start_at >= ? AND end_at <= ?', display_window[:start], display_window[:end])
@availabilities = @availabilities.where.not(available_type: 'event') unless Setting.get('events_in_calendar')
@ -20,19 +20,14 @@ class API::AvailabilitiesController < API::ApiController
end
def public
start_date = ActiveSupport::TimeZone[params[:timezone]].parse(params[:start])
end_date = ActiveSupport::TimeZone[params[:timezone]].parse(params[:end]).end_of_day
@reservations = Reservation.includes(:slots, :statistic_profile)
.references(:slots)
.where('slots.start_at >= ? AND slots.end_at <= ?', start_date, end_date)
display_window = window
machine_ids = params[:m] || []
service = Availabilities::PublicAvailabilitiesService.new(current_user)
@availabilities = service.public_availabilities(
start_date,
end_date,
@reservations,
machines: machine_ids, spaces: params[:s]
display_window,
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
(params[:evt] && params[:evt] == 'true')
)
@title_filter = { machine_ids: machine_ids.map(&:to_i) }
@ -47,10 +42,8 @@ class API::AvailabilitiesController < API::ApiController
authorize Availability
@availability = Availability.new(availability_params)
if @availability.save
if params[:availability][:occurrences]
service = Availabilities::CreateAvailabilitiesService.new
service.create(@availability, params[:availability][:occurrences])
end
service = Availabilities::CreateAvailabilitiesService.new
service.create(@availability, params[:availability][:occurrences])
render :show, status: :created, location: @availability
else
render json: @availability.errors, status: :unprocessable_entity
@ -78,27 +71,32 @@ class API::AvailabilitiesController < API::ApiController
end
def machine
@current_user_role = current_user.role
service = Availabilities::AvailabilitiesService.new(current_user, other: @visi_max_other, year: @visi_max_year)
@slots = service.machines(params[:machine_id], user)
service = Availabilities::AvailabilitiesService.new(current_user)
@machine = Machine.friendly.find(params[:machine_id])
@slots = service.machines([@machine], @customer, window)
end
def trainings
service = Availabilities::AvailabilitiesService.new(current_user, other: @visi_max_other, year: @visi_max_year)
@availabilities = service.trainings(params[:training_id], user)
service = Availabilities::AvailabilitiesService.new(current_user)
@trainings = if params[:training_id].is_number? || (params[:training_id].length.positive? && params[:training_id] != 'all')
[Training.friendly.find(params[:training_id])]
else
Training.all
end
@slots = service.trainings(@trainings, @customer, window)
end
def spaces
@current_user_role = current_user.role
service = Availabilities::AvailabilitiesService.new(current_user, other: @visi_max_other, year: @visi_max_year)
@slots = service.spaces(params[:space_id], user)
service = Availabilities::AvailabilitiesService.new(current_user)
@space = Space.friendly.find(params[:space_id])
@slots = service.spaces([@space], @customer, window)
end
def reservations
authorize Availability
@reservation_slots = @availability.slots.includes(reservations: [statistic_profile: [user: [:profile]]]).order('slots.start_at ASC')
@slots_reservations = @availability.slots_reservations
.includes(:slot, reservation: [statistic_profile: [user: [:profile]]])
.order('slots.start_at ASC')
end
def export_availabilities
@ -131,12 +129,22 @@ class API::AvailabilitiesController < API::ApiController
private
def user
if params[:member_id]
User.find(params[:member_id])
else
current_user
end
def window
start_date = ActiveSupport::TimeZone[params[:timezone]]&.parse(params[:start])
end_date = ActiveSupport::TimeZone[params[:timezone]]&.parse(params[:end])&.end_of_day
{ start: start_date, end: end_date }
end
def set_customer
@customer = if params[:member_id]
User.find(params[:member_id])
else
current_user
end
end
def set_operator_role
@operator_role = current_user.role
end
def set_availability
@ -155,43 +163,10 @@ class API::AvailabilitiesController < API::ApiController
end
def filter_availabilites(availabilities)
availabilities_filtered = []
availabilities.to_ary.each do |a|
# machine slot
if !a.try(:available_type)
availabilities_filtered << a
else
availabilities_filtered << a if filter_training?(a)
availabilities_filtered << a if filter_space?(a)
availabilities_filtered << a if filter_machine?(a)
availabilities_filtered << a if filter_event?(a)
end
end
availabilities_filtered.delete_if(&method(:remove_full?))
end
def filter_training?(availability)
params[:t] && availability.available_type == 'training' && params[:t].include?(availability.trainings.first.id.to_s)
end
def filter_space?(availability)
params[:s] && availability.available_type == 'space' && params[:s].include?(availability.spaces.first.id.to_s)
end
def filter_machine?(availability)
params[:m] && availability.available_type == 'machines' && (params[:m].map(&:to_i) & availability.machine_ids).any?
end
def filter_event?(availability)
params[:evt] && params[:evt] == 'true' && availability.available_type == 'event'
availabilities.delete_if(&method(:remove_full?))
end
def remove_full?(availability)
params[:dispo] == 'false' && (availability.is_reserved || (availability.try(:full?) && availability.full?))
end
def define_max_visibility
@visi_max_year = Setting.get('visibility_yearly').to_i.months.since
@visi_max_other = Setting.get('visibility_others').to_i.months.since
end
end

View File

@ -55,6 +55,8 @@ class API::EventsController < API::ApiController
authorize Event
@event = Event.new(event_params.permit!)
if @event.save
service = Availabilities::CreateAvailabilitiesService.new
service.create_slots(@event.availability)
render :show, status: :created, location: @event
else
render json: @event.errors, status: :unprocessable_entity

View File

@ -40,7 +40,7 @@ class API::MembersController < API::ApiController
def create
authorize :user, :create_member?
@member = User.new(user_params.permit!)
@member = User.new(Members::MembersService.handle_organization(user_params.permit!))
members_service = Members::MembersService.new(@member)
if members_service.create(current_user, user_params)

View File

@ -55,9 +55,8 @@ class API::PayzenController < API::PaymentsController
def check_cart
cart = shopping_cart
unless cart.valid?
render json: { error: 'unable to pay' }, status: :unprocessable_entity and return
end
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: { cart: 'ok' }, status: :ok
end

View File

@ -43,6 +43,6 @@ class API::ReservationsController < API::ApiController
def reservation_params
params.require(:reservation).permit(:message, :reservable_id, :reservable_type, :nb_reserve_places,
tickets_attributes: %i[event_price_category_id booked],
slots_attributes: %i[id start_at end_at availability_id offered])
slots_reservations_attributes: %i[id slot_id offered])
end
end

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
# API Controller for resources of type Slot
# Slots are used to cut Availabilities into reservable slots. The duration of these slots is configured per
# availability by Availability.slot_duration, or otherwise globally by Setting.get('slot_duration')
class API::SlotsController < API::ApiController
before_action :authenticate_user!
before_action :set_slot, only: %i[update cancel]
respond_to :json
def update
authorize @slot
if @slot.update(slot_params)
SubscriptionExtensionAfterReservation.new(@slot.reservation).extend_subscription_if_eligible
render :show, status: :created, location: @slot
else
render json: @slot.errors, status: :unprocessable_entity
end
end
def cancel
authorize @slot
SlotService.new.cancel(@slot)
end
private
def set_slot
@slot = Slot.find(params[:id])
end
def slot_params
params.require(:slot).permit(:start_at, :end_at, :availability_id)
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# API Controller for resources of type Slot
# Slots are used to cut Availabilities into reservable slots. The duration of these slots is configured per
# availability by Availability.slot_duration, or otherwise globally by Setting.get('slot_duration')
class API::SlotsReservationsController < API::ApiController
before_action :authenticate_user!
before_action :set_slots_reservation, only: %i[update cancel]
respond_to :json
def update
authorize @slot_reservation
if @slot_reservation.update(slot_params)
SubscriptionExtensionAfterReservation.new(@slot_reservation.reservation).extend_subscription_if_eligible
render :show, status: :created, location: @slot_reservation
else
render json: @slot_reservation.errors, status: :unprocessable_entity
end
end
def cancel
authorize @slot_reservation
SlotsReservationsService.cancel(@slot_reservation)
end
private
def set_slots_reservation
@slot_reservation = SlotsReservation.find(params[:id])
end
def slot_params
params.require(:slots_reservation).permit(:slot_id)
end
end

View File

@ -19,9 +19,8 @@ class API::StripeController < API::PaymentsController
res = nil # json of the API answer
cart = shopping_cart
unless cart.valid?
render json: { error: 'unable to pay' }, status: :unprocessable_entity and return
end
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
begin
amount = debit_amount(cart) # will contains the amount and the details of each invoice lines
if params[:payment_method_id].present?
@ -74,7 +73,7 @@ class API::StripeController < API::PaymentsController
def setup_subscription
cart = shopping_cart
raise InvalidSubscriptionError unless cart.valid?
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
service = Stripe::Service.new
method = service.attach_method_as_default(

View File

@ -52,8 +52,8 @@ class API::TrainingsController < API::ApiController
authorize Training
@training = Training.find(params[:id])
@availabilities = @training.availabilities
.includes(slots: { reservations: { statistic_profile: [:trainings, user: [:profile]] } })
.where('slots.canceled_at': nil)
.includes(slots: { slots_reservations: { reservations: { statistic_profile: [:trainings, user: [:profile]] } } })
.where('slots_reservations.canceled_at': nil)
.order('availabilities.start_at DESC')
end

View File

@ -14,7 +14,7 @@ class SocialBotController < ActionController::Base
@training = Training.friendly.find(Regexp.last_match(3).to_s)
render :training, status: :ok
else
puts "unknown bot request : #{request.original_url}"
Rails.logger.warn "unknown bot request : #{request.original_url}"
end
end
end

View File

@ -29,6 +29,8 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
// saves the state of the discovery endpoint
const [discoveryAvailable, setDiscoveryAvailable] = useState<boolean>(false);
const [scopesAvailable, setScopesAvailable] = useState<string[]>(null);
// this is a workaround for https://github.com/JedWatson/react-select/issues/1879
const [selectKey, setSelectKey] = useState<number>(0);
// when we have detected a discovery endpoint, we mark it as available
useEffect(() => {
@ -38,6 +40,11 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
);
}, [discoveryAvailable]);
// this will force the scope "select" to re-fetch the options
useEffect(() => {
setSelectKey(selectKey + 1);
}, [scopesAvailable]);
// when the component is mounted, we try to discover the discovery endpoint for the current configuration (if any)
useEffect(() => {
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
@ -69,7 +76,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
* The resulting list is provided through the callback parameter.
*/
const loadScopes = (inputValue: string, callback: (options: Array<{ value: string, label: string }>) => void): void => {
const current = currentFormValues.scope || [];
const current = currentFormValues?.scope || [];
if (scopesAvailable) {
// add custom scopes to the list of available scopes
const unlisted = difference(current, scopesAvailable);
@ -125,6 +132,7 @@ export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext ext
label={t('app.admin.authentication.openid_connect_form.scope')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.scope_help_html" />}
loadOptions={loadScopes}
selectKey={selectKey.toString()}
creatable
control={control} />
<FormSelect id="providable_attributes.prompt"

View File

@ -9,6 +9,7 @@ import { mappingType } from '../../models/authentication-provider';
import { BooleanMappingForm } from './boolean-mapping-form';
import { DateMappingForm } from './date-mapping-form';
import { StringMappingForm } from './string-mapping-form';
import { FormInput } from '../form/form-input';
interface TypeMappingModalProps<TFieldValues, TContext extends object> {
model: string,
@ -38,6 +39,10 @@ export const TypeMappingModal = <TFieldValues extends FieldValues, TContext exte
confirmButton={<i className="fa fa-check" />}
onConfirm={toggleModal}>
<span>{model} &gt; {field} ({t('app.admin.authentication.type_mapping_modal.TYPE_expected', { TYPE: t(`app.admin.authentication.type_mapping_modal.types.${type}`) })})</span>
<FormInput register={register}
id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.type`}
type="hidden"
defaultValue={type} />
{type === 'integer' && <IntegerMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
{type === 'boolean' && <BooleanMappingForm register={register} fieldMappingId={fieldMappingId} />}
{type === 'date' && <DateMappingForm control={control} fieldMappingId={fieldMappingId} />}

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { FabPanel } from '../../base/fab-panel';
import { Reservation, ReservationSlot } from '../../../models/reservation';
import { Reservation, SlotsReservation } from '../../../models/reservation';
import ReservationAPI from '../../../api/reservation';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
@ -38,16 +38,16 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
*/
const reservationsByDate = (state: 'past' | 'futur'): Array<Reservation> => {
return reservations.filter(r => {
return !!r.slots_attributes.find(s => filterSlot(s, state));
return !!r.slots_reservations_attributes.find(s => filterSlot(s, state));
});
};
/**
* Check if the given slot if past of futur
* Check if the given slot reservation if past of futur
*/
const filterSlot = (slot: ReservationSlot, state: 'past' | 'futur'): boolean => {
return (state === 'past' && moment(slot.start_at).isBefore()) ||
(state === 'futur' && moment(slot.start_at).isAfter());
const filterSlot = (sr: SlotsReservation, state: 'past' | 'futur'): boolean => {
return (state === 'past' && moment(sr.slot_attributes.start_at).isBefore()) ||
(state === 'futur' && moment(sr.slot_attributes.start_at).isAfter());
};
/**
@ -95,12 +95,12 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
return (
<li key={reservation.id} className="reservation">
<a className={`reservation-title ${details[reservation.id] ? 'clicked' : ''}`} onClick={toggleDetails(reservation.id)}>
{reservation.reservable.name} - {FormatLib.date(reservation.slots_attributes[0].start_at)}
{reservation.reservable.name} - {FormatLib.date(reservation.slots_reservations_attributes[0].slot_attributes.start_at)}
</a>
{details[reservation.id] && <FabPopover title={t('app.logged.dashboard.reservations.reservations_panel.slots_details')}>
{reservation.slots_attributes.filter(s => filterSlot(s, state)).map(
slot => <span key={slot.id} className="slot-details">
{FormatLib.date(slot.start_at)}, {FormatLib.time(slot.start_at)} - {FormatLib.time(slot.end_at)}
{reservation.slots_reservations_attributes.filter(s => filterSlot(s, state)).map(
slotReservation => <span key={slotReservation.id} className="slot-details">
{FormatLib.date(slotReservation.slot_attributes.start_at)}, {FormatLib.time(slotReservation.slot_attributes.start_at)} - {FormatLib.time(slotReservation.slot_attributes.end_at)}
</span>
)}
</FabPopover>}
@ -109,7 +109,7 @@ const ReservationsPanel: React.FC<SpaceReservationsProps> = ({ userId, onError,
};
const futur = reservationsByDate('futur');
const past = _.orderBy(reservationsByDate('past'), r => r.slots_attributes[0].start_at, 'desc');
const past = _.orderBy(reservationsByDate('past'), r => r.slots_reservations_attributes[0].slot_attributes.start_at, 'desc');
return (
<FabPanel className="reservations-panel" header={header()}>

View File

@ -10,13 +10,14 @@ export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<A
className?: string,
disabled?: boolean|((id: string) => boolean),
onLabelClick?: (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) => void,
inLine?: boolean,
}
/**
* This abstract component should not be used directly.
* Other forms components that are intended to be used with react-hook-form must extend this component.
*/
export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, children }: AbstractFormItemProps<TFieldValues>) => {
export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, inLine, children }: AbstractFormItemProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const [fieldError, setFieldError] = useState<{ message: string }>(error);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
@ -59,14 +60,21 @@ export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label,
return (
<label className={`form-item ${classNames}`} onClick={handleLabelClick}>
{label && <div className='form-item-header'>
{(label && !inLine) && <div className='form-item-header'>
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className='form-item-field'>
{inLine && <div className='form-item-header'><p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
{children}
</div>
{(isDirty && fieldError) && <div className="form-item-error">{fieldError.message}</div> }

View File

@ -16,6 +16,7 @@ interface CommonProps<TFieldValues, TContext extends object, TOptionValue> exten
onChange?: (values: Array<TOptionValue>) => void,
placeholder?: string,
creatable?: boolean,
selectKey?: string,
}
// we should provide either an array of options or a function that returns a promise, but not both
@ -35,7 +36,7 @@ type selectOption<TOptionValue> = { value: TOptionValue, label: string, select?:
* This component is a wrapper around react-select to use with react-hook-form.
* It is a multi-select component.
*/
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange, formState, warning, loadOptions, creatable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange, formState, warning, loadOptions, creatable, selectKey }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const { t } = useTranslation('shared');
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
@ -52,7 +53,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
useEffect(() => {
if (typeof loadOptions === 'function') {
loadOptions('', options => {
setAllOptions(options);
setTimeout(() => setAllOptions(options), 1);
});
}
}, [loadOptions]);
@ -73,7 +74,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
* This function will return the currently selected options, according to the selectedOptions state.
*/
const getCurrentValues = (value: Array<TOptionValue>): Array<selectOption<TOptionValue>> => {
return allOptions.filter(c => value?.includes(c.value));
return allOptions?.filter(c => value?.includes(c.value));
};
/**
@ -119,6 +120,7 @@ export const FormMultiSelect = <TFieldValues extends FieldValues, TContext exten
classNamePrefix: 'rs',
className: 'rs',
ref,
key: selectKey,
value: getCurrentValues(value),
placeholder,
isDisabled,

View File

@ -29,6 +29,7 @@ export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, t
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-switch ${className || ''}`} tooltip={tooltip}
disabled={disabled}
inLine
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}

View File

@ -163,7 +163,7 @@ const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, o
</button>
<PendingTrainingModal isOpen={pendingTraining}
toggleModal={togglePendingTrainingModal}
nextReservation={machine?.current_user_next_training_reservation?.slots_attributes[0]?.start_at} />
nextReservation={machine?.current_user_next_training_reservation?.slots_reservations_attributes[0]?.slot_attributes.start_at} />
<RequiredTrainingModal isOpen={trainingRequired}
toggleModal={toggleRequiredTrainingModal}
user={user}

View File

@ -249,7 +249,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
};
AbstractPaymentModal.defaultProps = {
title: 'app.shared.payment.online_payment',
title: 'app.shared.abstract_payment_modal.online_payment',
preventCgv: false,
preventScheduleInfo: false,
modalSize: ModalSize.medium

View File

@ -59,7 +59,9 @@ export const EditSocials = <TFieldValues extends FieldValues>({ register, setVal
<>
<div className='social-icons'>
{userNetworks.map((network, index) =>
!selectedNetworks.includes(network) && <img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
!selectedNetworks.includes(network) && <svg key={index} onClick={() => selectNetwork(network)} viewBox="0 0 24 24" >
<use href={`${Icons}#${network.name}`} />
</svg>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
@ -79,7 +81,7 @@ export const EditSocials = <TFieldValues extends FieldValues>({ register, setVal
label={network.name}
disabled={disabled}
placeholder={t('app.shared.edit_socials.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
icon={<svg viewBox="0 0 24 24"><use href={`${Icons}#${network.name}`}/></svg>}
addOn={<Trash size={16} />}
addOnAction={() => dispatch({ type: 'delete', payload: { network, field: `profile_attributes.${network.name}` } })} />
)}

View File

@ -85,7 +85,7 @@ export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, o
{fabNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<a key={index} href={network.url} target='_blank' rel="noreferrer">
<img src={`${Icons}#${network.name}`}></img>
<svg viewBox="0 0 24 24"><use href={`${Icons}#${network.name}`}/></svg>
</a>
)}
</div>
@ -95,7 +95,9 @@ export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, o
<div className='social-icons'>
{fabNetworks.map((network, index) =>
!selectedNetworks.includes(network) &&
<img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
<svg viewBox="0 0 24 24" key={index} onClick={() => selectNetwork(network)}>
<use href={`${Icons}#${network.name}`} />
</svg>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
@ -114,7 +116,7 @@ export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, o
defaultValue={network.url}
label={network.name}
placeholder={t('app.shared.fab_socials.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
icon={<svg viewBox="0 0 24 24"><use href={`${Icons}#${network.name}`}/></svg>}
addOn={<Trash size={16} />}
addOnAction={() => remove(network)} />
)}

View File

@ -7,6 +7,7 @@ import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { Avatar } from './avatar';
import { useTranslation } from 'react-i18next';
interface AvatarInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -20,6 +21,8 @@ interface AvatarInputProps<TFieldValues> {
* This component allows to set the user's avatar, in forms managed by react-hook-form.
*/
export const AvatarInput = <TFieldValues extends FieldValues>({ currentAvatar, userName, register, setValue, size }: AvatarInputProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [avatar, setAvatar] = useState<string|ArrayBuffer>(currentAvatar);
/**
* Check if the provided user has a configured avatar
@ -70,8 +73,8 @@ export const AvatarInput = <TFieldValues extends FieldValues>({ currentAvatar, u
<Avatar avatar={avatar} userName={userName} size="large" />
<div className="buttons">
<FabButton onClick={onAddAvatar} className="select-button">
{!hasAvatar() && <span>Add an avatar</span>}
{hasAvatar() && <span>Change</span>}
{!hasAvatar() && <span>{t('app.shared.avatar_input.add_an_avatar')}</span>}
{hasAvatar() && <span>{t('app.shared.avatar_input.change')}</span>}
<FormInput className="avatar-file-input"
type="file"
accept="image/*"

View File

@ -9,19 +9,21 @@ import { FieldValues } from 'react-hook-form/dist/types/fields';
import { PasswordInput } from './password-input';
import { FormState } from 'react-hook-form/dist/types/form';
import MemberAPI from '../../api/member';
import { User } from '../../models/user';
interface ChangePasswordProp<TFieldValues> {
register: UseFormRegister<TFieldValues>,
onError: (message: string) => void,
currentFormPassword: string,
formState: FormState<TFieldValues>,
user: User,
}
/**
* This component shows a button that trigger a modal dialog to verify the user's current password.
* If the user's current password is correct, the modal dialog is closed and the button is replaced by a form to set the new password.
*/
export const ChangePassword = <TFieldValues extends FieldValues>({ register, onError, currentFormPassword, formState }: ChangePasswordProp<TFieldValues>) => {
export const ChangePassword = <TFieldValues extends FieldValues>({ register, onError, currentFormPassword, formState, user }: ChangePasswordProp<TFieldValues>) => {
const { t } = useTranslation('shared');
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
@ -31,8 +33,8 @@ export const ChangePassword = <TFieldValues extends FieldValues>({ register, onE
const { handleSubmit, register: passwordRegister } = useForm<{ password: string }>();
useEffect(() => {
MemberAPI.current().then(user => {
setIsPrivileged(user.role === 'admin' || user.role === 'manager');
MemberAPI.current().then(operator => {
setIsPrivileged((operator.role === 'admin' || operator.role === 'manager') && user.id !== operator.id);
}).catch(error => onError(error));
}, []);

View File

@ -82,7 +82,9 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
}).catch(error => onError(error));
}
if (showTermsAndConditionsInput) {
CustomAssetAPI.get(CustomAssetName.CguFile).then(setTermsAndConditions).catch(error => onError(error));
CustomAssetAPI.get(CustomAssetName.CguFile).then(cgu => {
if (cgu?.custom_asset_file_attributes) setTermsAndConditions(cgu);
}).catch(error => onError(error));
}
ProfileCustomFieldAPI.index().then(data => {
const fData = data.filter(f => f.actived);
@ -252,6 +254,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
{ action === 'update' && <ChangePassword register={register}
onError={onError}
currentFormPassword={output.password}
user={user}
formState={formState} />}
{action === 'create' && <PasswordInput register={register}
currentFormPassword={output.password}

View File

@ -18,8 +18,8 @@
* Controller used in the calendar management page
*/
Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'AuthService', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', 'settingsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService',
function ($scope, $state, $uibModal, moment, AuthService, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, settingsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) {
Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'AuthService', 'Availability', 'SlotsReservation', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', 'settingsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService',
function ($scope, $state, $uibModal, moment, AuthService, Availability, SlotsReservation, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, settingsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) {
/* PRIVATE STATIC CONSTANTS */
// The calendar is divided in slots of 30 minutes
@ -78,9 +78,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
/**
* Open a confirmation modal to cancel the booking of a user for the currently selected event.
* @param slot {Object} reservation slot of a user, inherited from $resource
* @param slot_reservation {Object} reserved slot, as returned by /api/availabilities/:id/reservations
*/
$scope.cancelBooking = function (slot) {
$scope.cancelBooking = function (slot_reservation) {
// open a confirmation dialog
dialogs.confirm(
{
@ -89,19 +89,19 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
return {
title: _t('app.admin.calendar.confirmation_required'),
msg: _t('app.admin.calendar.do_you_really_want_to_cancel_the_USER_s_reservation_the_DATE_at_TIME_concerning_RESERVATION'
, { GENDER: getGender($scope.currentUser), USER: slot.user.name, DATE: moment(slot.start_at).format('L'), TIME: moment(slot.start_at).format('LT'), RESERVATION: slot.reservable.name })
, { GENDER: getGender($scope.currentUser), USER: slot_reservation.user.name, DATE: moment(slot_reservation.start_at).format('L'), TIME: moment(slot_reservation.start_at).format('LT'), RESERVATION: slot_reservation.reservable.name })
};
}
}
},
function () {
// the admin has confirmed, cancel the subscription
Slot.cancel(
{ id: slot.slot_id },
// the admin has confirmed, cancel the reservation
SlotsReservation.cancel(
{ id: slot_reservation.id },
function (data, status) { // success
// update the canceled_at attribute
for (const resa of Array.from($scope.reservations)) {
if (resa.slot_id === data.id) {
if (resa.id === data.id) {
resa.canceled_at = data.canceled_at;
break;
}

View File

@ -548,7 +548,7 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope',
* @returns {boolean}
*/
$scope.isCancelled = function (reservation) {
return !!(reservation.slots_attributes[0].canceled_at);
return !!(reservation.slots_reservations_attributes[0].canceled_at);
};
}]);

View File

@ -163,6 +163,9 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
// is user validation required
$scope.enableUserValidationRequired = (settingsPromise.user_validation_required === 'true');
// should we display the username in the list?
$scope.displayUsername = (settingsPromise.show_username_in_admin_list === 'true');
// Admins ordering/sorting. Default: not sorted
$scope.orderAdmin = null;

View File

@ -16,8 +16,8 @@
* Controller used in the public calendar global
*/
Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Slot', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'iCalendarPromise',
function ($scope, $state, $aside, moment, Availability, Slot, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, iCalendarPromise) {
Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'iCalendarPromise',
function ($scope, $state, $aside, moment, Availability, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, iCalendarPromise) {
/* PRIVATE STATIC CONSTANTS */
let currentMachineEvent = null;
machinesPromise.forEach(m => m.checked = true);
@ -216,8 +216,9 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$
} else if (event.available_type === 'training') {
$state.go('app.public.training_show', { id: event.training_id });
} else {
if (event.machine_id) {
$state.go('app.public.machines_show', { id: event.machine_id });
if (event.machine_ids) {
// TODO open modal to ask the user to select the machine to show
$state.go('app.public.machines_show', { id: event.machine_ids[0] });
} else if (event.space_id) {
$state.go('app.public.space_show', { id: event.space_id });
}

View File

@ -129,8 +129,8 @@ Application.Controllers.controller('EventsController', ['$scope', '$state', 'Eve
}
]);
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'Slot', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, Slot, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment) {
Application.Controllers.controller('ShowEventController', ['$scope', '$state', '$rootScope', 'Event', '$uibModal', 'Member', 'Reservation', 'Price', 'CustomAsset', 'SlotsReservation', 'eventPromise', 'growl', '_t', 'Wallet', 'AuthService', 'helpers', 'dialogs', 'priceCategoriesPromise', 'settingsPromise', 'LocalPayment',
function ($scope, $state,$rootScope, Event, $uibModal, Member, Reservation, Price, CustomAsset, SlotsReservation, eventPromise, growl, _t, Wallet, AuthService, helpers, dialogs, priceCategoriesPromise, settingsPromise, LocalPayment) {
/* PUBLIC SCOPE */
// reservations for the currently shown event
@ -353,36 +353,8 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* Callback to validate the booking of a free event
*/
$scope.validReserveEvent = function () {
const cartItems = {
customer_id: $scope.ctrl.member.id,
items: [
{
reservation: {
reservable_id: $scope.event.id,
reservable_type: 'Event',
slots_attributes: [],
nb_reserve_places: $scope.reserve.nbReservePlaces,
tickets_attributes: []
}
}
]
}
// a single slot is used for events
cartItems.items[0].reservation.slots_attributes.push({
start_at: $scope.event.start_date,
end_at: $scope.event.end_date,
availability_id: $scope.event.availability.id
});
// iterate over reservations per prices
for (let price_id in $scope.reserve.tickets) {
if (Object.prototype.hasOwnProperty.call($scope.reserve.tickets, price_id)) {
const seats = $scope.reserve.tickets[price_id];
cartItems.items[0].reservation.tickets_attributes.push({
event_price_category_id: price_id,
booked: seats
});
}
}
const reservation = mkReservation($scope.reserve, $scope.event)
const cartItems = mkCartItems(reservation, $scope.coupon.applied);
// set the attempting marker
$scope.attempting = true;
// save the reservation to the API
@ -393,19 +365,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
, function (response) {
// reservation failed
$scope.alerts = [];
$scope.alerts.push({
msg: response.data.card[0],
type: 'danger'
});
growl.error(response && response.data && response.data.card && response.data.card[0] || 'server error');
// unset the attempting marker
return $scope.attempting = false;
});
$scope.attempting = false;
})
};
/**
* Callback to cancel a reservation
* @param reservation {{id:number, reservable_id:number, nb_reserve_places:number, slots_attributes:[{id: number, canceled_at: string}], total_booked_seats: number}}
* @param reservation {Reservation}
*/
$scope.cancelReservation = function(reservation) {
dialogs.confirm({
@ -418,14 +386,14 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}
}, function() { // cancel confirmed
Slot.cancel({
id: reservation.slots_attributes[0].id
SlotsReservation.cancel({
id: reservation.slots_reservations_attributes[0].id
}, function() { // successfully canceled
let index;
growl.success(_t('app.public.events_show.reservation_was_successfully_cancelled'));
index = $scope.reservations.indexOf(reservation);
$scope.event.nb_free_places = $scope.event.nb_free_places + reservation.total_booked_seats;
$scope.reservations[index].slots_attributes[0].canceled_at = new Date();
$scope.reservations[index].slots_reservations_attributes[0].canceled_at = new Date();
}, function(error) {
growl.warning(_t('app.public.events_show.cancellation_failed'));
});
@ -434,17 +402,17 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Test if the provided reservation has been cancelled
* @param reservation {{slots_attributes: [{canceled_at: string}]}}
* @param reservation {Reservation}
* @returns {boolean}
*/
$scope.isCancelled = function(reservation) {
return !!(reservation.slots_attributes[0].canceled_at);
return !!(reservation.slots_reservations_attributes[0].canceled_at);
}
/**
* Callback to alter an already booked reservation date. A modal window will be opened to allow the user to choose
* a new date for his reservation (if any available)
* @param reservation {{id:number, reservable_id:number, nb_reserve_places:number}}
* @param reservation {Reservation}
*/
$scope.modifyReservation = function (reservation) {
const index = $scope.reservations.indexOf(reservation);
@ -475,9 +443,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
return eventToPlace = e;
}
});
$scope.reservation.slots_attributes[0].start_at = eventToPlace.start_date;
$scope.reservation.slots_attributes[0].end_at = eventToPlace.end_date;
$scope.reservation.slots_attributes[0].availability_id = eventToPlace.availability_id;
$scope.reservation.slots_reservations_attributes[0].slot_id = eventToPlace.slot_id;
$scope.attempting = true;
Reservation.update({ id: reservation.id }, { reservation: $scope.reservation }, function (reservation) {
$uibModalInstance.close(reservation);
@ -514,10 +480,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Checks if the provided reservation is able to be moved (date change)
* @param reservation {{slots_attributes:[], total_booked_seats:number}}
* @param reservation {Reservation}
*/
$scope.reservationCanModify = function (reservation) {
const slotStart = moment(reservation.slots_attributes[0].start_at);
const slotStart = moment(reservation.slots_reservations_attributes[0].slot_attributes.start_at);
const now = moment();
let isAble = false;
@ -529,10 +495,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Checks if the provided reservation is able to be cancelled
* @param reservation {{slots_attributes:[]}}
* @param reservation {Reservation}
*/
$scope.reservationCanCancel = function(reservation) {
const slotStart = moment(reservation.slots_attributes[0].start_at);
const slotStart = moment(reservation.slots_reservations_attributes[0].slot_attributes.start_at);
const now = moment();
return $scope.enableBookingCancel && slotStart.diff(now, "hours") >= $scope.cancelBookingDelay;
};
@ -653,22 +619,20 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* Create a hash map implementing the Reservation specs
* @param reserve {Object} Reservation parameters (places...)
* @param event {Object} Current event
* @return {{reservation: {reservable_id:number, reservable_type:string, slots_attributes:Array<Object>, nb_reserve_places:number}}}
* @return {{reservation: Reservation}}
*/
const mkReservation = function (reserve, event) {
const reservation = {
reservable_id: event.id,
reservable_type: 'Event',
slots_attributes: [],
slots_reservations_attributes: [],
nb_reserve_places: reserve.nbReservePlaces,
tickets_attributes: []
};
reservation.slots_attributes.push({
start_at: event.start_date,
end_at: event.end_date,
availability_id: event.availability.id,
offered: event.offered || false
reservation.slots_reservations_attributes.push({
offered: event.offered || false,
slot_id: event.availability.slot_id
});
for (let evt_px_cat of Array.from(event.prices)) {
@ -764,9 +728,12 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
cartItems () {
return mkCartItems(reservation, $scope.coupon.applied);
},
event () {
return $scope.event;
}
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, LocalPayment, wallet, helpers, $filter, coupon, cartItems) {
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'event',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, LocalPayment, wallet, helpers, $filter, coupon, cartItems, event) {
// User's wallet amount
$scope.wallet = wallet;
@ -782,6 +749,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// Reservation
$scope.reservation = reservation;
// the event
$scope.bookedEvent = event;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');

View File

@ -451,6 +451,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
*/
$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);
};
@ -461,25 +462,21 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
$scope.markSlotAsRemoved = function (slot) {
slot.backgroundColor = 'white';
slot.borderColor = FREE_SLOT_BORDER_COLOR;
slot.title = '';
slot.isValid = false;
slot.slot_id = null;
slot.is_reserved = false;
slot.can_modify = false;
slot.offered = false;
slot.title = slot.oldTitle;
updateEvents(slot);
};
/**
* Callback when a slot was successfully cancelled. Reset the slot style as 'ready to book'
*/
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
$scope.slotCancelled = function () { refreshCalendar() };
/**
* Change the last selected slot's appearance to looks like 'currently looking for a new destination to exchange'
*/
$scope.markSlotAsModifying = function () {
$scope.selectedEvent.backgroundColor = '#eee';
$scope.selectedEvent.oldTitle = $scope.selectedEvent.title;
$scope.selectedEvent.title = _t('app.logged.machines_reserve.i_change');
updateEvents($scope.selectedEvent);
};
@ -505,31 +502,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
* When modifying an already booked reservation, callback when the modification was successfully done.
*/
$scope.modifyMachineSlot = function () {
const save = {
slotId: $scope.events.modifiable.slot_id,
borderColor: $scope.events.modifiable.borderColor,
user: angular.copy($scope.events.modifiable.user),
title: (!$scope.events.modifiable.user || $scope.currentUser.id === $scope.events.modifiable.user.id) ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available')
};
$scope.events.modifiable.backgroundColor = 'white';
$scope.events.modifiable.title = '';
$scope.events.modifiable.borderColor = FREE_SLOT_BORDER_COLOR;
$scope.events.modifiable.slot_id = null;
$scope.events.modifiable.is_reserved = false;
$scope.events.modifiable.can_modify = false;
updateEvents($scope.events.modifiable);
$scope.events.placable.title = save.title;
$scope.events.placable.backgroundColor = 'white';
$scope.events.placable.borderColor = save.borderColor;
$scope.events.placable.slot_id = save.slotId;
$scope.events.placable.is_reserved = true;
$scope.events.placable.can_modify = true;
$scope.events.placable.user = angular.copy(save.user);
updateEvents($scope.events.placable);
refetchCalendar();
refreshCalendar();
};
/**
@ -540,7 +513,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
$scope.events.placable.backgroundColor = 'white';
$scope.events.placable.title = '';
}
$scope.events.modifiable.title = $scope.currentUser.id === $scope.events.modifiable.user.id ? _t('app.logged.machines_reserve.i_ve_reserved') : _t('app.logged.machines_reserve.not_available');
$scope.events.modifiable.title = $scope.events.modifiable.oldTitle;
$scope.events.modifiable.backgroundColor = 'white';
updateEvents($scope.events.placable, $scope.events.modifiable);
@ -553,7 +526,10 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
$scope.updateMember = function () {
$scope.plansAreShown = false;
$scope.selectedPlan = null;
Member.get({ id: $scope.ctrl.member.id }, function (member) { $scope.ctrl.member = member; });
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
refreshCalendar();
});
};
/**
@ -608,23 +584,6 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
*/
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.reserved, function (machineSlot, key) {
machineSlot.is_reserved = true;
machineSlot.can_modify = true;
if ($scope.currentUser.role === 'admin' || ($scope.currentUser.role === 'manager' && reservation.user_id !== $scope.currentUser.id)) {
// an admin or a manager booked for someone else
machineSlot.title = _t('app.logged.machines_reserve.not_available');
machineSlot.borderColor = UNAVAILABLE_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.ctrl.member);
} else {
// booked for "myself"
machineSlot.title = _t('app.logged.machines_reserve.i_ve_reserved');
machineSlot.borderColor = BOOKED_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.currentUser);
}
machineSlot.backgroundColor = 'white';
});
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
@ -633,8 +592,14 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
$scope.plansAreShown = false;
$scope.selectedPlan = null;
}
$scope.ctrl.member.training_credits = angular.copy(reservation.user.training_credits);
$scope.ctrl.member.machine_credits = angular.copy(reservation.user.machine_credits);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
}
refetchCalendar();
refreshCalendar();
// trigger the refresh of react components
setTimeout(() => {
@ -677,15 +642,12 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
*/
const initialize = function () {
$scope.eventSources.push({
events: function (start, end, timezone, callback) {
Availability.machine({ machineId: $transition$.params().id }, function (availabilities) {
callback(availabilities);
});
},
url: `/api/availabilities/machines/${$transition$.params().id}`,
textColor: 'black'
});
if ($scope.currentUser.role !== 'admin') {
$scope.ctrl.member = $scope.currentUser;
return Member.get({ id: $scope.currentUser.id }, function (member) { $scope.ctrl.member = member; });
}
};
@ -701,9 +663,30 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
return;
}
$scope.selectedEvent = event;
return $scope.selectionTime = new Date();
$scope.selectionTime = new Date();
};
/**
* Refetch all events from the API and re-populate the calendar with the resulting slots
*/
const refreshCalendar = function () {
const view = uiCalendarConfig.calendars.calendar.fullCalendar('getView');
return Availability.machine({
machineId: $scope.machine.id,
member_id: $scope.ctrl.member.id,
start: view.start,
end: view.end,
timezone: Fablab.timezone
}, function (slots) {
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
return $scope.eventSources.splice(0, 1, {
events: slots,
textColor: 'black'
}
);
});
}
/**
* Triggered when fullCalendar tries to graphically render an event block.
* Append the event tag into the block, just after the event title.
@ -719,24 +702,6 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
}
};
/**
* After payment, update the id of the newly reserved slot with the id returned by the server.
* This will allow the user to modify the reservation he just booked. The associated user will also be registered
* with the slot.
* @param slot {Object}
* @param reservation {Object}
* @param user {Object} user associated with the slot
*/
const updateMachineSlot = function (slot, reservation, user) {
angular.forEach(reservation.slots, function (s) {
if (slot.start.isSame(s.start_at)) {
slot.slot_id = s.id;
slot.user = user;
}
});
updateEvents(slot);
};
/**
* Update the calendar's display to render the new attributes of the events
* @param events Object[] events to update in full-calendar

View File

@ -391,6 +391,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
*/
$scope.markSlotAsAdded = function () {
$scope.selectedEvent.backgroundColor = SELECTED_EVENT_BG_COLOR;
$scope.selectedEvent.oldTitle = $scope.selectedEvent.title;
updateEvents($scope.selectedEvent);
};
@ -399,27 +400,22 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
*/
$scope.markSlotAsRemoved = function (slot) {
slot.backgroundColor = 'white';
slot.title = '';
slot.title = slot.oldTitle;
slot.borderColor = FREE_SLOT_BORDER_COLOR;
slot.slot_id = null;
slot.isValid = false;
slot.is_reserved = false;
slot.can_modify = false;
slot.offered = false;
if (slot.is_completed) { slot.is_completed = false; }
updateEvents(slot);
};
/**
* Callback when a slot was successfully cancelled. Reset the slot style as 'ready to book'
*/
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
$scope.slotCancelled = function () { refreshCalendar(); };
/**
* Change the last selected slot's appearance to looks like 'currently looking for a new destination to exchange'
*/
$scope.markSlotAsModifying = function () {
$scope.selectedEvent.backgroundColor = '#eee';
$scope.selectedEvent.oldTitle = $scope.selectedEvent.title;
$scope.selectedEvent.title = _t('app.logged.space_reserve.i_change');
updateEvents($scope.selectedEvent);
};
@ -445,30 +441,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
* When modifying an already booked reservation, callback when the modification was successfully done.
*/
$scope.modifySpaceSlot = function () {
const save = {
slotId: $scope.events.modifiable.slot_id,
borderColor: $scope.events.modifiable.borderColor,
title: _t('app.logged.space_reserve.i_ve_reserved')
};
$scope.events.modifiable.backgroundColor = 'white';
$scope.events.modifiable.title = '';
$scope.events.modifiable.borderColor = FREE_SLOT_BORDER_COLOR;
$scope.events.modifiable.slot_id = null;
$scope.events.modifiable.is_reserved = false;
$scope.events.modifiable.can_modify = false;
if ($scope.events.modifiable.is_completed) { $scope.events.modifiable.is_completed = false; }
updateEvents($scope.events.modifiable);
$scope.events.placable.title = save.title;
$scope.events.placable.backgroundColor = 'white';
$scope.events.placable.borderColor = save.borderColor;
$scope.events.placable.slot_id = save.slotId;
$scope.events.placable.is_reserved = true;
$scope.events.placable.can_modify = true;
updateEvents($scope.events.placable);
refetchCalendar();
refreshCalendar();
};
/**
@ -493,14 +466,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
if ($scope.ctrl.member) {
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
return Availability.spaces({ spaceId: $scope.space.id, member_id: $scope.ctrl.member.id }, function (spaces) {
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
return $scope.eventSources.splice(0, 1, {
events: spaces,
textColor: 'black'
}
);
});
refreshCalendar();
});
}
// as the events are re-fetched for the new user, we must re-init the cart
@ -561,16 +527,6 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
*/
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.paid, function (spaceSlot, key) {
spaceSlot.is_reserved = true;
spaceSlot.can_modify = true;
spaceSlot.title = _t('app.logged.space_reserve.i_ve_reserved');
spaceSlot.backgroundColor = 'white';
spaceSlot.borderColor = RESERVED_SLOT_BORDER_COLOR;
updateSpaceSlotId(spaceSlot, reservation);
updateEvents(spaceSlot);
});
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
@ -586,7 +542,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
}
refetchCalendar();
refreshCalendar();
});
};
@ -602,16 +558,13 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
*/
const initialize = function () {
if ($scope.currentUser.role !== 'admin') {
$scope.ctrl.member = $scope.currentUser;
Member.get({ id: $scope.currentUser.id }, function (member) { $scope.ctrl.member = member; });
}
// we load the availabilities from a callback function of the $scope.eventSources, instead of resolving a promise
// in the router because this allows to refetchEvents from fullCalendar API.
$scope.eventSources.push({
events: function (start, end, timezone, callback) {
Availability.spaces({ spaceId: $transition$.params().id }, function (availabilities) {
callback(availabilities);
});
},
url: `/api/availabilities/spaces/${$transition$.params().id}`,
textColor: 'black'
});
};
@ -631,6 +584,27 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
$scope.selectionTime = new Date();
};
/**
* Refetch all events from the API and re-populate the calendar with the resulting slots
*/
const refreshCalendar = function () {
const view = uiCalendarConfig.calendars.calendar.fullCalendar('getView');
return Availability.spaces({
spaceId: $scope.space.id,
member_id: $scope.ctrl.member.id,
start: view.start,
end: view.end,
timezone: Fablab.timezone
}, function (spaces) {
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
return $scope.eventSources.splice(0, 1, {
events: spaces,
textColor: 'black'
}
);
});
};
/**
* Triggered when fullCalendar tries to graphically render an event block.
* Append the event tag into the block, just after the event title.
@ -646,20 +620,6 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$transi
}
};
/**
* After payment, update the id of the newly reserved slot with the id returned by the server.
* This will allow the user to modify the reservation he just booked.
* @param slot {Object}
* @param reservation {Object}
*/
const updateSpaceSlotId = function (slot, reservation) {
angular.forEach(reservation.slots, function (s) {
if (slot.start.isSame(s.start_at)) {
slot.slot_id = s.id;
}
});
};
/**
* Update the calendar's display to render the new attributes of the events
* @param events Object[] events to update in full-calendar

View File

@ -188,19 +188,13 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
slot.backgroundColor = 'white';
slot.title = slot.training.name;
slot.borderColor = FREE_SLOT_BORDER_COLOR;
slot.id = null;
slot.isValid = false;
slot.is_reserved = false;
slot.can_modify = false;
slot.offered = false;
if (slot.is_completed) { slot.is_completed = false; }
updateEvents(slot);
};
/**
* Callback when a slot was successfully cancelled. Reset the slot style as 'ready to book'
*/
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
$scope.slotCancelled = function () { refreshCalendar() };
/**
* Change the last selected slot's appearence to looks like 'currently looking for a new destination to exchange'
@ -234,30 +228,7 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
* When modifying an already booked reservation, callback when the modification was successfully done.
*/
$scope.modifyTrainingSlot = function () {
const save = {
slotId: $scope.events.modifiable.slot_id,
borderColor: $scope.events.modifiable.borderColor,
title: !AuthService.isAuthorized(['admin', 'manager']) ? $scope.events.placable.training.name + ' - ' + _t('app.logged.trainings_reserve.i_ve_reserved') : $scope.events.placable.training.name,
};
$scope.events.modifiable.backgroundColor = 'white';
$scope.events.modifiable.title = $scope.events.modifiable.training.name;
$scope.events.modifiable.borderColor = FREE_SLOT_BORDER_COLOR;
$scope.events.modifiable.slot_id = null;
$scope.events.modifiable.is_reserved = false;
$scope.events.modifiable.can_modify = false;
if ($scope.events.modifiable.is_completed) { $scope.events.modifiable.is_completed = false; }
updateEvents($scope.events.modifiable);
$scope.events.placable.title = save.title;
$scope.events.placable.backgroundColor = 'white';
$scope.events.placable.borderColor = save.borderColor;
$scope.events.placable.slot_id = save.slotId;
$scope.events.placable.is_reserved = true;
$scope.events.placable.can_modify = true;
updateEvents($scope.events.placable);
refetchCalendar();
refreshCalendar();
};
/**
@ -282,21 +253,13 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
if ($scope.ctrl.member) {
Member.get({ id: $scope.ctrl.member.id }, function (member) {
$scope.ctrl.member = member;
const id = $transition$.params().id === 'all' ? $transition$.params().id : $scope.training.id;
return Availability.trainings({ trainingId: id, member_id: $scope.ctrl.member.id }, function (trainings) {
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
return $scope.eventSources.splice(0, 1, {
events: trainings,
textColor: 'black'
}
);
});
refreshCalendar();
});
}
// as the events are re-fetched for the new user, we must re-init the cart
$scope.events.reserved = [];
$scope.selectedPlan = null;
return $scope.plansAreShown = false;
$scope.plansAreShown = false;
};
/**
@ -351,16 +314,6 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
*/
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.paid, function (trainingSlot, key) {
trainingSlot.backgroundColor = 'white';
trainingSlot.is_reserved = true;
trainingSlot.can_modify = true;
updateTrainingSlotId(trainingSlot, reservation);
trainingSlot.borderColor = '#b2e774';
trainingSlot.title = trainingSlot.training.name + ' - ' + _t('app.logged.trainings_reserve.i_ve_reserved');
updateEvents(trainingSlot);
});
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
@ -376,7 +329,7 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
}
refetchCalendar();
refreshCalendar();
});
};
@ -392,16 +345,13 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
*/
const initialize = function () {
if ($scope.currentUser.role !== 'admin') {
$scope.ctrl.member = $scope.currentUser;
Member.get({ id: $scope.currentUser.id }, function (member) { $scope.ctrl.member = member; });
}
// we load the availabilities from a callback function of the $scope.eventSources, instead of resolving a promise
// in the router because this allows to refetchEvents from fullCalendar API.
$scope.eventSources.push({
events: function (start, end, timezone, callback) {
Availability.trainings({ trainingId: $transition$.params().id }, function (availabilities) {
callback(availabilities);
});
},
url: `/api/availabilities/trainings/${$transition$.params().id}`,
textColor: 'black'
});
};
@ -424,6 +374,27 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$tra
return $scope.selectionTime = new Date();
};
/**
* Refetch all events from the API and re-populate the calendar with the resulting slots
*/
const refreshCalendar = function () {
const view = uiCalendarConfig.calendars.calendar.fullCalendar('getView');
const id = $transition$.params().id === 'all' ? $transition$.params().id : $scope.training.id;
Availability.trainings({
trainingId: id,
member_id: $scope.ctrl.member.id,
start: view.start,
end: view.end,
timezone: Fablab.timezone
}, function (trainings) {
uiCalendarConfig.calendars.calendar.fullCalendar('removeEvents');
$scope.eventSources.splice(0, 1, {
events: trainings,
textColor: 'black'
});
});
}
/**
* Triggered when fullCalendar tries to graphicaly render an event block.
* Append the event tag into the block, just after the event title.

View File

@ -10,8 +10,8 @@
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'Payment', 'helpers', '_t',
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, Payment, helpers, _t) {
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'SlotsReservation', 'AuthService', 'Payment', 'helpers', '_t',
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, SlotsReservation, AuthService, Payment, helpers, _t) {
return ({
restrict: 'E',
scope: {
@ -232,19 +232,17 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
* When modifying an already booked reservation, confirm the modification.
*/
$scope.modifySlot = function () {
Slot.update({ id: $scope.events.modifiable.slot_id }, {
slot: {
start_at: $scope.events.placable.start,
end_at: $scope.events.placable.end,
availability_id: $scope.events.placable.availability_id
SlotsReservation.update({ id: $scope.events.modifiable.slots_reservations_ids[0] }, {
slots_reservation: {
slot_id: $scope.events.placable.slot_id
}
}
, function () { // success
, function (slotReservation) { // success
// -> run the callback
if (typeof $scope.onSlotModifySuccess === 'function') { $scope.onSlotModifySuccess(); }
// -> set the events as successfully moved (to display a summary)
$scope.events.moved = {
newSlot: $scope.events.placable,
newSlot: Object.assign($scope.events.placable, { slots_reservations_ids: [slotReservation.id] }),
oldSlot: $scope.events.modifiable
};
// -> reset the 'moving' status
@ -462,11 +460,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
*/
const validateSameTimeReservations = function (slot, callback) {
let sameTimeReservations = $scope.settings.overlapping_categories.split(',').map(function (k) {
return _.filter($scope.user[k], function (r) {
return slot.start.isSame(r.start_at) ||
(slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) ||
(slot.start.isAfter(r.start_at) && slot.start.isBefore(r.end_at)) ||
(slot.start.isBefore(r.start_at) && slot.end.isAfter(r.end_at));
return _.filter($scope.user[k], function (sr) {
return !sr.canceled_at && (
slot.start.isSame(sr.start_at) ||
(slot.end.isAfter(sr.start_at) && slot.end.isBefore(sr.end_at)) ||
(slot.start.isAfter(sr.start_at) && slot.start.isBefore(sr.end_at)) ||
(slot.start.isBefore(sr.start_at) && slot.end.isAfter(sr.end_at))
);
});
});
sameTimeReservations = _.union.apply(null, sameTimeReservations);
@ -519,10 +519,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
$scope.slot.group_ids = $scope.slot.plansGrouped.map(function (g) { return g.id; });
}
if (!$scope.slot.is_reserved && !$scope.events.modifiable && !$scope.slot.is_completed) {
// slot is not reserved and we are not currently modifying a slot
if (!$scope.slot.is_completed && $scope.slot.slots_reservations_ids.length === 0 && !$scope.events.modifiable) {
// slot is not fully reserved, and not reserved by the current user, and we are not currently modifying a slot
// -> can be added to cart or removed if already present
const index = _.findIndex($scope.events.reserved, (e) => e._id === $scope.slot._id);
const index = _.findIndex($scope.events.reserved, (e) => e.slot_id === $scope.slot.slot_id);
if (index === -1) {
if (($scope.limitToOneSlot === 'true') && $scope.events.reserved[0]) {
// if we limit the number of slots in the cart to 1, and there is already
@ -540,25 +540,30 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
resetCartState();
// finally, we update the prices
return updateCartPrice();
} else if (!$scope.slot.is_reserved && !$scope.slot.is_completed && $scope.events.modifiable) {
// slot is not reserved but we are currently modifying a slot
// -> we request the calender to change the rendering
} else if (!$scope.slot.is_completed && $scope.slot.slots_reservations_ids.length === 0 && $scope.events.modifiable) {
// slot is not fully reserved, not reserved by the current user, and we are currently modifying a slot
// -> we request the calendar to change the rendering
if (typeof $scope.onSlotModifyUnselect === 'function') {
// if the callback return false, cancel the selection for the current modification
const res = $scope.onSlotModifyUnselect();
if (!res) return;
}
// -> then, we re-affect the destination slot
if (!$scope.events.placable || ($scope.events.placable._id !== $scope.slot._id)) {
if (!$scope.events.placable || ($scope.events.placable.slot_id !== $scope.slot.slot_id)) {
return $scope.events.placable = $scope.slot;
} else {
return $scope.events.placable = null;
}
} else if ($scope.slot.is_reserved && $scope.events.modifiable && ($scope.slot.is_reserved._id === $scope.events.modifiable._id)) {
} else if ($scope.slot.slots_reservations_ids.length > 0 &&
$scope.events.modifiable &&
($scope.slot._id === $scope.events.modifiable._id)) {
// slot is reserved and currently modified
// -> we cancel the modification
$scope.cancelModifySlot();
} else if ($scope.slot.is_reserved && (slotCanBeModified($scope.slot) || slotCanBeCanceled($scope.slot)) && !$scope.events.modifiable && ($scope.events.reserved.length === 0)) {
} else if ($scope.slot.slots_reservations_ids.length > 0 &&
(slotCanBeModified($scope.slot) || slotCanBeCanceled($scope.slot)) &&
!$scope.events.modifiable &&
$scope.events.reserved.length === 0) {
// slot is reserved and is ok to be modified or cancelled
// but we are not currently running a modification or having any slots in the cart
// -> first affect the modification/cancellation rights attributes to the current slot
@ -595,7 +600,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
}
},
function () { // cancel confirmed
Slot.cancel({ id: $scope.slot.slot_id }, function () { // successfully canceled
SlotsReservation.cancel({ id: $scope.slot.slots_reservations_ids[0] }, function () { // successfully canceled
growl.success(_t('app.shared.cart.reservation_was_cancelled_successfully'));
if (typeof $scope.onSlotCancelSuccess === 'function') { return $scope.onSlotCancelSuccess(); }
}
@ -707,20 +712,18 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
/**
* Create a hash map implementing the Reservation specs
* @param slots {Array<Object>} Array of fullCalendar events: slots selected on the calendar
* @return {{reservation: {reservable_type: string, reservable_id: string, slots_attributes: []}}}
* @return {{reservation: Reservation}}
*/
const mkReservation = function (slots) {
const reservation = {
reservable_id: $scope.reservableId,
reservable_type: $scope.reservableType,
slots_attributes: []
slots_reservations_attributes: []
};
angular.forEach(slots, function (slot) {
reservation.slots_attributes.push({
start_at: slot.start,
end_at: slot.end,
availability_id: slot.availability_id,
offered: slot.offered || false
reservation.slots_reservations_attributes.push({
offered: slot.offered || false,
slot_id: slot.slot_id
});
});
@ -730,7 +733,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
/**
* Create a hash map implementing the Subscription specs
* @param planId {number}
* @return {{subscription: {plan_id: number}}}
* @return {{subscription: SubscriptionRequest}}
*/
const mkSubscription = function (planId) {
return {
@ -742,7 +745,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
/**
* Build the ShoppingCart object, from the current reservation
* @param items {Array<{reservation:{reservable_type: string, reservable_id: string, slots_attributes: []}}|{subscription: {plan_id: number}}>}
* @param items {Array<CartItem>}
* @param paymentMethod {string}
* @return {ShoppingCart}
*/

View File

@ -191,6 +191,14 @@ Application.Filters.filter('humanReadablePlanName', ['$filter', function ($filte
};
}]);
Application.Filters.filter('canceledReservationsFilter', [function () {
return function (elements) {
if (!angular.isUndefined(elements) && (elements != null)) {
return elements.filter(e => e.canceled_at === null);
}
};
}]);
Application.Filters.filter('trainingReservationsFilter', [function () {
return function (elements, selectedScope) {
if (!angular.isUndefined(elements) && !angular.isUndefined(selectedScope) && (elements != null) && (selectedScope != null)) {

View File

@ -25,10 +25,12 @@ export interface AuthenticationProviderMapping {
format: 'iso8601' | 'rfc2822' | 'rfc3339' | 'timestamp-s' | 'timestamp-ms',
true_value: string,
false_value: string,
mapping: {
from: string,
to: number|string
}
mapping: [
{
from: string,
to: number|string
}
]
}
}

View File

@ -3,15 +3,19 @@ import { ApiFilter } from './api';
export type ReservableType = 'Training' | 'Event' | 'Space' | 'Machine';
export interface ReservationSlot {
export interface SlotsReservation {
id?: number,
start_at: TDateISO,
end_at: TDateISO,
canceled_at?: TDateISO,
availability_id?: number,
offered?: boolean,
is_reserved?: boolean
slot_id?: number,
slot_attributes?: {
id: number,
start_at: TDateISO,
end_at: TDateISO,
availability_id: number
}
}
// TODO, refactor Reservation for cart_items (in payment) => should use slot_id instead of (start_at + end_at)
export interface Reservation {
id?: number,
@ -20,7 +24,7 @@ export interface Reservation {
message?: string,
reservable_id: number,
reservable_type: ReservableType,
slots_attributes: Array<ReservationSlot>,
slots_reservations_attributes: Array<SlotsReservation>,
reservable?: {
id: number,
name: string

View File

@ -136,7 +136,8 @@ export enum SettingName {
MachinesModule = 'machines_module',
UserChangeGroup = 'user_change_group',
UserValidationRequired = 'user_validation_required',
UserValidationRequiredList = 'user_validation_required_list'
UserValidationRequiredList = 'user_validation_required_list',
ShowUsernameInAdminList = 'show_username_in_admin_list'
}
export type SettingValue = string|boolean|number;

View File

@ -936,7 +936,7 @@ angular.module('application.router', ['ui.router'])
groupsPromise: ['Group', function (Group) { return Group.query().$promise; }],
tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }],
authProvidersPromise: ['AuthProvider', function (AuthProvider) { return AuthProvider.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display', 'user_validation_required']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display', 'user_validation_required', 'show_username_in_admin_list']" }).$promise; }]
}
})
.state('app.admin.members_new', {
@ -1093,7 +1093,7 @@ angular.module('application.router', ['ui.router'])
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," +
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'public_registrations'," +
"'extended_prices_in_same_day', 'recaptcha_site_key', 'recaptcha_secret_key', 'user_validation_required', " +
"'user_validation_required_list', 'machines_module', 'user_change_group']"
"'user_validation_required_list', 'machines_module', 'user_change_group', 'show_username_in_admin_list']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

View File

@ -1,15 +0,0 @@
'use strict';
Application.Services.factory('Slot', ['$resource', function ($resource) {
return $resource('/api/slots/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
},
cancel: {
method: 'PUT',
url: '/api/slots/:id/cancel'
}
}
);
}]);

View File

@ -0,0 +1,15 @@
'use strict';
Application.Services.factory('SlotsReservation', ['$resource', function ($resource) {
return $resource('/api/slots_reservations/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
},
cancel: {
method: 'PUT',
url: '/api/slots_reservations/:id/cancel'
}
}
);
}]);

View File

@ -408,9 +408,10 @@
.about-title, .about-title p {
margin: 0;
font-size: rem-calc(50);
line-height: rem-calc(48);
line-height: rem-calc(56);
color: #fff;
font-weight: 900;
text-align: left;
}
.about-title-aside {
@ -418,14 +419,6 @@
font-size: rem-calc(18);
}
.about-title,
.about-title p
{
font-size: rem-calc(18);
line-height: rem-calc(30);
text-align: left;
}
&.ng-hide {
opacity: 0;

View File

@ -103,12 +103,20 @@
}
.heading-title {
//overflow: hidden;
height: 94px;
min-height: 94px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding: 15px;
& > *:not(:last-child) {
margin-right: 3rem;
}
h1 {
margin: 0 0 0 15px;
padding: 36px 15px;
margin: 0;
padding: 15px 0;
}
}
@ -581,7 +589,7 @@ body.container {
border-radius: 3px;
overflow: hidden;
}
& > img {
& > svg {
border: 1px solid var(--gray-soft-dark);
background-color: var(--gray-soft-lightest);
&:hover { opacity: 0.65; }
@ -589,7 +597,7 @@ body.container {
& > a {
transition: transform 200ms ease-in-out;
&:hover { transform: translateY(-4px); }
img {
svg {
max-width: 100%;
height: inherit;
}
@ -665,13 +673,18 @@ body.container {
}
}
}
.bio-title {
display: inherit;
text-align: center;
height: 50px;
.profile-bio {
.bio-title {
display: inherit;
height: 50px;
}
a {
color: var(--gray-soft-lightest) !important;
text-decoration: underline;
}
}
.calendar-filter {
h3 {
line-height: 2.1rem !important;

View File

@ -34,7 +34,6 @@
.heading-title {
h1 {
font-size: rem-calc(16);
padding: 26px 15px;
}
}
}

View File

@ -12,6 +12,7 @@
p {
@include text-sm;
margin: 0;
cursor: pointer;
&::first-letter { text-transform: uppercase; }
}

View File

@ -1,28 +1,22 @@
.form-switch {
position: relative;
.form-item-header {
position: absolute;
top: 16px;
left: 16px;
padding-top: 2px;
width: fit-content;
margin-bottom: 0;
.item-tooltip {
margin-left: 12px;
position: unset;
& > *:not(:first-child) {
margin-left: 1.5rem;
}
.content {
right: unset;
left: 0;
}
.item-tooltip .content {
max-width: min(75vw, 30ch);
}
}
.form-item-field {
display: flex;
flex-direction: row-reverse;
background-color: white;
padding: 16px;
background-color: white;
& > *:not(:first-child) {
margin-left: 2rem;
}
}
}

View File

@ -23,4 +23,8 @@
&-inner > .item {
height: 100%;
}
}
.ui-select-bootstrap .ui-select-choices-row > span {
white-space: normal;
}

View File

@ -11,7 +11,7 @@
<section class="heading-title">
<h1 class="inline">{{ 'app.shared.user_admin.user' | translate }} {{ user.name }}</h1>
<span class="label label-danger text-white" ng-show="user.need_completion" translate>{{ 'app.shared.user_admin.incomplete_profile' }}</span>
<div class="pull-right" style="top: 35%;position: relative;right: 10px;" ng-if="enableUserValidationRequired">
<div ng-if="enableUserValidationRequired">
<user-validation member="user"
on-error="onError"
on-success="onValidateMemberSuccess" />

View File

@ -38,13 +38,14 @@
<thead>
<tr>
<th style="width:4%" class="hidden-xs" ng-if="enableUserValidationRequired"></th>
<th style="width:15%"><a ng-click="setOrderMember('last_name')">{{ 'app.admin.members.surname' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='last_name', 'fa fa-sort-alpha-desc': member.order=='-last_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:15%"><a ng-click="setOrderMember('first_name')">{{ 'app.admin.members.first_name' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='first_name', 'fa fa-sort-alpha-desc': member.order=='-first_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:15%" class="hidden-xs"><a ng-click="setOrderMember('email')">{{ 'app.admin.members.email' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='email', 'fa fa-sort-alpha-desc': member.order=='-email', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:9%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('phone')">{{ 'app.admin.members.phone' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-numeric-asc': member.order=='phone', 'fa fa-sort-numeric-desc': member.order=='-phone', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="hidden-xs hidden-sm"><a ng-click="setOrderMember('group')">{{ 'app.admin.members.user_type' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='group', 'fa fa-sort-alpha-desc': member.order=='-group', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('plan')">{{ 'app.admin.members.subscription' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='plan', 'fa fa-sort-alpha-desc': member.order=='-plan', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="buttons-col"></th>
<th style="width:8%" ng-show="displayUsername"><a ng-click="setOrderMember('username')">{{ 'app.admin.members.username' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='username', 'fa fa-sort-alpha-desc': member.order=='-username', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('last_name')">{{ 'app.admin.members.surname' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='last_name', 'fa fa-sort-alpha-desc': member.order=='-last_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%"><a ng-click="setOrderMember('first_name')">{{ 'app.admin.members.first_name' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='first_name', 'fa fa-sort-alpha-desc': member.order=='-first_name', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:14%" class="hidden-xs"><a ng-click="setOrderMember('email')">{{ 'app.admin.members.email' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='email', 'fa fa-sort-alpha-desc': member.order=='-email', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:8%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('phone')">{{ 'app.admin.members.phone' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-numeric-asc': member.order=='phone', 'fa fa-sort-numeric-desc': member.order=='-phone', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm"><a ng-click="setOrderMember('group')">{{ 'app.admin.members.user_type' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='group', 'fa fa-sort-alpha-desc': member.order=='-group', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:13%" class="hidden-xs hidden-sm hidden-md"><a ng-click="setOrderMember('plan')">{{ 'app.admin.members.subscription' | translate }} <i class="fa fa-arrows-v" ng-class="{'fa fa-sort-alpha-asc': member.order=='plan', 'fa fa-sort-alpha-desc': member.order=='-plan', 'fa fa-arrows-v': member.order }"></i></a></th>
<th style="width:12%" class="buttons-col"></th>
</tr>
</thead>
<tbody>
@ -52,6 +53,7 @@
<td class="text-center" ng-if="enableUserValidationRequired">
<span ng-class="{ 'text-success': !!m.validated_at }"><i class="fa fa-user-check"></i></span>
</td>
<td class="text-c" ng-show="displayUsername">{{ m.username }}</td>
<td class="text-c">{{ m.profile.last_name }}</td>
<td class="text-c">{{ m.profile.first_name }}</td>
<td class="hidden-xs">{{ m.email }}</td>

View File

@ -73,6 +73,27 @@
</div>
</div>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.settings.accounts_management' }}</span>
</div>
<div class="panel-body">
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.members_list' }}</h3>
<p class="alert alert-warning m-h-md" translate>
{{ 'app.admin.settings.members_list_info' }}
</p>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="'show_username_in_admin_list'"
settings="allSettings"
label="'app.admin.settings.show_username_in_admin_list' | translate"
on-success="onSuccess"
on-error="onError">
</boolean-setting>
</div>
</div>
</div>
</div>
<div class="panel panel-default m-t-md">
<div class="panel-heading">

View File

@ -179,7 +179,7 @@
</div>
<div class="panel-body">
<div class="row" ng-show="$root.modules.machines">
<h3 class="m-l" translate>{{ 'app.admin.settings.display_machine_reservation_user_name' }}</h3>
<h3 class="m-l" translate>{{ 'app.admin.settings.display_reservation_user_name' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.display_name_info_html' | translate"></p>
<boolean-setting name="'display_name_enable'"
label="'app.admin.settings.display_name' | translate"

View File

@ -39,7 +39,7 @@
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.training_reservations.length > 0">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future'" class="m-b" data-label="{{ 'app.admin.event_reservations.canceled' | translate}}" ng-class="{'reservation-canceled':r.canceled_at}">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future'" class="m-b" data-label="{{ 'app.logged.dashboard.trainings.canceled' | translate}}" ng-class="{'reservation-canceled':r.canceled_at}">
<span class="font-sbold">{{r.reservable.name}}</span> - <span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>
@ -54,7 +54,7 @@
</div>
<div class="widget-content bg-light auto wrapper r-b">
<ul class="list-unstyled" ng-if="user.training_reservations.length > 0">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'passed'" class="m-b" data-label="{{ 'app.admin.event_reservations.canceled' | translate}}" ng-class="{'reservation-canceled':r.canceled_at}">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'passed'" class="m-b" data-label="{{ 'app.logged.dashboard.trainings.canceled' | translate}}" ng-class="{'reservation-canceled':r.canceled_at}">
<span class="font-sbold">{{r.reservable.name}}</span> - <span class="label label-info text-white wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>

View File

@ -84,9 +84,15 @@
</div>
<small class="text-xs m-b"><i>{{ 'app.public.projects_show.posted_on_' | translate }} {{project.created_at | amDateFormat: 'LL'}}</i></small>
<div class="m" ng-if="project.themes">
<span ng-repeat="theme in project.themes" class="badge m-r-sm">
{{theme.name}}
</span>
</div>
</div>
<section class="widget panel b-a m" ng-if="project.project_caos_attributes">
<div class="panel-heading b-b">
<span class="badge bg-warning pull-right">{{project.project_caos_attributes.length}}</span>
@ -150,6 +156,15 @@
</div>
</section>
<section class="widget panel b-a m" ng-if="project.tags">
<div class="panel-heading b-b">
<h3 translate>{{ 'app.shared.project.tags' }}</h3>
</div>
<div class="panel-body">
<pre>{{ project.tags }}</pre>
</div>
</section>
<section class="widget b-t">
<div class="widget-content text-center m-t">

View File

@ -48,11 +48,11 @@
<div class="wrapper">
<section class="widget panel no-border bg-black-light text-white lt">
<section class="profile-bio widget panel no-border bg-black-light text-white lt">
<div class="panel-body">
<div class="wrapper m-t-xl m-b">
<div class="row m-b">
<div class="col-xs-5 text-right">
<div class="col-xs-5">
<span class="font-bold bio-title" translate>{{ 'app.shared.public_profile.interests' }}</span>
<div class="m-b m-t-sm" ng-bind-html="user.profile_attributes.interest"></div>
</div>
@ -74,7 +74,7 @@
<h1 class="red text-u-c" translate>{{ 'app.shared.public_profile.trainings' }}</h1>
<!-- <h3 class="text-u-c">Formations</h3> -->
<ul class="list-unstyled" ng-if="user.training_reservations.length > 0 || user.trainings.length > 0">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future'">
<li ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future' | canceledReservationsFilter">
{{r.reservable.name}} - <span class="label label-info text-white" translate>{{ 'app.shared.public_profile.to_come' }}</span>
</li>
<li ng-repeat="t in user.trainings">

View File

@ -7,10 +7,11 @@
<uib-alert ng-repeat="alert in alerts" type="{{alert.type}}" close="closeAlert($index)">{{alert.msg}}</uib-alert>
<div class="row">
<div ng-class="{'col-md-6': schedule, 'm-h-sm': !schedule}">
<div ng-if="reservation">
<!-- this modal dialog is only still used in events reservation, so the following workaround can do the trick -->
<div ng-if="reservation && bookedEvent">
<p translate>{{ 'app.shared.valid_reservation_modal.here_is_the_summary_of_the_slots_to_book_for_the_current_user' }}</p>
<ul ng-repeat="slot in reservation.slots_attributes">
<li><strong>{{slot.start_at | amDateFormat: 'LL'}} : {{slot.start_at | amDateFormat:'LT'}} - {{slot.end_at | amDateFormat:'LT'}}</strong></li>
<ul ng-repeat="sr in reservation.reservation.slots_reservations_attributes">
<li><strong>{{bookedEvent.availability.start_at | amDateFormat: 'LL'}} : {{bookedEvent.availability.start_at | amDateFormat:'LT'}} - {{bookedEvent.availability.end_at | amDateFormat:'LT'}}</strong></li>
</ul>
</div>
<div ng-if="subscription">

View File

@ -8,7 +8,7 @@ module AvailabilityHelper
EVENT_COLOR = '#dd7e6b'
IS_RESERVED_BY_CURRENT_USER = '#b2e774'
MACHINE_IS_RESERVED_BY_USER = '#1d98ec'
IS_COMPLETED = '#eeeeee'
IS_FULL = '#eeeeee'
def availability_border_color(availability)
case availability.available_type
@ -25,7 +25,7 @@ module AvailabilityHelper
def machines_slot_border_color(slot)
if slot.is_reserved
slot.is_reserved_by_current_user ? IS_RESERVED_BY_CURRENT_USER : IS_COMPLETED
slot.current_user_slots_reservations_ids.empty? ? IS_FULL : IS_RESERVED_BY_CURRENT_USER
else
MACHINE_COLOR
end
@ -34,8 +34,8 @@ module AvailabilityHelper
def space_slot_border_color(slot)
if slot.is_reserved
IS_RESERVED_BY_CURRENT_USER
elsif slot.complete?
IS_COMPLETED
elsif slot.full?
IS_FULL
else
SPACE_COLOR
end
@ -45,7 +45,7 @@ module AvailabilityHelper
if availability.is_reserved
IS_RESERVED_BY_CURRENT_USER
elsif availability.full?
IS_COMPLETED
IS_FULL
else
case availability.available_type
when 'training'

View File

@ -25,7 +25,7 @@ class NotificationsMailer < NotifyWith::NotificationsMailer
send(notification.notification_type)
rescue StandardError => e
STDERR.puts "[NotificationsMailer] notification cannot be sent: #{e}"
Rails.logger.error "[NotificationsMailer] notification cannot be sent: #{e}"
end
def helpers
@ -54,7 +54,7 @@ class NotificationsMailer < NotifyWith::NotificationsMailer
end
def notify_member_create_reservation
attachments[@attached_object.ics_filename] = @attached_object.to_ics.encode(Encoding::ISO_8859_15)
attachments[@attached_object.ics_filename] = @attached_object.to_ics.encode(Encoding::UTF_8)
mail(to: @recipient.email,
subject: t('notifications_mailer.notify_member_create_reservation.subject'),
template_name: 'notify_member_create_reservation')

View File

@ -20,6 +20,7 @@ class Availability < ApplicationRecord
has_many :spaces, through: :spaces_availabilities
has_many :slots
has_many :slots_reservations, through: :slots
has_many :reservations, through: :slots
has_one :event
@ -36,7 +37,7 @@ class Availability < ApplicationRecord
scope :trainings, -> { includes(:trainings).where(available_type: 'training') }
scope :spaces, -> { includes(:spaces).where(available_type: 'space') }
attr_accessor :is_reserved, :slot_id, :can_modify
attr_accessor :is_reserved, :current_user_slots_reservations_ids, :can_modify
validates :start_at, :end_at, presence: true
validate :length_must_be_slot_multiple, unless: proc { end_at.blank? or start_at.blank? }
@ -73,7 +74,7 @@ class Availability < ApplicationRecord
.joins(:slots)
.where('slots.availability_id = ?', id)
else
STDERR.puts "[safe_destroy] Availability with unknown type #{available_type}"
Rails.logger.warn "[safe_destroy] Availability with unknown type #{available_type}"
reservations = []
end
if reservations.size.zero?
@ -106,7 +107,7 @@ class Availability < ApplicationRecord
when 'space'
spaces.map(&:name).join(' - ')
else
STDERR.puts "[title] Availability with unknown type #{available_type}"
Rails.logger.warn "[title] Availability with unknown type #{available_type}"
'???'
end
end
@ -116,23 +117,25 @@ class Availability < ApplicationRecord
def full?
return false if nb_total_places.blank?
if available_type == 'training' || available_type == 'space'
nb_total_places <= slots.to_a.select { |s| s.canceled_at.nil? }.size
elsif available_type == 'event'
if available_type == 'event'
event.nb_free_places.zero?
else
slots.map(&:full?).reduce(:&)
end
end
def nb_total_places
def available_places_per_slot(reservable = nil)
case available_type
when 'training'
super.presence || trainings.map(&:nb_total_places).reduce(:+)
nb_total_places || reservable&.nb_total_places || trainings.map(&:nb_total_places).max
when 'event'
event.nb_total_places
when 'space'
super.presence || spaces.map(&:default_places).reduce(:+)
nb_total_places || reservable&.default_places || spaces.map(&:default_places).max
when 'machines'
reservable.nil? ? machines.count : 1
else
nil
raise TypeError
end
end

View File

@ -38,7 +38,7 @@ class CartItem::EventReservation < CartItem::Reservation
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Event.name,
slots_attributes: slots_params,
slots_reservations_attributes: slots_params,
tickets_attributes: tickets_params,
nb_reserve_places: @normal_tickets,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id

View File

@ -16,7 +16,7 @@ class CartItem::MachineReservation < CartItem::Reservation
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Machine.name,
slots_attributes: slots_params,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
@ -25,6 +25,22 @@ class CartItem::MachineReservation < CartItem::Reservation
'machine'
end
def valid?(all_items)
@slots.each do |slot|
same_hour_slots = SlotsReservation.joins(:reservation).where(
reservations: { reservable: @reservable },
slot_id: slot[:slot_id],
canceled_at: nil
).count
if same_hour_slots.positive?
@errors[:slot] = 'slot is reserved'
return false
end
end
super
end
protected
def credits

View File

@ -36,6 +36,8 @@ class CartItem::PaymentSchedule
end
def valid?(_all_items)
return true unless @requested && @plan&.monthly_payment
if @plan&.disabled
@errors[:item] = 'plan is disabled'
return false

View File

@ -11,7 +11,7 @@ class CartItem::Reservation < CartItem::BaseItem
@customer = customer
@operator = operator
@reservable = reservable
@slots = slots
@slots = slots.map { |s| expand_slot(s) }
super
end
@ -43,33 +43,27 @@ class CartItem::Reservation < CartItem::BaseItem
def valid?(all_items)
pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) }
@slots.each do |slot|
availability = Availability.find_by(id: slot[:availability_id])
if availability.nil?
@errors[:slot] = 'slot availability does not exist'
slot_db = Slot.find(slot[:slot_id])
if slot_db.nil?
@errors[:slot] = 'slot does not exist'
return false
end
if availability.available_type == 'machines'
s = Slot.includes(:reservations).where(start_at: slot[:start_at], end_at: slot[:end_at], availability_id: slot[:availability_id], canceled_at: nil, "reservations.reservable": @reservable)
unless s.empty?
@errors[:slot] = 'slot is reserved'
return false
end
elsif availability.available_type == 'space' && availability.spaces.first.disabled
@errors[:slot] = 'space is disabled'
availability = Availability.find_by(id: slot[:slot_attributes][:availability_id])
if availability.nil?
@errors[:availability] = 'availability does not exist'
return false
elsif availability.full?
@errors[:slot] = 'availability is complete'
end
if slot_db.full?
@errors[:slot] = 'availability is full'
return false
end
next if availability.plan_ids.empty?
next if (@customer.subscribed_plan && availability.plan_ids.include?(@customer.subscribed_plan.id)) ||
(pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) ||
(@operator.manager? && @customer.id != @operator.id) ||
@operator.admin?
next if required_subscription?(availability, pending_subscription)
@errors[:slot] = 'slot is restricted for subscribers'
@errors[:availability] = 'availability is restricted for subscribers'
return false
end
@ -88,7 +82,11 @@ class CartItem::Reservation < CartItem::BaseItem
def grouped_slots
return { all: @slots } unless Setting.get('extended_prices_in_same_day')
@slots.group_by { |slot| slot[:start_at].to_date }
@slots.group_by { |slot| slot[:slot_attributes][:start_at].to_date }
end
def expand_slot(slot)
slot.merge({ slot_attributes: Slot.find(slot[:slot_id]) })
end
##
@ -99,7 +97,7 @@ class CartItem::Reservation < CartItem::BaseItem
def get_slot_price_from_prices(prices, slot, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_minutes = (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
@ -129,7 +127,7 @@ class CartItem::Reservation < CartItem::BaseItem
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_rate = options[:has_credits] || (slot[:offered] && is_privileged) ? 0 : hourly_rate
slot_minutes = (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
# apply the base price to the real slot duration
real_price = if options[:is_division]
(slot_rate / MINUTES_PER_HOUR) * slot_minutes
@ -146,7 +144,7 @@ class CartItem::Reservation < CartItem::BaseItem
unless options[:elements].nil?
options[:elements][:slots].push(
start_at: slot[:start_at],
start_at: slot[:slot_attributes][:start_at],
price: real_price,
promo: (slot_rate != hourly_rate)
)
@ -160,7 +158,7 @@ class CartItem::Reservation < CartItem::BaseItem
# 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.
def applicable_prices(slots)
total_duration = slots.map { |slot| (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
total_duration = slots.map { |slot| (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
@ -196,6 +194,17 @@ class CartItem::Reservation < CartItem::BaseItem
end
def slots_params
@slots.map { |slot| slot.permit(:id, :start_at, :end_at, :availability_id, :offered) }
@slots.map { |slot| slot.permit(:id, :slot_id, :offered) }
end
##
# Check if the given availability requires a valid subscription. If so, check if the current customer
# has the required susbcription, otherwise, check if the operator is privileged
##
def required_subscription?(availability, pending_subscription)
(@customer.subscribed_plan && availability.plan_ids.include?(@customer.subscribed_plan.id)) ||
(pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) ||
(@operator.manager? && @customer.id != @operator.id) ||
@operator.admin?
end
end

View File

@ -9,6 +9,7 @@ class CartItem::SpaceReservation < CartItem::Reservation
super(customer, operator, space, slots)
@plan = plan
@space = space
@new_subscription = new_subscription
end
@ -16,7 +17,7 @@ class CartItem::SpaceReservation < CartItem::Reservation
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Space.name,
slots_attributes: slots_params,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
@ -25,6 +26,15 @@ class CartItem::SpaceReservation < CartItem::Reservation
'space'
end
def valid?(all_items)
if @space.disabled
@errors[:reservable] = 'space is disabled'
return false
end
super
end
protected
def credits

View File

@ -36,7 +36,7 @@ class CartItem::TrainingReservation < CartItem::Reservation
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Training.name,
slots_attributes: slots_params,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end

View File

@ -24,8 +24,8 @@ module ICalendarConcern
cal.event do |e|
e.dtstart = start_time
e.dtend = group_slots.last[:end_at]
e.summary = I18n.t('reservation_ics.summary', TYPE: I18n.t("reservation_ics.type.#{reservable.class.name}"))
e.description = I18n.t('reservation_ics.description', COUNT: group_slots.count, ITEM: reservable.name)
e.summary = title
e.description = description(group_slots)
e.ip_class = 'PRIVATE'
e.alarm do |a|
@ -38,5 +38,33 @@ module ICalendarConcern
end
cal
end
private
def title
case reservable_type
when 'Machine', 'Training', 'Space'
reservable.name
when 'Event'
reservable.title
else
Rails.logger.warn "Unexpected reservable type #{reservable_type}"
reservable_type
end
end
def description(group_slots)
case reservable_type
when 'Machine', 'Space'
I18n.t('reservation_ics.description_slot', COUNT: group_slots.count, ITEM: reservable.name)
when 'Training'
I18n.t('reservation_ics.description_training', TYPE: reservable.name)
when 'Event'
I18n.t('reservation_ics.description_event', NUMBER: nb_reserve_places + (tickets.map(&:booked).reduce(:+) || 0))
else
Rails.logger.warn "Unexpected reservable type #{reservable_type}"
I18n.t('reservation_ics.description_slot', COUNT: group_slots.count, ITEM: reservable_type)
end
end
end
end

View File

@ -40,37 +40,40 @@ module SingleSignOnConcern
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
## @param data {*} the data to put in the given key. Eg. 'user@example.com'
def set_data_from_sso_mapping(sso_mapping, data)
return if data.nil? || data.blank?
if sso_mapping.to_s.start_with? 'user.'
self[sso_mapping[5..-1].to_sym] = data unless data.nil? || data.blank?
self[sso_mapping[5..-1].to_sym] = data
elsif sso_mapping.to_s.start_with? 'profile.'
case sso_mapping.to_s
when 'profile.avatar'
profile.user_avatar ||= UserAvatar.new
profile.user_avatar.remote_attachment_url = data
when 'profile.address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.address ||= Address.new
invoicing_profile.address.address = data
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.address ||= Address.new
self.invoicing_profile.address.address = data
when 'profile.organization_name'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.name = data
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.organization ||= Organization.new
self.invoicing_profile.organization.name = data
when 'profile.organization_address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.address ||= Address.new
invoicing_profile.organization.address.address = data
self.invoicing_profile ||= InvoicingProfile.new
self.invoicing_profile.organization ||= Organization.new
self.invoicing_profile.organization.address ||= Address.new
self.invoicing_profile.organization.address.address = data
when 'profile.gender'
statistic_profile ||= StatisticProfile.new
statistic_profile.gender = data
self.statistic_profile ||= StatisticProfile.new
self.statistic_profile.gender = data
when 'profile.birthday'
statistic_profile ||= StatisticProfile.new
statistic_profile.birthday = data
self.statistic_profile ||= StatisticProfile.new
self.statistic_profile.birthday = data
else
profile[sso_mapping[8..-1].to_sym] = data unless data.nil?
profile[sso_mapping[8..-1].to_sym] = data
end
end
return if data.nil? || data.blank? || mapped_from_sso&.include?(sso_mapping)
return if mapped_from_sso&.include?(sso_mapping)
self.mapped_from_sso = [mapped_from_sso, sso_mapping].compact.join(',')
end
@ -121,7 +124,7 @@ module SingleSignOnConcern
logger.debug "mapping sso field #{field} with value=#{value}"
# we do not merge the email field if its end with the special value '-duplicate' as this means
# that the user is currently merging with the account that have the same email than the sso
set_data_from_sso_mapping(field, value) unless field == 'user.email' && value.end_with?('-duplicate')
set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && user.admin?)
end
# run the account transfer in an SQL transaction to ensure data integrity

View File

@ -25,8 +25,8 @@ class Event < ApplicationRecord
attr_accessor :recurrence, :recurrence_end_at
after_create :event_recurrence
before_save :update_nb_free_places
after_create :event_recurrence
# update event updated_at for index cache
after_save -> { touch }
@ -79,7 +79,10 @@ class Event < ApplicationRecord
if nb_total_places.nil?
self.nb_free_places = nil
else
reserved_places = reservations.joins(:slots).where('slots.canceled_at': nil).map(&:total_booked_seats).inject(0) { |sum, t| sum + t }
reserved_places = reservations.joins(:slots_reservations)
.where('slots_reservations.canceled_at': nil)
.map(&:total_booked_seats)
.inject(0) { |sum, t| sum + t }
self.nb_free_places = (nb_total_places - reserved_places)
end
end
@ -106,6 +109,7 @@ class Event < ApplicationRecord
nil
end
r = Recurrence.new(every: recurrence, on: on, starts: availability.start_at + 1.day, until: recurrence_end_at)
service = Availabilities::CreateAvailabilitiesService.new
r.events.each do |date|
days_diff = availability.end_at.day - availability.start_at.day
start_at = DateTime.new(
@ -154,6 +158,7 @@ class Event < ApplicationRecord
recurrence_id: id
)
event.save
service.create_slots(event.availability)
end
update_columns(recurrence_id: id)
end

View File

@ -129,6 +129,11 @@ class Invoice < PaymentDocument
def prevent_refund?
return true if user.nil?
if main_item.nil?
Rails.logger.error "Invoice (id: #{id}) does not have a main_item and is probably in error"
return true
end
if main_item.object_type == 'Reservation' && main_item.object&.reservable_type == 'Training'
user.trainings.include?(main_item.object.reservable_id)
else
@ -175,8 +180,9 @@ class Invoice < PaymentDocument
return unless Setting.get('invoicing_module')
unless Rails.env.test?
puts "Creating an InvoiceWorker job to generate the following invoice: id(#{id}), main_item.object_id(#{main_item.object_id}), " \
"main_item.object_type(#{main_item.object_type}), user_id(#{invoicing_profile.user_id})"
Rails.logger.info "Creating an InvoiceWorker job to generate the following invoice: id(#{id}), " \
"main_item.object_id(#{main_item.object_id}), " \
"main_item.object_type(#{main_item.object_type}), user_id(#{invoicing_profile.user_id})"
end
InvoiceWorker.perform_async(id, user&.subscription&.expired_at)
end
@ -185,9 +191,7 @@ class Invoice < PaymentDocument
return if Rails.env.test?
return unless changed?
puts "WARNING: Invoice update triggered [ id: #{id}, reference: #{reference} ]"
puts '---------- changes ----------'
puts changes
puts '---------------------------------'
Rails.logger.warn "Invoice update triggered [ id: #{id}, reference: #{reference} ]\n" \
"---------- changes ----------#{changes}\n---------------------------------"
end
end

View File

@ -50,9 +50,7 @@ class InvoiceItem < Footprintable
return if Rails.env.test?
return unless changed?
puts "WARNING: InvoiceItem update triggered [ id: #{id}, invoice reference: #{invoice.reference} ]"
puts '---------- changes ----------'
puts changes
puts '---------------------------------'
Rails.logger.warn "InvoiceItem update triggered [ id: #{id}, invoice reference: #{invoice.reference} ]\n" \
"---------- changes ----------\n#{changes}\n---------------------------------"
end
end

View File

@ -100,8 +100,9 @@ class PaymentSchedule < PaymentDocument
return unless Setting.get('invoicing_module')
unless Rails.env.test?
puts "Creating an PaymentScheduleWorker job to generate the following payment schedule: id(#{id}), main_object.object_id(#{main_object.object_id}), " \
"main_object.object_type(#{main_object.object_type}), user_id(#{invoicing_profile.user_id})"
Rails.logger.info "Creating an PaymentScheduleWorker job to generate the following payment schedule: id(#{id}), " \
"main_object.object_id(#{main_object.object_id}), " \
"main_object.object_type(#{main_object.object_type}), user_id(#{invoicing_profile.user_id})"
end
PaymentScheduleWorker.perform_async(id)
end

View File

@ -122,7 +122,7 @@ class Plan < ApplicationRecord
if !stat_type.nil? && !stat_subtype.nil?
StatisticTypeSubType.create!(statistic_type: stat_type, statistic_sub_type: stat_subtype)
else
puts 'ERROR: Unable to create the statistics association for the new plan. ' \
Rails.logger.error 'Unable to create the statistics association for the new plan. ' \
'Possible causes: the type or the subtype were not created successfully.'
end
end

View File

@ -12,7 +12,7 @@ class Reservation < ApplicationRecord
has_many :slots_reservations, dependent: :destroy
has_many :slots, through: :slots_reservations
accepts_nested_attributes_for :slots, allow_destroy: true
accepts_nested_attributes_for :slots_reservations, allow_destroy: true
belongs_to :reservable, polymorphic: true
has_many :tickets
@ -35,12 +35,17 @@ class Reservation < ApplicationRecord
# @param canceled if true, count the number of seats for this reservation, including canceled seats
def total_booked_seats(canceled: false)
# cases:
# - machine/training/space reservation => 1 slot = 1 seat (currently not covered by this function)
# - event reservation => seats = nb_reserve_place (normal price) + tickets.booked (other prices)
return 0 if slots.first.canceled_at && !canceled
if reservable_type == 'Event'
# - event reservation => seats = nb_reserve_place (normal price) + tickets.booked (other prices)
total = nb_reserve_places
total += tickets.map(&:booked).map(&:to_i).reduce(:+) if tickets.count.positive?
total = nb_reserve_places
total += tickets.map(&:booked).map(&:to_i).reduce(:+) if tickets.count.positive?
total = 0 unless slots_reservations.first&.canceled_at.nil?
else
# - machine/training/space reservation => 1 slot_reservation = 1 seat
total = slots_reservations.count
total -= slots_reservations.where.not(canceled_at: nil).count unless canceled
end
total
end
@ -93,26 +98,22 @@ class Reservation < ApplicationRecord
private
def machine_not_already_reserved
already_reserved = false
slots.each do |slot|
same_hour_slots = Slot.joins(:reservations).where(
slots_reservations.each do |slot|
same_hour_slots = SlotsReservation.joins(:reservation).where(
reservations: { reservable_type: reservable_type, reservable_id: reservable_id },
start_at: slot.start_at,
end_at: slot.end_at,
availability_id: slot.availability_id,
slot_id: slot.slot_id,
canceled_at: nil
)
if same_hour_slots.any?
already_reserved = true
).count
if same_hour_slots.positive?
errors.add(:reservable, 'already reserved')
break
end
end
errors.add(:machine, 'already reserved') if already_reserved
end
def training_not_fully_reserved
slot = slots.first
errors.add(:training, 'already fully reserved') if Availability.find(slot.availability_id).full?
full = slots_reservations.map(&:slot).map(&:full?).reduce(:&)
errors.add(:reservable, 'already fully reserved') if full
end
def slots_not_locked

View File

@ -147,7 +147,8 @@ class Setting < ApplicationRecord
machines_module
user_change_group
user_validation_required
user_validation_required_list] }
user_validation_required_list
show_username_in_admin_list] }
# WARNING: when adding a new key, you may also want to add it in:
# - config/locales/en.yml#settings
# - app/frontend/src/javascript/models/setting.ts#SettingName

View File

@ -2,7 +2,7 @@
# Stores data about a shopping data
class ShoppingCart
attr_accessor :customer, :operator, :payment_method, :items, :coupon, :payment_schedule
attr_accessor :customer, :operator, :payment_method, :items, :coupon, :payment_schedule, :errors
# @param items {Array<CartItem::BaseItem>}
# @param coupon {CartItem::Coupon}
@ -18,6 +18,7 @@ class ShoppingCart
@items = items
@coupon = coupon
@payment_schedule = payment_schedule
@errors = {}
end
# compute the price details of the current shopping cart
@ -55,13 +56,9 @@ class ShoppingCart
list = user_validation_required_list.split(',')
errors = []
items.each do |item|
if list.include?(item.type) && !@customer.validated_at?
errors.push("User validation is required for reserve #{item.type}")
end
end
unless errors.empty?
return { success: nil, payment: nil, errors: errors }
errors.push("User validation is required to reserve #{item.type}") if list.include?(item.type) && !@customer.validated_at?
end
return { success: nil, payment: nil, errors: errors } unless errors.empty?
end
end
@ -91,11 +88,18 @@ class ShoppingCart
items.each do |item|
next if item.valid?(@items)
@errors = item.errors
return false
end
unless @coupon.valid?(items)
@errors = @coupon.errors
return false
end
return false unless @coupon.valid?([])
return false unless @payment_schedule.valid?([])
unless @payment_schedule.valid?(items)
@errors = @payment_schedule.errors
return false
end
true
end

View File

@ -1,9 +1,8 @@
# frozen_string_literal: true
# Time range, slicing an Availability.
# Its duration is defined by globally by Setting.get('slot_duration') but can be overridden per availability
# During a slot a Reservation is possible
# Only reserved slots are persisted in DB, others are instantiated on the fly
# A Time range, slicing an Availability.
# Slots duration are defined globally by Setting.get('slot_duration') but can be
# overridden per availability.
class Slot < ApplicationRecord
include NotifyWith::NotificationAttachedObject
@ -11,64 +10,20 @@ class Slot < ApplicationRecord
has_many :reservations, through: :slots_reservations
belongs_to :availability
attr_accessor :is_reserved, :machine, :space, :title, :can_modify, :is_reserved_by_current_user
attr_accessor :is_reserved, :machine, :space, :title, :can_modify, :current_user_slots_reservations_ids
after_update :set_ex_start_end_dates_attrs, if: :dates_were_modified?
after_update :notify_member_and_admin_slot_is_modified, if: :dates_were_modified?
def full?(reservable = nil)
availability_places = availability.available_places_per_slot(reservable)
return false if availability_places.nil?
after_update :notify_member_and_admin_slot_is_canceled, if: :canceled?
after_update :update_event_nb_free_places, if: :canceled?
# for backward compatibility
def reservation
reservations.first
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
end
def destroy
update_column(:destroying, true)
super
end
def complete?
reservations.length >= availability.nb_total_places
end
private
def notify_member_and_admin_slot_is_modified
NotificationCenter.call type: 'notify_member_slot_is_modified',
receiver: reservation.user,
attached_object: self
NotificationCenter.call type: 'notify_admin_slot_is_modified',
receiver: User.admins_and_managers,
attached_object: self
end
def notify_member_and_admin_slot_is_canceled
NotificationCenter.call type: 'notify_member_slot_is_canceled',
receiver: reservation.user,
attached_object: self
NotificationCenter.call type: 'notify_admin_slot_is_canceled',
receiver: User.admins_and_managers,
attached_object: self
end
def dates_were_modified?
saved_change_to_start_at? || saved_change_to_end_at?
end
def canceled?
saved_change_to_canceled_at?
end
def set_ex_start_end_dates_attrs
update_columns(ex_start_at: start_at_before_last_save, ex_end_at: end_at_before_last_save)
end
def update_event_nb_free_places
return unless reservation.reservable_type == 'Event'
raise NotImplementedError if reservations.count > 1
reservation.update_event_nb_free_places
def duration
(end_at - start_at).seconds
end
end

View File

@ -1,16 +1,56 @@
# frozen_string_literal: true
# SlotsReservation is the relation table between a Slot and a Reservation.
# It holds detailed data about a Reservation for the attached Slot.
class SlotsReservation < ApplicationRecord
belongs_to :slot
belongs_to :reservation
after_destroy :cleanup_slots
# when the SlotsReservation is deleted (from Reservation destroy cascade), we delete the
# corresponding slot
def cleanup_slots
return unless slot.destroying
after_update :set_ex_start_end_dates_attrs, if: :slot_changed?
after_update :notify_member_and_admin_slot_is_modified, if: :slot_changed?
slot.destroy
after_update :notify_member_and_admin_slot_is_canceled, if: :canceled?
after_update :update_event_nb_free_places, if: :canceled?
def set_ex_start_end_dates_attrs
update_columns(ex_start_at: previous_slot.start_at, ex_end_at: previous_slot.end_at)
end
private
def slot_changed?
saved_change_to_slot_id?
end
def previous_slot
Slot.find(slot_id_before_last_save)
end
def canceled?
saved_change_to_canceled_at?
end
def update_event_nb_free_places
return unless reservation.reservable_type == 'Event'
reservation.update_event_nb_free_places
end
def notify_member_and_admin_slot_is_modified
NotificationCenter.call type: 'notify_member_slot_is_modified',
receiver: reservation.user,
attached_object: self
NotificationCenter.call type: 'notify_admin_slot_is_modified',
receiver: User.admins_and_managers,
attached_object: self
end
def notify_member_and_admin_slot_is_canceled
NotificationCenter.call type: 'notify_member_slot_is_canceled',
receiver: reservation.user,
attached_object: self
NotificationCenter.call type: 'notify_admin_slot_is_canceled',
receiver: User.admins_and_managers,
attached_object: self
end
end

View File

@ -3,5 +3,5 @@
# UserAvatar is the profile picture for an User
class UserAvatar < Asset
include ImageValidatorConcern
mount_uploader :attachment, ProfilImageUploader
mount_uploader :attachment, UserAvatarUploader
end

View File

@ -29,7 +29,7 @@ class PDF::Invoice < Prawn::Document
begin
image StringIO.new(Base64.decode64(img_b64)), fit: [415, 40]
rescue StandardError => e
puts "Unable to decode invoice logo from base64: #{e}"
Rails.logger.error "Unable to decode invoice logo from base64: #{e}"
end
move_down 20
# the following line is a special comment to workaround RubyMine inspection problem
@ -120,7 +120,7 @@ class PDF::Invoice < Prawn::Document
when 'StatisticProfilePrepaidPack'
object = I18n.t('invoices.prepaid_pack')
else
puts "ERROR : specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
Rails.logger.error "specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
end
end
text I18n.t('invoices.object') + ' ' + object
@ -233,12 +233,14 @@ class PDF::Invoice < Prawn::Document
# total verification
total = invoice.total / 100.00
puts "ERROR: totals are NOT equals => expected: #{total}, computed: #{total_calc}" if total_calc != total
Rails.logger.error "totals are NOT equals => expected: #{total}, computed: #{total_calc}" if total_calc != total
# TVA
vat_service = VatHistoryService.new
vat_rate_group = vat_service.invoice_vat(invoice)
if total_vat != 0
if total_vat.zero?
data += [[I18n.t('invoices.total_amount'), number_to_currency(total)]]
else
data += [[I18n.t('invoices.total_including_all_taxes'), number_to_currency(total)]]
vat_rate_group.each do |_type, rate|
data += [[I18n.t('invoices.including_VAT_RATE', RATE: rate[:vat_rate], AMOUNT: number_to_currency(rate[:amount] / 100.00)), number_to_currency(rate[:total_vat] / 100.00)]]
@ -247,13 +249,11 @@ class PDF::Invoice < Prawn::Document
data += [[I18n.t('invoices.including_amount_payed_on_ordering'), number_to_currency(total)]]
# checking the round number
rounded = sprintf('%.2f', total_vat / 100.00).to_f + sprintf('%.2f', total_ht / 100.00).to_f
if rounded != sprintf('%.2f', total_calc).to_f
puts 'ERROR: rounding the numbers cause an invoice inconsistency. ' \
"Total expected: #{sprintf('%.2f', total_calc)}, total computed: #{rounded}"
rounded = (sprintf('%.2f', total_vat / 100.00).to_f + sprintf('%.2f', total_ht / 100.00).to_f).to_s
if rounded != sprintf('%.2f', total_calc)
Rails.logger.error 'rounding the numbers cause an invoice inconsistency. ' \
"Total expected: #{sprintf('%.2f', total_calc)}, total computed: #{rounded}"
end
else
data += [[I18n.t('invoices.total_amount'), number_to_currency(total)]]
end
# display table
@ -305,7 +305,7 @@ class PDF::Invoice < Prawn::Document
when 'none'
payment_verbose = I18n.t('invoices.no_refund')
else
puts "ERROR : specified refunding method (#{payment_verbose}) is unknown"
Rails.logger.error "specified refunding method (#{payment_verbose}) is unknown"
end
payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))
else

View File

@ -32,7 +32,7 @@ class PDF::PaymentSchedule < Prawn::Document
begin
image StringIO.new(Base64.decode64(img_b64)), fit: [415, 40]
rescue StandardError => e
puts "Unable to decode invoice logo from base64: #{e}"
Rails.logger.error "Unable to decode invoice logo from base64: #{e}"
end
move_down 20
font('Open-Sans', size: 10) do

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
# Check the access policies for API::SlotsController
class SlotPolicy < ApplicationPolicy
# Check the access policies for API::SlotsReservationsController
class SlotsReservationPolicy < ApplicationPolicy
def update?
# check that the update is allowed and the prevention delay has not expired
delay = Setting.get('booking_move_delay').to_i
@ -9,7 +9,7 @@ class SlotPolicy < ApplicationPolicy
# these condition does not apply to admins
user.admin? || user.manager? ||
(record.reservation.user == user && enabled && ((record.start_at - DateTime.current).to_i / 3600 >= delay))
(record.reservation.user == user && enabled && ((record.slot.start_at - DateTime.current).to_i / 3600 >= delay))
end
def cancel?

View File

@ -32,7 +32,7 @@ class AccountingExportService
invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC')
invoices = invoices.where('total > 0') unless export_zeros
invoices.each do |i|
puts "processing invoice #{i.id}..." unless Rails.env.test?
Rails.logger.debug { "processing invoice #{i.id}..." } unless Rails.env.test?
content << generate_rows(i)
end
@ -62,20 +62,25 @@ class AccountingExportService
# Generate the "subscription" and "reservation" rows associated with the provided invoice
def items_rows(invoice)
rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : ''
if invoice.main_item.object_type == 'Reservation'
case invoice.main_item.object_type
when 'Reservation'
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
items.each do |item|
rows << "#{reservation_row(invoice, item)}\n"
end
elsif invoice.main_item.object_type == 'WalletTransaction'
when 'WalletTransaction'
rows << "#{wallet_row(invoice)}\n"
elsif invoice.main_item.object_type == 'StatisticProfilePrepaidPack'
when 'StatisticProfilePrepaidPack'
rows << "#{pack_row(invoice)}\n"
elsif invoice.main_item.object_type == 'Error'
when 'Error'
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
items.each do |item|
rows << "#{error_row(invoice, item)}\n"
end
when 'Subscription'
# do nothing, subscription was already handled by subscription_row
else
Rails.logger.warn { "Unknown main object type #{invoice.main_item.object_type}" }
end
rows
end
@ -185,18 +190,14 @@ class AccountingExportService
row << invoice.reference
when 'line_label'
row << line_label
when 'debit_origin'
when 'debit_origin', 'debit_euro'
row << method(debit_method).call(invoice, amount)
when 'credit_origin'
row << method(credit_method).call(invoice, amount)
when 'debit_euro'
row << method(debit_method).call(invoice, amount)
when 'credit_euro'
when 'credit_origin', 'credit_euro'
row << method(credit_method).call(invoice, amount)
when 'lettering'
row << ''
else
puts "Unsupported column: #{column}"
Rails.logger.debug { "Unsupported column: #{column}" }
end
row << separator
end
@ -214,30 +215,30 @@ class AccountingExportService
if invoice.subscription_invoice?
Setting.get("accounting_subscription_#{type}")
else
puts "WARN: Invoice #{invoice.id} has no subscription"
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no subscription" }
end
when :reservation
if invoice.main_item.object_type == 'Reservation'
Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}")
else
puts "WARN: Invoice #{invoice.id} has no reservation"
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no reservation" }
end
when :wallet
if invoice.main_item.object_type == 'WalletTransaction'
Setting.get("accounting_wallet_#{type}")
else
puts "WARN: Invoice #{invoice.id} is not a wallet credit"
Rails.logger.debug { "WARN: Invoice #{invoice.id} is not a wallet credit" }
end
when :pack
if invoice.main_item.object_type == 'StatisticProfilePrepaidPack'
Setting.get("accounting_Pack_#{type}")
else
puts "WARN: Invoice #{invoice.id} has no prepaid-pack"
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" }
end
when :error
Setting.get("accounting_Error_#{type}")
else
puts "Unsupported account #{account}"
Rails.logger.debug { "Unsupported account #{account}" }
end || ''
end

View File

@ -1,142 +1,104 @@
# frozen_string_literal: true
# Provides helper methods for Availability resources and properties
# List all Availability's slots for the given resources
class Availabilities::AvailabilitiesService
def initialize(current_user, maximum_visibility = {})
def initialize(current_user, level = 'slot')
@current_user = current_user
@maximum_visibility = maximum_visibility
@service = Availabilities::StatusService.new(current_user.role)
@maximum_visibility = {
year: Setting.get('visibility_yearly').to_i.months.since,
other: Setting.get('visibility_others').to_i.months.since
}
@service = Availabilities::StatusService.new(current_user&.role)
@level = level
end
# list all slots for the given machine, with reservations info, relatives to the given user
def machines(machine_id, user)
machine = Machine.friendly.find(machine_id)
reservations = reservations(machine, user)
availabilities = availabilities(machine, 'machines', user)
# list all slots for the given machines, with visibility relative to the given user
def machines(machines, user, window)
ma_availabilities = Availability.includes('machines_availabilities')
.where('machines_availabilities.machine_id': machines.map(&:id))
availabilities = availabilities(ma_availabilities, 'machines', user, window[:start], window[:end])
slots = []
availabilities.each do |a|
slot_duration = a.slot_duration || Setting.get('slot_duration').to_i
((a.end_at - a.start_at) / slot_duration.minutes).to_i.times do |i|
next unless (a.start_at + (i * slot_duration).minutes) > DateTime.current || user.admin?
slot = Slot.new(
start_at: a.start_at + (i * slot_duration).minutes,
end_at: a.start_at + (i * slot_duration).minutes + slot_duration.minutes,
availability_id: a.id,
availability: a,
machine: machine,
title: ''
)
slot = @service.machine_reserved_status(slot, reservations, @current_user)
slots << slot
end
if @level == 'slot'
availabilities.map(&:slots).flatten.map { |s| @service.slot_reserved_status(s, user, (machines & s.availability.machines)) }
else
availabilities.map { |a| @service.availability_reserved_status(a, user, (machines & a.machines)) }
end
slots
end
# list all slots for the given space, with reservations info, relatives to the given user
def spaces(space_id, user)
space = Space.friendly.find(space_id)
reservations = reservations(space, user)
availabilities = availabilities(space, 'space', user)
# list all slots for the given space, with visibility relative to the given user
def spaces(spaces, user, window)
sp_availabilities = Availability.includes('spaces_availabilities')
.where('spaces_availabilities.space_id': spaces.map(&:id))
availabilities = availabilities(sp_availabilities, 'space', user, window[:start], window[:end])
slots = []
availabilities.each do |a|
slot_duration = a.slot_duration || Setting.get('slot_duration').to_i
((a.end_at - a.start_at) / slot_duration.minutes).to_i.times do |i|
next unless (a.start_at + (i * slot_duration).minutes) > DateTime.current || user.admin?
slot = Slot.new(
start_at: a.start_at + (i * slot_duration).minutes,
end_at: a.start_at + (i * slot_duration).minutes + slot_duration.minutes,
availability_id: a.id,
availability: a,
space: space,
title: ''
)
slot = @service.space_reserved_status(slot, reservations, user)
slots << slot
end
if @level == 'slot'
availabilities.map(&:slots).flatten.map { |s| @service.slot_reserved_status(s, user, (spaces & s.availability.spaces)) }
else
availabilities.map { |a| @service.availability_reserved_status(a, user, (spaces & a.spaces)) }
end
slots.each do |s|
s.title = I18n.t('availabilities.not_available') if s.complete? && !s.is_reserved
end
slots
end
# list all slots for the given training, with reservations info, relatives to the given user
def trainings(training_id, user)
# first, we get the already-made reservations
reservations = user.reservations.where("reservable_type = 'Training'")
reservations = reservations.where('reservable_id = :id', id: training_id.to_i) if training_id.is_number?
reservations = reservations.joins(:slots).where('slots.start_at > ?', @current_user.admin? ? 1.month.ago : DateTime.current)
# list all slots for the given training(s), with visibility relative to the given user
def trainings(trainings, user, window)
tr_availabilities = Availability.includes('trainings_availabilities')
.where('trainings_availabilities.training_id': trainings.map(&:id))
availabilities = availabilities(tr_availabilities, 'training', user, window[:start], window[:end])
# visible availabilities depends on multiple parameters
availabilities = training_availabilities(training_id, user)
if @level == 'slot'
availabilities.map(&:slots).flatten.map { |s| @service.slot_reserved_status(s, user, (trainings & s.availability.trainings)) }
else
availabilities.map { |a| @service.availability_reserved_status(a, user, (trainings & a.trainings)) }
end
end
# finally, we merge the availabilities with the reservations
availabilities.each do |a|
a = @service.training_event_reserved_status(a, reservations, user)
# list all slots for the given event(s), with visibility relative to the given user
def events(events, user, window)
ev_availabilities = Availability.includes('event').where('events.id': events.map(&:id))
availabilities = availabilities(ev_availabilities, 'event', user, window[:start], window[:end])
if @level == 'slot'
availabilities.map(&:slots).flatten.map { |s| @service.slot_reserved_status(s, user, [s.availability.event]) }
else
availabilities.map { |a| @service.availability_reserved_status(a, user, [a.event]) }
end
end
private
def subscription_year?(user)
user.subscription && user.subscription.plan.interval == 'year' && user.subscription.expired_at >= DateTime.current
user&.subscription && user.subscription.plan.interval == 'year' && user.subscription.expired_at >= DateTime.current
end
# member must have validated at least 1 training and must have a valid yearly subscription.
def show_extended_slots?(user)
user.trainings.size.positive? && subscription_year?(user)
# members must have validated at least 1 training and must have a valid yearly subscription to view
# the trainings further in the futur. This is used to prevent users with a rolling subscription to take
# their first training in a very long delay.
def show_more_trainings?(user)
user&.trainings&.size&.positive? && subscription_year?(user)
end
def reservations(reservable, user)
Reservation.where('reservable_type = ? and reservable_id = ?', reservable.class.name, reservable.id)
.includes(:slots, statistic_profile: [user: [:profile]])
.references(:slots, :user)
.where('slots.start_at > ?', user.admin? ? 1.month.ago : DateTime.current)
end
def availabilities(reservable, type, user)
if user.admin? || user.manager?
reservable.availabilities
.includes(:tags, :plans)
.where('end_at > ? AND available_type = ?', 1.month.ago, type)
.where(lock: false)
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if subscription_year?(user)
reservable.availabilities
.includes(:tags, :plans)
.where('end_at > ? AND end_at < ? AND available_type = ?', DateTime.current, end_at, type)
.where('availability_tags.tag_id' => user.tag_ids.concat([nil]))
.where(lock: false)
end
end
def training_availabilities(training_id, user)
availabilities = if training_id.is_number? || (training_id.length.positive? && training_id != 'all')
Training.friendly.find(training_id).availabilities
else
Availability.trainings
end
def availabilities(availabilities, type, user, range_start, range_end)
# who made the request?
# 1) an admin (he can see all availabilities of 1 month ago and future)
if @current_user.admin?
availabilities.includes(:tags, :slots, :plans, trainings: [:machines])
.where('availabilities.start_at > ?', 1.month.ago)
# 1) an admin (he can see all availabilities from 1 month ago to anytime in the future)
if @current_user&.admin? || @current_user&.manager?
window_start = [range_start, 1.month.ago].max
availabilities.includes(:tags, :plans, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', range_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, range_end)
.where(lock: false)
# 2) an user (he cannot see availabilities further than 1 (or 3) months)
# 2) an user (he cannot see past availabilities neither those further than 1 (or 3) months in the future)
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if show_extended_slots?(user)
availabilities.includes(:tags, :slots, :availability_tags, :plans, trainings: [:machines])
.where('availabilities.start_at > ? AND availabilities.start_at < ?', DateTime.current, end_at)
.where('availability_tags.tag_id' => user.tag_ids.concat([nil]))
end_at = @maximum_visibility[:year] if subscription_year?(user) && type != 'training'
end_at = @maximum_visibility[:year] if show_more_trainings?(user) && type == 'training'
window_end = [end_at, range_end].min
window_start = [range_start, DateTime.current].max
availabilities.includes(:tags, :plans, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, window_end)
.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil]))
.where(lock: false)
end
end

View File

@ -1,16 +1,22 @@
# frozen_string_literal: true
# Provides helper methods to create an Availability with multiple occurrences
# Provides an helper method to create the slots for an Availability and optionnaly, for its multiple occurrences
class Availabilities::CreateAvailabilitiesService
def create(availability, occurrences = [])
def create(availability, occurrences)
occurrences = [] if occurrences.nil?
availability.update_attributes(occurrence_id: availability.id)
create_slots(availability)
occurrences.each do |o|
next if availability.start_at == o[:start_at] && availability.end_at == o[:end_at]
start_at = Time.zone.parse(o[:start_at])
end_at = Time.zone.parse(o[:end_at])
Availability.new(
start_at: o[:start_at],
end_at: o[:end_at],
next if availability.start_at == start_at && availability.end_at == end_at
occ = Availability.create!(
start_at: start_at,
end_at: end_at,
available_type: availability.available_type,
is_recurrent: availability.is_recurrent,
period: availability.period,
@ -24,6 +30,27 @@ class Availabilities::CreateAvailabilitiesService
nb_total_places: availability.nb_total_places,
slot_duration: availability.slot_duration,
plan_ids: availability.plan_ids
)
create_slots(occ)
end
end
def create_slots(availability)
slot_duration = availability.slot_duration || Setting.get('slot_duration').to_i
if %w[machines space].include?(availability.available_type)
((availability.end_at - availability.start_at) / slot_duration.minutes).to_i.times do |i|
Slot.new(
start_at: availability.start_at + (i * slot_duration).minutes,
end_at: availability.start_at + (i * slot_duration).minutes + slot_duration.minutes,
availability_id: availability.id
).save!
end
else
Slot.new(
start_at: availability.start_at,
end_at: availability.end_at,
availability_id: availability.id
).save!
end
end

View File

@ -7,96 +7,16 @@ class Availabilities::PublicAvailabilitiesService
@service = Availabilities::StatusService.new('public')
end
# provides a list of slots and availabilities for the machines, between the given dates
def machines(start_date, end_date, reservations, machine_ids)
availabilities = Availability.includes(:tags, :machines)
.where(available_type: 'machines')
.where('start_at >= ? AND end_at <= ?', start_date, end_date)
.where(lock: false)
slots = []
availabilities.each do |a|
slot_duration = a.slot_duration || Setting.get('slot_duration').to_i
a.machines.each do |machine|
next unless machine_ids&.include?(machine.id.to_s)
def public_availabilities(window, ids, events = false)
level = in_same_day(window[:start], window[:end]) ? 'slot' : 'availability'
service = Availabilities::AvailabilitiesService.new(@current_user, level)
((a.end_at - a.start_at) / slot_duration.minutes).to_i.times do |i|
slot = Slot.new(
start_at: a.start_at + (i * slot_duration).minutes,
end_at: a.start_at + (i * slot_duration).minutes + slot_duration.minutes,
availability_id: a.id,
availability: a,
machine: machine,
title: machine.name
)
slot = @service.machine_reserved_status(slot, reservations, @current_user)
slots << slot
end
end
end
{ availabilities: availabilities, slots: slots }
end
machines_slots = service.machines(Machine.where(id: ids[:machines]), @current_user, window)
spaces_slots = service.spaces(Space.where(id:ids[:spaces]), @current_user, window)
trainings_slots = service.trainings(Training.where(id: ids[:trainings]), @current_user, window)
events_slots = events ? service.events(Event.all, @current_user, window) : []
# provides a list of slots and availabilities for the spaces, between the given dates
def spaces(start_date, end_date, reservations, available_id)
availabilities = Availability.includes(:tags, :spaces).where(available_type: 'space')
.where('start_at >= ? AND end_at <= ?', start_date, end_date)
.where(lock: false)
availabilities.where(available_id: available_id) if available_id
slots = []
availabilities.each do |a|
slot_duration = a.slot_duration || Setting.get('slot_duration').to_i
space = a.spaces.first
((a.end_at - a.start_at) / slot_duration.minutes).to_i.times do |i|
next unless (a.start_at + (i * slot_duration).minutes) > DateTime.current
slot = Slot.new(
start_at: a.start_at + (i * slot_duration).minutes,
end_at: a.start_at + (i * slot_duration).minutes + slot_duration.minutes,
availability_id: a.id,
availability: a,
space: space,
title: space.name
)
slot = @service.space_reserved_status(slot, reservations, @current_user)
slots << slot
end
end
{ availabilities: availabilities, slots: slots }
end
def public_availabilities(start_date, end_date, reservations, ids)
if in_same_day(start_date, end_date)
# request for 1 single day
# trainings, events
training_event_availabilities = Availability.includes(:tags, :trainings, :slots)
.where(available_type: %w[training event])
.where('start_at >= ? AND end_at <= ?', start_date, end_date)
.where(lock: false)
# machines
machines_avail = machines(start_date, end_date, reservations, ids[:machines])
machine_slots = machines_avail[:slots]
# spaces
spaces_avail = spaces(start_date, end_date, reservations, ids[:spaces])
space_slots = spaces_avail[:slots]
[].concat(training_event_availabilities).concat(machine_slots).concat(space_slots)
else
# request for many days (week or month)
avails = Availability.includes(:tags, :machines, :trainings, :spaces, :event, :slots)
.where('start_at >= ? AND end_at <= ?', start_date, end_date)
.where(lock: false)
avails.each do |a|
if a.available_type == 'training' || a.available_type == 'event'
a = @service.training_event_reserved_status(a, reservations, @current_user)
elsif a.available_type == 'space'
a.is_reserved = @service.reserved_availability?(a, @current_user)
end
end
avails
end
[].concat(trainings_slots).concat(events_slots).concat(machines_slots).concat(spaces_slots)
end
private

View File

@ -4,88 +4,67 @@
class Availabilities::StatusService
def initialize(current_user_role)
@current_user_role = current_user_role
@show_name = (%w[admin manager].include?(@current_user_role) || Setting.get('display_name_enable'))
@show_name = (%w[admin manager].include?(@current_user_role) || (current_user_role && Setting.get('display_name_enable')))
end
# check that the provided machine slot is reserved or not and modify it accordingly
def machine_reserved_status(slot, reservations, user)
statistic_profile_id = user&.statistic_profile&.id
reservations.each do |r|
r.slots.each do |s|
next unless slot.machine.id == r.reservable_id
next unless s.start_at == slot.start_at && s.canceled_at.nil?
slot.id = s.id
slot.is_reserved = true
user_name = r.user ? r.user&.profile&.full_name : I18n.t('availabilities.deleted_user');
slot.title = "#{slot.machine.name} - #{@show_name ? user_name : I18n.t('availabilities.deleted_user')}"
slot.can_modify = true if %w[admin manager].include?(@current_user_role)
slot.reservations.push r
next unless r.statistic_profile_id == statistic_profile_id
slot.title = "#{slot.machine.name} - #{I18n.t('availabilities.i_ve_reserved')}"
slot.can_modify = true
slot.is_reserved_by_current_user = true
end
# check that the provided slot is reserved for the given reservable (machine, training or space).
# Mark it accordingly for display in the calendar
def slot_reserved_status(slot, user, reservables)
if reservables.map(&:class).map(&:name).uniq.size > 1
raise TypeError('[Availabilities::StatusService#slot_reserved_status] reservables have differents types')
end
statistic_profile_id = user&.statistic_profile&.id
slots_reservations = slot.slots_reservations
.includes(:reservation)
.where('reservations.reservable_type': reservables.map(&:class).map(&:name))
.where('reservations.reservable_id': reservables.map(&:id))
.where('slots_reservations.canceled_at': nil)
user_slots_reservations = slots_reservations.where('reservations.statistic_profile_id': statistic_profile_id)
slot.is_reserved = !slots_reservations.empty?
slot.title = slot_title(slots_reservations, user_slots_reservations, reservables)
slot.can_modify = true if %w[admin manager].include?(@current_user_role) || !user_slots_reservations.empty?
slot.current_user_slots_reservations_ids = user_slots_reservations.map(&:id)
slot
end
# check that the provided space slot is reserved or not and modify it accordingly
def space_reserved_status(slot, reservations, user)
statistic_profile_id = user&.statistic_profile&.id
reservations.each do |r|
r.slots.each do |s|
next unless slot.space.id == r.reservable_id
next unless s.start_at == slot.start_at && s.canceled_at.nil?
slot.can_modify = true if %w[admin manager].include?(@current_user_role)
slot.reservations.push r
next unless r.statistic_profile_id == statistic_profile_id
slot.id = s.id
slot.title = I18n.t('availabilities.i_ve_reserved')
slot.can_modify = true
slot.is_reserved = true
end
end
slot
end
# check that the provided availability (training or event) is reserved or not and modify it accordingly
def training_event_reserved_status(availability, reservations, user)
statistic_profile_id = user&.statistic_profile&.id
reservations.each do |r|
r.slots.each do |s|
next unless (
(availability.available_type == 'training' && availability.trainings.first.id == r.reservable_id) ||
(availability.available_type == 'event' && availability.event.id == r.reservable_id)
) && s.start_at == availability.start_at && s.canceled_at.nil?
availability.slot_id = s.id
if r.statistic_profile_id == statistic_profile_id
availability.is_reserved = true
availability.can_modify = true
end
end
end
availability
end
# check that the provided ability is reserved by the given user
def reserved_availability?(availability, user)
if user
reserved_slots = []
availability.slots.each do |s|
reserved_slots << s if s.canceled_at.nil?
end
reserved_slots.map(&:reservations).flatten.map(&:statistic_profile_id).include? user.statistic_profile&.id
def availability_reserved_status(availability, user, reservables)
if reservables.map(&:class).map(&:name).uniq.size > 1
raise TypeError('[Availabilities::StatusService#availability_reserved_status] reservables have differents types')
end
slots_reservations = availability.slots_reservations
.includes(:reservation)
.where('reservations.reservable_type': reservables.map(&:class).map(&:name))
.where('reservations.reservable_id': reservables.map(&:id))
.where('slots_reservations.canceled_at': nil)
user_slots_reservations = slots_reservations.where('reservations.statistic_profile_id': user&.statistic_profile&.id)
availability.is_reserved = !slots_reservations.empty?
availability.current_user_slots_reservations_ids = user_slots_reservations.map(&:id)
availability
end
private
def slot_title(slots_reservations, user_slots_reservations, reservables)
name = reservables.map(&:name).join(', ')
if user_slots_reservations.empty? && slots_reservations.empty?
name
elsif user_slots_reservations.empty? && !slots_reservations.empty?
user_names = slots_reservations.map(&:reservation)
.map(&:user)
.map { |u| u&.profile&.full_name || I18n.t('availabilities.deleted_user') }
.join(', ')
"#{name} #{@show_name ? "- #{user_names}" : ''}"
else
false
"#{name} - #{I18n.t('availabilities.i_ve_reserved')}"
end
end
end

View File

@ -28,7 +28,9 @@ class CartService
end
coupon = CartItem::Coupon.new(@customer, @operator, cart_items[:coupon_code])
schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule], @customer, plan_info[:subscription]&.start_at)
schedule = CartItem::PaymentSchedule.new(
plan_info[:plan], coupon, cart_items[:payment_schedule], @customer, plan_info[:subscription]&.start_at
)
ShoppingCart.new(
@customer,
@ -108,32 +110,32 @@ class CartService
CartItem::MachineReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_attributes],
cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
when Training
CartItem::TrainingReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_attributes],
cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
when Event
CartItem::EventReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_attributes],
cart_item[:slots_reservations_attributes],
normal_tickets: cart_item[:nb_reserve_places],
other_tickets: cart_item[:tickets_attributes])
when Space
CartItem::SpaceReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_attributes],
cart_item[:slots_reservations_attributes],
plan: plan_info[:plan],
new_subscription: plan_info[:new_subscription])
else
STDERR.puts "WARNING: the reservable #{reservable} is not implemented"
Rails.logger.warn "the reservable #{reservable} is not implemented"
raise NotImplementedError
end
end
@ -145,32 +147,32 @@ class CartService
CartItem::MachineReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
when Training
CartItem::TrainingReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
when Event
CartItem::EventReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
object.reservation.slots_reservations,
normal_tickets: object.reservation.nb_reserve_places,
other_tickets: object.reservation.tickets)
when Space
CartItem::SpaceReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
else
STDERR.puts "WARNING: the reservable #{reservable} is not implemented"
Rails.logger.warn "WARNING: the reservable #{reservable} is not implemented"
raise NotImplementedError
end
end

View File

@ -166,27 +166,33 @@ class EventService
e_params = e_params.merge(
event_files_attributes: ef_attributes
)
original_slots_ids = event.availability.slots.map(&:id)
begin
results[:events].push status: !!e.update(e_params.permit!), event: e # rubocop:disable Style/DoubleNegation
rescue StandardError => err
results[:events].push status: false, event: e, error: err.try(:record).try(:class).try(:name), message: err.message
end
results[:slots].concat(update_slots(e.availability_id))
results[:slots].concat(update_slots(e.availability_id, original_slots_ids))
end
original_slots_ids = event.availability.slots.map(&:id)
begin
event_params[:availability_attributes][:id] = event.availability_id
results[:events].push status: !!event.update(event_params), event: event # rubocop:disable Style/DoubleNegation
rescue StandardError => err
results[:events].push status: false, event: event, error: err.try(:record).try(:class).try(:name), message: err.message
end
results[:slots].concat(update_slots(event.availability_id))
results[:slots].concat(update_slots(event.availability_id, original_slots_ids))
results
end
def update_slots(availability_id)
def update_slots(availability_id, original_slots_ids)
results = []
avail = Availability.find(availability_id)
avail.slots.each do |s|
results.push(status: !!s.update_attributes(start_at: avail.start_at, end_at: avail.end_at), slot: s) # rubocop:disable Style/DoubleNegation
Slot.where(id: original_slots_ids).each do |slot|
results.push(
status: !!slot.update_attributes(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation
slot: slot
)
rescue StandardError => err
results.push status: false, slot: s, error: err.try(:record).try(:class).try(:name), message: err.message
end

View File

@ -51,19 +51,18 @@ class FootprintService
return saved if Rails.env.test?
if saved.nil?
puts "Debug data not found for #{klass} [ id: #{item.id} ]"
Rails.logger.debug { "Debug data not found for #{klass} [ id: #{item.id} ]" }
else
puts "Debug footprint for #{klass} [ id: #{item.id} ]"
puts '-----------------------------------------'
puts "columns: [ #{columns.join(', ')} ]"
puts "current: #{current}"
puts " saved: #{saved.format_data(item.id)}"
puts '-----------------------------------------'
Rails.logger.debug do
"Debug footprint for #{klass} [ id: #{item.id} ]\n" \
"-----------------------------------------\ncolumns: [ #{columns.join(', ')} ]\n" \
"current: #{current}\n saved: #{saved.format_data(item.id)}\n" \
'-----------------------------------------'
end
item.footprint_children.map(&:debug_footprint)
end
others = FootprintDebug.where('klass = ? AND data LIKE ? AND id != ?', klass, "#{item.id}%", saved&.id)
puts "other possible matches IDs: #{others.map(&:id)}"
puts '-----------------------------------------'
Rails.logger.debug { "other possible matches IDs: #{others.map(&:id)}\n-----------------------------------------" }
end
private

View File

@ -121,7 +121,7 @@ class InvoicesService
def self.generate_event_item(invoice, reservation, payment_details, main = false)
raise TypeError unless reservation.reservable.is_a? Event
reservation.slots.each do |slot|
reservation.slots_reservations.map(&:slot).each do |slot|
description = "#{reservation.reservable.name}\n"
description += if slot.start_at.to_date != slot.end_at.to_date
I18n.t('events.from_STARTDATE_to_ENDDATE',
@ -152,7 +152,7 @@ class InvoicesService
def self.generate_reservation_item(invoice, reservation, payment_details, main = false)
raise TypeError unless [Space, Machine, Training].include? reservation.reservable.class
reservation.slots.each do |slot|
reservation.slots_reservations.map(&:slot).each do |slot|
description = reservation.reservable.name +
" #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"

View File

@ -26,7 +26,8 @@ class Members::ListService
# ILIKE => PostgreSQL case-insensitive LIKE
if params[:search].size.positive?
@query = @query.where('profiles.first_name ILIKE :search OR ' \
@query = @query.where('users.username ILIKE :search OR ' \
'profiles.first_name ILIKE :search OR ' \
'profiles.last_name ILIKE :search OR ' \
'profiles.phone ILIKE :search OR ' \
'email ILIKE :search OR ' \
@ -41,7 +42,7 @@ class Members::ListService
end
def search(current_user, query, subscription, include_admins = 'false')
members = User.includes(:profile)
members = User.includes(:profile, :statistic_profile)
.joins(:profile,
:statistic_profile,
:roles,
@ -83,6 +84,8 @@ class Members::ListService
offset = ((params[:page]&.to_i || 1) - 1) * (params[:size]&.to_i || 1)
order_key = case order_key
when 'username'
'users.username'
when 'last_name'
'profiles.last_name'
when 'first_name'

View File

@ -36,17 +36,13 @@ class Members::MembersService
end
end
if params[:invoicing_profile_attributes][:organization] == 'false'
params[:invoicing_profile_attributes].reject! { |p| %w[organization_attributes organization].include?(p) }
else
params[:invoicing_profile_attributes].reject! { |p| p == 'organization' }
end
Members::MembersService.handle_organization(params)
not_complete = member.need_completion?
up_result = member.update(params)
notify_user_profile_complete(not_complete) if up_result
member.notify_group_changed(ex_group, validated_at_changed) if group_changed
member.notify_group_changed(ex_group, validated_at_changed) if group_changed && !ex_group.nil?
up_result
end
@ -99,6 +95,18 @@ class Members::MembersService
is_updated
end
def self.handle_organization(params)
return params unless params[:invoicing_profile_attributes] && params[:invoicing_profile_attributes][:organization]
if params[:invoicing_profile_attributes][:organization] == 'false'
params[:invoicing_profile_attributes].reject! { |p| %w[organization_attributes organization].include?(p) }
else
params[:invoicing_profile_attributes].reject! { |p| p == 'organization' }
end
params
end
private
def notify_user_profile_complete(previous_state)

View File

@ -1,21 +0,0 @@
# frozen_string_literal: true
# helpers for managing slots (reservations sub-units)
class SlotService
def cancel(slot)
# first we mark ths slot as cancelled in DB, to free a ticket
slot.update_attributes(canceled_at: DateTime.current)
# then we try to remove this reservation from ElasticSearch, to keep the statistics up-to-date
model_name = slot.reservation.reservable.class.name
client = Elasticsearch::Model.client
model = "Stats::#{model_name}".constantize
client.delete_by_query(
index: model.index_name,
type: model.document_type,
conflicts: 'proceed',
body: { query: { match: { reservationId: slot.reservation.id } } }
)
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
# helpers for managing slots reservations (reservations for a time unit)
class SlotsReservationsService
class << self
def cancel(slot_reservation)
# first we mark ths slot reservation as cancelled in DB, to free a ticket
slot_reservation.update_attributes(canceled_at: DateTime.current)
# then we try to remove this reservation from ElasticSearch, to keep the statistics up-to-date
model_name = slot_reservation.reservation.reservable.class.name
client = Elasticsearch::Model.client
model = "Stats::#{model_name}".constantize
client.delete_by_query(
index: model.index_name,
type: model.document_type,
conflicts: 'proceed',
body: { query: { match: { reservationId: slot_reservation.reservation_id } } }
)
rescue Faraday::ConnectionFailed
warn 'Unable to update data in elasticsearch'
end
end
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Extend the user's current subscription after his first training reservation if
# he subscribed to a rolling plan
class SubscriptionExtensionAfterReservation
attr_accessor :user, :reservation
@ -24,7 +26,7 @@ class SubscriptionExtensionAfterReservation
def extend_subscription
user.subscription.update_columns(
expiration_date: reservation.slots.first.start_at + user.subscribed_plan.duration
expiration_date: reservation.slots_reservations.first.slot.start_at + user.subscribed_plan.duration
)
end
end

View File

@ -94,16 +94,16 @@ module UsersCredits
super
will_use_credits, free_hours_count, machine_credit = _will_use_credits?
if will_use_credits
users_credit = user.users_credits.find_or_initialize_by(credit_id: machine_credit.id)
return unless will_use_credits
if users_credit.new_record?
users_credit.hours_used = free_hours_count
else
users_credit.hours_used += free_hours_count
end
users_credit.save!
users_credit = user.users_credits.find_or_initialize_by(credit_id: machine_credit.id)
if users_credit.new_record?
users_credit.hours_used = free_hours_count
else
users_credit.hours_used += free_hours_count
end
users_credit.save!
end
private
@ -111,19 +111,16 @@ module UsersCredits
def _will_use_credits?
return false, 0 unless plan
if machine_credit = plan.machine_credits.find_by(creditable_id: reservation.reservable_id)
machine_credit = plan.machine_credits.find_by(creditable_id: reservation.reservable_id)
if machine_credit
users_credit = user.users_credits.find_by(credit_id: machine_credit.id)
already_used_hours = users_credit ? users_credit.hours_used : 0
remaining_hours = machine_credit.hours - already_used_hours
free_hours_count = [remaining_hours, reservation.slots.size].min
free_hours_count = [remaining_hours, reservation.slots_reservations.size].min
if free_hours_count.positive?
return true, free_hours_count, machine_credit
else
return false, free_hours_count, machine_credit
end
return free_hours_count&.positive?, free_hours_count, machine_credit
end
[false, 0]
end
@ -138,9 +135,8 @@ module UsersCredits
def update_credits
super
will_use_credits, training_credit = _will_use_credits?
if will_use_credits
user.credits << training_credit # we create a new UsersCredit object
end
user.credits << training_credit if will_use_credits # we create a new UsersCredit object
end
private
@ -149,11 +145,10 @@ module UsersCredits
return false, nil unless plan
# if there is a training_credit defined for this plan and this training
if training_credit = plan.training_credits.find_by(creditable_id: reservation.reservable_id)
training_credit = plan.training_credits.find_by(creditable_id: reservation.reservable_id)
if training_credit
# if user has not used all the plan credits
if user.training_credits.where(plan: plan).count < plan.training_credit_nb
return true, training_credit
end
return true, training_credit if user.training_credits.where(plan: plan).count < plan.training_credit_nb
end
[false, nil]
end
@ -200,19 +195,16 @@ module UsersCredits
def _will_use_credits?
return false, 0 unless plan
if space_credit = plan.space_credits.find_by(creditable_id: reservation.reservable_id)
space_credit = plan.space_credits.find_by(creditable_id: reservation.reservable_id)
if space_credit
users_credit = user.users_credits.find_by(credit_id: space_credit.id)
already_used_hours = users_credit ? users_credit.hours_used : 0
remaining_hours = space_credit.hours - already_used_hours
free_hours_count = [remaining_hours, reservation.slots.size].min
free_hours_count = [remaining_hours, reservation.slots_reservations.size].min
if free_hours_count.positive?
return true, free_hours_count, space_credit
else
return false, free_hours_count, space_credit
end
return free_hours_count&.positive?, free_hours_count, space_credit
end
[false, 0]
end

Some files were not shown because too many files have changed in this diff Show More