├── .coveralls.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── acts_as_bookable.gemspec ├── app ├── assets │ ├── images │ │ └── acts_as_bookable │ │ │ └── .keep │ ├── javascripts │ │ └── acts_as_bookable │ │ │ └── application.js │ └── stylesheets │ │ └── acts_as_bookable │ │ └── application.css ├── controllers │ └── acts_as_bookable │ │ └── application_controller.rb ├── helpers │ └── acts_as_bookable │ │ └── application_helper.rb └── views │ └── layouts │ └── acts_as_bookable │ └── application.html.erb ├── bin └── rails ├── config ├── locales │ └── en.yml └── routes.rb ├── db └── migrate │ └── 20160217085200_create_acts_as_bookable_bookings.rb ├── gemfiles ├── activerecord_3.2.gemfile ├── activerecord_4.0.gemfile ├── activerecord_4.1.gemfile ├── activerecord_4.2.gemfile └── activerecord_5.0.gemfile ├── lib ├── acts_as_bookable.rb ├── acts_as_bookable │ ├── bookable.rb │ ├── bookable │ │ └── core.rb │ ├── booker.rb │ ├── booking.rb │ ├── db_utils.rb │ ├── engine.rb │ ├── t.rb │ ├── time_utils.rb │ └── version.rb └── tasks │ └── acts_as_bookable_tasks.rake └── spec ├── acts_as_bookable ├── acts_as_bookable_spec.rb ├── acts_as_booker_spec.rb ├── bookable │ └── core_spec.rb ├── bookable_spec.rb ├── booker_spec.rb ├── booking_spec.rb ├── schedule_spec.rb └── time_utils_spec.rb ├── factories ├── bookable.rb ├── booker.rb └── room.rb ├── internal ├── app │ └── models │ │ ├── Bookable.rb │ │ ├── Booker.rb │ │ ├── Event.rb │ │ ├── Generic.rb │ │ ├── NotBooker.rb │ │ ├── Room.rb │ │ ├── Show.rb │ │ └── Unbookable.rb ├── config │ └── database.yml.sample └── db │ └── schema.rb ├── spec_helper.rb └── support ├── 0-helpers.rb ├── 1-database.rb ├── 2-database_cleaner.rb └── 3-factory_girl.rb /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.sqlite3 3 | /pkg/* 4 | .bundle 5 | .ruby-version 6 | spec/internal/config/database.yml 7 | tmp*.sw? 8 | *.sw? 9 | tmp 10 | *.gem 11 | *.lock 12 | coverage 13 | docker-compose.yml 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --backtrace 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.3.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.3.1 5 | - 2.3.0 6 | - 2.2.2 7 | - 2.1 8 | - 2.0.0 9 | - rbx-2 10 | 11 | env: 12 | - DB=sqlite3 13 | - DB=mysql 14 | - DB=postgresql 15 | 16 | gemfile: 17 | - gemfiles/activerecord_3.2.gemfile 18 | - gemfiles/activerecord_4.0.gemfile 19 | - gemfiles/activerecord_4.1.gemfile 20 | - gemfiles/activerecord_4.2.gemfile 21 | - gemfiles/activerecord_5.0.gemfile 22 | 23 | sudo: false 24 | 25 | bundler_args: '--without local_development --jobs 3 --retry 3' 26 | 27 | script: bundle exec rake 28 | 29 | matrix: 30 | fast_finish: true 31 | allow_failures: 32 | - gemfile: gemfiles/activerecord_edge.gemfile 33 | - rvm: rbx-2 34 | exclude: 35 | - rvm: 2.0.0 36 | gemfile: gemfiles/activerecord_4.2.gemfile 37 | - rvm: 2.2.2 38 | gemfile: gemfiles/activerecord_3.2.gemfile 39 | - rvm: rbx-2 40 | gemfile: gemfiles/activerecord_3.2.gemfile 41 | - rvm: 2.1 42 | gemfile: gemfiles/activerecord_5.0.gemfile 43 | - rvm: 2.0.0 44 | gemfile: gemfiles/activerecord_5.0.gemfile 45 | - rvm: rbx-2 46 | gemfile: gemfiles/activerecord_5.0.gemfile 47 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-3.2" do 2 | gem "activerecord", github: "rails/rails" , branch: '3-2-stable' 3 | end 4 | 5 | appraise "activerecord-4.0" do 6 | gem "activerecord", github: "rails/rails" , branch: '4-0-stable' 7 | end 8 | 9 | appraise "activerecord-4.1" do 10 | gem "activerecord", github: "rails/rails" , branch: '4-1-stable' 11 | end 12 | 13 | appraise "activerecord-4.2" do 14 | gem "railties", github: "rails/rails" , branch: '4-2-stable' 15 | gem "activerecord", github: "rails/rails" , branch: '4-2-stable' 16 | end 17 | 18 | appraise "activerecord-5.0" do 19 | gem "railties", github: "rails/rails" , branch: '5-0-stable' 20 | gem "activerecord", github: "rails/rails" , branch: '5-0-stable' 21 | end 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v0.1.4](https://github.com/tandusrl/acts_as_bookable/tree/v0.1.4) (2017-01-31) 4 | [Full Changelog](https://github.com/tandusrl/acts_as_bookable/compare/v0.1.3...v0.1.4) 5 | 6 | **Closed issues:** 7 | 8 | - Not compatible with Rails 5/activerecord [\#14](https://github.com/tandusrl/acts_as_bookable/issues/14) 9 | 10 | ## [v0.1.3](https://github.com/tandusrl/acts_as_bookable/tree/v0.1.3) (2016-11-22) 11 | [Full Changelog](https://github.com/tandusrl/acts_as_bookable/compare/v0.1.2...v0.1.3) 12 | 13 | - @bookable.schedule method missing [\#18](https://github.com/tandusrl/acts_as_bookable/issues/18) 14 | - what data does @user.bookings return? [\#17](https://github.com/tandusrl/acts_as_bookable/issues/17) 15 | 16 | ## [v0.1.2](https://github.com/tandusrl/acts_as_bookable/tree/v0.1.2) (2016-02-22) 17 | [Full Changelog](https://github.com/tandusrl/acts_as_bookable/compare/v0.1.1...v0.1.2) 18 | 19 | **Fixed bugs:** 20 | 21 | - Fix dependencies [\#11](https://github.com/tandusrl/acts_as_bookable/issues/11) 22 | - Fix build failing for some DBs [\#10](https://github.com/tandusrl/acts_as_bookable/issues/10) 23 | 24 | ## [v0.1.1](https://github.com/tandusrl/acts_as_bookable/tree/v0.1.1) (2016-02-22) 25 | [Full Changelog](https://github.com/tandusrl/acts_as_bookable/compare/v0.1.0...v0.1.1) 26 | 27 | ## [v0.1.0](https://github.com/tandusrl/acts_as_bookable/tree/v0.1.0) (2016-02-17) 28 | **Implemented enhancements:** 29 | 30 | - Remove location\_type: :range [\#1](https://github.com/tandusrl/acts_as_bookable/issues/1) 31 | 32 | **Closed issues:** 33 | 34 | - Add an option to allow bookings that start in an occurrence and end in another [\#8](https://github.com/tandusrl/acts_as_bookable/issues/8) 35 | - Move ice\_cube gem from Gemfile to gemspec [\#7](https://github.com/tandusrl/acts_as_bookable/issues/7) 36 | - Implement booking with date costraints [\#5](https://github.com/tandusrl/acts_as_bookable/issues/5) 37 | - Merge date and time [\#4](https://github.com/tandusrl/acts_as_bookable/issues/4) 38 | - Create a README [\#3](https://github.com/tandusrl/acts_as_bookable/issues/3) 39 | 40 | 41 | 42 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Declare your gem's dependencies in acts_as_bookable.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | 13 | # To use a debugger 14 | group :local_development do 15 | gem 'guard' 16 | gem 'guard-rspec' 17 | gem 'appraisal' 18 | gem 'byebug' , platform: :mri_21 19 | gem 'pry-nav' 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | acts_as_bookable (0.1.3) 5 | activerecord (>= 3.2, < 5.1) 6 | ice_cube_chosko (~> 0.1.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (5.0.1) 12 | actionpack (= 5.0.1) 13 | nio4r (~> 1.2) 14 | websocket-driver (~> 0.6.1) 15 | actionmailer (5.0.1) 16 | actionpack (= 5.0.1) 17 | actionview (= 5.0.1) 18 | activejob (= 5.0.1) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 2.0) 21 | actionpack (5.0.1) 22 | actionview (= 5.0.1) 23 | activesupport (= 5.0.1) 24 | rack (~> 2.0) 25 | rack-test (~> 0.6.3) 26 | rails-dom-testing (~> 2.0) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 28 | actionview (5.0.1) 29 | activesupport (= 5.0.1) 30 | builder (~> 3.1) 31 | erubis (~> 2.7.0) 32 | rails-dom-testing (~> 2.0) 33 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 34 | activejob (5.0.1) 35 | activesupport (= 5.0.1) 36 | globalid (>= 0.3.6) 37 | activemodel (5.0.1) 38 | activesupport (= 5.0.1) 39 | activerecord (5.0.1) 40 | activemodel (= 5.0.1) 41 | activesupport (= 5.0.1) 42 | arel (~> 7.0) 43 | activesupport (5.0.1) 44 | concurrent-ruby (~> 1.0, >= 1.0.2) 45 | i18n (~> 0.7) 46 | minitest (~> 5.1) 47 | tzinfo (~> 1.1) 48 | appraisal (2.1.0) 49 | bundler 50 | rake 51 | thor (>= 0.14.0) 52 | arel (7.1.4) 53 | awesome_print (1.7.0) 54 | barrier (1.0.2) 55 | builder (3.2.3) 56 | byebug (9.0.6) 57 | coderay (1.1.1) 58 | concurrent-ruby (1.0.4) 59 | coveralls (0.8.19) 60 | json (>= 1.8, < 3) 61 | simplecov (~> 0.12.0) 62 | term-ansicolor (~> 1.3) 63 | thor (~> 0.19.1) 64 | tins (~> 1.6) 65 | database_cleaner (1.5.3) 66 | diff-lcs (1.3) 67 | docile (1.1.5) 68 | erubis (2.7.0) 69 | factory_girl (4.8.0) 70 | activesupport (>= 3.0.0) 71 | factory_girl_rails (4.8.0) 72 | factory_girl (~> 4.8.0) 73 | railties (>= 3.0.0) 74 | ffi (1.9.17) 75 | formatador (0.2.5) 76 | globalid (0.3.7) 77 | activesupport (>= 4.1.0) 78 | guard (2.14.1) 79 | formatador (>= 0.2.4) 80 | listen (>= 2.7, < 4.0) 81 | lumberjack (~> 1.0) 82 | nenv (~> 0.1) 83 | notiffany (~> 0.0) 84 | pry (>= 0.9.12) 85 | shellany (~> 0.0) 86 | thor (>= 0.18.1) 87 | guard-compat (1.2.1) 88 | guard-rspec (4.7.3) 89 | guard (~> 2.1) 90 | guard-compat (~> 1.1) 91 | rspec (>= 2.99.0, < 4.0) 92 | i18n (0.7.0) 93 | ice_cube_chosko (0.1.0) 94 | json (2.0.3) 95 | listen (3.1.5) 96 | rb-fsevent (~> 0.9, >= 0.9.4) 97 | rb-inotify (~> 0.9, >= 0.9.7) 98 | ruby_dep (~> 1.2) 99 | loofah (2.0.3) 100 | nokogiri (>= 1.5.9) 101 | lumberjack (1.0.11) 102 | mail (2.6.4) 103 | mime-types (>= 1.16, < 4) 104 | method_source (0.8.2) 105 | mime-types (3.1) 106 | mime-types-data (~> 3.2015) 107 | mime-types-data (3.2016.0521) 108 | mini_portile2 (2.1.0) 109 | minitest (5.10.1) 110 | mysql2 (0.3.21) 111 | nenv (0.3.0) 112 | nio4r (1.2.1) 113 | nokogiri (1.7.0.1) 114 | mini_portile2 (~> 2.1.0) 115 | notiffany (0.1.1) 116 | nenv (~> 0.1) 117 | shellany (~> 0.0) 118 | pg (0.19.0) 119 | pry (0.10.4) 120 | coderay (~> 1.1.0) 121 | method_source (~> 0.8.1) 122 | slop (~> 3.4) 123 | pry-nav (0.2.4) 124 | pry (>= 0.9.10, < 0.11.0) 125 | rack (2.0.1) 126 | rack-test (0.6.3) 127 | rack (>= 1.0) 128 | rails (5.0.1) 129 | actioncable (= 5.0.1) 130 | actionmailer (= 5.0.1) 131 | actionpack (= 5.0.1) 132 | actionview (= 5.0.1) 133 | activejob (= 5.0.1) 134 | activemodel (= 5.0.1) 135 | activerecord (= 5.0.1) 136 | activesupport (= 5.0.1) 137 | bundler (>= 1.3.0, < 2.0) 138 | railties (= 5.0.1) 139 | sprockets-rails (>= 2.0.0) 140 | rails-dom-testing (2.0.2) 141 | activesupport (>= 4.2.0, < 6.0) 142 | nokogiri (~> 1.6) 143 | rails-html-sanitizer (1.0.3) 144 | loofah (~> 2.0) 145 | railties (5.0.1) 146 | actionpack (= 5.0.1) 147 | activesupport (= 5.0.1) 148 | method_source 149 | rake (>= 0.8.7) 150 | thor (>= 0.18.1, < 2.0) 151 | rake (12.0.0) 152 | rb-fsevent (0.9.8) 153 | rb-inotify (0.9.8) 154 | ffi (>= 0.5.0) 155 | rspec (3.5.0) 156 | rspec-core (~> 3.5.0) 157 | rspec-expectations (~> 3.5.0) 158 | rspec-mocks (~> 3.5.0) 159 | rspec-core (3.5.4) 160 | rspec-support (~> 3.5.0) 161 | rspec-expectations (3.5.0) 162 | diff-lcs (>= 1.2.0, < 2.0) 163 | rspec-support (~> 3.5.0) 164 | rspec-mocks (3.5.0) 165 | diff-lcs (>= 1.2.0, < 2.0) 166 | rspec-support (~> 3.5.0) 167 | rspec-rails (3.5.2) 168 | actionpack (>= 3.0) 169 | activesupport (>= 3.0) 170 | railties (>= 3.0) 171 | rspec-core (~> 3.5.0) 172 | rspec-expectations (~> 3.5.0) 173 | rspec-mocks (~> 3.5.0) 174 | rspec-support (~> 3.5.0) 175 | rspec-support (3.5.0) 176 | ruby_dep (1.5.0) 177 | shellany (0.0.1) 178 | simplecov (0.12.0) 179 | docile (~> 1.1.0) 180 | json (>= 1.8, < 3) 181 | simplecov-html (~> 0.10.0) 182 | simplecov-html (0.10.0) 183 | slop (3.6.0) 184 | sprockets (3.7.1) 185 | concurrent-ruby (~> 1.0) 186 | rack (> 1, < 3) 187 | sprockets-rails (3.2.0) 188 | actionpack (>= 4.0) 189 | activesupport (>= 4.0) 190 | sprockets (>= 3.0.0) 191 | sqlite3 (1.3.13) 192 | term-ansicolor (1.4.0) 193 | tins (~> 1.0) 194 | thor (0.19.4) 195 | thread_safe (0.3.5) 196 | tins (1.13.0) 197 | tzinfo (1.2.2) 198 | thread_safe (~> 0.1) 199 | websocket-driver (0.6.5) 200 | websocket-extensions (>= 0.1.0) 201 | websocket-extensions (0.1.2) 202 | 203 | PLATFORMS 204 | ruby 205 | 206 | DEPENDENCIES 207 | acts_as_bookable! 208 | appraisal 209 | awesome_print (~> 1.6) 210 | barrier (~> 1.0) 211 | byebug 212 | coveralls (~> 0.8) 213 | database_cleaner (~> 1.5) 214 | factory_girl_rails (~> 4.6) 215 | guard 216 | guard-rspec 217 | mysql2 (~> 0.3.7) 218 | pg (~> 0.18) 219 | pry-nav 220 | rails 221 | rake 222 | rspec (~> 3) 223 | rspec-rails (~> 3) 224 | sqlite3 (~> 1.3) 225 | 226 | BUNDLED WITH 227 | 1.14.3 228 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: "bundle exec rspec" do 2 | watch(%r{^spec/.+_spec\.rb}) 3 | watch(%r{^lib/(.+)\.rb}) { |m| "spec/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end 6 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Tandù srl 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActsAsBookable 2 | 3 | [![Gem Version](https://badge.fury.io/rb/acts_as_bookable.svg)](http://badge.fury.io/rb/acts_as_bookable) 4 | [![Build Status](https://secure.travis-ci.org/tandusrl/acts_as_bookable.png)](http://travis-ci.org/tandusrl/acts_as_bookable) 5 | [![Code Climate](https://codeclimate.com/github/tandusrl/acts_as_bookable.png)](https://codeclimate.com/github/tandusrl/acts_as_bookable) 6 | [![Coverage Status](https://coveralls.io/repos/github/tandusrl/acts_as_bookable/badge.svg?branch=master)](https://coveralls.io/github/tandusrl/acts_as_bookable?branch=master) 7 | [![Inline docs](http://inch-ci.org/github/tandusrl/acts_as_bookable.png)](http://inch-ci.org/github/tandusrl/acts_as_bookable) 8 | 9 | ActsAsBookable allows resources to be booked by users. It: 10 | 11 | * Is a MVC solution based on Rails engines 12 | * Is designed to cover many use cases (hotels bookings, restaurant reservations, shows...) 13 | * Allows to define bookable availabilities with recurring times and exceptions (based on ice_cube) 14 | 15 | ## Getting started 16 | 17 | ### Installation 18 | 19 | #### Include the gem 20 | 21 | ActsAsBookable works with ActiveRecord 3.2 onwards. You can add it to your Gemfile with: 22 | 23 | ```ruby 24 | gem 'acts_as_bookable' 25 | ``` 26 | 27 | run `bundle install` to install it. 28 | 29 | #### Install and run migrations 30 | 31 | ```bash 32 | bundle exec rake acts_as_bookable_engine:install:migrations 33 | bundle exec rake db:migrate 34 | ``` 35 | 36 | ### Bookables, Bookers and Bookings 37 | 38 | To set-up a **Bookable** model, use `acts_as_bookable`. A Bookable model is enabled to accept bookings. 39 | 40 | ```ruby 41 | class Room < ActiveRecord::Base 42 | acts_as_bookable 43 | end 44 | ``` 45 | 46 | To set-up a **Booker** model, use `acts_as_booker`. Only Bookers can create bookings. 47 | 48 | ```ruby 49 | class User < ActiveRecord::Base 50 | acts_as_booker 51 | end 52 | ``` 53 | 54 | From this time on, a User can book a Room with 55 | 56 | ```ruby 57 | @user.book! @room 58 | ``` 59 | 60 | Or a Room can accept a booking from a User with 61 | 62 | ```ruby 63 | @room.be_booked! @user 64 | ``` 65 | 66 | The functions above perform the same operation: they create and save a new **Booking** that has relations with the **Booker** and the **Bookable**. 67 | 68 | Since only **Bookers** can book **Bookables**, you must configure both the models. You can even have two or more models configured as **Bookable**, as well as two or more models configured as **Booker**. 69 | 70 | You can access bookings both from the Bookable and the Booker 71 | 72 | ```ruby 73 | @room.bookings # return all bookings created on this room 74 | @user.bookings # return all bookings made by this user 75 | ``` 76 | 77 | ## Configuring ActsAsBookable options 78 | 79 | There are a number available options to make your models behave differently. They are all configurable in the Bookable model, passing a hash to `acts_as_bookable` 80 | 81 | Available options (with values) are: 82 | 83 | * `:time_type`: Specifies how the Bookable must be booked in terms of time. Allowed values are: 84 | * `:none` 85 | * `:fixed` 86 | * `:range` 87 | * `:capacity_type`: Specifies how the `amount` of a booking (e.g. number of people of a restaurant reservation) affects the future availability of the bookable. Allowed values are: 88 | * `:none` 89 | * `:open` 90 | * `:closed` 91 | * `:bookable_across_occurrences`: Allows or denies the possibility to book across different occurrences of the availability schedule of the bookable (further explanation below) 92 | 93 | > WARNING - Some of the options above need migrations. They are explained in the sections below 94 | 95 | ### No constraints 96 | 97 | The model accepts booking without any constraint. This means every booker can create an infinite number of bookings on it and no capacity or time checks are performed. 98 | 99 | Creating a booking on this model means booking it forever and without care for other existing bookings. In other words, the time and the number of bookings do not affect the availability of this bookable. (e.g. pre-ordering a product that will be released soon) 100 | 101 | **Configuration** 102 | 103 | ```ruby 104 | class Product < ActiveRecord::Base 105 | acts_as_bookable 106 | end 107 | ``` 108 | 109 | **Creating a new bookable** 110 | 111 | ```ruby 112 | # Creating a new bookable without constraints does not require any additional attribute 113 | @product = Product.create!(...) 114 | ``` 115 | 116 | **Booking** 117 | 118 | ```ruby 119 | # Booking a model without constraints does not require any additional option 120 | @user.book! @product 121 | ``` 122 | 123 | ### Time constraints 124 | 125 | The option `time_type` may be used to set a constraint over the booking time. 126 | 127 | #### No time constraints - `time_type: :none` 128 | 129 | The model is bookable without time constraints. 130 | 131 | 132 | ```ruby 133 | class Product < ActiveRecord::Base 134 | # As `time_type: :none` is a default, you can omit it. It's shown here for explanation purposes 135 | acts_as_bookable time_type: :none 136 | end 137 | ``` 138 | 139 | #### Fixed time constraint - `time_type: :fixed` 140 | 141 | > WARNING - **migration needed!** - with this option the model must have an attribute `schedule: :text` 142 | 143 | The model accepts bookings that specify a fixed `:time`, and the availability is affected only for that time. (e.g. a show in a movie theater) 144 | 145 | **Configuration** 146 | 147 | ```ruby 148 | class Show < ActiveRecord::Base 149 | acts_as_bookable time_type: :fixed 150 | end 151 | ``` 152 | 153 | **Creating a new bookable** 154 | 155 | Each instance of the model must define its availability in terms of time with an [IceCube Schedule](https://github.com/seejohnrun/ice_cube) 156 | 157 | ```ruby 158 | @show = Show.new(...) 159 | @show.schedule = IceCube::Schedule.new 160 | # This show is available every day at 6PM and 10PM 161 | @show.schedule.add_recurrence_rule IceCube::Rule.daily.hour_of_day(18,22) 162 | @show.save! 163 | ``` 164 | 165 | **Booking** 166 | 167 | ```ruby 168 | time_ok = Date.today + 18.hours # Today at 6PM 169 | time_wrong = Date.today + 19.hours # Today at 7PM 170 | # Booking a model with `time_type: :fixed` requires a `:time` option 171 | @user1.book! @show, time: time_ok # OK 172 | @user2.book! @show, time: time_wrong # raise ActsAsBookable::AvailabilityError 173 | ``` 174 | 175 | #### Time range constraint - `time_type: :range` 176 | 177 | > WARNING - **migration needed!** - with this option the model must have an attribute `schedule: :text` 178 | 179 | The model accepts bookings that specify a `:time_start` and a `:time_end`. After a booking is created, the bookable availability is affected only within that range. (e.g. a meeting room) 180 | 181 | **Configuration** 182 | 183 | ```ruby 184 | class MeetingRoom < ActiveRecord::Base 185 | acts_as_bookable time_type: :range 186 | end 187 | ``` 188 | 189 | **Creating a new bookable** 190 | 191 | Each instance of the model must define its availability in terms of time with an [IceCube Schedule](https://github.com/seejohnrun/ice_cube). Although it's not strictly required, it's strongly suggested to create a schedule with a `:duration`, unless you know exactly what you are doing. 192 | 193 | ```ruby 194 | @meeting_room = MeetingRoom.new(...) 195 | # The schedule starts now and each occurrence is 10 hours long 196 | @meeting_room.schedule = IceCube::Schedule.new(Time.now, duration: 10.hours) 197 | # This meeting_room is available on Mondays starting from 8 AM 198 | @meeting_room.schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday).hour_of_day(8) 199 | @meeting_room.save! 200 | ``` 201 | 202 | **Booking** 203 | 204 | ```ruby 205 | # Next Monday from 9AM to 11AM 206 | from_ok = Date.today.next_week + 9.hours 207 | to_ok = from_ok + 2.hours 208 | # Next Tuesday from 9AM to 11AM 209 | from_wrong = Date.today.next_week + 1.day + 9.hours 210 | to_wrong = from_wrong + 2.hours 211 | 212 | # Booking a model with `time_type: :range` requires `:time_start` and `:time_end` 213 | @user1.book! @meeting_room, time_start: from_ok, time_end: to_ok # OK 214 | @user2.book! @meeting_room, time_start: from_wrong, time_end: to_wrong # raise ActsAsBookable::AvailabilityError 215 | ``` 216 | 217 | ### Bookability across occurrences 218 | 219 | Combined with `time_type: :range`, the option `bookable_across_occurrences` allows for creating bookings that start in an occurrence of the schedule and end in another occurrence. By default, it's set to `false`. 220 | 221 | Let's use two examples to better explain the difference 222 | 223 | #### Not bookable across occurrences **`bookable_across_occurrences: false`** 224 | 225 | The model accepts only bookings that start and end within the same occurrence (e.g. a meeting room) 226 | 227 | **Configuration** 228 | 229 | ```ruby 230 | class MeetingRoom < ActiveRecord::Base 231 | # bookable_across_occurrences is always combined with time_type: :range 232 | # As `bookable_across_occurrences: false` is a default, you can omit it. It's shown here for explanation purposes 233 | acts_as_bookable time_type: :range, bookable_across_occurrences: false 234 | end 235 | ``` 236 | 237 | **Creating a new bookable** 238 | 239 | ```ruby 240 | @meeting_room = MeetingRoom.new(...) 241 | # The schedule starts now and each occurrence is 4 hours long 242 | @meeting_room.schedule = IceCube::Schedule.new(Time.now, duration: 4.hours) 243 | # This meeting_room is available everyday, from 9AM to 13AM and from 2PM to 6PM 244 | @meeting_room.schedule.add_recurrence_rule IceCube::Rule.daily.hour_of_day(9,14) 245 | @meeting_room.save! 246 | ``` 247 | 248 | **Booking** 249 | 250 | ```ruby 251 | # Next Monday from 9AM to 11AM 252 | from_ok = Date.today.next_week + 9.hours 253 | to_ok = from_ok + 2.hours 254 | 255 | # Next Monday from 11AM to 6PM 256 | from_wrong = Date.today.next_week + 11.hours 257 | to_wrong = Date.today.next_week + 18.hours 258 | 259 | # OK - time_start and time_end belong to the same occurrence 260 | @user1.book! @meeting_room, time_start: from_ok, time_end: to_ok 261 | 262 | # raise ActsAsBookable::AvailabilityError - both time_start and time_end are inside the schedule, but they belong to different occurrences 263 | @user2.book! @meeting_room, time_start: from_wrong, time_end: to_wrong 264 | ``` 265 | 266 | #### Bookable across occurrences **`bookable_across_occurrences: true`** 267 | 268 | The model may accept bookings that start and end in different occurrences (e.g. a hotel room) 269 | 270 | **Configuration** 271 | 272 | ```ruby 273 | class Room < ActiveRecord::Base 274 | # bookable_across_occurrences is always combined with time_type: :range 275 | acts_as_bookable time_type: :range, bookable_across_occurrences: true 276 | end 277 | ``` 278 | 279 | **Creating a new bookable** 280 | 281 | ```ruby 282 | @room = Room.new(...) 283 | # The schedule starts today and each occurrence is 1 day long 284 | @room.schedule = IceCube::Schedule.new(Date.today, duration: 1.day) 285 | # This room is available every week, on weekends 286 | @room.schedule.add_recurrence_rule IceCube::Rule.weekly.day(:friday,:saturday,:sunday) 287 | @room.save! 288 | ``` 289 | 290 | **Booking** 291 | 292 | ```ruby 293 | # check-in Friday, check-out Sunday 294 | check_in_ok = Date.today.next_week + 4.days 295 | check_out_ok = check_in_ok + 2.days 296 | 297 | # check-in Tuesday, check-out Sunday 298 | check_in_wrong = Date.today.next_week + 4.days 299 | check_out_wrong = check_in_wrong + 3.days 300 | 301 | # OK - time_start and time_end belong to different occurrences 302 | @user1.book! @room, time_start: check_in_ok, time_end: check_out_ok 303 | 304 | # raise ActsAsBookable::AvailabilityError - while time_end belongs to an occurrence, time_begin doesn't belong to any occurrence of the schedule 305 | @user2.book! @room, time_start: check_in_wrong, time_end: check_out_wrong 306 | ``` 307 | 308 | ### Capacity constraints 309 | 310 | The option `capacity_type` may be used to set a constraint over the `amount` attribute of the booking 311 | 312 | #### No capacity constraints - `capacity_type: :none` 313 | 314 | The model is bookable without capacity constraints. 315 | 316 | ```ruby 317 | class Product < ActiveRecord::Base 318 | # As `capacity_type: :none` is a default, you can omit it. It's shown here for explanation purposes 319 | acts_as_bookable capacity_type: :none 320 | end 321 | ``` 322 | 323 | #### Open capacity - `capacity_type: :open` 324 | 325 | > WARNING - **migration needed!** - with this option the model must have an attribute `capacity: :integer` 326 | 327 | The model is bookable until its `capacity` is reached. (e.g. an event) 328 | 329 | **Configuration** 330 | 331 | ```ruby 332 | class Event < ActiveRecord::Base 333 | acts_as_bookable capacity_type: :open 334 | end 335 | ``` 336 | 337 | **Creating a new bookable** 338 | 339 | Each instance of the model must define its capacity. 340 | 341 | ```ruby 342 | @event = Event.new(...) 343 | @event.capacity = 30 # This event accepts 30 people 344 | @event.save! 345 | ``` 346 | 347 | **Booking** 348 | 349 | ```ruby 350 | # Booking a model with `capacity_type: :open` requires `:amount` 351 | @user1.book! @event, amount: 5 # booking the event for 5 people, OK 352 | @user2.book! @event, amount: 20 # booking the event for other 20 people, OK 353 | @user3.book! @event, amount: 10 # overbooking! raise ActsAsBookable::AvailabilityError 354 | ``` 355 | 356 | #### Closed capacity - `capacity_type: :closed` 357 | 358 | > WARNING - **migration needed!** - with this option the model must have an attribute `capacity: :integer` 359 | 360 | Similar to open capacity, but after the model is booked, it's no more available, no matter if capacity has not been reached. (e.g. a private room) 361 | 362 | **Configuration** 363 | 364 | ```ruby 365 | class PrivateRoom < ActiveRecord::Base 366 | acts_as_bookable capacity_type: :closed 367 | end 368 | ``` 369 | 370 | **Creating a new bookable** 371 | 372 | Each instance of the model must define its capacity. 373 | 374 | ```ruby 375 | @private_room = PrivateRoom.new(...) 376 | @private_room.capacity = 30 # This private_room accepts 30 people 377 | @private_room.save! 378 | ``` 379 | 380 | **Booking** 381 | 382 | ```ruby 383 | # Booking a model with `capacity_type: :closed` requires `:amount` 384 | @user1.book! @private_room, amount: 35 # overbooking! raise ActsAsBookable::AvailabilityError 385 | @user2.book! @private_room, amount: 5 # booking for 5 people, OK 386 | @user3.book! @private_room, amount: 5 # not available! Although the room can still hosts (potentially) 25 people, it has already been booked. raise ActsAsBookable::AvailabilityError 387 | 388 | ``` 389 | 390 | ### Mixing options 391 | 392 | All the options may be mixed together to achieve different goals. 393 | 394 | Adding an option means adding a constraint to the effectiveness of a booking on a bookable. 395 | 396 | ### Presets 397 | 398 | Some combinations of common options are provided as built-in presets. They are activated using just the option `:preset` 399 | 400 | As for now only these presets are provided, others are coming soon: 401 | 402 | * `:room` 403 | 404 | #### Room - `preset: :room` 405 | 406 | > WARNING - **migration needed!** - with this option the model must have an attribute `capacity: :integer` and an attribute `schedule: :text` 407 | 408 | An hotel room has the following costraints: 409 | 410 | 1. It accepts bookings that specify a **range of time** (i.e. check-in and check-out) 411 | 2. It has a **capacity** that cannot be exceeded 412 | 3. After it has been booked, it becomes unavailable for the given range of time, even though its capacity has not been reached. 413 | 4. Its availability is expressed in terms of opening days (a schedule of 1 day long occurrences), but a single booking may cover more than one day (e.g. a weekend) 414 | 415 | **Configuration**: 416 | 417 | ```ruby 418 | class Room < ActiveRecord::Base 419 | acts_as_bookable preset: :room 420 | end 421 | ``` 422 | 423 | Which is equivalent to 424 | 425 | ```ruby 426 | class Room < ActiveRecord::Base 427 | acts_as_bookable time_type: :range, 428 | capacity_type: :closed, 429 | bookable_across_occurrences: true 430 | end 431 | ``` 432 | 433 | 434 | **Booking:** 435 | 436 | ```ruby 437 | # A @user books a @room for 2 people. Check-in is today and check-out is tomorrow. 438 | @user.book! @room, time_start: Date.today, time_end: Date.tomorrow, amount: 2 439 | ``` 440 | 441 | ## FYI 442 | 443 | ### Hey... Why not just an initializer? 444 | 445 | We decided not to provide an initializer to configure bookings because one of the goals of this gem is to allow for different kinds of booking inside the same application. 446 | 447 | This is achieved delegating the responsability of deciding "how a booking should be created" to the Bookable itself. In this way, different configurations of `acts_as_bookable` inside a model bring to different ways of creating and managing bookings for ***that*** *(and only that)* model. 448 | 449 | ### License 450 | 451 | Copyright 2016 Tandù srl 452 | 453 | Permission is hereby granted, free of charge, to any person obtaining 454 | a copy of this software and associated documentation files (the 455 | "Software"), to deal in the Software without restriction, including 456 | without limitation the rights to use, copy, modify, merge, publish, 457 | distribute, sublicense, and/or sell copies of the Software, and to 458 | permit persons to whom the Software is furnished to do so, subject to 459 | the following conditions: 460 | 461 | The above copyright notice and this permission notice shall be 462 | included in all copies or substantial portions of the Software. 463 | 464 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 465 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 466 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 467 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 468 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 469 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 470 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 471 | 472 | ### Acknowledgements 473 | 474 | To speed-up the initialization process of this project, the structure of this repository was strongly influenced by [ActsAsTaggableOn](https://github.com/mbleigh/acts-as-taggable-on) by Michael Bleigh and Intridea Inc. 475 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | desc 'Default: run specs' 5 | task default: :spec 6 | 7 | desc 'Copy sample spec database.yml over if not exists' 8 | task :copy_db_config do 9 | cp 'spec/internal/config/database.yml.sample', 'spec/internal/config/database.yml' 10 | end 11 | 12 | task spec: [:copy_db_config] 13 | 14 | require 'rspec/core/rake_task' 15 | RSpec::Core::RakeTask.new do |t| 16 | t.pattern = 'spec/**/*_spec.rb' 17 | end 18 | 19 | Bundler::GemHelper.install_tasks 20 | -------------------------------------------------------------------------------- /acts_as_bookable.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | # Maintain your gem's version: 5 | require "acts_as_bookable/version" 6 | 7 | # Describe your gem and declare its dependencies: 8 | Gem::Specification.new do |gem| 9 | gem.name = "acts_as_bookable" 10 | gem.version = ActsAsBookable::VERSION 11 | gem.authors = ["Chosko"] 12 | gem.email = ["ruben.caliandro@gmail.com"] 13 | gem.homepage = "https://github.com/tandusrl/acts_as_bookable" 14 | gem.summary = "The reservation engine for Rails applications that allows resources to be booked" 15 | gem.description = "ActsAsBookable is a reservation engine for Rails applications that allows resources to be booked. You can define availability rules for bookable models and set costraints to implement different types of booking (hotels, theaters, meeting rooms...)" 16 | gem.licenses = ["MIT"] 17 | gem.platform = Gem::Platform::RUBY 18 | 19 | gem.files = `git ls-files`.split($/) 20 | gem.test_files = gem.files.grep(%r{^spec/}) 21 | 22 | gem.require_paths = ['lib'] 23 | gem.required_ruby_version = '>= 2.0.0' 24 | 25 | if File.exist?('UPGRADING.md') 26 | gem.post_install_message = File.read('UPGRADING.md') 27 | end 28 | 29 | gem.add_dependency 'ice_cube_chosko', '~> 0.1.0' 30 | gem.add_runtime_dependency 'activerecord', ['>= 3.2', '< 5.1'] 31 | 32 | gem.add_development_dependency 'rake' 33 | gem.add_development_dependency 'rails' 34 | gem.add_development_dependency 'sqlite3', '~> 1.3' 35 | gem.add_development_dependency 'mysql2', '~> 0.3.7' 36 | gem.add_development_dependency 'pg', '~> 0.18' 37 | gem.add_development_dependency 'rspec-rails', '~> 3' 38 | gem.add_development_dependency 'rspec', '~> 3' 39 | gem.add_development_dependency 'coveralls', '~> 0.8' 40 | gem.add_development_dependency 'factory_girl_rails', '~> 4.6' 41 | gem.add_development_dependency 'barrier', '~> 1.0' 42 | gem.add_development_dependency 'database_cleaner', '~>1.5' 43 | gem.add_development_dependency 'awesome_print', '~>1.6' 44 | end 45 | -------------------------------------------------------------------------------- /app/assets/images/acts_as_bookable/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tandusrl/acts_as_bookable/45b78114e1a5dab96e59fc70933277a56f65b53b/app/assets/images/acts_as_bookable/.keep -------------------------------------------------------------------------------- /app/assets/javascripts/acts_as_bookable/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /app/assets/stylesheets/acts_as_bookable/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/controllers/acts_as_bookable/application_controller.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | class ApplicationController < ActionController::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/helpers/acts_as_bookable/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | module ApplicationHelper 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/views/layouts/acts_as_bookable/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ActsAsBookable 5 | <%= stylesheet_link_tag "acts_as_bookable/application", media: "all" %> 6 | <%= javascript_include_tag "acts_as_bookable/application" %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application. 3 | 4 | ENGINE_ROOT = File.expand_path('../..', __FILE__) 5 | ENGINE_PATH = File.expand_path('../../lib/acts_as_bookable/engine', __FILE__) 6 | 7 | # Set up gems listed in the Gemfile. 8 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 9 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 10 | 11 | require 'rails/all' 12 | require 'rails/engine/commands' 13 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | acts_as_bookable: 3 | errors: 4 | messages: 5 | booking: 6 | bookable_must_be_bookable: "cannot book a %{model} as it\'s not bookable" 7 | booker_must_be_booker: "a %{model} cannot book a resource, because %{model} is not a booker" 8 | availability: 9 | amount_gt_capacity: "amount cannot be greater than %{model} capacity" 10 | already_booked: "the %{model} is fully booked" 11 | unavailable_interval: "the %{model} is not available from %{time_start} to %{time_end}" 12 | unavailable_time: "the %{model} is not available at %{time}" 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ActsAsBookable::Engine.routes.draw do 2 | end 3 | -------------------------------------------------------------------------------- /db/migrate/20160217085200_create_acts_as_bookable_bookings.rb: -------------------------------------------------------------------------------- 1 | class CreateActsAsBookableBookings < ActiveRecord::Migration 2 | def change 3 | create_table :acts_as_bookable_bookings, force: true do |t| 4 | t.references :bookable, polymorphic: true, index: {name: "index_acts_as_bookable_bookings_bookable"} 5 | t.references :booker, polymorphic: true, index: {name: "index_acts_as_bookable_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 | t.datetime :created_at 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /gemfiles/activerecord_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", :github => "rails/rails", :branch => "3-2-stable" 6 | 7 | group :local_development do 8 | gem "guard" 9 | gem "guard-rspec" 10 | gem "appraisal" 11 | gem "byebug", :platform => :mri_21 12 | gem "pry-nav" 13 | end 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", :github => "rails/rails", :branch => "4-0-stable" 6 | 7 | group :local_development do 8 | gem "guard" 9 | gem "guard-rspec" 10 | gem "appraisal" 11 | gem "byebug", :platform => :mri_21 12 | gem "pry-nav" 13 | end 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", :github => "rails/rails", :branch => "4-1-stable" 6 | 7 | group :local_development do 8 | gem "guard" 9 | gem "guard-rspec" 10 | gem "appraisal" 11 | gem "byebug", :platform => :mri_21 12 | gem "pry-nav" 13 | end 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "railties", :github => "rails/rails", :branch => "4-2-stable" 6 | gem "activerecord", :github => "rails/rails", :branch => "4-2-stable" 7 | 8 | group :local_development do 9 | gem "guard" 10 | gem "guard-rspec" 11 | gem "appraisal" 12 | gem "byebug", :platform => :mri_21 13 | gem "pry-nav" 14 | end 15 | 16 | gemspec :path => "../" 17 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "railties", :github => "rails/rails", :branch => "5-0-stable" 6 | gem "activerecord", :github => "rails/rails", :branch => "5-0-stable" 7 | 8 | group :local_development do 9 | gem "guard" 10 | gem "guard-rspec" 11 | gem "appraisal" 12 | gem "byebug", :platform => :mri_21 13 | gem "pry-nav" 14 | end 15 | 16 | gemspec :path => "../" 17 | -------------------------------------------------------------------------------- /lib/acts_as_bookable.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_record/version' 3 | require 'active_support/core_ext/module' 4 | require_relative 'acts_as_bookable/engine' if defined?(Rails) 5 | require 'ice_cube' 6 | IceCube.compatibility = 12 # Drop compatibility for :start_date, avoiding a bunch of warnings caused by serialization 7 | 8 | module ActsAsBookable 9 | extend ActiveSupport::Autoload 10 | 11 | autoload :Bookable 12 | autoload :Booker 13 | autoload :Booking 14 | autoload :T 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 acts_as_bookable 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 ActsAsBookable::Bookable 41 | include ActsAsBookable::Booker 42 | end 43 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/bookable.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 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 | # acts_as_bookable 14 | # end 15 | def acts_as_bookable(options={}) 16 | bookable(options) 17 | end 18 | 19 | private 20 | 21 | # Make a model bookable 22 | def bookable(options) 23 | 24 | if bookable? 25 | self.booking_opts = options 26 | else 27 | class_attribute :booking_opts 28 | self.booking_opts = options 29 | 30 | class_eval do 31 | serialize :schedule, IceCube::Schedule 32 | 33 | has_many :bookings, as: :bookable, dependent: :destroy, class_name: '::ActsAsBookable::Booking' 34 | 35 | validates_presence_of :schedule, if: :schedule_required? 36 | validates_presence_of :capacity, if: :capacity_required? 37 | validates_numericality_of :capacity, if: :capacity_required?, only_integer: true, greater_than_or_equal_to: 0 38 | 39 | def self.bookable? 40 | true 41 | end 42 | 43 | def schedule_required? 44 | self.booking_opts && self.booking_opts && self.booking_opts[:time_type] != :none 45 | end 46 | 47 | def capacity_required? 48 | self.booking_opts && self.booking_opts[:capacity_type] != :none 49 | end 50 | end 51 | end 52 | 53 | include Core 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/bookable/core.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable::Bookable 2 | module Core 3 | def self.included(base) 4 | base.extend ActsAsBookable::Bookable::Core::ClassMethods 5 | base.send :include, ActsAsBookable::Bookable::Core::InstanceMethods 6 | 7 | base.initialize_acts_as_bookable_core 8 | end 9 | 10 | module ClassMethods 11 | ## 12 | # Initialize the core of Bookable 13 | # 14 | def initialize_acts_as_bookable_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 ActsAsBookable::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 ActsAsBookable::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 ActsAsBookable::InitializationError.new(self, "#{key} is not a valid option") 114 | elsif !permitted_options[key].include? val 115 | raise ActsAsBookable::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 ActsAsBookable::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 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.amount_gt_capacity', model: self.class.to_s) 173 | end 174 | end 175 | 176 | ## 177 | # Time check 178 | # 179 | if self.booking_opts[:time_type] == :range 180 | time_check_ok = true 181 | # If it's bookable across recurrences, just check start time and end time 182 | if self.booking_opts[:bookable_across_occurrences] 183 | # Check start time 184 | if !(ActsAsBookable::TimeUtils.time_in_schedule?(self.schedule, opts[:time_start])) 185 | time_check_ok = false 186 | end 187 | # Check end time 188 | if !(ActsAsBookable::TimeUtils.time_in_schedule?(self.schedule, opts[:time_end])) 189 | time_check_ok = false 190 | end 191 | # If it's not bookable across recurrences, check if the whole interval is included in an occurrence 192 | else 193 | # Check the whole interval 194 | if !(ActsAsBookable::TimeUtils.interval_in_schedule?(self.schedule, opts[:time_start], opts[:time_end])) 195 | time_check_ok = false 196 | end 197 | end 198 | # If something went wrong 199 | unless time_check_ok 200 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.unavailable_interval', model: self.class.to_s, time_start: opts[:time_start], time_end: opts[:time_end]) 201 | end 202 | end 203 | if self.booking_opts[:time_type] == :fixed 204 | if !(ActsAsBookable::TimeUtils.time_in_schedule?(self.schedule, opts[:time])) 205 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.unavailable_time', model: self.class.to_s, time: opts[:time]) 206 | end 207 | end 208 | 209 | ## 210 | # Real capacity check (calculated with overlapped bookings) 211 | # 212 | overlapped = ActsAsBookable::Booking.overlapped(self, opts) 213 | # If capacity_type is :closed cannot book if already booked (no matter if amount < capacity) 214 | if (self.booking_opts[:capacity_type] == :closed && !overlapped.empty?) 215 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.already_booked', model: self.class.to_s) 216 | end 217 | # if capacity_type is :open, check if amount <= maximum amount of overlapped booking 218 | if (self.booking_opts[:capacity_type] == :open && !overlapped.empty?) 219 | # if time_type is :range, split in sub-intervals and check the maximum sum of amounts against capacity for each sub-interval 220 | if (self.booking_opts[:time_type] == :range) 221 | # Map overlapped bookings to a set of intervals with amount 222 | intervals = overlapped.map { |e| {time_start: e.time_start, time_end: e.time_end, amount: e.amount} } 223 | # Make subintervals from overlapped bookings and check capacity for each of them 224 | ActsAsBookable::TimeUtils.subintervals(intervals) do |a,b,op| 225 | case op 226 | when :open 227 | res = {amount: a[:amount] + b[:amount]} 228 | when :close 229 | res = {amount: a[:amount] - b[:amount]} 230 | end 231 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.already_booked', model: self.class.to_s) if (res[:amount] > self.capacity) 232 | res 233 | end 234 | # else, just sum the amounts (fixed times are not intervals and they overlap if are the same) 235 | else 236 | if(overlapped.sum(:amount) + opts[:amount] > self.capacity) 237 | raise ActsAsBookable::AvailabilityError.new ActsAsBookable::T.er('.availability.already_booked', model: self.class.to_s) 238 | end 239 | end 240 | end 241 | true 242 | end 243 | 244 | ## 245 | # Check availability of current bookable 246 | # 247 | # @param opts The booking options 248 | # @return true if the bookable is available for given options, otherwise return false 249 | # 250 | # Example: 251 | # @room.check_availability!(from: Date.today, to: Date.tomorrow, amount: 2) 252 | def check_availability(opts) 253 | begin 254 | check_availability!(opts) 255 | rescue ActsAsBookable::AvailabilityError 256 | false 257 | end 258 | end 259 | 260 | ## 261 | # Accept a booking by a booker. This is an alias method, 262 | # equivalent to @booker.book!(@bookable, opts) 263 | # 264 | # @param booker The booker model 265 | # @param opts The booking options 266 | # 267 | # Example: 268 | # @room.be_booked!(@user, from: Date.today, to: Date.tomorrow, amount: 2) 269 | def be_booked!(booker, opts={}) 270 | booker.book!(self, opts) 271 | end 272 | 273 | ## 274 | # Check if options passed for booking this Bookable are valid 275 | # 276 | # @raise ActsAsBookable::OptionsInvalid if options are not valid 277 | # @param opts The booking options 278 | # 279 | def validate_booking_options!(opts) 280 | self.class.validate_booking_options!(opts) 281 | end 282 | 283 | def booker? 284 | self.class.booker? 285 | end 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/booker.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 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 | # acts_as_booker 15 | # end 16 | def acts_as_booker(opts={}) 17 | class_eval do 18 | has_many :bookings, as: :booker, dependent: :destroy, class_name: '::ActsAsBookable::Booking' 19 | end 20 | 21 | include ActsAsBookable::Booker::InstanceMethods 22 | extend ActsAsBookable::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 ActsAsBookable::OptionsInvalid if opts are not valid for given bookable 37 | # @raise ActsAsBookable::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 = ActsAsBookable::Booking.create!(booking_params) 49 | 50 | # reload the bookable to make changes available 51 | bookable.reload 52 | self.reload 53 | booking 54 | end 55 | 56 | def booker? 57 | self.class.booker? 58 | end 59 | end 60 | 61 | module SingletonMethods 62 | def booker? 63 | true 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/booking.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | ## 3 | # Booking model. Store in database bookings made by bookers on bookables 4 | # 5 | class Booking < ::ActiveRecord::Base 6 | self.table_name = 'acts_as_bookable_bookings' 7 | 8 | belongs_to :bookable, polymorphic: true 9 | belongs_to :booker, polymorphic: true 10 | 11 | validates_presence_of :bookable 12 | validates_presence_of :booker 13 | validate :bookable_must_be_bookable, 14 | :booker_must_be_booker 15 | 16 | ## 17 | # Retrieves overlapped bookings, given a bookable and some booking options 18 | # 19 | scope :overlapped, ->(bookable,opts) { 20 | query = where(bookable_id: bookable.id) 21 | 22 | # Time options 23 | if(opts[:time].present?) 24 | query = DBUtils.time_comparison(query,'time','=',opts[:time]) 25 | end 26 | if(opts[:time_start].present?) 27 | query = DBUtils.time_comparison(query,'time_end', '>=', opts[:time_start]) 28 | end 29 | if(opts[:time_end].present?) 30 | query = DBUtils.time_comparison(query,'time_start', '<', opts[:time_end]) 31 | end 32 | query 33 | } 34 | 35 | private 36 | ## 37 | # Validation method. Check if the bookable resource is actually bookable 38 | # 39 | def bookable_must_be_bookable 40 | if bookable.present? && !bookable.class.bookable? 41 | errors.add(:bookable, T.er('booking.bookable_must_be_bookable', model: bookable.class.to_s)) 42 | end 43 | end 44 | 45 | ## 46 | # Validation method. Check if the booker model is actually a booker 47 | # 48 | def booker_must_be_booker 49 | if booker.present? && !booker.class.booker? 50 | errors.add(:booker, T.er('booking.booker_must_be_booker', model: booker.class.to_s)) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/db_utils.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | module DBUtils 3 | class << self 4 | def connection 5 | ActsAsBookable::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 | ## 34 | # Compare times according to the DB 35 | # 36 | def time_comparison (query, field, operator, time) 37 | if using_postgresql? 38 | query.where("#{field}::timestamp #{operator} ?::timestamp", time.to_time.utc.to_s) 39 | elsif using_sqlite? 40 | query.where("Datetime(#{field}) #{operator} Datetime('#{time.to_time.utc.iso8601}')") 41 | else 42 | query.where("#{field} #{operator} ?", time.to_time) 43 | end 44 | end 45 | 46 | # escape _ and % characters in strings, since these are wildcards in SQL. 47 | def escape_like(str) 48 | str.gsub(/[!%_]/) { |x| '!' + x } 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/engine.rb: -------------------------------------------------------------------------------- 1 | require 'rails/engine' 2 | module ActsAsBookable 3 | class Engine < ::Rails::Engine 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/t.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | module T 3 | def self.t(message, opts={}) 4 | I18n.t('.acts_as_bookable.' + message, opts) 5 | end 6 | 7 | def self.er(message, opts={}) 8 | self.t('errors.messages.' + message, opts) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/time_utils.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 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, 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 136 | -------------------------------------------------------------------------------- /lib/acts_as_bookable/version.rb: -------------------------------------------------------------------------------- 1 | module ActsAsBookable 2 | VERSION = "0.1.4" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/acts_as_bookable_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :acts_as_bookable do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/acts_as_bookable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'acts_as_bookable' do 4 | it "should provide a class method 'bookable?' that is false for unbookable models" do 5 | expect(Unbookable).not_to be_bookable 6 | end 7 | 8 | describe 'Bookable Method Generation' do 9 | before :each do 10 | Unbookable.acts_as_bookable 11 | @bookable = Unbookable.new() 12 | end 13 | 14 | it "should respond 'true' to bookable?" do 15 | expect(@bookable.class).to be_bookable 16 | end 17 | end 18 | 19 | describe 'class configured as Bookable' do 20 | before(:each) do 21 | @bookable = Bookable.new 22 | end 23 | 24 | it 'should add #bookable? query method to the class-side' do 25 | expect(Bookable).to respond_to(:bookable?) 26 | end 27 | 28 | it 'should return true from the class-side #bookable?' do 29 | expect(Bookable.bookable?).to be_truthy 30 | end 31 | 32 | it 'should return false from the base #bookable?' do 33 | expect(ActiveRecord::Base.bookable?).to be_falsy 34 | end 35 | 36 | # it 'should add #tag method on the instance-side' do 37 | # expect(@bookable).to respond_to(:tag) 38 | # end 39 | 40 | # it 'should generate an association for #owned_taggings and #owned_tags' do 41 | # expect(@bookable).to respond_to(:owned_taggings, :owned_tags) 42 | # end 43 | end 44 | 45 | describe 'Reloading' do 46 | it 'should save a model instantiated by Model.find' do 47 | bookable = create(:bookable) 48 | found_bookable = Bookable.find(bookable.id) 49 | expect(found_bookable.save).to eq true 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/acts_as_booker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'acts_as_booker' do 4 | it "should provide a class method 'booker?' that is false for not booker models" do 5 | expect(NotBooker).not_to be_booker 6 | end 7 | 8 | describe 'Booker Method Generation' do 9 | before :each do 10 | NotBooker.acts_as_booker 11 | @booker = NotBooker.new() 12 | end 13 | 14 | it "should respond 'true' to booker?" do 15 | expect(@booker.class).to be_booker 16 | end 17 | end 18 | 19 | describe 'class configured as Booker' do 20 | before(:each) do 21 | @booker = Booker.new 22 | end 23 | 24 | it 'should add #booker? query method to the class-side' do 25 | expect(Booker).to respond_to(:booker?) 26 | end 27 | 28 | it 'should return true from the class-side #booker?' do 29 | expect(Booker.booker?).to be_truthy 30 | end 31 | 32 | it 'should return false from the base #booker?' do 33 | expect(ActiveRecord::Base.booker?).to be_falsy 34 | end 35 | 36 | it 'should add #booker? query method to the instance-side' do 37 | expect(@booker).to respond_to(:booker?) 38 | end 39 | 40 | it 'should add #booker? query method to the instance-side' do 41 | expect(@booker.booker?).to be_truthy 42 | end 43 | 44 | 45 | # it 'should generate an association for #owned_taggings and #owned_tags' do 46 | # expect(@booker).to respond_to(:owned_taggings, :owned_tags) 47 | # end 48 | end 49 | 50 | describe 'Reloading' do 51 | it 'should save a model instantiated by Model.find' do 52 | booker = Booker.create!(name: 'Booker') 53 | found_booker = Booker.find(booker.id) 54 | expect(found_booker.save).to eq true 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/bookable/core_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Bookable model' do 4 | describe 'InstanceMethods' do 5 | it 'should add a method #check_availability! in instance-side' do 6 | @bookable = Bookable.new 7 | expect(@bookable).to respond_to :check_availability! 8 | end 9 | 10 | it 'should add a method #check_availability in instance-side' do 11 | @bookable = Bookable.new 12 | expect(@bookable).to respond_to :check_availability 13 | end 14 | 15 | it 'should add a method #validate_booking_options! in instance-side' do 16 | @bookable = Bookable.new 17 | expect(@bookable).to respond_to :validate_booking_options! 18 | end 19 | 20 | describe '#check_availability! and check_availability' do 21 | after(:each) do 22 | Bookable.booking_opts = {} 23 | Bookable.initialize_acts_as_bookable_core 24 | end 25 | 26 | describe 'whithout any constraint' do 27 | before(:each) do 28 | Bookable.booking_opts = { 29 | time_type: :none, 30 | capacity_type: :none 31 | } 32 | Bookable.initialize_acts_as_bookable_core 33 | @bookable = Bookable.create!(name: 'bookable') 34 | end 35 | 36 | it 'should be always available' do 37 | expect(@bookable.check_availability({})).to be_truthy 38 | expect(@bookable.check_availability!({})).to be_truthy 39 | end 40 | end 41 | end 42 | 43 | describe 'with time_type: :range' do 44 | before(:each) do 45 | Bookable.booking_opts = { 46 | time_type: :range, 47 | capacity_type: :none 48 | } 49 | Bookable.initialize_acts_as_bookable_core 50 | @bookable = Bookable.create!(name: 'bookable', schedule: IceCube::Schedule.new('2016-01-01'.to_date, duration: 1.day)) 51 | ## bookable the first and third day of the month 52 | @bookable.schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month([1,3]) 53 | @bookable.save! 54 | end 55 | 56 | it 'should be available in bookable times' do 57 | time = '2016-01-01'.to_date 58 | endtime = time + 10.minutes 59 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 60 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 61 | end 62 | 63 | it 'should not be available in not bookable times' do 64 | time = '2016-01-02'.to_date 65 | endtime = '2016-01-04'.to_date 66 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_falsy 67 | expect{ @bookable.check_availability!({time_start: time, time_end: endtime}) }.to raise_error ActsAsBookable::AvailabilityError 68 | begin 69 | @bookable.check_availability!({time_start: time, time_end: endtime}) 70 | rescue ActsAsBookable::AvailabilityError => e 71 | expect(e.message).to include "the Bookable is not available from #{time.to_time} to #{endtime.to_time}" 72 | end 73 | end 74 | 75 | it 'should be bookable within a bookable time' do 76 | time = '2016-01-01'.to_date + 1.minute 77 | endtime = '2016-01-02'.to_date - 1.minute 78 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 79 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 80 | end 81 | 82 | it 'should be bookable within a bookable time' do 83 | time = '2016-01-01'.to_date + 1.minute 84 | endtime = '2016-01-02'.to_date - 1.minute 85 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 86 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 87 | end 88 | 89 | it 'should not be available when time_start is available, time_end is available but the availability is splitted in between' do 90 | time = '2016-01-01'.to_date + 1.minute 91 | endtime = '2016-01-03'.to_date + 1.minute 92 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_falsy 93 | expect{ @bookable.check_availability!({time_start: time, time_end: endtime}) }.to raise_error ActsAsBookable::AvailabilityError 94 | begin 95 | @bookable.check_availability!({time_start: time, time_end: endtime}) 96 | rescue ActsAsBookable::AvailabilityError => e 97 | expect(e.message).to include "the Bookable is not available from #{time} to #{endtime}" 98 | end 99 | end 100 | end 101 | 102 | describe 'with time_type: :range and with bookable_across_occurrences: true' do 103 | before(:each) do 104 | Bookable.booking_opts = { 105 | time_type: :range, 106 | capacity_type: :none, 107 | bookable_across_occurrences: true 108 | } 109 | Bookable.initialize_acts_as_bookable_core 110 | @bookable = Bookable.create!(name: 'bookable', schedule: IceCube::Schedule.new('2016-01-01'.to_date, duration: 1.day)) 111 | ## bookable the first and third day of the month 112 | @bookable.schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month([1,3]) 113 | @bookable.save! 114 | end 115 | 116 | it 'should be available in bookable times' do 117 | time = '2016-01-01'.to_date 118 | endtime = time + 10.minutes 119 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 120 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 121 | end 122 | 123 | it 'should not be available in not bookable times' do 124 | time = '2016-01-02'.to_date 125 | endtime = '2016-01-04'.to_date 126 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_falsy 127 | expect{ @bookable.check_availability!({time_start: time, time_end: endtime}) }.to raise_error ActsAsBookable::AvailabilityError 128 | begin 129 | @bookable.check_availability!({time_start: time, time_end: endtime}) 130 | rescue ActsAsBookable::AvailabilityError => e 131 | expect(e.message).to include "the Bookable is not available from #{time.to_time} to #{endtime.to_time}" 132 | end 133 | end 134 | 135 | it 'should be bookable within a bookable time' do 136 | time = '2016-01-01'.to_date + 1.minute 137 | endtime = '2016-01-02'.to_date - 1.minute 138 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 139 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 140 | end 141 | 142 | it 'should be bookable within a bookable time' do 143 | time = '2016-01-01'.to_date + 1.minute 144 | endtime = '2016-01-02'.to_date - 1.minute 145 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 146 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 147 | end 148 | 149 | it 'should be available when time_start is available, time_end is available and the availability is splitted in between' do 150 | time = '2016-01-01'.to_date + 1.minute 151 | endtime = '2016-01-03'.to_date + 1.minute 152 | expect(@bookable.check_availability({time_start: time, time_end: endtime})).to be_truthy 153 | expect(@bookable.check_availability!({time_start: time, time_end: endtime})).to be_truthy 154 | end 155 | end 156 | 157 | describe 'with time_type: :fixed' do 158 | before(:each) do 159 | Bookable.booking_opts = { 160 | time_type: :fixed, 161 | capacity_type: :none 162 | } 163 | Bookable.initialize_acts_as_bookable_core 164 | @bookable = Bookable.create!(name: 'bookable', schedule: IceCube::Schedule.new('2016-01-01'.to_date)) 165 | ## bookable the first and third day of the month, at 9AM 166 | @bookable.schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month([1,3]).hour_of_day(9) 167 | @bookable.save! 168 | end 169 | 170 | it 'should be available in available times' do 171 | time = '2016-01-01'.to_date + 9.hours 172 | expect(@bookable.check_availability(time: time)).to be_truthy 173 | expect(@bookable.check_availability!(time: time)).to be_truthy 174 | time = '2016-01-03'.to_date + 9.hours 175 | expect(@bookable.check_availability(time: time)).to be_truthy 176 | expect(@bookable.check_availability!(time: time)).to be_truthy 177 | end 178 | 179 | it 'should not be available in not bookable day' do 180 | time = '2016-01-02'.to_date 181 | expect(@bookable.check_availability(time: time)).to be_falsy 182 | expect{ @bookable.check_availability!(time: time) }.to raise_error ActsAsBookable::AvailabilityError 183 | begin 184 | @bookable.check_availability!(time: time) 185 | rescue ActsAsBookable::AvailabilityError => e 186 | expect(e.message).to include "the Bookable is not available at #{time}" 187 | end 188 | end 189 | 190 | 191 | it 'should not be available in bookable day but not bookable time' do 192 | time = '2016-01-02'.to_date + 10.hours 193 | expect(@bookable.check_availability(time: time)).to be_falsy 194 | expect{ @bookable.check_availability!(time: time) }.to raise_error ActsAsBookable::AvailabilityError 195 | begin 196 | @bookable.check_availability!(time: time) 197 | rescue ActsAsBookable::AvailabilityError => e 198 | expect(e.message).to include "the Bookable is not available at #{time}" 199 | end 200 | end 201 | 202 | it 'should not be available very close to a bookable time but not the exact second' do 203 | time = '2016-01-02'.to_date + 9.hours + 1.second 204 | expect(@bookable.check_availability(time: time)).to be_falsy 205 | expect{ @bookable.check_availability!(time: time) }.to raise_error ActsAsBookable::AvailabilityError 206 | begin 207 | @bookable.check_availability!(time: time) 208 | rescue ActsAsBookable::AvailabilityError => e 209 | expect(e.message).to include "the Bookable is not available at #{time}" 210 | end 211 | end 212 | end 213 | 214 | describe 'with capacity_type: :open' do 215 | before(:each) do 216 | Bookable.booking_opts = { 217 | time_type: :none, 218 | capacity_type: :open 219 | } 220 | Bookable.initialize_acts_as_bookable_core 221 | @bookable = Bookable.create!(name: 'bookable', capacity: 4) 222 | end 223 | 224 | it 'should be available if amount <= capacity' do 225 | (1..@bookable.capacity).each do |amount| 226 | expect(@bookable.check_availability(amount: amount)).to be_truthy 227 | expect(@bookable.check_availability!(amount: amount)).to be_truthy 228 | end 229 | end 230 | 231 | it 'should not be available if amount > capacity' do 232 | expect(@bookable.check_availability(amount: @bookable.capacity + 1)).to be_falsy 233 | expect { @bookable.check_availability!(amount: @bookable.capacity + 1) }.to raise_error ActsAsBookable::AvailabilityError 234 | begin 235 | @bookable.check_availability!(amount: @bookable.capacity + 1) 236 | rescue ActsAsBookable::AvailabilityError => e 237 | expect(e.message).to include 'cannot be greater' 238 | end 239 | end 240 | 241 | it 'should be available if already booked but amount <= conditional capacity' do 242 | booker = create(:booker) 243 | @bookable.be_booked!(booker, amount: 2) 244 | (1..(@bookable.capacity - 2)).each do |amount| 245 | expect(@bookable.check_availability(amount: amount)).to be_truthy 246 | expect(@bookable.check_availability!(amount: amount)).to be_truthy 247 | end 248 | end 249 | 250 | it 'should not be available if amount <= capacity but already booked and amount > conditional capacity' do 251 | booker = create(:booker) 252 | @bookable.be_booked!(booker, amount: 2) 253 | amount = @bookable.capacity - 2 + 1 254 | expect(@bookable.check_availability(amount: amount)).to be_falsy 255 | expect { @bookable.check_availability!(amount: amount) }.to raise_error ActsAsBookable::AvailabilityError 256 | begin 257 | @bookable.check_availability!(amount: amount) 258 | rescue ActsAsBookable::AvailabilityError => e 259 | expect(e.message).to include 'is fully booked' 260 | end 261 | end 262 | 263 | it 'should be available if amount <= capacity and already booked and amount > conditional capacity but overlappings are separated in time and space' do 264 | Bookable.booking_opts = { 265 | time_type: :range, 266 | capacity_type: :open, 267 | bookable_across_occurrences: true 268 | } 269 | Bookable.initialize_acts_as_bookable_core 270 | @bookable = Bookable.create!(name: 'bookable', capacity: 4, schedule: IceCube::Schedule.new(Date.today, duration: 1.day)) 271 | @bookable.schedule.add_recurrence_rule IceCube::Rule.daily 272 | @bookable.save! 273 | booker = create(:booker) 274 | @bookable.be_booked!(booker, amount: 3, time_start: Date.today, time_end: Date.today + 8.hours) 275 | @bookable.be_booked!(booker, amount: 3, time_start: Date.today + 8.hours, time_end: Date.today + 16.hours) 276 | @bookable.be_booked!(booker, amount: 3, time_start: Date.today + 16.hours, time_end: Date.today + 24.hours) 277 | amount = 1 278 | expect(@bookable.check_availability(amount: amount, time_start: Date.today, time_end: Date.today + 24.hours)).to be_truthy 279 | end 280 | 281 | it 'should not be available if amount <= capacity and already booked and amount > conditional capacity' do 282 | Bookable.booking_opts = { 283 | time_type: :range, 284 | capacity_type: :open, 285 | bookable_across_occurrences: true 286 | } 287 | Bookable.initialize_acts_as_bookable_core 288 | @bookable = Bookable.create!(name: 'bookable', capacity: 4, schedule: IceCube::Schedule.new(Date.today, duration: 1.day)) 289 | @bookable.schedule.add_recurrence_rule IceCube::Rule.daily 290 | @bookable.save! 291 | booker = create(:booker) 292 | @bookable.be_booked!(booker, amount: 3, time_start: Date.today, time_end: Date.today + 8.hours) 293 | @bookable.be_booked!(booker, amount: 3, time_start: Date.today + 8.hours, time_end: Date.today + 16.hours) 294 | @bookable.be_booked!(booker, amount: 1, time_start: Date.today + 8.hours, time_end: Date.today + 24.hours) 295 | amount = 2 296 | expect(@bookable.check_availability(amount: amount, time_start: Date.today, time_end: Date.today + 8.hours)).to be_truthy 297 | expect(@bookable.check_availability(amount: amount, time_start: Date.today + 8.hours, time_end: Date.today + 16.hours)).to be_truthy 298 | expect(@bookable.check_availability(amount: amount, time_start: Date.today + 16.hours, time_end: Date.today + 24.hours)).to be_truthy 299 | expect(@bookable.check_availability(amount: amount, time_start: Date.today, time_end: Date.today + 24.hours)).to be_truthy 300 | end 301 | end 302 | 303 | describe 'with capacity_type: :closed' do 304 | before(:each) do 305 | Bookable.booking_opts = { 306 | time_type: :none, 307 | capacity_type: :closed 308 | } 309 | Bookable.initialize_acts_as_bookable_core 310 | @bookable = Bookable.create!(name: 'bookable', capacity: 4) 311 | end 312 | 313 | it 'should be available if amount <= capacity' do 314 | (1..@bookable.capacity).each do |amount| 315 | expect(@bookable.check_availability(amount: amount)).to be_truthy 316 | expect(@bookable.check_availability!(amount: amount)).to be_truthy 317 | end 318 | end 319 | 320 | it 'should not be available if amount > capacity' do 321 | expect(@bookable.check_availability(amount: @bookable.capacity + 1)).to be_falsy 322 | expect { @bookable.check_availability!(amount: @bookable.capacity + 1) }.to raise_error ActsAsBookable::AvailabilityError 323 | begin 324 | @bookable.check_availability!(amount: @bookable.capacity + 1) 325 | rescue ActsAsBookable::AvailabilityError => e 326 | expect(e.message).to include 'cannot be greater' 327 | end 328 | end 329 | 330 | it 'should not be available if already booked (even though amount < capacity - overlapped amounts)' do 331 | booker = create(:booker) 332 | @bookable.be_booked!(booker, amount: 1) 333 | (1..(@bookable.capacity + 1)).each do |amount| 334 | expect(@bookable.check_availability(amount: amount)).to be_falsy 335 | expect { @bookable.check_availability!(amount: amount) }.to raise_error ActsAsBookable::AvailabilityError 336 | begin 337 | @bookable.check_availability!(amount: amount) 338 | rescue ActsAsBookable::AvailabilityError => e 339 | if(amount <= @bookable.capacity) 340 | expect(e.message).to include('is fully booked') 341 | else 342 | expect(e.message).to include('cannot be greater') 343 | end 344 | end 345 | end 346 | end 347 | end 348 | end 349 | 350 | describe 'classMethods' do 351 | before(:each) do 352 | Bookable.booking_opts = {} 353 | Bookable.initialize_acts_as_bookable_core 354 | end 355 | after(:each) do 356 | Bookable.booking_opts = {} 357 | Bookable.initialize_acts_as_bookable_core 358 | end 359 | 360 | describe 'self.initialize_acts_as_bookable_core' do 361 | describe '#set_options' do 362 | it 'preset options for room' do 363 | [:room,:event,:show].each do |p| 364 | Bookable.booking_opts = {preset: p} 365 | Bookable.initialize_acts_as_bookable_core 366 | expect(Bookable.booking_opts[:preset]).to eq p 367 | expect(Bookable.booking_opts[:time_type]).to be(:range).or be(:fixed).or be(:none) 368 | expect(Bookable.booking_opts[:capacity_type]).to be(:open).or be(:closed) 369 | expect(Bookable.booking_opts[:bookable_across_occurrences]).to be(true).or be(false) 370 | end 371 | end 372 | 373 | it 'fails when using an unknown preset' do 374 | Bookable.booking_opts = {preset: 'unknown'} 375 | expect{ Bookable.initialize_acts_as_bookable_core }.to raise_error ActsAsBookable::InitializationError 376 | end 377 | 378 | it 'correctly set undefined options' do 379 | Bookable.booking_opts = {} 380 | Bookable.initialize_acts_as_bookable_core 381 | expect(Bookable.booking_opts[:preset]).not_to be_present 382 | expect(Bookable.booking_opts[:date_type]).not_to be_present 383 | expect(Bookable.booking_opts[:time_type]).to be_present 384 | expect(Bookable.booking_opts[:location_type]).not_to be_present 385 | expect(Bookable.booking_opts[:capacity_type]).to be_present 386 | expect(Bookable.booking_opts[:bookable_across_occurrences]).not_to be_nil 387 | end 388 | 389 | it 'correctly merges options' do 390 | Bookable.booking_opts = { 391 | time_type: :range, 392 | capacity_type: :closed, 393 | bookable_across_occurrences: false 394 | } 395 | Bookable.initialize_acts_as_bookable_core 396 | expect(Bookable.booking_opts[:preset]).not_to be_present 397 | expect(Bookable.booking_opts[:date_type]).not_to be_present 398 | expect(Bookable.booking_opts[:time_type]).to be :range 399 | expect(Bookable.booking_opts[:location_type]).not_to be_present 400 | expect(Bookable.booking_opts[:capacity_type]).to be :closed 401 | expect(Bookable.booking_opts[:bookable_across_occurrences]).to be false 402 | end 403 | 404 | it 'should not allow unknown keys' do 405 | Bookable.booking_opts = {unknown: 'lol'} 406 | expect { Bookable.initialize_acts_as_bookable_core }.to raise_error ActsAsBookable::InitializationError 407 | begin 408 | Bookable.initialize_acts_as_bookable_core 409 | rescue ActsAsBookable::InitializationError => e 410 | expect(e.message).to include 'is not a valid option' 411 | end 412 | end 413 | 414 | it 'should not allow unknown values on :time_type' do 415 | Bookable.booking_opts = {time_type: :unknown} 416 | expect { Bookable.initialize_acts_as_bookable_core }.to raise_error ActsAsBookable::InitializationError 417 | begin 418 | Bookable.initialize_acts_as_bookable_core 419 | rescue ActsAsBookable::InitializationError => e 420 | expect(e.message).to include 'is not a valid value for time_type' 421 | end 422 | end 423 | 424 | it 'should not allow unknown values on :capacity_type' do 425 | Bookable.booking_opts = {capacity_type: :unknown} 426 | expect { Bookable.initialize_acts_as_bookable_core }.to raise_error ActsAsBookable::InitializationError 427 | begin 428 | Bookable.initialize_acts_as_bookable_core 429 | rescue ActsAsBookable::InitializationError => e 430 | expect(e.message).to include 'is not a valid value for capacity_type' 431 | end 432 | end 433 | 434 | it 'should not allow unknown values on bookable_across_occurrences' do 435 | Bookable.booking_opts = {bookable_across_occurrences: :unknown} 436 | expect { Bookable.initialize_acts_as_bookable_core }.to raise_error ActsAsBookable::InitializationError 437 | begin 438 | Bookable.initialize_acts_as_bookable_core 439 | rescue ActsAsBookable::InitializationError => e 440 | expect(e.message).to include 'is not a valid value for bookable_across_occurrences' 441 | end 442 | end 443 | end 444 | end 445 | 446 | describe 'self.validate_booking_options!' do 447 | before(:each) do 448 | Bookable.booking_opts = { 449 | time_type: :none, 450 | capacity_type: :none 451 | } 452 | @opts = {} 453 | end 454 | 455 | describe 'with capacity_type: :none and time_type: :none' do 456 | it 'validates with default options' do 457 | expect(Bookable.validate_booking_options!(@opts)).to be_truthy 458 | end 459 | end 460 | 461 | describe 'with time_type = ' do 462 | describe ':range' do 463 | before(:each) do 464 | Bookable.booking_opts[:time_type] = :range 465 | Bookable.initialize_acts_as_bookable_core 466 | @opts[:time_start] = Time.now + 1.hour 467 | @opts[:time_end] = Time.now + 4.hours 468 | end 469 | 470 | it 'validates with all options fields set' do 471 | expect(Bookable.validate_booking_options!(@opts)).to be_truthy 472 | end 473 | 474 | it 'requires time_start as Time' do 475 | @opts[:time_start] = nil 476 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 477 | @opts[:time_start] = 'String' 478 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 479 | end 480 | 481 | it 'requires time_end as Time' do 482 | @opts[:time_end] = nil 483 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 484 | @opts[:time_end] = 'String' 485 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 486 | end 487 | 488 | it 'doesn\'t accept a fixed time' do 489 | @opts[:time] = Time.now 490 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 491 | end 492 | end 493 | 494 | describe ':fixed' do 495 | before(:each) do 496 | Bookable.booking_opts[:time_type] = :fixed 497 | Bookable.initialize_acts_as_bookable_core 498 | @opts[:time] = Time.now + 1.hour 499 | end 500 | 501 | it 'validates with the right fields set' do 502 | expect(Bookable.validate_booking_options!(@opts)).to be_truthy 503 | end 504 | 505 | it 'requires date as Time' do 506 | @opts[:time] = nil 507 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 508 | @opts[:time] = 'String' 509 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 510 | end 511 | 512 | it 'doesn\'t accept time_start' do 513 | @opts[:time_start] = Time.now + 13 514 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 515 | end 516 | 517 | it 'doesn\'t accept time_end' do 518 | @opts[:time_end] = Time.now + 15 519 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 520 | end 521 | end 522 | 523 | describe ':none' do 524 | before(:each) do 525 | Bookable.initialize_acts_as_bookable_core 526 | end 527 | 528 | it 'doesn\'t accept time' do 529 | @opts[:time] = Time.now + 13 530 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 531 | end 532 | 533 | it 'doesn\'t accept time_start' do 534 | @opts[:time_start] = Time.now + 13 535 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 536 | end 537 | 538 | it 'doesn\'t accept time_end' do 539 | @opts[:time_end] = Time.now + 15 540 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 541 | end 542 | end 543 | end 544 | 545 | describe 'with capacity_type = ' do 546 | describe ':open' do 547 | before(:each) do 548 | Bookable.booking_opts[:capacity_type] = :open 549 | Bookable.initialize_acts_as_bookable_core 550 | @opts[:amount] = 2 551 | end 552 | 553 | it 'validates with all options fields set' do 554 | expect(Bookable.validate_booking_options!(@opts)).to be_truthy 555 | end 556 | 557 | it 'requires :amount as integer' do 558 | @opts[:amount] = nil 559 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 560 | end 561 | end 562 | 563 | describe ':closed' do 564 | before(:each) do 565 | Bookable.booking_opts[:capacity_type] = :closed 566 | Bookable.initialize_acts_as_bookable_core 567 | @opts[:amount] = 2 568 | end 569 | 570 | it 'validates with all options fields set' do 571 | expect(Bookable.validate_booking_options!(@opts)).to be_truthy 572 | end 573 | 574 | it 'requires :amount as integer' do 575 | @opts[:amount] = nil 576 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 577 | end 578 | end 579 | 580 | describe ':none' do 581 | before(:each) do 582 | Bookable.initialize_acts_as_bookable_core 583 | end 584 | 585 | it 'doesn\'t accept amount' do 586 | @opts[:amount] = 2.3 587 | expect{ Bookable.validate_booking_options!(@opts) }.to raise_error ActsAsBookable::OptionsInvalid 588 | end 589 | end 590 | end 591 | end 592 | end 593 | end 594 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/bookable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Bookable model' do 4 | before(:each) do 5 | @bookable = build(:bookable) 6 | end 7 | 8 | describe 'conditional validations' do 9 | it 'should be valid with all required fields set' do 10 | expect(@bookable).to be_valid 11 | end 12 | 13 | it 'should save a bookable' do 14 | expect(@bookable.save).to be_truthy 15 | end 16 | 17 | describe 'when capacity is required' do 18 | before(:each) do 19 | Bookable.booking_opts[:capacity_type] = :closed 20 | Bookable.initialize_acts_as_bookable_core 21 | end 22 | after(:all) do 23 | Bookable.booking_opts = {} 24 | Bookable.initialize_acts_as_bookable_core 25 | end 26 | 27 | it 'should not validate with capacity < 0 if capacity is required' do 28 | @bookable.capacity = -1 29 | expect(@bookable.valid?).to be_falsy 30 | end 31 | 32 | it 'should not validate without capacity' do 33 | @bookable.capacity = nil 34 | expect(@bookable.valid?).to be_falsy 35 | end 36 | end 37 | 38 | describe 'when capacity is not required' do 39 | before(:each) do 40 | Bookable.booking_opts[:capacity_type] = :none 41 | Bookable.initialize_acts_as_bookable_core 42 | end 43 | after(:all) do 44 | Bookable.booking_opts = {} 45 | Bookable.initialize_acts_as_bookable_core 46 | end 47 | 48 | it 'should validate with capacity < 0' do 49 | @bookable.capacity = -1 50 | expect(@bookable.valid?).to be_truthy 51 | end 52 | 53 | it 'should validate without capacity if it\'s not required' do 54 | @bookable.capacity = nil 55 | expect(@bookable.valid?).to be_truthy 56 | end 57 | end 58 | 59 | describe 'when schedule is required' do 60 | before(:each) do 61 | Bookable.booking_opts[:time_type] = :range 62 | Bookable.initialize_acts_as_bookable_core 63 | end 64 | after(:all) do 65 | Bookable.booking_opts = {} 66 | Bookable.initialize_acts_as_bookable_core 67 | end 68 | 69 | it 'should not validate without schedule' do 70 | @bookable.schedule = nil 71 | expect(@bookable.valid?).to be_falsy 72 | end 73 | end 74 | 75 | describe 'when schedule is not required' do 76 | before(:each) do 77 | Bookable.booking_opts[:time_type] = :none 78 | Bookable.initialize_acts_as_bookable_core 79 | end 80 | after(:all) do 81 | Bookable.booking_opts = {} 82 | Bookable.initialize_acts_as_bookable_core 83 | end 84 | 85 | it 'should validate without schedule if it\'s not required' do 86 | @bookable.schedule = nil 87 | expect(@bookable.valid?).to be_truthy 88 | end 89 | end 90 | end 91 | 92 | 93 | describe 'has_many :bookings' do 94 | before(:each) do 95 | @bookable.save! 96 | booker1 = create(:booker, name: 'Booker 1') 97 | booker2 = create(:booker, name: 'Booker 2') 98 | booking1 = ActsAsBookable::Booking.create!(booker: booker1, bookable: @bookable) 99 | booking2 = ActsAsBookable::Booking.create!(booker: booker1, bookable: @bookable) 100 | @bookable.reload 101 | end 102 | 103 | it 'should have many bookings' do 104 | expect(@bookable.bookings).to be_present 105 | expect(@bookable.bookings.count).to eq 2 106 | end 107 | 108 | it 'dependent: :destroy' do 109 | count = ActsAsBookable::Booking.count 110 | @bookable.destroy 111 | expect(ActsAsBookable::Booking.count).to eq count -2 112 | end 113 | end 114 | 115 | describe '#schedule' do 116 | it 'allows for creation of a bookable with a IceCube schedule' do 117 | schedule = IceCube::Schedule.new 118 | # Every Monday,Tuesday and Friday 119 | schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday, :tuesday, :friday) 120 | @bookable.schedule = schedule 121 | expect(@bookable.save).to be_truthy 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/booker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Booker model' do 4 | before(:each) do 5 | @booker = build(:booker) 6 | end 7 | 8 | it 'should be valid with all required fields set' do 9 | expect(@booker).to be_valid 10 | end 11 | 12 | it 'should save a booker' do 13 | expect(@booker.save).to be_truthy 14 | end 15 | 16 | describe 'has_many :bookings' do 17 | before(:each) do 18 | @booker.save! 19 | bookable1 = create(:bookable) 20 | bookable2 = create(:bookable) 21 | booking1 = ActsAsBookable::Booking.create(bookable: bookable1, booker: @booker) 22 | booking2 = ActsAsBookable::Booking.create(bookable: bookable2, booker: @booker) 23 | @booker.reload 24 | end 25 | 26 | it 'should have many bookings' do 27 | expect(@booker.bookings).to be_present 28 | expect(@booker.bookings.count).to eq 2 29 | end 30 | 31 | it 'dependent: :destroy' do 32 | count = ActsAsBookable::Booking.count 33 | @booker.destroy 34 | expect(ActsAsBookable::Booking.count).to eq count -2 35 | end 36 | end 37 | 38 | describe '#book!' do 39 | before(:each) do 40 | @bookable = create(:room) 41 | 42 | end 43 | 44 | it 'should respond to #book!' do 45 | expect(@booker).to respond_to :book! 46 | end 47 | 48 | it 'should create a new booking' do 49 | count = @booker.bookings.count 50 | new_booking = @booker.book!(@bookable, time_start: Date.today, time_end: Date.today + 1.day, amount: 2) 51 | expect(@booker.bookings.count).to eq count+1 52 | expect(new_booking.class.to_s).to eq "ActsAsBookable::Booking" 53 | end 54 | 55 | it 'new booking should have all fields set' do 56 | new_booking = @booker.book!(@bookable, time_start: Date.today, time_end: Date.today + 1.day, amount: 2) 57 | new_booking.reload 58 | expect(new_booking.time_start).to be_present 59 | expect(new_booking.time_end).to be_present 60 | expect(new_booking.amount).to be_present 61 | end 62 | 63 | it 'should raise ActiveRecord::RecordInvalid if new booking is not valid' do 64 | expect{ @booker.book!(Generic.new) }.to raise_error ActiveRecord::RecordInvalid 65 | end 66 | 67 | it 'should not create a new booking if it\'s not valid' do 68 | count = @booker.bookings.count 69 | begin 70 | @booker.book!(Generic.new) 71 | rescue ActiveRecord::RecordInvalid => er 72 | end 73 | expect(@booker.bookings.count).to eq count 74 | end 75 | 76 | it 'should raise ActsAsBookable::AvailabilityError if the bookable is not available' do 77 | @booker.book!(@bookable, time_start: Date.today, time_end: Date.today + 1.day, amount: 2) 78 | expect{ @booker.book!(@bookable, time_start: Date.today, time_end: Date.today + 1.day, amount: 2)}.to raise_error ActsAsBookable::AvailabilityError 79 | end 80 | end 81 | 82 | 83 | 84 | # describe 'Booker Method Generation' do 85 | # before :each do 86 | # Generic.acts_as_booker 87 | # @booker = Generic.new() 88 | # end 89 | # 90 | # it "should responde 'true' to booker?" do 91 | # expect(@booker.class).to be_booker 92 | # end 93 | # end 94 | # 95 | # describe 'class configured as Booker' do 96 | # before(:each) do 97 | # @booker = Booker.new 98 | # end 99 | # 100 | # it 'should add #booker? query method to the class-side' do 101 | # expect(Booker).to respond_to(:booker?) 102 | # end 103 | # 104 | # it 'should return true from the class-side #booker?' do 105 | # expect(Booker.booker?).to be_truthy 106 | # end 107 | # 108 | # it 'should return false from the base #booker?' do 109 | # expect(ActiveRecord::Base.booker?).to be_falsy 110 | # end 111 | # 112 | # it 'should add #booker? query method to the singleton' do 113 | # expect(@booker).to respond_to(:booker?) 114 | # end 115 | # 116 | # it 'should add #booker? query method to the instance-side' do 117 | # expect(@booker).to respond_to(:booker?) 118 | # end 119 | # 120 | # it 'should add #booker? query method to the instance-side' do 121 | # expect(@booker.booker?).to be_truthy 122 | # end 123 | # 124 | # # it 'should add #tag method on the instance-side' do 125 | # # expect(@booker).to respond_to(:tag) 126 | # # end 127 | # 128 | # # it 'should generate an association for #owned_taggings and #owned_tags' do 129 | # # expect(@booker).to respond_to(:owned_taggings, :owned_tags) 130 | # # end 131 | # end 132 | # 133 | # describe 'Reloading' do 134 | # it 'should save a model instantiated by Model.find' do 135 | # booker = Generic.create!(name: 'Booker') 136 | # found_booker = Generic.find(booker.id) 137 | # expect(found_booker.save).to eq true 138 | # end 139 | # end 140 | end 141 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/booking_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Booking model' do 4 | before(:each) do 5 | @booking = ActsAsBookable::Booking.new(amount: 2) 6 | @booker = create(:booker) 7 | @bookable = create(:bookable) 8 | @booking.booker = @booker 9 | @booking.bookable = @bookable 10 | end 11 | 12 | it 'should be valid with all required fields set' do 13 | expect(@booking).to be_valid 14 | end 15 | 16 | it 'should save a booking' do 17 | expect(@booking.save).to be_truthy 18 | end 19 | 20 | it 'should not be valid without a booker' do 21 | @booking.booker = nil 22 | expect(@booking).not_to be_valid 23 | end 24 | 25 | it 'should not be valid without a bookable' do 26 | @booking.bookable = nil 27 | expect(@booking).not_to be_valid 28 | end 29 | 30 | it 'should not be valid if booking.booker.booker? is false' do 31 | not_booker = Generic.create(name: 'New generic model') 32 | @booking.booker = not_booker 33 | expect(@booking).not_to be_valid 34 | expect(@booking.errors.messages[:booker]).to be_present 35 | expect(@booking.errors.messages[:booker][0]).to include "Generic" 36 | expect(@booking.errors.messages).not_to include "missing translation" 37 | end 38 | 39 | it 'should not be valid if booking.bookable.bookable? is false' do 40 | bookable = Generic.create(name: 'New generic model') 41 | @booking.bookable = bookable 42 | expect(@booking).not_to be_valid 43 | expect(@booking.errors.messages[:bookable]).to be_present 44 | expect(@booking.errors.messages[:bookable][0]).to include "Generic" 45 | expect(@booking.errors.messages).not_to include "missing translation" 46 | end 47 | 48 | it 'should belong to booker' do 49 | expect(@booking.booker.id).to eq @booker.id 50 | end 51 | 52 | it 'should belong to bookable' do 53 | expect(@booking.bookable.id).to eq @bookable.id 54 | end 55 | 56 | describe "overlapped scope" do 57 | 58 | describe "without time" do 59 | it "returns a booking without checking the time" do 60 | time = Date.today.to_time 61 | booking = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time, bookable: @bookable, booker: @booker) 62 | opts = {} 63 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 1 64 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts)[0].id).to eq booking.id 65 | end 66 | 67 | it "returns all the bookings without checking the time" do 68 | booking1 = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: Time.now, bookable: @bookable, booker: @booker) 69 | booking2 = ActsAsBookable::Booking.create!(time_start: Time.now, time_end: Time.now + 3.hours, time: nil, bookable: @bookable, booker: @booker) 70 | booking3 = ActsAsBookable::Booking.create!(time_start: Time.now - 10.days, time_end: Time.now - 9.days, time: nil, bookable: @bookable, booker: @booker) 71 | opts = {} 72 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 3 73 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[0].id).to eq booking1.id 74 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[1].id).to eq booking2.id 75 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[2].id).to eq booking3.id 76 | end 77 | end 78 | 79 | describe "with fixed time" do 80 | it "returns overlapped booking" do 81 | time = Date.today.to_time 82 | booking = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time, bookable: @bookable, booker: @booker) 83 | opts = {time: time} 84 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 1 85 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts)[0].id).to eq booking.id 86 | end 87 | 88 | it "returns all the overlapped bookings" do 89 | time = Date.today.to_time 90 | booking1 = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time, bookable: @bookable, booker: @booker) 91 | booking2 = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time, bookable: @bookable, booker: @booker) 92 | booking3 = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time + 1.hour, bookable: @bookable, booker: @booker) 93 | opts = {time: time} 94 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 2 95 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[0].id).to eq booking1.id 96 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[1].id).to eq booking2.id 97 | end 98 | 99 | it "returns no overlapped booking if time is wrong" do 100 | time = Date.today.to_time 101 | booking = ActsAsBookable::Booking.create!(time_start: nil, time_end: nil, time: time, bookable: @bookable, booker: @booker) 102 | opts = {time: (time + 1.hour)} 103 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 0 104 | end 105 | end 106 | 107 | describe "with time range" do 108 | it "returns overlapped booking" do 109 | time_start = Date.today.to_time 110 | time_end = Date.today.to_time + 10.hours 111 | booking = ActsAsBookable::Booking.create!(time_start: time_start, time_end: time_end, time: nil, bookable: @bookable, booker: @booker) 112 | opts = { time_start: time_start, time_end: time_end } 113 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 1 114 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts)[0].id).to eq booking.id 115 | end 116 | 117 | it "returns more overlapped bookings" do 118 | time_start = Date.today.to_time 119 | time_end = Date.today.to_time + 10.hours 120 | booking1 = ActsAsBookable::Booking.create!(time_start: time_start, time_end: time_end, time: nil, bookable: @bookable, booker: @booker) 121 | booking2 = ActsAsBookable::Booking.create!(time_start: time_start, time_end: time_end, time: nil, bookable: @bookable, booker: @booker) 122 | booking3 = ActsAsBookable::Booking.create!(time_start: time_start-10.days, time_end: time_end-10.days, time: nil, bookable: @bookable, booker: @booker) 123 | opts = { time_start: time_start, time_end: time_end } 124 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 2 125 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[0].id).to eq booking1.id 126 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[1].id).to eq booking2.id 127 | end 128 | 129 | it "doesn't return any booking if time is wrong" do 130 | time_start = Date.today.to_time 131 | time_end = Date.today.to_time + 10.hours 132 | booking = ActsAsBookable::Booking.create!(time_start: time_start - 10.days, time_end: time_end - 10.days, time: nil, bookable: @bookable, booker: @booker) 133 | opts = { time_start: time_start, time_end: time_end } 134 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 0 135 | end 136 | 137 | it "returns also a booking overlapping but with time_start outside interval" do 138 | time_start = Date.today.to_time 139 | time_end = Date.tomorrow.to_time 140 | booking = ActsAsBookable::Booking.create!(time_start: time_start - 10.hours, time_end: time_end, bookable: @bookable, booker: @booker) 141 | opts = { time_start: time_start, time_end: time_end } 142 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 1 143 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts)[0].id).to eq booking.id 144 | end 145 | 146 | it "returns also a booking overlapping but with time_end outside interval" do 147 | time_start = Date.today.to_time 148 | time_end = Date.tomorrow.to_time 149 | booking = ActsAsBookable::Booking.create!(time_start: time_start, time_end: time_end + 10.hours, bookable: @bookable, booker: @booker) 150 | opts = { time_start: time_start, time_end: time_end } 151 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 1 152 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts)[0].id).to eq booking.id 153 | end 154 | 155 | it "returns more overlapped bookings, some of them not completely overlapping" do 156 | time_start = Date.today.to_time 157 | time_end = Date.today.to_time + 10.hours 158 | booking1 = ActsAsBookable::Booking.create!(time_start: time_start - 5.hours, time_end: time_end - 5.hours, time: nil, bookable: @bookable, booker: @booker) 159 | booking2 = ActsAsBookable::Booking.create!(time_start: time_start + 5.hours, time_end: time_end + 5.hours, time: nil, bookable: @bookable, booker: @booker) 160 | booking3 = ActsAsBookable::Booking.create!(time_start: time_start + 2.hours, time_end: time_end - 2.hours, time: nil, bookable: @bookable, booker: @booker) 161 | booking4 = ActsAsBookable::Booking.create!(time_start: time_start-10.days, time_end: time_end-10.days, time: nil, bookable: @bookable, booker: @booker) 162 | booking4 = ActsAsBookable::Booking.create!(time_start: time_start+10.days, time_end: time_end+10.days, time: nil, bookable: @bookable, booker: @booker) 163 | opts = { time_start: time_start, time_end: time_end } 164 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).count).to eq 3 165 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[0].id).to eq booking1.id 166 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[1].id).to eq booking2.id 167 | expect(ActsAsBookable::Booking.overlapped(@bookable,opts).order(:id)[2].id).to eq booking3.id 168 | end 169 | 170 | describe "should handle limit cases" do 171 | before :each do 172 | @time_start = Date.today.to_time 173 | @time_end = Date.today.to_time + 10.hours 174 | 175 | @time_before_start = @time_start - 1.second 176 | @time_after_start = @time_start + 1.second 177 | @time_before_end = @time_end - 1.second 178 | @time_after_end = @time_end + 1.second 179 | 180 | @opts = { time_start: @time_start, time_end: @time_end } 181 | end 182 | 183 | it "excludes intervals with end before time_start and start after time_end" do 184 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_before_start, time: nil, bookable: @bookable, booker: @booker) 185 | booking2 = ActsAsBookable::Booking.create!(time_start: @time_after_end, time_end: @time_end + 5.hours, time: nil, bookable: @bookable, booker: @booker) 186 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 0 187 | end 188 | 189 | it "excludes intervals with start matching exactly with time_end" do 190 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_end, time_end: @time_end + 2.hours, time: nil, bookable: @bookable, booker: @booker) 191 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 0 192 | end 193 | 194 | it "includes intervals with end after time_start" do 195 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_after_start, time: nil, bookable: @bookable, booker: @booker) 196 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 1 197 | end 198 | 199 | it "includes intervals with end exactly at time_start" do 200 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_start, time: nil, bookable: @bookable, booker: @booker) 201 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 1 202 | end 203 | end 204 | 205 | describe "should convert dates to times" do 206 | before :each do 207 | @time_start = Date.today 208 | @time_end = Date.tomorrow 209 | 210 | @time_before_start = @time_start - 1.second 211 | @time_after_start = @time_start + 1.second 212 | @time_before_end = @time_end - 1.second 213 | @time_after_end = @time_end + 1.second 214 | 215 | @opts = { time_start: @time_start, time_end: @time_end } 216 | end 217 | 218 | it "excludes intervals with end before time_start and start after time_end" do 219 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_before_start, time: nil, bookable: @bookable, booker: @booker) 220 | booking2 = ActsAsBookable::Booking.create!(time_start: @time_after_end, time_end: @time_end + 5.hours, time: nil, bookable: @bookable, booker: @booker) 221 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 0 222 | end 223 | 224 | it "excludes intervals with start matching exactly with time_end" do 225 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_end, time_end: @time_end + 2.hours, time: nil, bookable: @bookable, booker: @booker) 226 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 0 227 | end 228 | 229 | it "includes intervals with end after time_start" do 230 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_after_start, time: nil, bookable: @bookable, booker: @booker) 231 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 1 232 | end 233 | 234 | it "includes intervals with end exactly at time_start" do 235 | booking1 = ActsAsBookable::Booking.create!(time_start: @time_start - 5.hours, time_end: @time_start, time: nil, bookable: @bookable, booker: @booker) 236 | expect(ActsAsBookable::Booking.overlapped(@bookable,@opts).count).to eq 1 237 | end 238 | end 239 | end 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/schedule_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Schedules' do 4 | describe "Room schedules" do 5 | before :each do 6 | @test_from = '2016-02-01'.to_date # it's a monday 7 | @schedule = IceCube::Schedule.new @test_from 8 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday,:tuesday,:wednesday,:thursday,:friday) 9 | end 10 | 11 | it "Weekly, From monday to friday" do 12 | # Weekly, From monday to friday 13 | expect(@schedule.occurring_at?(@test_from)).to be true 14 | expect(@schedule.occurring_at?(@test_from + 1.day)).to be true 15 | expect(@schedule.occurring_at?(@test_from + 2.day)).to be true 16 | expect(@schedule.occurring_at?(@test_from + 3.day)).to be true 17 | expect(@schedule.occurring_at?(@test_from + 4.day)).to be true 18 | expect(@schedule.occurring_at?(@test_from + 5.day)).to be false 19 | expect(@schedule.occurring_at?(@test_from + 6.day)).to be false 20 | end 21 | 22 | it "doesn't match if not at exact minute" do 23 | expect(@schedule.occurring_at?(@test_from + 1.minute)).to be false 24 | end 25 | 26 | describe "with daily duration" do 27 | before :each do 28 | @test_from = '2016-02-01'.to_date # it's a monday 29 | @schedule = IceCube::Schedule.new @test_from 30 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday,:tuesday,:wednesday,:thursday,:friday) 31 | @schedule.duration = 1.day 32 | end 33 | 34 | it "match with the exact minute" do 35 | # Weekly, From monday to friday 36 | expect(@schedule.occurring_at?(@test_from)).to be true 37 | end 38 | 39 | it "matches if not at exact minute" do 40 | expect(@schedule.occurring_at?(@test_from + 1.minute)).to be true 41 | end 42 | 43 | it "matches if at end of day" do 44 | expect(@schedule.occurring_at?(@test_from + 1.day - 1.minute)).to be true 45 | expect(@schedule.occurring_at?(@test_from + 1.day - 1.minute)).to be true 46 | expect(@schedule.occurring_at?(@test_from + 4.day - 1.second)).to be true 47 | expect(@schedule.occurring_at?(@test_from + 4.day - 1.second)).to be true 48 | end 49 | 50 | it "doesn't match at the first second of the first day not included" do 51 | expect(@schedule.occurring_at?(@test_from + 5.days)).to be false 52 | expect(@schedule.occurring_at?(@test_from + 5.days + 1.second)).to be false 53 | end 54 | end 55 | 56 | describe "except the first and the last day of the month" do 57 | before :each do 58 | @test_from = '2016-02-01'.to_date # it's a monday 59 | @schedule = IceCube::Schedule.new @test_from 60 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday,:tuesday,:wednesday,:thursday,:friday) 61 | @schedule.add_exception_rule IceCube::Rule.monthly.day_of_month(1, -1) 62 | end 63 | 64 | it "doesn't match on the first of the month" do 65 | expect(@schedule.occurring_at?(@test_from)).to be false 66 | expect(@schedule.occurring_at?(@test_from.end_of_month)).to be false 67 | expect(@schedule.occurring_at?(@test_from + 1.month)).to be false 68 | expect(@schedule.occurring_at?((@test_from + 1.month).end_of_month)).to be false 69 | expect(@schedule.occurring_at?('2017-01-01'.to_date)).to be false # sunday 70 | expect(@schedule.occurring_at?('2017-01-01'.to_date + 1.second)).to be false # sunday 71 | expect(@schedule.occurring_at?('2017-01-01'.to_date + 1.day)).to be true # monday 72 | end 73 | end 74 | end 75 | 76 | describe "Gym schedules" do 77 | before :each do 78 | # Mon | Tue | Wed | Thu | Fri | Sat |Sun 79 | # 9-10 | 10-11 | 9-10 | 10-11 | | 14-15 | 80 | # 18:10-19:10 | 20-21 | 18:10-19:10 | 20-21 | | | 81 | # Except for the third saturday of the month 82 | @test_from = '2016-02-01'.to_date # it's a monday 83 | @schedule = IceCube::Schedule.new(@test_from, duration: 1.hour) 84 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday,:wednesday).hour_of_day(9) 85 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:monday,:wednesday).hour_of_day(18).minute_of_hour(10) 86 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:tuesday,:thursday).hour_of_day(10,20) 87 | @schedule.add_recurrence_rule IceCube::Rule.weekly.day(:saturday).hour_of_day(14) 88 | @schedule.add_exception_rule IceCube::Rule.monthly.day_of_week(saturday: [3]).hour_of_day(14) 89 | @schedule.add_exception_rule IceCube::Rule.monthly.day_of_week(wednesday: [3]).hour_of_day(9) 90 | end 91 | 92 | it "matches at the occurrences" do 93 | (0...12).each do |i| 94 | minute = (i * 5).minutes 95 | expect(@schedule.occurring_at?(@test_from + 9.hours + minute)).to be true # monday 96 | expect(@schedule.occurring_at?(@test_from + 18.hours + 10.minutes + minute)).to be true # monday 97 | expect(@schedule.occurring_at?(@test_from + 5.days + 14.hours + minute)).to be true # tuesday 98 | end 99 | end 100 | 101 | it "doesn't match close to the occurrences" do 102 | expect(@schedule.occurring_at?(@test_from + 9.hours - 1.minute)).to be false # monday 103 | expect(@schedule.occurring_at?(@test_from + 10.hours)).to be false # monday 104 | expect(@schedule.occurring_at?(@test_from + 18.hours + 9.minutes)).to be false # monday 105 | expect(@schedule.occurring_at?(@test_from + 19.hours + 9.minutes)).to be true # monday 106 | expect(@schedule.occurring_at?(@test_from + 19.hours + 10.minutes)).to be false # monday 107 | end 108 | 109 | it "doesn't match the saturday of the third week of the month" do 110 | (0...12).each do |i| 111 | minute = (i * 5).minutes 112 | expect(@schedule.occurring_at?('2016-02-20'.to_date + 14.hours + minute)).to be false # monday 113 | end 114 | end 115 | 116 | describe "borderline matchings" do 117 | before :each do 118 | # Except the third wednesday of the month 119 | @test_from = '2016-02-01'.to_date # it's a monday 120 | @schedule = IceCube::Schedule.new(@test_from) 121 | @schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_week(wednesday: [3]) 122 | end 123 | 124 | it "matches the third wednesday of the month, in normal conditions" do 125 | expect(@schedule.occurring_at?('2016-02-17'.to_date)).to be true 126 | end 127 | 128 | it "matches the third wednesday of the month, if the month starts on friday" do 129 | expect(@schedule.occurring_at?('2016-04-20'.to_date)).to be true 130 | end 131 | 132 | it "doesn't match the wednesday of the third week of the month, if the month starts on friday" do 133 | expect(@schedule.occurring_at?('2016-04-13'.to_date)).to be false 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/acts_as_bookable/time_utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'ActsAsBookable::TimeUtils' do 4 | 5 | describe '#time_in_interval?' do 6 | before :each do 7 | @interval_start = Time.now 8 | @interval_end = Time.now + 1.hour 9 | end 10 | 11 | describe 'returns true' do 12 | it 'when time is the interval_start' do 13 | time = @interval_start 14 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq true 15 | end 16 | 17 | it 'when time is bewteen interval_start and interval_end' do 18 | time = @interval_start + 5.minutes 19 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq true 20 | end 21 | 22 | it 'when time is very close to interval end' do 23 | time = @interval_end - 1.second 24 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq true 25 | end 26 | 27 | end 28 | 29 | describe 'returns false' do 30 | it 'when time is before interval_start' do 31 | time = @interval_start - 1.second 32 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq false 33 | end 34 | 35 | it 'when time is after interval_end' do 36 | time = @interval_end + 1.second 37 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq false 38 | end 39 | 40 | it 'when time is interval_end' do 41 | time = @interval_end 42 | expect(ActsAsBookable::TimeUtils.time_in_interval?(time,@interval_start,@interval_end)).to eq false 43 | end 44 | end 45 | end 46 | 47 | describe '#interval_in_schedule?' do 48 | before :each do 49 | @day0 = '2016-01-05'.to_date.to_time 50 | @schedule = IceCube::Schedule.new(@day0,duration: 1.day) 51 | @schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(1,3,5,7) 52 | end 53 | 54 | describe 'returns true' do 55 | it 'when range starts and ends in the middle of an occurrence' do 56 | time_start = @day0 + 1.hour 57 | time_end = @day0 + 3.hours 58 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq true 59 | end 60 | 61 | it 'when range starts and ends in the middle of another occurrence' do 62 | time_start = @day0 + 2.days + 1.hour 63 | time_end = @day0 + 2.days + 3.hours 64 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq true 65 | end 66 | 67 | it 'when range starts at the beginning of an occurrence and ends at the end of the same occurence' do 68 | time_start = @day0 69 | time_end = @day0 + 1.day - 1.second 70 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq true 71 | end 72 | end 73 | 74 | describe 'retuns false' do 75 | it 'when range starts and ends outside any occurrence' do 76 | time_start = '2016-01-15'.to_date 77 | time_end = time_start + 1.day 78 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 79 | end 80 | 81 | it 'when range starts and ends outside any occurrence but contains an occurrence' do 82 | time_start = @day0 - 1.hour 83 | time_end = @day0 + 1.day + 1.hour 84 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 85 | end 86 | 87 | it 'when range starts within an occurrence but ends outside it' do 88 | time_start = @day0 + 1.hour 89 | time_end = @day0 + 1.day + 1.hour 90 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 91 | end 92 | 93 | it 'when range starts outside any occurrence but ends within an occurrence' do 94 | time_start = @day0 - 1.hour 95 | time_end = @day0 + 1.hour 96 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 97 | end 98 | 99 | it 'when range starts within an occurrence and ends within a different occurrence' do 100 | time_start = @day0 + 1.hour 101 | time_end = @day0 + 2.days + 1.hour 102 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 103 | end 104 | 105 | it 'when range starts within an occurrence and ends just after the end of the same occurrence' do 106 | time_start = @day0 + 1.hour 107 | time_end = @day0 + 1.day 108 | expect(ActsAsBookable::TimeUtils.interval_in_schedule?(@schedule,time_start,time_end)).to eq false 109 | end 110 | end 111 | end 112 | 113 | describe '#time_in_schedule?' do 114 | before :each do 115 | @day0 = '2016-01-05'.to_date 116 | @schedule = IceCube::Schedule.new(@day0,duration: 1.day) 117 | @schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(1,3,5,7) 118 | end 119 | 120 | describe 'returns true' do 121 | it 'when time is at the beginning of an occurrence' do 122 | time = @day0 123 | expect(ActsAsBookable::TimeUtils.time_in_schedule?(@schedule,time)).to eq true 124 | end 125 | 126 | it 'when time is in the middle of an occurrence' do 127 | time = @day0 + 5.hours 128 | expect(ActsAsBookable::TimeUtils.time_in_schedule?(@schedule,time)).to eq true 129 | end 130 | 131 | it 'when time is at the end of an occurrence' do 132 | time = @day0 + 1.day - 1.second 133 | expect(ActsAsBookable::TimeUtils.time_in_schedule?(@schedule, time)).to eq true 134 | end 135 | end 136 | 137 | describe 'retuns false' do 138 | it 'when time is outside an occurrence' do 139 | time = '2016-01-15'.to_date 140 | expect(ActsAsBookable::TimeUtils.time_in_schedule?(@schedule, time)).to eq false 141 | end 142 | 143 | it 'when time is close to the end of an occurrence, but outside it' do 144 | time = @day0 + 1.day 145 | expect(ActsAsBookable::TimeUtils.time_in_schedule?(@schedule, time)).to eq false 146 | end 147 | 148 | it 'when time is close to the beginning of an occurrence, but outside it' do 149 | time = @day0 + 2.days - 1.second 150 | end 151 | end 152 | end 153 | 154 | describe '#subintervals' do 155 | before :each do 156 | @time = Time.now 157 | end 158 | 159 | it 'returns ArgumentError if called without an array' do 160 | expect{ ActsAsBookable::TimeUtils.subintervals(1) }.to raise_error ArgumentError 161 | end 162 | 163 | it 'returns ArgumentError if an interval has no time_start or time_end' do 164 | intervals = [ 165 | {time_start: @time, time_end: @time + 1.hour}, 166 | {time_start: @time} 167 | ] 168 | expect{ ActsAsBookable::TimeUtils.subintervals(1) }.to raise_error ArgumentError 169 | intervals = [ 170 | {time_start: @time, time_end: @time + 1.hour}, 171 | {time_end: @time} 172 | ] 173 | expect{ ActsAsBookable::TimeUtils.subintervals(1) }.to raise_error ArgumentError 174 | end 175 | 176 | it 'returns ArgumentError if time_start or time_end is not a Time or Date' do 177 | intervals = [ 178 | {time_start: @time, time_end: 1} 179 | ] 180 | expect{ ActsAsBookable::TimeUtils.subintervals(1) }.to raise_error ArgumentError 181 | intervals = [ 182 | {time_start: 2, time_end: @time + 1.hour} 183 | ] 184 | expect{ ActsAsBookable::TimeUtils.subintervals(1) }.to raise_error ArgumentError 185 | end 186 | 187 | it 'returns empty array if input is an empty array' do 188 | expect(ActsAsBookable::TimeUtils.subintervals([])).to eq [] 189 | end 190 | 191 | # |----| 192 | # => 193 | # |----| 194 | it 'returns a copy of the same interval if input is a single interval' do 195 | intervals = [ 196 | {time_start: @time, time_end: @time + 1.hour} 197 | ] 198 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 199 | expect(subintervals.length).to eq 1 200 | expect(subintervals[0][:time_start]).to eq intervals[0][:time_start] 201 | expect(subintervals[0][:time_end]).to eq intervals[0][:time_end] 202 | end 203 | 204 | # |----| |----| |----| 205 | # => 206 | # |----| |----| |----| 207 | it 'returns a copy of the same intervals if they are all separated' do 208 | intervals = [ 209 | {time_start: @time, time_end: @time + 1.hour}, 210 | {time_start: @time + 2.hours, time_end: @time + 3.hours}, 211 | {time_start: @time + 4.hours, time_end: @time + 5.hours} 212 | ] 213 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 214 | expect(subintervals.length).to eq 3 215 | (0..2).each do |i| 216 | expect(subintervals[i][:time_start]).to eq intervals[i][:time_start] 217 | expect(subintervals[i][:time_end]).to eq intervals[i][:time_end] 218 | end 219 | end 220 | 221 | # |----| 222 | # |----| 223 | # |----| 224 | # => 225 | # |----| 226 | # |----| 227 | # |----| 228 | it 'returns the sub-intervals sorted' do 229 | time0 = @time 230 | time1 = @time + 1.hour 231 | time2 = @time + 2.hours 232 | time3 = @time + 3.hours 233 | time4 = @time + 4.hours 234 | time5 = @time + 5.hours 235 | time6 = @time + 6.hours 236 | 237 | intervals = [ 238 | {time_start: time4, time_end: time5}, 239 | {time_start: time2, time_end: time3}, 240 | {time_start: time0, time_end: time1} 241 | ] 242 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 243 | expect(subintervals.length).to eq 3 244 | expect(subintervals[0][:time_start]).to eq time0 245 | expect(subintervals[0][:time_end]).to eq time1 246 | expect(subintervals[1][:time_start]).to eq time2 247 | expect(subintervals[1][:time_end]).to eq time3 248 | expect(subintervals[2][:time_start]).to eq time4 249 | expect(subintervals[2][:time_end]).to eq time5 250 | end 251 | 252 | # |----| 253 | # |----| 254 | # |----| 255 | # => 256 | # |----| 257 | it 'merges intervals if they have same time_start and time_end' do 258 | intervals = [ 259 | {time_start: @time, time_end: @time + 1.hour}, 260 | {time_start: @time, time_end: @time + 1.hour}, 261 | {time_start: @time, time_end: @time + 1.hour} 262 | ] 263 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 264 | expect(subintervals.length).to eq 1 265 | expect(subintervals[0][:time_start]).to eq intervals[0][:time_start] 266 | expect(subintervals[0][:time_end]).to eq intervals[0][:time_end] 267 | end 268 | 269 | # |---| 270 | # |------| 271 | # => 272 | # |---| 273 | # |--| 274 | it 'returns two intervals if input is 2 intervals with same time_start and different time_end' do 275 | time0 = @time 276 | time1 = @time + 1.hour 277 | time2 = @time + 2.hours 278 | intervals = [ 279 | {time_start: time0, time_end: time1}, 280 | {time_start: time0, time_end: time2} 281 | ] 282 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 283 | expect(subintervals.length).to eq 2 284 | expect(subintervals[0][:time_start]).to eq time0 285 | expect(subintervals[0][:time_end]).to eq time1 286 | expect(subintervals[1][:time_start]).to eq time1 287 | expect(subintervals[1][:time_end]).to eq time2 288 | end 289 | 290 | # |------| 291 | # |---| 292 | # => 293 | # |--| 294 | # |---| 295 | it 'returns two intervals if input is 2 intervals with same time_end and different time_start' do 296 | time0 = @time 297 | time1 = @time + 1.hour 298 | time2 = @time + 2.hours 299 | intervals = [ 300 | {time_start: time0, time_end: time2}, 301 | {time_start: time1, time_end: time2} 302 | ] 303 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 304 | expect(subintervals.length).to eq 2 305 | expect(subintervals[0][:time_start]).to eq time0 306 | expect(subintervals[0][:time_end]).to eq time1 307 | expect(subintervals[1][:time_start]).to eq time1 308 | expect(subintervals[1][:time_end]).to eq time2 309 | end 310 | 311 | # |---------| 312 | # |---| 313 | # => 314 | # |--| 315 | # |---| 316 | # |--| 317 | it 'returns three intervals if one includes another' do 318 | time0 = @time 319 | time1 = @time + 1.hour 320 | time2 = @time + 2.hours 321 | time3 = @time + 3.hours 322 | intervals = [ 323 | {time_start: time0, time_end: time3}, 324 | {time_start: time1, time_end: time2} 325 | ] 326 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) 327 | expect(subintervals.length).to eq 3 328 | expect(subintervals[0][:time_start]).to eq time0 329 | expect(subintervals[0][:time_end]).to eq time1 330 | expect(subintervals[1][:time_start]).to eq time1 331 | expect(subintervals[1][:time_end]).to eq time2 332 | expect(subintervals[2][:time_start]).to eq time2 333 | expect(subintervals[2][:time_end]).to eq time3 334 | end 335 | 336 | # |---2---| 337 | # |------4------| 338 | # |----3------| 339 | # |----1----| 340 | # |----8----| 341 | # => 342 | # |-5-| 343 | # |-9-| 344 | # |-7-| 345 | # |--4--| 346 | # |----9----| 347 | it 'correctly merges interval attributes' do 348 | time0 = @time 349 | time1 = @time + 1.hour 350 | time2 = @time + 2.hours 351 | time3 = @time + 3.hours 352 | time4 = @time + 4.hours 353 | time5 = @time + 5.hours 354 | time6 = @time + 6.hours 355 | intervals = [ 356 | {time_start: time0, time_end: time2, attr: 2}, 357 | {time_start: time1, time_end: time4, attr: 4}, 358 | {time_start: time0, time_end: time3, attr: 3}, 359 | {time_start: time5, time_end: time6, attr: 1}, 360 | {time_start: time5, time_end: time6, attr: 8} 361 | ] 362 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) do |a,b,op| 363 | if op == :open 364 | res = {attr: a[:attr] + b[:attr]} 365 | end 366 | if op == :close 367 | res = {attr: a[:attr] - b[:attr]} 368 | end 369 | res 370 | end 371 | expect(subintervals.length).to eq 5 372 | expect(subintervals[0][:time_start]).to eq time0 373 | expect(subintervals[0][:time_end]).to eq time1 374 | expect(subintervals[0][:attr]).to eq 5 375 | expect(subintervals[1][:time_start]).to eq time1 376 | expect(subintervals[1][:time_end]).to eq time2 377 | expect(subintervals[1][:attr]).to eq 9 378 | expect(subintervals[2][:time_start]).to eq time2 379 | expect(subintervals[2][:time_end]).to eq time3 380 | expect(subintervals[2][:attr]).to eq 7 381 | expect(subintervals[3][:time_start]).to eq time3 382 | expect(subintervals[3][:time_end]).to eq time4 383 | expect(subintervals[3][:attr]).to eq 4 384 | expect(subintervals[4][:time_start]).to eq time5 385 | expect(subintervals[4][:time_end]).to eq time6 386 | expect(subintervals[4][:attr]).to eq 9 387 | end 388 | 389 | # |---2---| 390 | # |------4------| 391 | # |----3------| 392 | # |----1----| 393 | # |----8----| 394 | # => 395 | # |-5-| 396 | # |-9-| 397 | # |-7-| 398 | # |--4--| 399 | # |----9----| 400 | it 'correctly merges interval attributes handling dates and times' do 401 | time0 = Date.today.to_time 402 | time1 = time0 + 1 403 | time2 = time0 + 2 404 | time3 = time0 + 3 405 | time4 = time0 + 4.days + 1.hours 406 | time5 = time0 + 5.days + 1.hours 407 | time6 = time0 + 6.days + 1.hours 408 | intervals = [ 409 | {time_start: time0, time_end: time2, attr: 2}, 410 | {time_start: time1, time_end: time4, attr: 4}, 411 | {time_start: time0, time_end: time3, attr: 3}, 412 | {time_start: time5, time_end: time6, attr: 1}, 413 | {time_start: time5, time_end: time6, attr: 8} 414 | ] 415 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) do |a,b,op| 416 | if op == :open 417 | res = {attr: a[:attr] + b[:attr]} 418 | end 419 | if op == :close 420 | res = {attr: a[:attr] - b[:attr]} 421 | end 422 | res 423 | end 424 | expect(subintervals.length).to eq 5 425 | expect(subintervals[0][:time_start]).to eq time0 426 | expect(subintervals[0][:time_end]).to eq time1 427 | expect(subintervals[0][:attr]).to eq 5 428 | expect(subintervals[1][:time_start]).to eq time1 429 | expect(subintervals[1][:time_end]).to eq time2 430 | expect(subintervals[1][:attr]).to eq 9 431 | expect(subintervals[2][:time_start]).to eq time2 432 | expect(subintervals[2][:time_end]).to eq time3 433 | expect(subintervals[2][:attr]).to eq 7 434 | expect(subintervals[3][:time_start]).to eq time3 435 | expect(subintervals[3][:time_end]).to eq time4 436 | expect(subintervals[3][:attr]).to eq 4 437 | expect(subintervals[4][:time_start]).to eq time5 438 | expect(subintervals[4][:time_end]).to eq time6 439 | expect(subintervals[4][:attr]).to eq 9 440 | end 441 | 442 | # |---2---| 443 | # |---4---| 444 | # |---3---| 445 | # |---5---| 446 | # |---1---| 447 | # |---1---| 448 | # => 449 | # |---6---| 450 | # |---10---| 451 | it 'merges 3 intervals partially matching' do 452 | time0 = @time 453 | time1 = @time + 1.hour 454 | time2 = @time + 2.hours 455 | intervals = [ 456 | {time_start: time0, time_end: time1, attr: 2}, 457 | {time_start: time1, time_end: time2, attr: 4}, 458 | {time_start: time0, time_end: time1, attr: 3}, 459 | {time_start: time1, time_end: time2, attr: 5}, 460 | {time_start: time0, time_end: time1, attr: 1}, 461 | {time_start: time1, time_end: time2, attr: 1} 462 | ] 463 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) do |a,b,op| 464 | if op == :open 465 | res = {attr: a[:attr] + b[:attr]} 466 | end 467 | if op == :close 468 | res = {attr: a[:attr] - b[:attr]} 469 | end 470 | res 471 | end 472 | expect(subintervals.length).to eq 2 473 | expect(subintervals[0][:time_start]).to eq time0 474 | expect(subintervals[0][:time_end]).to eq time1 475 | expect(subintervals[0][:attr]).to eq 6 476 | expect(subintervals[1][:time_start]).to eq time1 477 | expect(subintervals[1][:time_end]).to eq time2 478 | expect(subintervals[1][:attr]).to eq 10 479 | end 480 | 481 | # |---2----| 482 | # |----4---| 483 | # |---3----| 484 | # |----5---| 485 | # |---1----| 486 | # |----1---| 487 | # => 488 | # |-6-| 489 | # |-16-| 490 | # |-10-| 491 | it 'merges and split 3 intervals partially matching' do 492 | time0 = @time 493 | time1 = @time + 1.hour 494 | time2 = @time + 2.hours 495 | time3 = @time + 3.hours 496 | intervals = [ 497 | {time_start: time0, time_end: time2, attr: 2}, 498 | {time_start: time1, time_end: time3, attr: 4}, 499 | {time_start: time0, time_end: time2, attr: 3}, 500 | {time_start: time1, time_end: time3, attr: 5}, 501 | {time_start: time0, time_end: time2, attr: 1}, 502 | {time_start: time1, time_end: time3, attr: 1} 503 | ] 504 | subintervals = ActsAsBookable::TimeUtils.subintervals(intervals) do |a,b,op| 505 | if op == :open 506 | res = {attr: a[:attr] + b[:attr]} 507 | end 508 | if op == :close 509 | res = {attr: a[:attr] - b[:attr]} 510 | end 511 | res 512 | end 513 | expect(subintervals.length).to eq 3 514 | expect(subintervals[0][:time_start]).to eq time0 515 | expect(subintervals[0][:time_end]).to eq time1 516 | expect(subintervals[0][:attr]).to eq 6 517 | expect(subintervals[1][:time_start]).to eq time1 518 | expect(subintervals[1][:time_end]).to eq time2 519 | expect(subintervals[1][:attr]).to eq 16 520 | expect(subintervals[2][:time_start]).to eq time2 521 | expect(subintervals[2][:time_end]).to eq time3 522 | expect(subintervals[2][:attr]).to eq 10 523 | end 524 | end 525 | end 526 | -------------------------------------------------------------------------------- /spec/factories/bookable.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :bookable, class: 'Bookable' do 3 | name 'Bookable name' 4 | capacity 4 5 | schedule IceCube::Schedule.new 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/booker.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :booker, class: 'Booker' do 3 | name 'Booker name' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/room.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :room, class: 'Room' do 3 | name 'Room name' 4 | capacity 4 5 | schedule { 6 | schedule = IceCube::Schedule.new(Date.today, duration: 1.day) 7 | schedule.add_recurrence_rule IceCube::Rule.daily 8 | schedule 9 | } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/internal/app/models/Bookable.rb: -------------------------------------------------------------------------------- 1 | class Bookable < ActiveRecord::Base 2 | acts_as_bookable 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/Booker.rb: -------------------------------------------------------------------------------- 1 | class Booker < ActiveRecord::Base 2 | acts_as_booker 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/Event.rb: -------------------------------------------------------------------------------- 1 | class Event < ActiveRecord::Base 2 | acts_as_bookable preset: :event 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/Generic.rb: -------------------------------------------------------------------------------- 1 | class Generic < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/internal/app/models/NotBooker.rb: -------------------------------------------------------------------------------- 1 | class NotBooker < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/internal/app/models/Room.rb: -------------------------------------------------------------------------------- 1 | class Room < ActiveRecord::Base 2 | acts_as_bookable preset: :room 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/Show.rb: -------------------------------------------------------------------------------- 1 | class Show < ActiveRecord::Base 2 | acts_as_bookable preset: :show 3 | end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/Unbookable.rb: -------------------------------------------------------------------------------- 1 | class Unbookable < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml.sample: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: ':memory:' 4 | 5 | mysql: 6 | adapter: mysql2 7 | host: localhost 8 | username: root 9 | database: acts_as_bookable 10 | charset: utf8 11 | 12 | postgresql: 13 | adapter: postgresql 14 | hostname: localhost 15 | username: postgres 16 | database: acts_as_bookable 17 | encoding: utf8 18 | -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define version: 0 do 2 | create_table :acts_as_bookable_bookings, force: true do |t| 3 | t.references :bookable, polymorphic: true, index: {name: "index_acts_as_bookable_bookings_bookable"} 4 | t.references :booker, polymorphic: true, index: {name: "index_acts_as_bookable_bookings_booker"} 5 | t.column :amount, :integer 6 | t.column :schedule, :text 7 | t.column :time_start, :datetime 8 | t.column :time_end, :datetime 9 | t.column :time, :datetime 10 | t.datetime :created_at 11 | end 12 | 13 | create_table :bookables, force: true do |t| 14 | t.column :name, :string 15 | t.column :schedule, :text 16 | t.column :capacity, :integer 17 | end 18 | 19 | create_table :rooms, force: true do |t| 20 | t.column :name, :string 21 | t.column :schedule, :text 22 | t.column :capacity, :integer 23 | end 24 | 25 | create_table :events, force: true do |t| 26 | t.column :name, :string 27 | t.column :capacity, :integer 28 | end 29 | 30 | create_table :shows, force: true do |t| 31 | t.column :name, :string 32 | t.column :schedule, :text 33 | t.column :capacity, :integer 34 | end 35 | 36 | create_table :unbookables, force: true do |t| 37 | t.column :name, :string 38 | end 39 | 40 | create_table :not_bookers, force: true do |t| 41 | t.column :name, :string 42 | end 43 | 44 | create_table :generics, force: true do |t| 45 | t.column :name, :string 46 | end 47 | 48 | create_table :bookers, force: true do |t| 49 | t.column :name, :string 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | begin 5 | require 'pry-nav' 6 | rescue LoadError 7 | end 8 | $LOAD_PATH << '.' unless $LOAD_PATH.include?('.') 9 | $LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) 10 | require 'logger' 11 | 12 | require File.expand_path('../../lib/acts_as_bookable', __FILE__) 13 | I18n.enforce_available_locales = true 14 | require 'rails' 15 | require 'barrier' 16 | require 'database_cleaner' 17 | require 'factory_girl_rails' 18 | require 'awesome_print' 19 | 20 | ENGINE_RAILS_ROOT=File.join(File.dirname(__FILE__), '../') 21 | Dir[File.join(ENGINE_RAILS_ROOT, "spec/factories/**/*.rb")].each {|f| require f } 22 | 23 | Dir['./spec/support/**/*.rb'].sort.each { |f| require f } 24 | 25 | I18n.load_path << File.expand_path("../../config/locales/en.yml", __FILE__) 26 | 27 | RSpec.configure do |config| 28 | config.raise_errors_for_deprecations! 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/0-helpers.rb: -------------------------------------------------------------------------------- 1 | def using_sqlite? 2 | ActsAsBookable::Utils.connection && ActsAsBookable::Utils.connection.adapter_name == 'SQLite' 3 | end 4 | 5 | def supports_concurrency? 6 | !using_sqlite? 7 | end 8 | 9 | def using_postgresql? 10 | ActsAsBookable::Utils.using_postgresql? 11 | end 12 | 13 | def postgresql_version 14 | if using_postgresql? 15 | ActsAsBookable::Utils.connection.execute('SHOW SERVER_VERSION').first['server_version'].to_f 16 | else 17 | 0.0 18 | end 19 | end 20 | 21 | def postgresql_support_json? 22 | postgresql_version >= 9.2 23 | end 24 | 25 | 26 | def using_mysql? 27 | ActsAsBookable::Utils.using_mysql? 28 | end 29 | 30 | def using_case_insensitive_collation? 31 | using_mysql? && ActsAsBookable::Utils.connection.collation =~ /_ci\Z/ 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/1-database.rb: -------------------------------------------------------------------------------- 1 | # set adapter to use, default is sqlite3 2 | # to use an alternative adapter run => rake spec DB='postgresql' 3 | db_name = ENV['DB'] || 'sqlite3' 4 | database_yml = File.expand_path('../../internal/config/database.yml', __FILE__) 5 | 6 | if File.exist?(database_yml) 7 | 8 | ActiveRecord::Migration.verbose = false 9 | ActiveRecord::Base.default_timezone = :utc 10 | ActiveRecord::Base.configurations = YAML.load_file(database_yml) 11 | ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), '../debug.log')) 12 | ActiveRecord::Base.logger.level = ENV['TRAVIS'] ? ::Logger::ERROR : ::Logger::DEBUG 13 | config = ActiveRecord::Base.configurations[db_name] 14 | 15 | begin 16 | #activerecord 4 uses symbol 17 | #TODO, remove when activerecord 3 support is dropped 18 | if ActsAsBookable::Utils.active_record4? || ActsAsBookable::Utils.active_record5? 19 | ActiveRecord::Base.establish_connection(db_name.to_sym) 20 | else 21 | ActiveRecord::Base.establish_connection(db_name) 22 | end 23 | ActiveRecord::Base.connection 24 | rescue 25 | case db_name 26 | when /mysql/ 27 | ActiveRecord::Base.establish_connection(config.merge('database' => nil)) 28 | ActiveRecord::Base.connection.create_database(config['database'], {charset: 'utf8', collation: 'utf8_unicode_ci'}) 29 | when 'postgresql' 30 | ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public')) 31 | ActiveRecord::Base.connection.create_database(config['database'], config.merge('encoding' => 'utf8')) 32 | end 33 | 34 | ActiveRecord::Base.establish_connection(config) 35 | end 36 | 37 | load(File.dirname(__FILE__) + '/../internal/db/schema.rb') 38 | # load(File.dirname(__FILE__) + '/../internal/app/models/models.rb') 39 | Dir['./spec/internal/app/models/*.rb'].sort.each { |f| load f } 40 | else 41 | fail "Please create #{database_yml} first to configure your database. Take a look at: #{database_yml}.sample" 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/2-database_cleaner.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | 3 | config.before(:suite) do 4 | DatabaseCleaner.clean_with(:truncation) 5 | DatabaseCleaner.strategy = :transaction 6 | DatabaseCleaner.clean 7 | end 8 | 9 | config.after(:suite) do 10 | DatabaseCleaner.clean 11 | end 12 | 13 | config.before(:each) do 14 | DatabaseCleaner.start 15 | end 16 | 17 | config.after(:each) do 18 | DatabaseCleaner.clean 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/3-factory_girl.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.include FactoryGirl::Syntax::Methods 3 | 4 | config.before(:suite) do 5 | begin 6 | DatabaseCleaner.start 7 | FactoryGirl.lint 8 | ensure 9 | DatabaseCleaner.clean 10 | end 11 | end 12 | end 13 | --------------------------------------------------------------------------------