diff options
| author | Vlatka Pavisic | 2016-12-29 17:20:06 +0100 | 
|---|---|---|
| committer | Vlatka Pavisic | 2016-12-29 17:20:06 +0100 | 
| commit | adfb4aebfab39b5a7a4b9a70ac62f639567aead6 (patch) | |
| tree | 3ef864b0f48e946d04d1c79947a2e8efe1f96853 | |
| parent | 08b68565fb7f82bc7754bd62ad3f72efca6ea62d (diff) | |
| download | chouette-core-adfb4aebfab39b5a7a4b9a70ac62f639567aead6.tar.bz2 | |
Refs #2262 Refs #2263 Refs #2264 Refs #2265 : Calendars
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 + | 
