active_bookings 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4a094c563d3e4de794dafa566582dc5d0cc3511bc0c7397b4a50f49346dd4ba0
4
+ data.tar.gz: 23746232c3e41bcddd9b11db629ce8a077b024e5df83b091e342fa7f765d2039
5
+ SHA512:
6
+ metadata.gz: 64c45f54e6f47f27a5b019ee139ae5c99b21cb1ef511ef0d38a61979b796caedaf8ca2eaeea1c1f705791db238045c690a068f1cb9057473aa5a34af7fc25dbe
7
+ data.tar.gz: 1386a8ccd484468fe6da08051735c10d87645e59107fb4a8a8b92c7509b573f12a876464f1915a68729ee46839aa7cbceef932ec67ada9f6832dcf0bc8a05243
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at info@hardpixel.eu. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in smart_navigation.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Jonian Guveli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # ActiveBookings
2
+
3
+ Make ActiveRecord models bookable with availability rules, schedule etc.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/active_bookings.svg)](https://badge.fury.io/rb/active_bookings)
6
+ [![Build Status](https://travis-ci.org/hardpixel/active-bookings.svg?branch=master)](https://travis-ci.org/hardpixel/active-bookings)
7
+ [![Maintainability](https://api.codeclimate.com/v1/badges/22f68b2e62b222e7efae/maintainability)](https://codeclimate.com/github/hardpixel/active-bookings/maintainability)
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'active_bookings'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install active_bookings
24
+
25
+ ## Usage
26
+
27
+ TODO: Write usage instructions here
28
+
29
+ ## Development
30
+
31
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`.
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hardpixel/active-bookings. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the ActiveBookings project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hardpixel/active-bookings/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,42 @@
1
+ require 'active_record'
2
+ require 'active_record/version'
3
+ require 'active_support/core_ext/module'
4
+ require 'ice_cube'
5
+
6
+ IceCube.compatibility = 12 # Drop compatibility for :start_date, avoiding a bunch of warnings caused by serialization
7
+
8
+ module ActiveBookings
9
+ extend ActiveSupport::Autoload
10
+
11
+ autoload :Serializer
12
+ autoload :Bookable
13
+ autoload :Booker
14
+ autoload :Booking
15
+ autoload :VERSION
16
+ autoload :TimeUtils
17
+ autoload :DBUtils
18
+
19
+ autoload_under 'bookable' do
20
+ autoload :Core
21
+ end
22
+
23
+ class InitializationError < StandardError
24
+ def initialize model, message
25
+ super "Error initializing active_bookings on #{model.to_s} - " + message
26
+ end
27
+ end
28
+
29
+ class OptionsInvalid < StandardError
30
+ def initialize model, message
31
+ super "Error validating options for #{model.to_s} - " + message
32
+ end
33
+ end
34
+
35
+ class AvailabilityError < StandardError
36
+ end
37
+ end
38
+
39
+ ActiveSupport.on_load(:active_record) do
40
+ extend ActiveBookings::Bookable
41
+ include ActiveBookings::Booker
42
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveBookings
2
+ module Bookable
3
+
4
+ def bookable?
5
+ false
6
+ end
7
+
8
+ ##
9
+ # Make a model bookable
10
+ #
11
+ # Example:
12
+ # class Room < ActiveRecord::Base
13
+ # is_bookable
14
+ # end
15
+ def is_bookable(options={})
16
+ bookable(options)
17
+ end
18
+
19
+ private
20
+
21
+ # Make a model bookable
22
+ def bookable(options)
23
+ assoc_class_name = options.delete(:class_name) || '::ActiveBookings::Booking'
24
+
25
+ if bookable?
26
+ self.booking_opts = options
27
+ else
28
+ class_attribute :booking_opts
29
+ self.booking_opts = options
30
+
31
+ class_eval do
32
+ serialize :schedule, ActiveBookings::Serializer
33
+ has_many :bookings, as: :bookable, dependent: :destroy, class_name: assoc_class_name
34
+
35
+ validates_numericality_of :capacity, if: :capacity?, only_integer: true, greater_than_or_equal_to: 0
36
+
37
+ def self.bookable?
38
+ true
39
+ end
40
+
41
+ def schedule_required?
42
+ self.booking_opts && self.booking_opts && self.booking_opts[:time_type] != :none
43
+ end
44
+
45
+ def capacity_required?
46
+ self.booking_opts && self.booking_opts[:capacity_type] != :none
47
+ end
48
+ end
49
+ end
50
+
51
+ include Core
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,298 @@
1
+ module ActiveBookings::Bookable
2
+ module Core
3
+ def self.included(base)
4
+ base.extend ActiveBookings::Bookable::Core::ClassMethods
5
+ base.send :include, ActiveBookings::Bookable::Core::InstanceMethods
6
+
7
+ base.initialize_active_bookings_core
8
+ end
9
+
10
+ module ClassMethods
11
+ ##
12
+ # Initialize the core of Bookable
13
+ #
14
+ def initialize_active_bookings_core
15
+ # Manage the options
16
+ set_options
17
+ end
18
+
19
+ ##
20
+ # Check if options passed for booking this Bookable are valid
21
+ #
22
+ # @raise ActiveBookings::OptionsInvalid if options are not valid
23
+ #
24
+ def validate_booking_options!(options)
25
+ unpermitted_params = []
26
+ required_params = {}
27
+
28
+ #
29
+ # Set unpermitted parameters and required parameters depending on Bookable options
30
+ #
31
+
32
+ # Switch :time_type
33
+ case self.booking_opts[:time_type]
34
+ # when :range, we need :time_start and :time_end
35
+ when :range
36
+ required_params[:time_start] = [Time,Date]
37
+ required_params[:time_end] = [Time,Date]
38
+ unpermitted_params << :time
39
+ when :fixed
40
+ required_params[:time] = [Time,Date]
41
+ unpermitted_params << :time_start
42
+ unpermitted_params << :time_end
43
+ when :none
44
+ unpermitted_params << :time_start
45
+ unpermitted_params << :time_end
46
+ unpermitted_params << :time
47
+ end
48
+
49
+ # Switch :capacity_type
50
+ case self.booking_opts[:capacity_type]
51
+ when :closed
52
+ required_params[:amount] = [Integer]
53
+ when :open
54
+ required_params[:amount] = [Integer]
55
+ when :none
56
+ unpermitted_params << :amount
57
+ end
58
+
59
+ #
60
+ # Actual validation
61
+ #
62
+ unpermitted_params = unpermitted_params
63
+ .select{ |p| options.has_key?(p) }
64
+ .map{ |p| "'#{p}'"}
65
+ wrong_types = required_params
66
+ .select{ |k,v| options.has_key?(k) && (v.select{|type| options[k].is_a?(type)}.length == 0) }
67
+ .map{ |k,v| "'#{k}' must be a '#{v.join(' or ')}' but '#{options[k].class.to_s}' found" }
68
+ required_params = required_params
69
+ .select{ |k,v| !options.has_key?(k) }
70
+ .map{ |k,v| "'#{k}'" }
71
+
72
+ #
73
+ # Raise OptionsInvalid if some invalid parameters were found
74
+ #
75
+ if unpermitted_params.length + required_params.length + wrong_types.length > 0
76
+ message = ""
77
+ message << " unpermitted parameters: #{unpermitted_params.join(',')}." if (unpermitted_params.length > 0)
78
+ message << " missing parameters: #{required_params.join(',')}." if (required_params.length > 0)
79
+ message << " parameters type mismatch: #{wrong_types.join(',')}" if (wrong_types.length > 0)
80
+ raise ActiveBookings::OptionsInvalid.new(self, message)
81
+ end
82
+
83
+ #
84
+ # Convert options (Date to Time)
85
+ #
86
+ options[:time_start] = options[:time_start].to_time if options[:time_start].present?
87
+ options[:time_end] = options[:time_end].to_time if options[:time_end].present?
88
+ options[:time] = options[:time].to_time if options[:time].present?
89
+
90
+ # Return true if everything's ok
91
+ true
92
+ end
93
+
94
+ private
95
+ ##
96
+ # Set the options
97
+ #
98
+ def set_options
99
+ # The default preset is 'room'
100
+ self.booking_opts[:preset]
101
+
102
+ defaults = nil
103
+
104
+ # Validates options
105
+ permitted_options = {
106
+ time_type: [:range, :fixed, :none],
107
+ capacity_type: [:open, :closed, :none],
108
+ preset: [:room, :event, :show],
109
+ bookable_across_occurrences: [true, false]
110
+ }
111
+ self.booking_opts.each_pair do |key, val|
112
+ if !permitted_options.has_key? key
113
+ raise ActiveBookings::InitializationError.new(self, "#{key} is not a valid option")
114
+ elsif !permitted_options[key].include? val
115
+ raise ActiveBookings::InitializationError.new(self, "#{val} is not a valid value for #{key}. Allowed values are: #{permitted_options[key]}")
116
+ end
117
+ end
118
+
119
+ case self.booking_opts[:preset]
120
+ # Room preset
121
+ when :room
122
+ defaults = {
123
+ time_type: :range, # time_start is check-in, time_end is check-out
124
+ capacity_type: :closed, # capacity is closed: after the first booking the room is not bookable anymore, even though the capacity has not been reached
125
+ bookable_across_occurrences: true # a room is bookable across recurrences: if a recurrence is daily, a booker must be able to book from a date to another date, even though time_start and time_end falls in different occurrences of the schedule
126
+ }
127
+ # Event preset (e.g. a birthday party)
128
+ when :event
129
+ defaults = {
130
+ time_type: :none, # time is ininfluent for booking an event.
131
+ capacity_type: :open, # capacity is open: after a booking the event is still bookable until capacity is reached.
132
+ bookable_across_occurrences: false # an event is not bookable across recurrences
133
+ }
134
+ # Show preset (e.g. a movie)
135
+ when :show
136
+ defaults = {
137
+ time_type: :fixed, # time is fixed: a user chooses the time of the show (the show may have a number of occurrences)
138
+ capacity_type: :open, # capacity is open: after a booking the show is still bookable until capacity is reached
139
+ bookable_across_occurrences: false # a show is not bookable across recurrences
140
+ }
141
+ else
142
+ defaults = {
143
+ time_type: :none,
144
+ capacity_type: :none,
145
+ bookable_across_occurrences: false
146
+ }
147
+ end
148
+
149
+ # Merge options with defaults
150
+ self.booking_opts.reverse_merge!(defaults)
151
+ end
152
+ end
153
+
154
+ module InstanceMethods
155
+ ##
156
+ # Check availability of current bookable, raising an error if the bookable is not available
157
+ #
158
+ # @param opts The booking options
159
+ # @return true if the bookable is available for given options
160
+ # @raise ActiveBookings::AvailabilityError if the bookable is not available for given options
161
+ #
162
+ # Example:
163
+ # @room.check_availability!(from: Date.today, to: Date.tomorrow, amount: 2)
164
+ def check_availability!(opts)
165
+ # validates options
166
+ self.validate_booking_options!(opts)
167
+
168
+ # Capacity check (done first because it doesn't require additional queries)
169
+ if self.booking_opts[:capacity_type] != :none
170
+ # Amount > capacity
171
+ if opts[:amount] > self.capacity
172
+ error = I18n.t('.active_bookings.availability.amount_gt_capacity', model: self.class.to_s)
173
+ raise ActiveBookings::AvailabilityError.new error
174
+ end
175
+ end
176
+
177
+ ##
178
+ # Time check
179
+ #
180
+ if self.booking_opts[:time_type] == :range
181
+ time_check_ok = true
182
+ # If it's bookable across recurrences, just check start time and end time
183
+ if self.booking_opts[:bookable_across_occurrences]
184
+ # Check start time
185
+ if !(ActiveBookings::TimeUtils.time_in_schedule?(self.schedule, opts[:time_start]))
186
+ time_check_ok = false
187
+ end
188
+ # Check end time
189
+ if !(ActiveBookings::TimeUtils.time_in_schedule?(self.schedule, opts[:time_end]))
190
+ time_check_ok = false
191
+ end
192
+ # If it's not bookable across recurrences, check if the whole interval is included in an occurrence
193
+ else
194
+ # Check the whole interval
195
+ if !(ActiveBookings::TimeUtils.interval_in_schedule?(self.schedule, opts[:time_start], opts[:time_end]))
196
+ time_check_ok = false
197
+ end
198
+ end
199
+ # If something went wrong
200
+ unless time_check_ok
201
+ error = I18n.t('.active_bookings.availability.unavailable_interval', model: self.class.to_s, time_start: opts[:time_start], time_end: opts[:time_end])
202
+ raise ActiveBookings::AvailabilityError.new error
203
+ end
204
+ end
205
+ if self.booking_opts[:time_type] == :fixed
206
+ if !(ActiveBookings::TimeUtils.time_in_schedule?(self.schedule, opts[:time]))
207
+ error = I18n.t('.active_bookings.availability.unavailable_time', model: self.class.to_s, time: opts[:time])
208
+ raise ActiveBookings::AvailabilityError.new error
209
+ end
210
+ end
211
+
212
+ ##
213
+ # Real capacity check (calculated with overlapped bookings)
214
+ #
215
+ overlapped = ActiveBookings::Booking.overlapped(self, opts)
216
+ # If capacity_type is :closed cannot book if already booked (no matter if amount < capacity)
217
+ if (self.booking_opts[:capacity_type] == :closed && !overlapped.empty?)
218
+ error = I18n.t('.active_bookings.availability.already_booked', model: self.class.to_s)
219
+ raise ActiveBookings::AvailabilityError.new error
220
+ end
221
+ # if capacity_type is :open, check if amount <= maximum amount of overlapped booking
222
+ if (self.booking_opts[:capacity_type] == :open && !overlapped.empty?)
223
+ # if time_type is :range, split in sub-intervals and check the maximum sum of amounts against capacity for each sub-interval
224
+ if (self.booking_opts[:time_type] == :range)
225
+ # Map overlapped bookings to a set of intervals with amount
226
+ intervals = overlapped.map { |e| {time_start: e.time_start, time_end: e.time_end, amount: e.amount} }
227
+ # Make subintervals from overlapped bookings and check capacity for each of them
228
+ ActiveBookings::TimeUtils.subintervals(intervals) do |a,b,op|
229
+ case op
230
+ when :open
231
+ res = {amount: a[:amount] + b[:amount]}
232
+ when :close
233
+ res = {amount: a[:amount] - b[:amount]}
234
+ end
235
+
236
+ if (res[:amount] >= self.capacity)
237
+ error = I18n.t('.active_bookings.availability.already_booked', model: self.class.to_s)
238
+ raise ActiveBookings::AvailabilityError.new error
239
+ end
240
+
241
+ res
242
+ end
243
+ # else, just sum the amounts (fixed times are not intervals and they overlap if are the same)
244
+ else
245
+ if(overlapped.sum(:amount) + opts[:amount] > self.capacity)
246
+ error = I18n.t('.active_bookings.availability.already_booked', model: self.class.to_s)
247
+ raise ActiveBookings::AvailabilityError.new error
248
+ end
249
+ end
250
+ end
251
+ true
252
+ end
253
+
254
+ ##
255
+ # Check availability of current bookable
256
+ #
257
+ # @param opts The booking options
258
+ # @return true if the bookable is available for given options, otherwise return false
259
+ #
260
+ # Example:
261
+ # @room.check_availability!(from: Date.today, to: Date.tomorrow, amount: 2)
262
+ def check_availability(opts)
263
+ begin
264
+ check_availability!(opts)
265
+ rescue ActiveBookings::AvailabilityError
266
+ false
267
+ end
268
+ end
269
+
270
+ ##
271
+ # Accept a booking by a booker. This is an alias method,
272
+ # equivalent to @booker.book!(@bookable, opts)
273
+ #
274
+ # @param booker The booker model
275
+ # @param opts The booking options
276
+ #
277
+ # Example:
278
+ # @room.book!(@user, from: Date.today, to: Date.tomorrow, amount: 2)
279
+ def book!(booker, opts={})
280
+ booker.book!(self, opts)
281
+ end
282
+
283
+ ##
284
+ # Check if options passed for booking this Bookable are valid
285
+ #
286
+ # @raise ActiveBookings::OptionsInvalid if options are not valid
287
+ # @param opts The booking options
288
+ #
289
+ def validate_booking_options!(opts)
290
+ self.class.validate_booking_options!(opts)
291
+ end
292
+
293
+ def booker?
294
+ self.class.booker?
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,68 @@
1
+ module ActiveBookings
2
+ module Booker
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ ##
9
+ # Make a model a booker. This allows an instance of a model to claim ownership
10
+ # of bookings.
11
+ #
12
+ # Example:
13
+ # class User < ActiveRecord::Base
14
+ # is_booker
15
+ # end
16
+ def is_booker(opts={})
17
+ class_eval do
18
+ has_many :bookings, as: :booker, dependent: :destroy, class_name: '::ActiveBookings::Booking'
19
+ end
20
+
21
+ include ActiveBookings::Booker::InstanceMethods
22
+ extend ActiveBookings::Booker::SingletonMethods
23
+ end
24
+
25
+ def booker?
26
+ false
27
+ end
28
+ end
29
+
30
+ module InstanceMethods
31
+ ##
32
+ # Book a bookable model
33
+ #
34
+ # @param bookable The resource that will be booked
35
+ # @return The booking created
36
+ # @raise ActiveBookings::OptionsInvalid if opts are not valid for given bookable
37
+ # @raise ActiveBookings::AvailabilityError if the bookable is not available for given options
38
+ # @raise ActiveRecord::RecordInvalid if trying to create an invalid booking
39
+ #
40
+ # Example:
41
+ # @user.book!(@room)
42
+ def book!(bookable, opts={})
43
+ # check availability
44
+ bookable.check_availability!(opts) if bookable.class.bookable?
45
+
46
+ # create the new booking
47
+ booking_params = opts.merge({booker: self, bookable: bookable})
48
+ booking_class = bookable.class.reflect_on_association(:bookings).klass
49
+ booking = booking_class.create!(booking_params)
50
+
51
+ # reload the bookable to make changes available
52
+ bookable.reload
53
+ self.reload
54
+ booking
55
+ end
56
+
57
+ def booker?
58
+ self.class.booker?
59
+ end
60
+ end
61
+
62
+ module SingletonMethods
63
+ def booker?
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ module ActiveBookings
2
+ # Booking model. Store in database bookings made by bookers on bookables
3
+ class Booking < ::ActiveRecord::Base
4
+ self.table_name = 'bookings'
5
+
6
+ belongs_to :bookable, polymorphic: true
7
+ belongs_to :booker, polymorphic: true
8
+
9
+ validates_presence_of :bookable
10
+ validates_presence_of :booker
11
+
12
+ validate :bookable_must_be_bookable, :booker_must_be_booker
13
+
14
+ # Retrieves overlapped bookings, given a bookable and some booking options
15
+ scope :overlapped, -> (bookable,opts) {
16
+ query = where(bookable_id: bookable.id)
17
+
18
+ # Time options
19
+ query = DBUtils.time_comparison(query, 'time','=', opts[:time]) if opts[:time].present?
20
+ query = DBUtils.time_comparison(query, 'time_end', '>=', opts[:time_start]) if opts[:time_start].present?
21
+ query = DBUtils.time_comparison(query, 'time_start', '<', opts[:time_end]) if opts[:time_end].present?
22
+
23
+ query
24
+ }
25
+
26
+ private
27
+ # Validation method. Check if the bookable resource is actually bookable
28
+ def bookable_must_be_bookable
29
+ if bookable.present? && !bookable.class.bookable?
30
+ errors.add(:bookable, T.er('booking.bookable_must_be_bookable', model: bookable.class.to_s))
31
+ end
32
+ end
33
+
34
+ # Validation method. Check if the booker model is actually a booker
35
+ def booker_must_be_booker
36
+ if booker.present? && !booker.class.booker?
37
+ errors.add(:booker, T.er('booking.booker_must_be_booker', model: booker.class.to_s))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
1
+ module ActiveBookings
2
+ module DBUtils
3
+ class << self
4
+ def connection
5
+ ActiveBookings::Booking.connection
6
+ end
7
+
8
+ def using_postgresql?
9
+ connection && connection.adapter_name == 'PostgreSQL'
10
+ end
11
+
12
+ def using_mysql?
13
+ #We should probably use regex for mysql to support prehistoric adapters
14
+ connection && connection.adapter_name == 'Mysql2'
15
+ end
16
+
17
+ def using_sqlite?
18
+ connection && connection.adapter_name == 'SQLite'
19
+ end
20
+
21
+ def active_record4?
22
+ ::ActiveRecord::VERSION::MAJOR == 4
23
+ end
24
+
25
+ def active_record5?
26
+ ::ActiveRecord::VERSION::MAJOR == 5
27
+ end
28
+
29
+ def like_operator
30
+ using_postgresql? ? 'ILIKE' : 'LIKE'
31
+ end
32
+
33
+ # Compare times according to the DB
34
+ def time_comparison(query, field, operator, time)
35
+ if using_postgresql?
36
+ query.where("#{field}::timestamp #{operator} ?::timestamp", time.to_time.utc.to_s)
37
+ elsif using_sqlite?
38
+ query.where("Datetime(#{field}) #{operator} Datetime('#{time.to_time.utc.iso8601}')")
39
+ else
40
+ query.where("#{field} #{operator} ?", time.to_time)
41
+ end
42
+ end
43
+
44
+ # escape _ and % characters in strings, since these are wildcards in SQL.
45
+ def escape_like(str)
46
+ str.gsub(/[!%_]/) { |x| '!' + x }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ module ActiveBookings
2
+ module Serializer
3
+ class << self
4
+ def load(string)
5
+ if string.present?
6
+ IceCube::Schedule.from_yaml(string)
7
+ end
8
+ end
9
+
10
+ def dump(object)
11
+ if object.is_a? IceCube::Schedule
12
+ object.to_yaml
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,135 @@
1
+ module ActiveBookings
2
+ ##
3
+ # Provide helper functions to manage operations and queries related to times
4
+ # and schedules
5
+ #
6
+ module TimeUtils
7
+ class << self
8
+ ##
9
+ # Check if time is included in a time interval. The ending time is excluded
10
+ #
11
+ # @param time The time to check
12
+ # @param interval_start The beginning time of the interval to match against
13
+ # @param interval_end The ending time of the interval to match against
14
+ #
15
+ def time_in_interval? (time, interval_start, interval_end)
16
+ time >= interval_start && time < interval_end
17
+ end
18
+
19
+ ##
20
+ # Check if there is an occurrence of a schedule that contains a time interval
21
+ #
22
+ # @param schedule The schedule
23
+ # @param interval_start The beginning Time of the interval
24
+ # @param interval_end The ending Time of the interval
25
+ # @return true if the interval falls within an occurrence of the schedule, otherwise false
26
+ #
27
+ def interval_in_schedule?(schedule, interval_start, interval_end)
28
+ # Check if interval_start and interval_end falls within any occurrence
29
+ return false if(!time_in_schedule?(schedule,interval_start) || !time_in_schedule?(schedule,interval_end))
30
+
31
+ # Check if both interval_start and interval_end falls within the SAME occurrence
32
+ between = schedule.occurrences_between(interval_start, interval_end, { spans: true })
33
+ contains = false
34
+ between.each do |oc|
35
+ oc_end = oc + schedule.duration
36
+ contains = true if (time_in_interval?(interval_start,oc,oc_end) && time_in_interval?(interval_end,oc,oc_end))
37
+ break if contains
38
+ end
39
+
40
+ contains
41
+ end
42
+
43
+ ##
44
+ # Check if there is an occurrence of a schedule that contains a time
45
+ # @param schedule The schedule
46
+ # @param time The time
47
+ # @return true if the time falls within an occurrence of the schedule, otherwise false
48
+ #
49
+ def time_in_schedule?(schedule, time)
50
+ return schedule.occurring_at? time
51
+ end
52
+
53
+ ##
54
+ # Returns an array of sub-intervals given another array of intervals, which are the overlapping insersections of each-others.
55
+ #
56
+ # @param intervals an array of intervals
57
+ # @return an array of subintervals, sorted by time_start
58
+ #
59
+ # An interval is defined as a hash with at least the following fields: `time_from` and `time_end`. An interval may contain more
60
+ # fields. In that case, it's suggested to give a block with the instructions to correctly merge two intervals when needed.
61
+ #
62
+ # e.g: given these 7 intervals
63
+ # |------| |---| |----------|
64
+ # |---| |--|
65
+ # |------| |--| |-------------|
66
+ # the output is an array containing these 8 intervals:
67
+ # |--| |--| |---| |--| |---| |------|
68
+ # |---| |------|
69
+ # the number of subintervals may increase or decrease because some intervals may be split, while
70
+ # some others may be merged.
71
+ #
72
+ # If a block is given, it's called before merging two intervals. The block should provide instructions to merge intervals, and should return the merged fields in a hash
73
+ def subintervals(intervals, &block)
74
+ raise ArgumentError.new('intervals must be an array') unless intervals.is_a? Array
75
+
76
+ steps = [] # Steps will be extracted from intervals
77
+ subintervals = [] # The output
78
+ last_time = nil
79
+ last_attrs = nil
80
+ started_count = 0 # The number of intervals opened inside the cycle
81
+
82
+ # Extract start times and end times from intervals, and create steps
83
+ intervals.each do |el|
84
+ begin
85
+ ts = el[:time_start].to_time
86
+ te = el[:time_end].to_time
87
+ rescue NoMethodError
88
+ raise ArgumentError.new('intervals must define :time_start and :time_end as Time or Date')
89
+ end
90
+ attrs = el.clone
91
+ attrs.delete(:time_start)
92
+ attrs.delete(:time_end)
93
+ steps << { opening: 1, time: el[:time_start], attrs: attrs } # Start step
94
+ steps << { opening: -1, time: el[:time_end], attrs: attrs.clone } # End step
95
+ end
96
+
97
+ # Sort steps by time (and opening if time is the same)
98
+ steps.sort! do |a,b|
99
+ diff = a[:time] <=> b[:time]
100
+ diff = a[:opening] <=> b[:opening] if (diff == 0)
101
+ diff
102
+ end
103
+
104
+ # Iterate over steps
105
+ steps.each do |step|
106
+ if (started_count == 0)
107
+ last_time = step[:time]
108
+ last_attrs = step[:attrs]
109
+ else
110
+ if(step[:time] > last_time)
111
+ subintervals << ({
112
+ time_start: last_time,
113
+ time_end: step[:time]
114
+ }.merge(last_attrs))
115
+
116
+ last_time = step[:time]
117
+ end
118
+
119
+ if block_given?
120
+ last_attrs = block.call(last_attrs.clone, step[:attrs],(step[:opening] == 1 ? :open : :close))
121
+ else
122
+ last_attrs = step[:attrs]
123
+ end
124
+ end
125
+
126
+ # Update started_count
127
+ started_count += step[:opening]
128
+ end
129
+
130
+ subintervals
131
+ end
132
+
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveBookings
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,28 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module ActiveBookings
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ desc 'Generates migrations to add Active Bookings table.'
10
+ source_root File.expand_path('../templates', __FILE__)
11
+
12
+ def create_migration_file
13
+ migration_template 'migration/migration.rb', 'db/migrate/create_active_bookings.rb'
14
+ end
15
+
16
+ def create_model_file
17
+ template 'model/booking.rb', 'app/models/booking.rb'
18
+ end
19
+
20
+ def create_locale_file
21
+ template 'locale/en.yml', 'config/locales/active_bookings.en.yml'
22
+ end
23
+
24
+ def self.next_migration_number(dirname)
25
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+ en:
2
+ active_bookings:
3
+ booking:
4
+ bookable_must_be_bookable: "cannot book a %{model} as it is not bookable"
5
+ booker_must_be_booker: "a %{model} cannot book a resource, because %{model} is not a booker"
6
+ availability:
7
+ amount_gt_capacity: "amount cannot be greater than %{model} capacity"
8
+ already_booked: "the %{model} is fully booked"
9
+ unavailable_interval: "the %{model} is not available from %{time_start} to %{time_end}"
10
+ unavailable_time: "the %{model} is not available at %{time}"
@@ -0,0 +1,15 @@
1
+ class CreateActiveBookings < ActiveRecord::Migration[5.0]
2
+ def change
3
+ create_table :bookings do |t|
4
+ t.references :bookable, polymorphic: true, index: { name: 'index_active_bookings_bookable' }
5
+ t.references :booker, polymorphic: true, index: { name: 'index_active_bookings_booker' }
6
+ t.column :amount, :integer
7
+ t.column :schedule, :text
8
+ t.column :time_start, :datetime
9
+ t.column :time_end, :datetime
10
+ t.column :time, :datetime
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,2 @@
1
+ class Booking < ActiveBookings::Booking
2
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_bookings
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonian Guveli
8
+ - Olibia Tsati
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2018-05-25 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '5.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '5.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: ice_cube
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '0.16'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '0.16'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bundler
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '1.14'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '1.14'
56
+ - !ruby/object:Gem::Dependency
57
+ name: rake
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '10.0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '10.0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: minitest
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '5.0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '5.0'
84
+ description: Make ActiveRecord models bookable with availability rules, schedule etc.
85
+ email:
86
+ - info@hardpixel.eu
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CODE_OF_CONDUCT.md
92
+ - Gemfile
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - lib/active_bookings.rb
97
+ - lib/active_bookings/bookable.rb
98
+ - lib/active_bookings/bookable/core.rb
99
+ - lib/active_bookings/booker.rb
100
+ - lib/active_bookings/booking.rb
101
+ - lib/active_bookings/db_utils.rb
102
+ - lib/active_bookings/serializer.rb
103
+ - lib/active_bookings/time_utils.rb
104
+ - lib/active_bookings/version.rb
105
+ - lib/generators/active_bookings/install_generator.rb
106
+ - lib/generators/active_bookings/templates/locale/en.yml
107
+ - lib/generators/active_bookings/templates/migration/migration.rb
108
+ - lib/generators/active_bookings/templates/model/booking.rb
109
+ homepage: https://github.com/hardpixel/active-bookings
110
+ licenses:
111
+ - MIT
112
+ metadata: {}
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubyforge_project:
129
+ rubygems_version: 2.7.7
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Rails reservation engine that allows bookable resources
133
+ test_files: []