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