active_bookings 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []