diff options
24 files changed, 614 insertions, 8 deletions
diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb new file mode 100644 index 000000000..84b432bf3 --- /dev/null +++ b/app/controllers/calendars_controller.rb @@ -0,0 +1,72 @@ +class CalendarsController < BreadcrumbController + defaults resource_class: Calendar + before_action :check_policy, only: [:edit, :update, :destroy] + + def new + new! do + @calendar.date_ranges = [] + @calendar.dates = [] + end + end + + def create + @calendar = current_organisation.calendars.build(calendar_params) + + if @calendar.valid? + respond_with @calendar + else + render action: 'new' + end + end + + def update + update! do |success, failure| + success.html { redirect_to calendar_path(@calendar) } + end + end + + def destroy + destroy! do |success, failure| + success.html { redirect_to calendars_path } + end + end + + private + def calendar_params + params.require(:calendar).permit(:id, :name, :short_name, :shared, ranges_attributes: [:id, :begin, :end, :_destroy], dates: []) + end + + def sort_column + current_organisation.calendars.column_names.include?(params[:sort]) ? params[:sort] : 'short_name' + end + + def sort_direction + %w[asc desc].include?(params[:direction]) ? params[:direction] : 'asc' + end + + protected + def collection + # if params[:q] + # if params[:q][:shared_eq] + # if params[:q][:shared_eq] == 'all' + # params[:q].delete(:shared_eq) + # else + # params[:q][:shared_eq] = params[:q][:shared_eq] == 'true' + # end + # end + # end + + @q = current_organisation.calendars.search(params[:q]) + + if sort_column && sort_direction + @calendars ||= @q.result(distinct: true).order(sort_column + ' ' + sort_direction).paginate(page: params[:page]) + else + @calendars ||= @q.result(distinct: true).paginate(page: params[:page]) + end + end + + def check_policy + authorize resource + end +end + diff --git a/app/helpers/breadcrumb_helper.rb b/app/helpers/breadcrumb_helper.rb index dc68bd897..d764ad60f 100644 --- a/app/helpers/breadcrumb_helper.rb +++ b/app/helpers/breadcrumb_helper.rb @@ -38,6 +38,8 @@ module BreadcrumbHelper timeband_breadcrumb action when 'Chouette::RoutingConstraintZone' routing_constraint_zone_breadcrumb action + when 'Calendar' + calendar_breadcrumb action when "StopAreaCopy" stop_area_copy_breadcrumb action when "Import" @@ -70,6 +72,11 @@ module BreadcrumbHelper end end + def calendar_breadcrumb(action) + add_breadcrumb Calendar.model_name.human.pluralize + add_breadcrumb @calendar.name if %i(show edit).include? action + end + def workbench_breadcrumb(action) add_breadcrumb I18n.t("breadcrumbs.referentials"), referentials_path add_breadcrumb breadcrumb_label(@workbench), workbench_path(@workbench), :title => breadcrumb_tooltip(@workbench) diff --git a/app/helpers/newfront_helper.rb b/app/helpers/newfront_helper.rb index 2abfd589d..7bedbeea9 100644 --- a/app/helpers/newfront_helper.rb +++ b/app/helpers/newfront_helper.rb @@ -55,13 +55,15 @@ module NewfrontHelper polymorph_url << action end - if current_referential - polymorph_url << current_referential - polymorph_url << item.line if item.respond_to? :line - elsif item.respond_to? :referential - polymorph_url << item.referential - elsif item.respond_to? :line_referential - polymorph_url << item.line_referential + unless item.class.to_s == 'Calendar' + if current_referential + polymorph_url << current_referential + polymorph_url << item.line if item.respond_to? :line + elsif item.respond_to? :referential + polymorph_url << item.referential + elsif item.respond_to? :line_referential + polymorph_url << item.line_referential + end end polymorph_url << item diff --git a/app/models/calendar.rb b/app/models/calendar.rb new file mode 100644 index 000000000..a04aa6d0a --- /dev/null +++ b/app/models/calendar.rb @@ -0,0 +1,152 @@ +class Calendar < ActiveRecord::Base + belongs_to :organisation + + validates_presence_of :name, :short_name, :organisation + validates_uniqueness_of :short_name + validate :date_not_in_date_ranges + + private + def date_not_in_date_ranges + errors.add(:dates, I18n.t('activerecord.errors.models.calendar.attributes.dates.date_in_date_ranges')) if dates && date_ranges && dates_and_date_ranges_overlap? + end + + def dates_and_date_ranges_overlap? + overlap = false + dates.each do |date| + date_ranges.each do |date_range| + overlap = true if date_range.cover? date + end + end + overlap + end + + class DateRange + include ActiveAttr::Model + + attribute :id, type: Integer + attribute :begin, type: Date + attribute :end, type: Date + + validates_presence_of :begin, :end + validate :check_end_greather_than_begin + + def check_end_greather_than_begin + if self.begin and self.end and self.begin > self.end + errors.add(:end, :invalid) + end + end + + def self.from_range(index, range) + DateRange.new id: index, begin: range.begin, end: range.end + end + + def range + if self.begin and self.end and self.begin <= self.end + Range.new self.begin, self.end + end + end + + def intersect?(*other) + return false if range.nil? + + other = other.flatten + other = other.delete_if { |o| o.id == id } if id + + other.any? do |date_range| + if other_range = date_range.range + (range & other_range).present? + end + end + end + + def cover? date + range.cover? date + end + + # Stuff required for coocon + def new_record? + !persisted? + end + def persisted? + id.present? + end + + def mark_for_destruction + self._destroy = true + end + + attribute :_destroy, type: Boolean + alias_method :marked_for_destruction?, :_destroy + end + + # Required by coocon + def build_date_range + DateRange.new + end + + def ranges + @ranges ||= init_ranges + end + + def init_ranges + if date_ranges + date_ranges.each_with_index.map { |r, index| DateRange.from_range(index, r) } + else + [] + end + end + private :init_ranges + + validate :validate_ranges + + def validate_ranges + ranges_are_valid = true + + unless ranges.all?(&:valid?) + ranges_are_valid = false + end + + ranges.each do |range| + if range.intersect?(ranges) + range.errors.add(:base, I18n.t("referentials.errors.overlapped_period")) + ranges_are_valid = false + end + end + + errors.add(:ranges, :invalid) unless ranges_are_valid + end + + def ranges_attributes=(attributes = {}) + @ranges = [] + attributes.each do |index, range_attribute| + range = DateRange.new(range_attribute.merge(id: index)) + @ranges << range unless range.marked_for_destruction? + end + + date_ranges_will_change! + end + + before_validation :fill_date_ranges + + def fill_date_ranges + if @ranges + self.date_ranges = @ranges.map(&:range).compact.sort_by(&:begin) + end + end + + after_save :clear_ranges + + def clear_ranges + @ranges = nil + end + private :clear_ranges +end + +class Range + def intersection(other) + return nil if (self.max < other.begin or other.max < self.begin) + [self.begin, other.begin].max..[self.max, other.max].min + end + alias_method :&, :intersection +end + diff --git a/app/models/organisation.rb b/app/models/organisation.rb index af1042081..8547ce5e1 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -13,6 +13,7 @@ class Organisation < ActiveRecord::Base has_many :line_referentials, through: :line_referential_memberships has_many :workbenches + has_many :calendars validates_presence_of :name validates_uniqueness_of :code diff --git a/app/policies/calendar_policy.rb b/app/policies/calendar_policy.rb new file mode 100644 index 000000000..4fbed2ccb --- /dev/null +++ b/app/policies/calendar_policy.rb @@ -0,0 +1,25 @@ +class CalendarPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope + end + end + + def show? + organisation_match? || share? + end + + def create? ; true end + def update? ; true end + def new? ; true end + def edit? ; true end + def destroy? ; true end + + def share? + record.shared + end + + def organisation_match? + current_organisation == record.organisation + end +end diff --git a/app/views/calendars/_calendars.html.slim b/app/views/calendars/_calendars.html.slim new file mode 100644 index 000000000..7813ff89e --- /dev/null +++ b/app/views/calendars/_calendars.html.slim @@ -0,0 +1,12 @@ +- if @calendars.any? + = table_builder @calendars, + { @calendars.human_attribute_name(:short_name) => 'short_name', @calendars.human_attribute_name(:shared) => 'shared' }, + [:show, :edit, :delete], + 'table table-bordered' + + .text-center + = will_paginate @calendars, container: false, renderer: RemoteBootstrapPaginationLinkRenderer + +- else + = replacement_msg t('calendars.search_no_results') + diff --git a/app/views/calendars/_date_fields.html.slim b/app/views/calendars/_date_fields.html.slim new file mode 100644 index 000000000..8fd1b5f14 --- /dev/null +++ b/app/views/calendars/_date_fields.html.slim @@ -0,0 +1,4 @@ +.nested-fields + = f.input :date, as: :date, html5: true + /= link_to_remove_association f, data: { confirm: t('are_you_sure') } do + span.fa.fa-trash diff --git a/app/views/calendars/_form.html.slim b/app/views/calendars/_form.html.slim new file mode 100644 index 000000000..d12473b3e --- /dev/null +++ b/app/views/calendars/_form.html.slim @@ -0,0 +1,20 @@ += simple_form_for @calendar, html: { class: 'form-horizontal' } do |f| + = f.input :name + = f.input :short_name + / = f.label :dates + / = f.simple_fields_for :dates do |date| + / = render 'date_fields', f: date + / .links + / = link_to_add_association '+', f, :dates + = f.label :date_ranges + = f.simple_fields_for :ranges do |range| + = render 'range_fields', f: range + .links + = link_to_add_association '+', f, :ranges + + .form-actions + = f.button :submit, as: :button + = link_to t('cancel'), calendars_path + + +/ TODO : cocoon diff --git a/app/views/calendars/_range_fields.html.slim b/app/views/calendars/_range_fields.html.slim new file mode 100644 index 000000000..92cadc4e9 --- /dev/null +++ b/app/views/calendars/_range_fields.html.slim @@ -0,0 +1,6 @@ +.nested-fields + = f.input :begin, as: :date, html5: true, label: t('simple_form.labels.calendar.ranges.begin') + = f.input :end, as: :date, html5: true, label: t('simple_form.labels.calendar.ranges.end') + /= link_to_remove_association f, data: { confirm: t('are_you_sure') } do + span.fa.fa-trash + diff --git a/app/views/calendars/edit.html.slim b/app/views/calendars/edit.html.slim new file mode 100644 index 000000000..f827e2eb6 --- /dev/null +++ b/app/views/calendars/edit.html.slim @@ -0,0 +1,3 @@ += title_tag t('.title') + += render 'form' diff --git a/app/views/calendars/index.html.slim b/app/views/calendars/index.html.slim new file mode 100644 index 000000000..3feca1b1c --- /dev/null +++ b/app/views/calendars/index.html.slim @@ -0,0 +1,16 @@ += title_tag t('.title') + += search_form_for @q, url: calendars_path, remote: true, html: { method: :get, class: 'form', id: 'search', role: 'form' } do |f| + + .panel.panel-default + .panel-heading + .input-group.col-md-12 + = f.search_field :short_name_cont, placeholder: t('.short_name_cont'), class: 'form-control' + + .input-group-btn + button.btn.btn-primary#search-btn type='submit' + span.fa.fa-search + +#calendars + = render 'calendars' + diff --git a/app/views/calendars/new.html.slim b/app/views/calendars/new.html.slim new file mode 100644 index 000000000..ccae7ce89 --- /dev/null +++ b/app/views/calendars/new.html.slim @@ -0,0 +1,5 @@ += title_tag t('.title') + += @calendar.inspect + += render 'form' diff --git a/app/views/calendars/show.html.slim b/app/views/calendars/show.html.slim new file mode 100644 index 000000000..419cec78e --- /dev/null +++ b/app/views/calendars/show.html.slim @@ -0,0 +1,28 @@ += title_tag t('.title', calendar: @calendar.name) + +p + label => "#{Calendar.human_attribute_name('name')} : " + = @calendar.name +p + label => "#{Calendar.human_attribute_name('short_name')} : " + = @calendar.short_name +p + label => "#{Calendar.human_attribute_name('date_ranges')} : " + = @calendar.date_ranges +p + label => "#{Calendar.human_attribute_name('dates')} : " + = @calendar.dates +p + label => "#{Calendar.human_attribute_name('shared')} : " + = @calendar.shared +p + label => "#{Organisation.model_name.human} : " + = @calendar.organisation.name + + +- content_for(:sidebar) do + ul.actions + li + = link_to t('calendars.actions.edit'), edit_calendar_path(@calendar), class: 'edit' + li + = link_to t('calendars.actions.destroy'), calendar_path(@calendar), method: :delete, data: { confirm: t('calendars.actions.destroy_confirm') }, class: 'remove' diff --git a/config/locales/actions.en.yml b/config/locales/actions.en.yml index 1321e6761..ba5bc1506 100644 --- a/config/locales/actions.en.yml +++ b/config/locales/actions.en.yml @@ -10,4 +10,5 @@ en: search_hint: "Type in a search term" no_result_text: "No Results" searching_term: "Searching..." + are_you_sure: Are you sure? diff --git a/config/locales/actions.fr.yml b/config/locales/actions.fr.yml index 6c3e22933..16c687458 100644 --- a/config/locales/actions.fr.yml +++ b/config/locales/actions.fr.yml @@ -10,3 +10,4 @@ fr: search_hint: "Entrez un texte à rechercher" no_result_text: "Aucun résultat" searching_term: "Recherche en cours..." + are_you_sure: Etes vous sûr ? diff --git a/config/locales/calendars.en.yml b/config/locales/calendars.en.yml new file mode 100644 index 000000000..26aa3a7bd --- /dev/null +++ b/config/locales/calendars.en.yml @@ -0,0 +1,46 @@ +en: + calendars: + search_no_results: No calendar matching your query + actions: + new: Add a new calendar + edit: Edit this calendar + destroy: Remove this calendar + destroy_confirm: Are you sure you want destroy this calendar? + activerecord: + models: + calendar: Calendar + attributes: + calendar: + name: Name + short_name: Short name + date_ranges: Date ranges + dates: Dates + shared: Shared + activerecord: + errors: + models: + calendar: + attributes: + dates: + date_in_date_ranges: A date can not be in Dates and in Date ranges. + index: + title: Calendars + short_name_cont: Search by short name + new: + title: "Add a new calendar" + edit: + title: "Update calendar %{calendar}" + show: + title: "Calendar %{calendar}" + simple_form: + labels: + calendar: + name: Name + short_name: Short name + dates: Dates + shared: Shared + date_ranges: Date ranges + ranges: + begin: Beginning + end: End + diff --git a/config/locales/calendars.fr.yml b/config/locales/calendars.fr.yml new file mode 100644 index 000000000..292566532 --- /dev/null +++ b/config/locales/calendars.fr.yml @@ -0,0 +1,47 @@ +fr: + calendars: + search_no_results: "Aucun calendrier ne correspond à votre recherche" + actions: + new: Ajouter un calendrier + edit: Modifier cet calendrier + destroy: Supprimer cet calendrier + destroy_confirm: Etes vous sûr de supprimer cet calendrier ? + activerecord: + models: + calendar: Calendrier + attributes: + calendar: + name: Nom + short_name: Nom court + date_ranges: Intervalles de dates + dates: Dates + shared: Partagé + index: + title: Calendriers + short_name_cont: Recherche par nom court + new: + title: "Ajouter un calendrier" + edit: + title: "Modifier le calendrier %{calendar}" + show: + title: "Calendrier %{calendar}" + simple_form: + labels: + calendar: + name: Nom + short_name: Nom court + dates: Dates + shared: Partagé + date_ranges: Intervalles de dates + ranges: + begin: Début + end: Fin + activerecord: + errors: + models: + calendar: + attributes: + dates: + date_in_date_ranges: 'Une même date ne peut pas être à la fois dans Dates et dans Intervalles de dates.' + + diff --git a/config/locales/users.fr.yml b/config/locales/users.fr.yml index dcb81b808..f50218605 100644 --- a/config/locales/users.fr.yml +++ b/config/locales/users.fr.yml @@ -20,3 +20,5 @@ fr: user: name: "Nom complet" username: "Nom d'utilisateur" + + diff --git a/config/routes.rb b/config/routes.rb index c49b28a07..b2d3922fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,8 @@ ChouetteIhm::Application.routes.draw do resources :networks end + resources :calendars + resources :referentials do resources :api_keys resources :autocomplete_stop_areas diff --git a/db/migrate/20161228092957_create_calendars.rb b/db/migrate/20161228092957_create_calendars.rb new file mode 100644 index 000000000..d8bc89103 --- /dev/null +++ b/db/migrate/20161228092957_create_calendars.rb @@ -0,0 +1,15 @@ +class CreateCalendars < ActiveRecord::Migration + def change + create_table :calendars do |t| + t.string :name + t.string :short_name + t.daterange :date_ranges, array: true + t.date :dates, array: true + t.boolean :shared + t.belongs_to :organisation, index: true + + t.timestamps + end + add_index :calendars, :short_name, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0277cae91..562bf2ab6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161208120132) do +ActiveRecord::Schema.define(version: 20161228092957) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -77,6 +77,20 @@ ActiveRecord::Schema.define(version: 20161208120132) do t.datetime "updated_at" end + create_table "calendars", force: true do |t| + t.string "name" + t.string "short_name" + t.daterange "date_ranges", array: true + t.date "dates", array: true + t.boolean "shared" + t.integer "organisation_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "calendars", ["organisation_id"], :name => "index_calendars_on_organisation_id" + add_index "calendars", ["short_name"], :name => "index_calendars_on_short_name", :unique => true + create_table "clean_up_results", force: true do |t| t.string "message_key" t.hstore "message_attributs" diff --git a/spec/factories/calendars.rb b/spec/factories/calendars.rb new file mode 100644 index 000000000..5f3188bee --- /dev/null +++ b/spec/factories/calendars.rb @@ -0,0 +1,16 @@ +FactoryGirl.define do + factory :calendar do + sequence(:name) { |n| "Calendar #{n}" } + sequence(:short_name) { |n| "Cal #{n}" } + date_ranges { [generate(:date_range)] } + sequence(:dates) { |n| [ Date.yesterday - n, Date.yesterday - 2*n ] } + shared false + organisation + end + + sequence :date_range do |n| + date = Date.today + 2*n + date..(date+1) + end +end + diff --git a/spec/models/calendar_spec.rb b/spec/models/calendar_spec.rb new file mode 100644 index 000000000..1143c6615 --- /dev/null +++ b/spec/models/calendar_spec.rb @@ -0,0 +1,109 @@ +require 'rails_helper' + +RSpec.describe Calendar, :type => :model do + + it { should belong_to(:organisation) } + + it { is_expected.to validate_presence_of(:organisation) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:short_name) } + it { is_expected.to validate_uniqueness_of(:short_name) } + + describe 'validations' do + it 'validates that dates and date_ranges do not overlap' do + calendar = build(:calendar, dates: [Date.today], date_ranges: [Date.today..Date.tomorrow]) + expect { + calendar.save! + }.to raise_error(ActiveRecord::RecordInvalid) + expect(calendar.errors.messages[:dates]).to eq([I18n.t('activerecord.errors.models.calendar.attributes.dates.date_in_date_ranges')]) + end + end + + describe 'DateRange' do + + subject { date_range } + + def date_range(attributes = {}) + return @date_range if attributes.empty? and @date_range + Calendar::DateRange.new(attributes).tap do |date_range| + @date_range = date_range if attributes.empty? + end + end + + it 'should support mark_for_destruction (required by cocoon)' do + date_range.mark_for_destruction + expect(date_range).to be_marked_for_destruction + end + + it 'should support _destroy attribute (required by coocon)' do + date_range._destroy = true + expect(date_range).to be_marked_for_destruction + end + + it 'should support new_record? (required by cocoon)' do + expect(Calendar::DateRange.new).to be_new_record + expect(date_range(id: 42)).not_to be_new_record + end + + it 'should cast begin as date attribute' do + expect(date_range(begin: '2016-11-22').begin).to eq(Date.new(2016,11,22)) + end + + it 'should cast end as date attribute' do + expect(date_range(end: '2016-11-22').end).to eq(Date.new(2016,11,22)) + end + + it { is_expected.to validate_presence_of(:begin) } + it { is_expected.to validate_presence_of(:end) } + + it 'should validate that end is greather than or equlals to begin' do + expect(date_range(begin: '2016-11-21', end: '2016-11-22')).to be_valid + expect(date_range(begin: '2016-11-21', end: '2016-11-21')).to be_valid + expect(date_range(begin: '2016-11-22', end: '2016-11-21')).to_not be_valid + end + + describe 'intersect?' do + + it 'should detect date in common with other date_ranges' do + november = date_range(begin: '2016-11-01', end: '2016-11-30') + mid_november_mid_december = date_range(begin: '2016-11-15', end: '2016-12-15') + expect(november.intersect?(mid_november_mid_december)).to be(true) + end + + it 'should not intersect when no date is in common' do + november = date_range(begin: '2016-11-01', end: '2016-11-30') + december = date_range(begin: '2016-12-01', end: '2016-12-31') + + expect(november.intersect?(december)).to be(false) + + january = date_range(begin: '2017-01-01', end: '2017-01-31') + expect(november.intersect?(december, january)).to be(false) + end + + it 'should not intersect itself' do + date_range = date_range(id: 42, begin: '2016-11-01', end: '2016-11-30') + expect(date_range.intersect?(date_range)).to be(false) + end + + end + + end + + describe 'before_validation' do + let(:calendar) { create(:calendar, date_ranges: []) } + + it 'shoud fill date_ranges with date ranges' do + expected_ranges = [ + Range.new(Date.today, Date.tomorrow) + ] + expected_ranges.each_with_index do |range, index| + calendar.date_ranges << Calendar::DateRange.from_range(index, range) + end + calendar.valid? + + expect(calendar.date_ranges.map { |date_range| date_range.begin..date_range.end }).to eq(expected_ranges) + end + end + +end + |
