From 176338591bf75ac0175c5d157c078424a6a3cfe6 Mon Sep 17 00:00:00 2001
From: Oren Kanner
Date: Thu, 10 Dec 2015 03:47:20 -0500
Subject: [PATCH] Add modular reservations calendar
Resolves #1360
- Add Calendarable controller concern to generate HTML, JSON, and
iCalendar representations of all the reservations for a given
resource
- Add calendarable routing concern and clean up config/routes.rb
- Add Reservation#end_date to find the last day for a reservation,
status-dependent (with model specs)
- Add the associated controller specs as a shared example as well
as feature specs for equipment models
- Add calendars for categories, equipment models, equipment items,
and users
- Fix routing issue with jQuery-UI-Bootstrap assets
---
.travis.yml | 2 +-
Gemfile | 9 +-
Gemfile.lock | 27 ++-
app/assets/javascripts/application.js | 13 ++
.../stylesheets/_jquery_overrides.scss.erb | 7 +
app/assets/stylesheets/application.css.scss | 3 +
.../equipment_models/_show.css.scss | 2 +-
app/controllers/categories_controller.rb | 11 ++
app/controllers/concerns/calendarable.rb | 80 +++++++++
app/controllers/equipment_items_controller.rb | 12 ++
.../equipment_models_controller.rb | 12 ++
app/controllers/users_controller.rb | 11 ++
app/models/reservation.rb | 9 +
app/queries/reservations/for_cat_query.rb | 8 +
app/views/application/_res_calendar.html.erb | 30 ++++
app/views/application/calendar.html.erb | 4 +
app/views/application/calendar.json.jbuilder | 19 +++
app/views/categories/show.html.erb | 1 +
app/views/equipment_items/show.html.erb | 6 +-
app/views/equipment_models/index.html.erb | 1 +
app/views/equipment_models/show.html.erb | 3 +-
app/views/reservations/_top_buttons.html.erb | 8 +
app/views/users/calendar.html.erb | 12 ++
config/routes.rb | 158 +++++++++---------
.../controllers/categories_controller_spec.rb | 3 +
.../equipment_items_controller_spec.rb | 2 +
.../equipment_models_controller_spec.rb | 2 +
spec/controllers/users_controller_spec.rb | 2 +
.../features/equipment_model_calendar_spec.rb | 92 ++++++++++
spec/models/reservation_spec.rb | 35 ++++
spec/spec_helper.rb | 24 ++-
spec/support/calendarable_spec.rb | 125 ++++++++++++++
spec/support/factory_girl.rb | 4 +
spec/support/feature_helpers.rb | 2 +-
34 files changed, 644 insertions(+), 95 deletions(-)
create mode 100644 app/controllers/concerns/calendarable.rb
create mode 100644 app/queries/reservations/for_cat_query.rb
create mode 100644 app/views/application/_res_calendar.html.erb
create mode 100644 app/views/application/calendar.html.erb
create mode 100644 app/views/application/calendar.json.jbuilder
create mode 100644 app/views/users/calendar.html.erb
create mode 100644 spec/features/equipment_model_calendar_spec.rb
create mode 100644 spec/support/calendarable_spec.rb
create mode 100644 spec/support/factory_girl.rb
diff --git a/.travis.yml b/.travis.yml
index 64e4ef7e0..2e74ccff9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -36,7 +36,7 @@ script:
# - jshint --reporter=node_modules/jshint-stylish/stylish.js .
# rubocop Ruby / Rails linter
- bundle exec rubocop -D
- - bundle exec rake
+ - xvfb-run -a bundle exec rake
# From Travis CI Support: This will route jobs to our beta build environment,
# which has much faster boot times, making it easier to debug via Travis.
diff --git a/Gemfile b/Gemfile
index c7dd24b99..7d7f90991 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,7 +6,7 @@ ruby '2.2.3' # Version in .ruby-version must match
gem 'rails', '~> 4.2.5'
gem 'mysql2', '~> 0.4.2'
gem 'rake', '~> 10.4.2'
-gem 'rdoc', '~> 4.2.1'
+gem 'jbuilder', '~> 2.4.0'
# simulate environment variables
gem 'dotenv-rails', '~> 2.0.2', :require => 'dotenv/rails-now'
@@ -38,6 +38,9 @@ gem 'nilify_blanks', '~> 1.2.1'
# ui
gem 'jquery-rails', '~> 4.0.5'
gem 'jquery-ui-rails', '~> 5.0.5'
+gem 'jquery-datatables-rails', '~> 3.3.0'
+gem 'fullcalendar-rails', '~> 2.5.0.0'
+gem 'momentjs-rails', '~> 2.10.6'
gem 'rails4-autocomplete', '~> 1.1.1'
gem 'select2-rails', '~> 4.0.1'
gem 'kaminari', '~> 0.16.3'
@@ -49,6 +52,9 @@ gem 'simple_form', '~> 3.2.1'
gem 'cocoon', '~> 1.2.6'
gem 'redcarpet', '~> 3.3.4'
+# iCalendar export
+gem 'icalendar', '~> 2.3.0'
+
group :development, :test do
gem 'pry', '~> 0.10.3'
gem 'pry-rails', '~> 0.3.4'
@@ -61,6 +67,7 @@ group :development, :test do
gem 'rspec-rails', '~> 3.4.0'
gem 'shoulda-matchers', '~> 3.0.1'
gem 'capybara', '~> 2.5.0'
+ gem 'capybara-webkit', '~> 1.7.1'
gem 'guard-rspec', '~> 4.6.4'
gem 'spring', '~> 1.6.2'
gem 'spring-commands-rspec', '~> 1.0.4'
diff --git a/Gemfile.lock b/Gemfile.lock
index 84f43cf9a..a8d0ddc15 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -76,6 +76,9 @@ GEM
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (~> 2.0)
+ capybara-webkit (1.7.1)
+ capybara (>= 2.3.0, < 2.6.0)
+ json
chronic (0.10.2)
climate_control (0.0.3)
activesupport (>= 3.0)
@@ -135,6 +138,10 @@ GEM
font-awesome-rails (4.5.0.0)
railties (>= 3.2, < 5.0)
formatador (0.2.5)
+ fullcalendar-rails (2.5.0.0)
+ jquery-rails (>= 4.0.5, < 5.0.0)
+ jquery-ui-rails (>= 5.0.2)
+ momentjs-rails (>= 2.9.0)
fuubar (2.0.0)
rspec (~> 3.0)
ruby-progressbar (~> 1.4)
@@ -164,10 +171,19 @@ GEM
highline (1.7.8)
http_parser.rb (0.6.0)
i18n (0.7.0)
+ icalendar (2.3.0)
inline_svg (0.6.1)
activesupport (>= 4.0.4)
loofah (>= 2.0)
nokogiri (~> 1.6)
+ jbuilder (2.4.1)
+ activesupport (>= 3.0.0, < 5.1)
+ multi_json (~> 1.2)
+ jquery-datatables-rails (3.3.0)
+ actionpack (>= 3.1)
+ jquery-rails
+ railties (>= 3.1)
+ sass-rails
jquery-rails (4.0.5)
rails-dom-testing (~> 1.0)
railties (>= 4.2.0)
@@ -201,6 +217,8 @@ GEM
mimemagic (0.3.0)
mini_portile2 (2.0.0)
minitest (5.8.3)
+ momentjs-rails (2.10.6)
+ railties (>= 3.1)
multi_json (1.11.2)
multipart-post (2.0.0)
mysql2 (0.4.2)
@@ -311,8 +329,6 @@ GEM
rb-fsevent (0.9.7)
rb-inotify (0.9.5)
ffi (>= 0.5.0)
- rdoc (4.2.1)
- json (~> 1.4)
redcarpet (3.3.4)
ref (2.0.0)
remotipart (1.2.1)
@@ -430,6 +446,7 @@ DEPENDENCIES
capistrano-rails (~> 1.1.5)
capistrano-rvm (~> 0.1.2)
capybara (~> 2.5.0)
+ capybara-webkit (~> 1.7.1)
cocoon (~> 1.2.6)
codeclimate-test-reporter (~> 0.4.8)
coffee-rails (~> 4.1.1)
@@ -441,16 +458,21 @@ DEPENDENCIES
factory_girl_rails (~> 4.5.0)
ffaker (~> 2.1.0)
font-awesome-rails (~> 4.5.0)
+ fullcalendar-rails (~> 2.5.0.0)
fuubar (~> 2.0.0)
guard-livereload (~> 2.5.1)
guard-rspec (~> 4.6.4)
highline (~> 1.7.8)
+ icalendar (~> 2.3.0)
inline_svg (~> 0.6.1)
+ jbuilder (~> 2.4.0)
+ jquery-datatables-rails (~> 3.3.0)
jquery-rails (~> 4.0.5)
jquery-ui-rails (~> 5.0.5)
kaminari (~> 0.16.3)
letter_opener (~> 1.4.1)
letter_opener_web (~> 1.3.0)
+ momentjs-rails (~> 2.10.6)
mysql2 (~> 0.4.2)
net-ldap (~> 0.13.0)
nilify_blanks (~> 1.2.1)
@@ -470,7 +492,6 @@ DEPENDENCIES
rails_12factor (~> 0.0.3)
rails_admin (~> 0.8.1)
rake (~> 10.4.2)
- rdoc (~> 4.2.1)
redcarpet (~> 3.3.4)
rspec-rails (~> 3.4.0)
rubocop (~> 0.35.1)
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index bbede28e1..875f80a8b 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -20,6 +20,8 @@
//= require select2
//= require jquery.sticky.js
//= require jquery.dotdotdot.js
+//= require moment
+//= require fullcalendar
//= require_tree
//= require_self
@@ -98,6 +100,17 @@ $(document).ready(function() {
$("#res-history-future").DataTable().order([2, "asc"]).draw();
$("#res-history-overdue,#res-history-past,#res-history-past_overdue").DataTable().order([4, "desc"]).draw();
+ // For reservation calendars
+ $('.res-cal').fullCalendar({
+ events: $('.res-cal').attr('data-src'),
+ eventRender: function(event, element) {
+ element.attr('data-role', 'cal-item');
+ if(event.hasItem) {
+ $(element).tooltip({title: event.location});
+ }
+ },
+ buttonText: { today: 'Today' }
+ });
// ### REPORTS JS ### //
diff --git a/app/assets/stylesheets/_jquery_overrides.scss.erb b/app/assets/stylesheets/_jquery_overrides.scss.erb
index e8c88f125..61cfec67c 100644
--- a/app/assets/stylesheets/_jquery_overrides.scss.erb
+++ b/app/assets/stylesheets/_jquery_overrides.scss.erb
@@ -3,6 +3,13 @@
nicely with the asset pipeline in production [2015-12-30]
*/
+/* Component containers
+----------------------------------*/
+
+.ui-widget-content {
+ background: #ffffff/*{bgColorContent}*/ url(<%= image_path("jquery-ui/ui-bg_flat_75_ffffff_40x100.png") %>)/*{bgImgUrlContent}*/ 50%/*{bgContentXPos}*/ 50%/*{bgContentYPos}*/ repeat-x/*{bgContentRepeat}*/;
+}
+
/* Icons
----------------------------------*/
diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss
index e39dcf344..52a932fc3 100644
--- a/app/assets/stylesheets/application.css.scss
+++ b/app/assets/stylesheets/application.css.scss
@@ -27,6 +27,9 @@
@import "jquery-ui-1.10.3.theme";
@import "jquery_overrides";
+// Calendar assets
+@import "fullcalendar";
+
// Autocomplete CSS
// ============================
@import "autocomplete";
diff --git a/app/assets/stylesheets/equipment_models/_show.css.scss b/app/assets/stylesheets/equipment_models/_show.css.scss
index c58db7c91..4820d7c8c 100644
--- a/app/assets/stylesheets/equipment_models/_show.css.scss
+++ b/app/assets/stylesheets/equipment_models/_show.css.scss
@@ -128,7 +128,7 @@ $border-att: 1px solid darken($body-bg, 20%);
a { vertical-align: middle; }
}
-section {
+section ~ section {
padding-top: 86px;
}
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index f8a044b48..d001d5d85 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -6,6 +6,7 @@ class CategoriesController < ApplicationController
include ActivationHelper
include CsvExport
+ include Calendarable
# --------- before filter methods -------- #
def set_current_category
@@ -81,4 +82,14 @@ def category_params
:max_renewal_times, :max_renewal_length, :sort_order,
:renewal_days_before_due)
end
+
+ def generate_calendar_reservations
+ Reservation.for_cat(@category.id).includes(:equipment_item)
+ .overlaps_with_date_range(@start_date, @end_date).finalized + \
+ Reservation.for_cat(@category.id).includes(:equipment_item).overdue
+ end
+
+ def generate_calendar_resource
+ @category
+ end
end
diff --git a/app/controllers/concerns/calendarable.rb b/app/controllers/concerns/calendarable.rb
new file mode 100644
index 000000000..06d328661
--- /dev/null
+++ b/app/controllers/concerns/calendarable.rb
@@ -0,0 +1,80 @@
+module Calendarable
+ extend ActiveSupport::Concern
+
+ included do
+ # before filters and such
+ end
+
+ def calendar # rubocop:disable AbcSize, MethodLength
+ prepare_calendar_vars
+
+ # extract calendar data
+ respond_to do |format|
+ format.html
+ format.json { @calendar_res = generate_calendar_reservations }
+ # generate iCal version
+ # see https://gorails.com/forum/multi-event-ics-file-generation
+ format.ics do
+ @calendar_res = generate_calendar_reservations
+ cal = Icalendar::Calendar.new
+
+ @calendar_res.each do |r|
+ event = Icalendar::Event.new
+ event.dtstart = Icalendar::Values::Date.new(r.start_date)
+ event.dtend = Icalendar::Values::Date.new(r.end_date + 1.day)
+ event.summary = r.reserver.name
+ event.location = r.equipment_item.name unless r.equipment_item.nil?
+ event.url = reservation_url(r, format: :html)
+ cal.add_event(event)
+ end
+ cal.publish
+
+ response.headers['Content-Type'] = 'text/calendar'
+ response.headers['Content-Disposition'] =
+ 'attachment; filename=reservations.ics'
+ render text: cal.to_ical
+ end
+ end
+ end
+
+ private
+
+ def prepare_calendar_vars
+ @start_date = calendar_start_date
+ @end_date = calendar_end_date
+ @resource = generate_calendar_resource
+ @src_path = generate_source_path
+ end
+
+ def calendar_start_date
+ if params[:start]
+ Time.zone.parse(params[:start]).to_date
+ elsif params[:calendar] && params[:calendar][:start_date]
+ Time.zone.parse(params[:calendar][:start_date]).to_date
+ else
+ Time.zone.today - 6.months
+ end
+ end
+
+ def calendar_end_date
+ if params[:end]
+ Time.zone.parse(params[:end]).to_date
+ elsif params[:calendar] && params[:calendar][:end_date]
+ Time.zone.parse(params[:calendar][:end_date]).to_date
+ else
+ Time.zone.today + 6.months
+ end
+ end
+
+ def generate_calendar_reservations
+ fail NotImplementedError
+ end
+
+ def generate_calendar_resource
+ fail NotImplementedError
+ end
+
+ def generate_source_path
+ "calendar_#{@resource.class.to_s.underscore}_path".to_sym
+ end
+end
diff --git a/app/controllers/equipment_items_controller.rb b/app/controllers/equipment_items_controller.rb
index 0df1dcb7f..70c375318 100644
--- a/app/controllers/equipment_items_controller.rb
+++ b/app/controllers/equipment_items_controller.rb
@@ -7,6 +7,7 @@ class EquipmentItemsController < ApplicationController
before_action :set_equipment_model_if_possible, only: [:index, :new]
include ActivationHelper
+ include Calendarable
# ---------- before filter methods ---------- #
@@ -31,6 +32,7 @@ def index
end
def show
+ prepare_calendar_vars
end
def new
@@ -110,4 +112,14 @@ def equipment_item_params
.permit(:name, :serial, :deleted_at, :equipment_model_id,
:deactivation_reason, :notes)
end
+
+ def generate_calendar_reservations
+ @equipment_item.reservations.includes(:equipment_item)
+ .overlaps_with_date_range(@start_date, @end_date).finalized + \
+ @equipment_item.reservations.includes(:equipment_item).overdue
+ end
+
+ def generate_calendar_resource
+ @equipment_item
+ end
end
diff --git a/app/controllers/equipment_models_controller.rb b/app/controllers/equipment_models_controller.rb
index 509421c67..912517ba2 100644
--- a/app/controllers/equipment_models_controller.rb
+++ b/app/controllers/equipment_models_controller.rb
@@ -10,6 +10,7 @@ class EquipmentModelsController < ApplicationController
before_action :set_category_if_possible, only: [:index, :new]
include ActivationHelper
+ include Calendarable
# --------- before filter methods --------- #
def set_equipment_model
@@ -192,4 +193,15 @@ def fix_content_type(filedata)
def type_from_file_command(file)
Paperclip::FileCommandContentTypeDetector.new(file).detect
end
+
+ def generate_calendar_reservations
+ Reservation.for_eq_model(@equipment_model.id).includes(:equipment_item)
+ .overlaps_with_date_range(@start_date, @end_date).finalized + \
+ Reservation.for_eq_model(@equipment_model.id).includes(:equipment_item)
+ .overdue
+ end
+
+ def generate_calendar_resource
+ @equipment_model
+ end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 3dfacac6d..49e96d946 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -14,6 +14,7 @@ class UsersController < ApplicationController
:edit, :update]
include Autocomplete
+ include Calendarable
include CsvExport
# ------------ before filter methods ------------ #
@@ -253,4 +254,14 @@ def user_params
p[:view_mode] = p[:role] if p[:role]
p
end
+
+ def generate_calendar_reservations
+ @user.reservations.includes(:equipment_item)
+ .overlaps_with_date_range(@start_date, @end_date).finalized + \
+ @user.reservations.includes(:equipment_item).overdue
+ end
+
+ def generate_calendar_resource
+ @user
+ end
end
diff --git a/app/models/reservation.rb b/app/models/reservation.rb
index 08041929f..2b225f057 100644
--- a/app/models/reservation.rb
+++ b/app/models/reservation.rb
@@ -79,6 +79,7 @@ class Reservation < ActiveRecord::Base
# more complex / task-specific scopes
scope :checkoutable, Reservations::CheckoutableQuery
+ scope :for_cat, Reservations::ForCatQuery
scope :future, Reservations::FutureQuery
scope :notes_unsent, Reservations::NotesUnsentQuery
scope :overlaps_with_date_range, Reservations::OverlapsWithDateRangeQuery
@@ -180,6 +181,14 @@ def human_status # rubocop:disable all
end
end
+ # returns end of reservation, either checkin date (if returned), today (if
+ # overdue), or due date otherwise
+ def end_date
+ return checked_in if checked_in
+ return Time.zone.today if overdue
+ due_date
+ end
+
def duration
due_date - start_date + 1
end
diff --git a/app/queries/reservations/for_cat_query.rb b/app/queries/reservations/for_cat_query.rb
new file mode 100644
index 000000000..62b11a8cc
--- /dev/null
+++ b/app/queries/reservations/for_cat_query.rb
@@ -0,0 +1,8 @@
+module Reservations
+ class ForCatQuery < Reservations::ReservationsQueryBase
+ def call(cat_id)
+ ems = EquipmentModel.where('category_id = ?', cat_id).map(&:id)
+ @relation.where(equipment_model_id: ems)
+ end
+ end
+end
diff --git a/app/views/application/_res_calendar.html.erb b/app/views/application/_res_calendar.html.erb
new file mode 100644
index 000000000..60c65c118
--- /dev/null
+++ b/app/views/application/_res_calendar.html.erb
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Export reservations as an iCalendar-formatted file (e.g. for Google Calendar)
+
+
+
+
+ <%= form_tag send(@src_path, @resource, format: :ics), method: :get, class: 'form-inline' do %>
+
+ <%= label_tag :start_date, 'Start of calendar' %>
+ <%= text_field "calendar", "start_date", :class => 'date_start_no_min form-control',
+ :value => @start_date.strftime('%m/%d/%Y') %>
+ <%= hidden_field_tag "calendar[start_date]", @start_date, id: 'date_start_alt' %>
+
+
+ <%= label_tag :end_date, 'End of calendar' %>
+ <%= text_field "calendar", "end_date", :class => 'date_end_no_min form-control',
+ :value => @end_date.strftime('%m/%d/%Y') %>
+ <%= hidden_field_tag "calendar[end_date]", @end_date, id: 'date_end_alt' %>
+
+ <%= submit_tag "Export Calendar", class: 'btn btn-primary' %>
+ <% end %>
+
+
+
\ No newline at end of file
diff --git a/app/views/application/calendar.html.erb b/app/views/application/calendar.html.erb
new file mode 100644
index 000000000..a28f8ab4c
--- /dev/null
+++ b/app/views/application/calendar.html.erb
@@ -0,0 +1,4 @@
+<% title "#{@resource.name} Calendar" %>
+
+<%= render 'res_calendar' %>
+<%= link_to "Back to #{@resource.name}", @resource, class: 'btn btn-default' %>
\ No newline at end of file
diff --git a/app/views/application/calendar.json.jbuilder b/app/views/application/calendar.json.jbuilder
new file mode 100644
index 000000000..efbda19ed
--- /dev/null
+++ b/app/views/application/calendar.json.jbuilder
@@ -0,0 +1,19 @@
+# colors (currently "borrowed" from Bootstrap)
+overdue_clr = '#d9534f'
+reserved_clr = '#337ab7'
+checked_out_clr = '#5bc0de'
+returned_clr = '#5cb85c'
+missed_clr = '#888'
+
+# generate json
+json.array!(@calendar_res) do |res|
+ json.extract! res, :id
+ json.title res.reserver.name
+ json.start res.start_date
+ json.end res.end_date + 1.day
+ json.backgroundColor res.overdue ? overdue_clr : eval("#{res.status}_clr")
+ json.allDay true
+ json.url reservation_url(res, format: :html)
+ json.hasItem !res.equipment_item.nil?
+ json.location res.equipment_item.name unless res.equipment_item.nil?
+end
diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb
index 6346c344b..074c876f0 100644
--- a/app/views/categories/show.html.erb
+++ b/app/views/categories/show.html.erb
@@ -55,6 +55,7 @@
<%= render 'equipment_models/table' %>
+ <%= link_to "#{@category.name} Reservations Calendar", calendar_category_path(@category), class: 'btn btn-default' %>
<%= link_to "Edit Category", edit_category_path(@category), class: 'btn btn-default' %>
<%= category.make_deactivate_btn %>
<%= link_to "View All Categories", categories_path, class: 'btn btn-default' %>
diff --git a/app/views/equipment_items/show.html.erb b/app/views/equipment_items/show.html.erb
index 9c7453526..8956203c7 100644
--- a/app/views/equipment_items/show.html.erb
+++ b/app/views/equipment_items/show.html.erb
@@ -21,16 +21,20 @@
<%# NOTES %>
-
+
<% if @equipment_item.notes.present? && (can? :view, :eq_log) %>
Notes
<%= markdown(@equipment_item.notes) %>
<% end %>
+
+ <%= render 'res_calendar' %>
+
+<%= link_to "#{@equipment_item.name} Reservations Calendar", calendar_equipment_item_path(@equipment_item), class: 'btn btn-default' %>
<%= link_to "Edit", edit_equipment_item_path(@equipment_item), class: 'btn btn-default' %>
<%= equipment_item.make_deactivate_btn %>
<%= link_to("View All", equipment_model_equipment_items_path(@equipment_item.equipment_model), class: 'btn btn-default' ) %>
diff --git a/app/views/equipment_models/index.html.erb b/app/views/equipment_models/index.html.erb
index f26b79ad0..39d9b4fb9 100644
--- a/app/views/equipment_models/index.html.erb
+++ b/app/views/equipment_models/index.html.erb
@@ -6,6 +6,7 @@
<% if can? :manage, EquipmentModel %>
<% if @category %>
<%= link_to "New #{@category.name.singularize}", new_category_equipment_model_path(@category), :class => 'btn btn-primary' %>
+ <%= link_to "#{@category.name} Reservations Calendar", calendar_category_path(@category), class: 'btn btn-default' %>
<%= link_to "View All Equipment Models", equipment_models_path, :class => 'btn btn-default' %>
<% else %>
<%= link_to "New Equipment Model", new_equipment_model_path, :class => "btn btn-primary" %>
diff --git a/app/views/equipment_models/show.html.erb b/app/views/equipment_models/show.html.erb
index eaaf10c4b..478f6ab41 100644
--- a/app/views/equipment_models/show.html.erb
+++ b/app/views/equipment_models/show.html.erb
@@ -77,7 +77,8 @@
<% if can? :manage, EquipmentModel %>
Items
Procedures
-
Pending Reservations
+ Pending Reservations
+
<%= link_to 'Reservations Calendar', calendar_equipment_model_path(@equipment_model) %>
<% end %>
diff --git a/app/views/reservations/_top_buttons.html.erb b/app/views/reservations/_top_buttons.html.erb
index cc1b86098..7f1d80672 100644
--- a/app/views/reservations/_top_buttons.html.erb
+++ b/app/views/reservations/_top_buttons.html.erb
@@ -20,4 +20,12 @@
<%= link_to "#{reserver.first_name}'s Reservations", current_reservations_for_user_path(reserver), :class => 'btn btn-default' %>
<% end %>
+ <% if can? :manage, User %>
+ <% if url == calendar_user_path(reserver) %>
+ <%= link_to "#{reserver.first_name}'s Calendar", calendar_user_path(reserver), :class => 'btn btn-default disabled' %>
+ <% else %>
+ <%= link_to "#{reserver.first_name}'s Calendar", calendar_user_path(reserver), :class => 'btn btn-default' %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/users/calendar.html.erb b/app/views/users/calendar.html.erb
new file mode 100644
index 000000000..4a9cb1aa7
--- /dev/null
+++ b/app/views/users/calendar.html.erb
@@ -0,0 +1,12 @@
+<% title @user.first_name + " " + (!@user.nickname.blank? ? "(#{@user.nickname}) " : "") + @user.last_name + "'s Reservations Calendar" %>
+
+<% if can? :read, User %>
+
+
+ <%= render :partial => 'reservations/top_buttons', :locals => {:reserver => @user} %>
+
+
+
+<% end %>
+
+<%= render 'res_calendar' %>
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 87db8845f..856bdd0dc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,15 +1,14 @@
Reservations::Application.routes.draw do
+ root to: 'catalog#index'
+
# routes for Devise
devise_scope :user do
devise_for :users
end
- root to: 'catalog#index'
-
mount RailsAdmin::Engine => '/admin', as: 'rails_admin'
- get 'status/index'
-
+ ## Concerns
concern :deactivatable do
member do
put :deactivate
@@ -17,40 +16,44 @@
end
end
+ concern :calendarable do
+ member { get :calendar }
+ end
+
resources :documents,
:requirements
- resources :equipment_items, except: [:index], concerns: :deactivatable
+ resources :equipment_items, except: [:index],
+ concerns: [:deactivatable, :calendarable]
resources :announcements, except: [:show]
- resources :categories, concerns: :deactivatable do
- resources :equipment_models
+ resources :categories, concerns: [:deactivatable, :calendarable] do
+ resources :equipment_models, concern: :calendarable
end
- resources :equipment_models, concerns: :deactivatable do
+ resources :equipment_models, concerns: [:deactivatable, :calendarable] do
collection do
put 'update_cart'
delete 'empty_cart'
end
- resources :equipment_items
+ resources :equipment_items, concerns: :calendarable
end
- get 'equipment_objects' => redirect('equipment_items')
- get 'equipment_objects/:id' =>
- redirect('equipment_items/%{id}')
+ get 'equipment_objects', to: redirect('equipment_items')
+ get 'equipment_objects/:id', to: redirect('equipment_items/%{id}')
- get '/import_users/import' => 'import_users#import_page',
- :as => :csv_import_page
- post '/import_users/imported' => 'import_users#import',
- :as => :csv_imported
+ get '/import_users/import', to: 'import_users#import_page',
+ as: :csv_import_page
+ post '/import_users/imported', to: 'import_users#import',
+ as: :csv_imported
- get '/import_equipment/import' => 'import_equipment#import_page',
- :as => :equip_import_page
- post '/import_equipment/imported' => 'import_equipment#import',
- :as => :equip_imported
+ get '/import_equipment/import', to: 'import_equipment#import_page',
+ as: :equip_import_page
+ post '/import_equipment/imported', to: 'import_equipment#import',
+ as: :equip_imported
- resources :users do
+ resources :users, concerns: :calendarable do
collection do
post :find
post :quick_new
@@ -63,9 +66,9 @@
get :autocomplete_user_last_name, on: :collection
end
- get '/catalog/search' => 'catalog#search',
- :as => :catalog_search # what kind of http request is this?
- get '/markdown_help' => 'application#markdown_help', :as => :markdown_help
+ # what kind of http request is this?
+ get '/catalog/search', to: 'catalog#search', as: :catalog_search
+ get '/markdown_help', to: 'application#markdown_help', as: :markdown_help
resources :reservations do
member do
@@ -76,32 +79,32 @@
end
# reservations views
- get '/reservations/manage/:user_id' => 'reservations#manage',
- :as => :manage_reservations_for_user
- get '/reservations/current/:user_id' => 'reservations#current',
- :as => :current_reservations_for_user
-
- get '/reservations/review/:id' => 'reservations#review',
- :as => :review_request
- put '/reservations/approve/:id' => 'reservations#approve_request',
- :as => :approve_request
- put '/reservations/deny/:id' => 'reservations#deny_request',
- :as => :deny_request
+ get '/reservations/manage/:user_id', to: 'reservations#manage',
+ as: :manage_reservations_for_user
+ get '/reservations/current/:user_id', to: 'reservations#current',
+ as: :current_reservations_for_user
+
+ get '/reservations/review/:id', to: 'reservations#review',
+ as: :review_request
+ put '/reservations/approve/:id', to: 'reservations#approve_request',
+ as: :approve_request
+ put '/reservations/deny/:id', to: 'reservations#deny_request',
+ as: :deny_request
# reservation checkout / check-in actions
- put '/reservations/checkout/:user_id' => 'reservations#checkout',
- :as => :checkout
- put '/reservations/check-in/:user_id' => 'reservations#checkin',
- :as => :checkin
- get '/blackouts/flash_message' => 'blackouts#flash_message',
- :as => :flash_message
- get '/blackouts/new_recurring' => 'blackouts#new_recurring',
- :as => :new_recurring_blackout
-
- put '/reservation/update_index_dates' => 'reservations#update_index_dates',
- :as => :update_index_dates
- put '/reservation/view_all_dates' => 'reservations#view_all_dates',
- :as => :view_all_dates
+ put '/reservations/checkout/:user_id', to: 'reservations#checkout',
+ as: :checkout
+ put '/reservations/check-in/:user_id', to: 'reservations#checkin',
+ as: :checkin
+ get '/blackouts/flash_message', to: 'blackouts#flash_message',
+ as: :flash_message
+ get '/blackouts/new_recurring', to: 'blackouts#new_recurring',
+ as: :new_recurring_blackout
+
+ put '/reservation/update_index_dates', to: 'reservations#update_index_dates',
+ as: :update_index_dates
+ put '/reservation/view_all_dates', to: 'reservations#view_all_dates',
+ as: :view_all_dates
resources :blackouts do
collection do
@@ -113,50 +116,47 @@
end
end
- put '/catalog/update_view' => 'catalog#update_user_per_cat_page',
- :as => :update_user_per_cat_page
- get '/catalog' => 'catalog#index', :as => :catalog
- put '/add_to_cart/:id' => 'catalog#add_to_cart', :as => :add_to_cart
- put '/remove_from_cart/:id' => 'catalog#remove_from_cart',
- :as => :remove_from_cart
- put '/catalog/edit_cart_item/:id' => 'catalog#edit_cart_item',
- :as => :edit_cart_item
- # delete '/cart/empty' => 'application#empty_cart', :as => :empty_cart
- # put '/cart/update' => 'application#update_cart', :as => :update_cart
-
- get '/reports/index' => 'reports#index', :as => :reports
- get '/reports/subreport/:class/:id' => 'reports#subreport',
- as: :subreport
- put '/reports/update' => 'reports#update_dates',
- :as => :update_dates
-
- get '/terms_of_service' => 'application#terms_of_service',
- :as => :tos
+ put '/catalog/update_view', to: 'catalog#update_user_per_cat_page',
+ as: :update_user_per_cat_page
+ get '/catalog', to: 'catalog#index', as: :catalog
+ put '/add_to_cart/:id', to: 'catalog#add_to_cart', as: :add_to_cart
+ put '/remove_from_cart/:id', to: 'catalog#remove_from_cart',
+ as: :remove_from_cart
+ put '/catalog/edit_cart_item/:id', to: 'catalog#edit_cart_item',
+ as: :edit_cart_item
+
+ get '/reports/index', to: 'reports#index', as: :reports
+ get '/reports/subreport/:class/:id', to: 'reports#subreport', as: :subreport
+ put '/reports/update', to: 'reports#update_dates', as: :update_dates
+
+ get '/terms_of_service', to: 'application#terms_of_service', as: :tos
# yes, both of these are needed to override rails defaults of
# /controller/:id/edit
- get '/app_configs/' => 'app_configs#edit', :as => :edit_app_configs # match
+ get '/app_configs/', to: 'app_configs#edit', as: :edit_app_configs # match
resources :app_configs, only: [:update]
- get '/new_admin_user' => 'application_setup#new_admin_user',
- :as => :new_admin_user
- post '/create_admin_user' => 'application_setup#create_admin_user',
- :as => :create_admin_user
+ get '/new_admin_user', to: 'application_setup#new_admin_user',
+ as: :new_admin_user
+ post '/create_admin_user', to: 'application_setup#create_admin_user',
+ as: :create_admin_user
resources :application_setup, only: [:new_admin_user, :create_admin_user]
- get '/new_app_configs' => 'application_setup#new_app_configs',
- :as => :new_app_configs
- post '/create_app_configs' => 'application_setup#create_app_configs',
- :as => :create_app_configs
+ get '/new_app_configs', to: 'application_setup#new_app_configs',
+ as: :new_app_configs
+ post '/create_app_configs', to: 'application_setup#create_app_configs',
+ as: :create_app_configs
- get 'contact' => 'contact#new', :as => 'contact_us'
- post 'contact' => 'contact#create', :as => 'contact_submitted'
+ get 'contact', to: 'contact#new', as: 'contact_us'
+ post 'contact', to: 'contact#create', as: 'contact_submitted'
get 'announcements/:id/hide',
to: 'announcements#hide',
as: 'hide_announcement'
- get 'status' => 'status#index'
+ get 'status', to: 'status#index'
+
+ get 'status/index'
# generalized matcher
match ':controller(/:action(/:id(.:format)))', via: [:get, :post, :put,
diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb
index 6d3fb603b..ac6c24ded 100644
--- a/spec/controllers/categories_controller_spec.rb
+++ b/spec/controllers/categories_controller_spec.rb
@@ -7,6 +7,9 @@
before(:each) do
@category = FactoryGirl.create(:category)
end
+
+ it_behaves_like 'calendarable', Category
+
describe 'GET index' do
before(:each) do
@inactive_category =
diff --git a/spec/controllers/equipment_items_controller_spec.rb b/spec/controllers/equipment_items_controller_spec.rb
index 202e38d84..9c32896f3 100644
--- a/spec/controllers/equipment_items_controller_spec.rb
+++ b/spec/controllers/equipment_items_controller_spec.rb
@@ -5,6 +5,8 @@
let!(:item) { FactoryGirl.create(:equipment_item) }
let!(:deactivated_item) { FactoryGirl.create(:deactivated) }
+ it_behaves_like 'calendarable', EquipmentItem
+
describe 'GET index' do
context 'with admin user' do
before do
diff --git a/spec/controllers/equipment_models_controller_spec.rb b/spec/controllers/equipment_models_controller_spec.rb
index 84ece2e28..920b8dbb4 100644
--- a/spec/controllers/equipment_models_controller_spec.rb
+++ b/spec/controllers/equipment_models_controller_spec.rb
@@ -108,6 +108,8 @@
before(:all) { @app_config = FactoryGirl.create(:app_config) }
let!(:model) { FactoryGirl.create(:equipment_model) }
+ it_behaves_like 'calendarable', EquipmentModel
+
describe 'GET index' do
context 'with admin user' do
before do
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 59a7bdbc3..4fd1270e2 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -344,4 +344,6 @@
end
end
end
+
+ it_behaves_like 'calendarable', User
end
diff --git a/spec/features/equipment_model_calendar_spec.rb b/spec/features/equipment_model_calendar_spec.rb
new file mode 100644
index 000000000..683f88b71
--- /dev/null
+++ b/spec/features/equipment_model_calendar_spec.rb
@@ -0,0 +1,92 @@
+require 'spec_helper'
+
+RSpec.feature 'Equipment model calendar view' do
+ context 'as admin or superuser' do
+ before(:each) { sign_in_as_user(@admin) }
+ after(:each) { sign_out }
+
+ it 'has useful links' do
+ visit calendar_equipment_model_path(@eq_model)
+
+ expect(page).to \
+ have_css "input[type=submit][value='Export Calendar']"
+ expect(page).to have_link "Back to #{@eq_model.name}",
+ href: equipment_model_path(@eq_model)
+ end
+
+ it 'shows all reservations in the current month', :js do
+ create_res_in_current_month(2)
+
+ visit calendar_equipment_model_path(@eq_model)
+
+ expect(page).to have_css '[data-role=cal-item]', count: 2
+ end
+
+ it 'does not show reservations next month', :js do
+ create_res_in_current_month
+ FactoryGirl.create(:valid_reservation,
+ start_date: Time.zone.today + 1.month,
+ due_date: Time.zone.today + 1.month + 1.day)
+
+ visit calendar_equipment_model_path(@eq_model)
+
+ expect(page).to have_css '[data-role=cal-item]', count: 1
+ end
+
+ it 'links to the reservation', :js do
+ res = create_res_in_current_month
+
+ visit calendar_equipment_model_path(@eq_model)
+
+ # this is super hacky, but the url host was super weird (127.0.0.1:37353)
+ expect(page.find('[data-role=cal-item]')[:href]).to \
+ include reservation_path(res) + '.html'
+ end
+ end
+
+ context 'as non-admin' do
+ shared_examples 'fails' do
+ it 'redirects to catalog' do
+ visit calendar_equipment_model_path(@eq_model)
+
+ expect(page).to have_css 'h1', text: 'Catalog'
+ end
+ end
+
+ context 'as checkout person' do
+ before(:each) { sign_in_as_user(@checkout_person) }
+ after(:each) { sign_out }
+
+ it_behaves_like 'fails'
+ end
+
+ context 'as patron' do
+ before(:each) { sign_in_as_user(@user) }
+ after(:each) { sign_out }
+
+ it_behaves_like 'fails'
+ end
+
+ context 'as banned user' do
+ before(:each) { sign_in_as_user(@banned) }
+ after(:each) { sign_out }
+
+ it_behaves_like 'fails'
+ end
+ end
+
+ def create_res_in_current_month(count = 1)
+ fail ArgumentError if count > 28
+ day0 = Time.zone.today.beginning_of_month # to ensure it's in this month
+ (1..count).each do |i|
+ # make sure it's a 1-day reservation so there's only a single cell
+ # (avoid weekend overlaps)
+ res = build :reservation, equipment_model: @eq_model,
+ start_date: day0 + (i - 1).days,
+ due_date: day0 + (i - 1).days,
+ status: Reservation.statuses[:reserved]
+ res.save(validate: false)
+ return res if i == count # return last reservation if you want it
+ end
+ end
+end
diff --git a/spec/models/reservation_spec.rb b/spec/models/reservation_spec.rb
index 0bb977413..e6fbecd10 100644
--- a/spec/models/reservation_spec.rb
+++ b/spec/models/reservation_spec.rb
@@ -647,5 +647,40 @@
end
end
+ context '#end_date' do
+ context 'if checked in' do
+ before { @res = FactoryGirl.build(:checked_in_reservation) }
+
+ it 'returns the checkin date' do
+ expect(@res.end_date).to eq(@res.checked_in)
+ end
+
+ it 'does not care if overdue' do
+ @res.overdue = true
+ expect(@res.end_date).to eq(@res.checked_in)
+ end
+ end
+
+ context 'if overdue' do
+ before { @res = FactoryGirl.build(:overdue_reservation) }
+
+ it 'returns today' do
+ expect(@res.end_date).to eq(Time.zone.today)
+ end
+ end
+
+ context 'otherwise' do
+ it 'returns due date for request' do
+ res = FactoryGirl.build(:request)
+ expect(res.end_date).to eq(res.due_date)
+ end
+
+ it 'returns due date for reserved' do
+ res = FactoryGirl.build(:valid_reservation)
+ expect(res.end_date).to eq(res.due_date)
+ end
+ end
+ end
+
it_behaves_like 'linkable'
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 0dd643c9e..9febd88ec 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -33,6 +33,7 @@
# Needed in order to do integration tests with capybara
config.include Capybara::DSL
Capybara.asset_host = 'http://0.0.0.0:3000'
+ Capybara.javascript_driver = :webkit
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_path = "#{::Rails.root}/spec/fixtures"
@@ -40,7 +41,7 @@
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
- config.use_transactional_fixtures = true
+ config.use_transactional_fixtures = false
# If true, the base class of anonymous controllers will be inferred
# automatically. This will be the default behavior in future versions of
@@ -53,16 +54,25 @@
# --seed 1234
config.order = 'random'
- # DatabaseCleaner setup
+ # DatabaseCleaner setup (2016-01-04 based on Rails Testing book)
config.before(:suite) do
+ DatabaseCleaner.clean_with(:deletion)
+ end
+
+ config.before(:each) do
DatabaseCleaner.strategy = :transaction
- DatabaseCleaner.clean_with(:truncation)
end
- config.around(:each) do |example|
- DatabaseCleaner.cleaning do
- example.run
- end
+ config.before(:each, js: true) do
+ DatabaseCleaner.strategy = :deletion
+ end
+
+ config.before(:each) do
+ DatabaseCleaner.start
+ end
+
+ config.after(:each) do
+ DatabaseCleaner.clean
end
# set up app before all integration specs, wish we didn't have to use :each
diff --git a/spec/support/calendarable_spec.rb b/spec/support/calendarable_spec.rb
new file mode 100644
index 000000000..f3f6cbd48
--- /dev/null
+++ b/spec/support/calendarable_spec.rb
@@ -0,0 +1,125 @@
+require 'spec_helper'
+
+shared_examples_for 'calendarable' do |model|
+ before(:each) do
+ sign_in FactoryGirl.create(:admin)
+ @obj = FactoryGirl.create(model.to_s.underscore.to_sym)
+ @res = send("gen_#{model.to_s.underscore}_res".to_sym, @obj)
+ @res2 = send("gen_#{model.to_s.underscore}_res".to_sym)
+ end
+
+ context 'GET calendar' do
+ before { get :calendar, id: @obj }
+
+ it 'stores instance variables' do
+ expect(assigns(:resource)).to eq(@obj)
+ expect(assigns(:src_path)).to \
+ eq("calendar_#{@obj.class.to_s.underscore}_path".to_sym)
+ end
+ it 'responds with HTML' do
+ expect(response.content_type).to eq('text/html')
+ end
+ end
+
+ context 'GET calendar dates' do
+ it 'defaults to +/- 6 months' do
+ get :calendar, id: @obj
+
+ expect(assigns(:start_date)).to eq(Time.zone.today - 6.months)
+ expect(assigns(:end_date)).to eq(Time.zone.today + 6.months)
+ end
+
+ it 'uses start and end' do
+ start_date = Time.zone.today
+ end_date = Time.zone.today + 1.day
+
+ get :calendar, id: @obj, start: start_date, end: end_date
+
+ expect(assigns(:start_date)).to eq(start_date)
+ expect(assigns(:end_date)).to eq(end_date)
+ end
+
+ it 'uses calendar[start_date] and calendar[end_date]' do
+ start_date = Time.zone.today
+ end_date = Time.zone.today + 1.day
+
+ get :calendar, id: @obj, calendar: { start_date: start_date,
+ end_date: end_date }
+
+ expect(assigns(:start_date)).to eq(start_date)
+ expect(assigns(:end_date)).to eq(end_date)
+ end
+ end
+
+ context 'GET calendar JSON' do
+ before { get :calendar, format: :json, id: @obj }
+
+ it 'stores reservations for the object correctly' do
+ expect(assigns(:calendar_res)).to include(@res)
+ expect(assigns(:calendar_res)).not_to include(@res2)
+ end
+ it 'stores other instance variables' do
+ expect(assigns(:resource)).to eq(@obj)
+ expect(assigns(:src_path)).to \
+ eq("calendar_#{@obj.class.to_s.underscore}_path".to_sym)
+ end
+ it 'responds with JSON' do
+ expect(response.content_type).to eq('application/json')
+ end
+ end
+
+ context 'GET calendar ICS' do
+ before { get :calendar, format: :ics, id: @obj }
+
+ it 'stores reservations for the object correctly' do
+ expect(assigns(:calendar_res)).to include(@res)
+ expect(assigns(:calendar_res)).not_to include(@res2)
+ end
+ it 'stores other instance variables' do
+ expect(assigns(:resource)).to eq(@obj)
+ expect(assigns(:src_path)).to \
+ eq("calendar_#{@obj.class.to_s.underscore}_path".to_sym)
+ end
+
+ it 'responds with ICS format' do
+ expect(response.content_type).to eq('text/calendar')
+ end
+ end
+end
+
+def gen_user_res(user = nil)
+ user ||= FactoryGirl.create(:user)
+ gen_res(user)
+end
+
+def gen_category_res(cat = nil)
+ cat ||= FactoryGirl.create(:category)
+
+ gen_equipment_model_res(nil, cat)
+end
+
+def gen_equipment_model_res(em = nil, cat = nil)
+ opts = cat ? { category: cat } : {}
+
+ em ||= FactoryGirl.create(:equipment_model, opts)
+
+ gen_equipment_item_res(nil, em)
+end
+
+def gen_equipment_item_res(ei = nil, em = nil)
+ opts = em ? { equipment_model: em } : {}
+
+ ei ||= FactoryGirl.create(:equipment_item, opts)
+
+ gen_res(nil, ei)
+end
+
+def gen_res(user = nil, ei = nil)
+ return if ei && ei.equipment_model.nil? # check for invalid ei
+
+ ei ||= FactoryGirl.create(:equipment_item)
+ user ||= FactoryGirl.create(:user)
+
+ FactoryGirl.create(:valid_reservation, equipment_item: ei, reserver: user,
+ equipment_model: ei.equipment_model)
+end
diff --git a/spec/support/factory_girl.rb b/spec/support/factory_girl.rb
new file mode 100644
index 000000000..4dd2ba6ea
--- /dev/null
+++ b/spec/support/factory_girl.rb
@@ -0,0 +1,4 @@
+# Adds FactoryGirl methods to global context
+RSpec.configure do |config|
+ config.include FactoryGirl::Syntax::Methods
+end
diff --git a/spec/support/feature_helpers.rb b/spec/support/feature_helpers.rb
index b5092a0e7..d1b521a06 100644
--- a/spec/support/feature_helpers.rb
+++ b/spec/support/feature_helpers.rb
@@ -70,7 +70,7 @@ def sign_in_as_user(user)
def sign_out
visit root_path
- click_link 'Log Out'
+ click_link 'Log Out' if has_link?('Log Out')
@current_user = nil
end