aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorVlatka Pavisic2017-01-05 16:24:25 +0100
committerVlatka Pavisic2017-01-05 16:24:25 +0100
commit858a42cabbd96f3654b0b1b0c49225a7f1bcd4ae (patch)
treee9aa20f898e5f1a1158d6595b6f9670569b77201
parent75cc7574b4d138aa93fa774507f58a7ee94a0944 (diff)
parent89ab282c5737f7c0d723917ec989f028e196e9c5 (diff)
downloadchouette-core-858a42cabbd96f3654b0b1b0c49225a7f1bcd4ae.tar.bz2
Merge calendars
-rw-r--r--app/assets/stylesheets/main/calendars.sass11
-rw-r--r--app/controllers/calendars_controller.rb47
-rw-r--r--app/helpers/breadcrumb_helper.rb7
-rw-r--r--app/helpers/newfront_helper.rb16
-rw-r--r--app/models/calendar.rb257
-rw-r--r--app/models/organisation.rb1
-rw-r--r--app/policies/calendar_policy.rb31
-rw-r--r--app/views/calendars/_calendars.html.slim12
-rw-r--r--app/views/calendars/_date_value_fields.html.slim14
-rw-r--r--app/views/calendars/_form.html.slim27
-rw-r--r--app/views/calendars/_period_fields.html.slim16
-rw-r--r--app/views/calendars/edit.html.slim3
-rw-r--r--app/views/calendars/index.html.slim29
-rw-r--r--app/views/calendars/index.js.slim1
-rw-r--r--app/views/calendars/new.html.slim3
-rw-r--r--app/views/calendars/show.html.slim46
-rw-r--r--app/views/referentials/_period_fields.html.slim1
-rw-r--r--config/locales/actions.en.yml1
-rw-r--r--config/locales/actions.fr.yml1
-rw-r--r--config/locales/calendars.en.yml48
-rw-r--r--config/locales/calendars.fr.yml49
-rw-r--r--config/locales/users.fr.yml2
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20161228092957_create_calendars.rb15
-rw-r--r--db/schema.rb16
-rw-r--r--lib/range_ext.rb7
-rw-r--r--spec/factories/calendars.rb16
-rw-r--r--spec/features/calendars_spec.rb59
-rw-r--r--spec/models/calendar_spec.rb148
29 files changed, 878 insertions, 8 deletions
diff --git a/app/assets/stylesheets/main/calendars.sass b/app/assets/stylesheets/main/calendars.sass
new file mode 100644
index 000000000..298ce2a62
--- /dev/null
+++ b/app/assets/stylesheets/main/calendars.sass
@@ -0,0 +1,11 @@
+#calendar_form
+ .btn
+ margin: 5px
+ #delete-btn
+ margin-top: 23px
+ .well
+ padding-left: 50px
+
+#calendar_search_form
+ button
+ margin-top: 24px
diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb
new file mode 100644
index 000000000..9784820f9
--- /dev/null
+++ b/app/controllers/calendars_controller.rb
@@ -0,0 +1,47 @@
+class CalendarsController < BreadcrumbController
+ defaults resource_class: Calendar
+ before_action :check_policy, only: [:edit, :update, :destroy]
+
+ respond_to :html
+ respond_to :js, only: :index
+
+ private
+ def calendar_params
+ permitted_params = [:id, :name, :short_name, periods_attributes: [:id, :begin, :end, :_destroy], date_values_attributes: [:id, :value, :_destroy]]
+ permitted_params << :shared if policy(Calendar).share?
+ params.require(:calendar).permit(*permitted_params)
+ end
+
+ def sort_column
+ Calendar.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 resource
+ @calendar = Calendar.where('organisation_id = ? OR shared = true', current_organisation.id).find_by_id(params[:id])
+ end
+
+ def build_resource
+ super.tap do |calendar|
+ calendar.organisation = current_organisation
+ end
+ end
+
+ def collection
+ return @calendars if @calendars
+
+ @q = Calendar.where('organisation_id = ? OR shared = true', current_organisation.id).search(params[:q])
+ calendars = @q.result
+ calendars = calendars.order(sort_column + ' ' + sort_direction) if sort_column && sort_direction
+ @calendars = calendars.paginate(page: params[:page])
+ end
+
+ def check_policy
+ authorize resource
+ end
+end
+
diff --git a/app/helpers/breadcrumb_helper.rb b/app/helpers/breadcrumb_helper.rb
index dc68bd897..6ff4845f9 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 I18n.t('calendars.index.title'), calendars_path
+ 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..54237e9cc
--- /dev/null
+++ b/app/models/calendar.rb
@@ -0,0 +1,257 @@
+class Calendar < ActiveRecord::Base
+ belongs_to :organisation
+
+ validates_presence_of :name, :short_name, :organisation
+ validates_uniqueness_of :short_name
+
+ after_initialize :init_dates_and_date_ranges
+
+ scope :contains_date, ->(date) { where('date ? = any (dates) OR date ? <@ any (date_ranges)', date, date) }
+
+ def init_dates_and_date_ranges
+ self.dates ||= []
+ self.date_ranges ||= []
+ end
+
+ def self.ransackable_scopes(auth_object = nil)
+ [:contains_date]
+ end
+
+ class Period
+ 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)
+ Period.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 |period|
+ if other_range = period.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_period
+ Period.new
+ end
+
+ def periods
+ @periods ||= init_periods
+ end
+
+ def init_periods
+ if date_ranges
+ date_ranges.each_with_index.map { |r, index| Period.from_range(index, r) }
+ else
+ []
+ end
+ end
+ private :init_periods
+
+ validate :validate_periods
+
+ def validate_periods
+ periods_are_valid = true
+
+ unless periods.all?(&:valid?)
+ periods_are_valid = false
+ end
+
+ periods.each do |period|
+ if period.intersect?(periods)
+ period.errors.add(:base, I18n.t('calendars.errors.overlapped_periods'))
+ periods_are_valid = false
+ end
+ end
+
+ unless periods_are_valid
+ errors.add(:periods, :invalid)
+ end
+ end
+
+ def periods_attributes=(attributes = {})
+ @periods = []
+ attributes.each do |index, period_attribute|
+ period = Period.new(period_attribute.merge(id: index))
+ @periods << period unless period.marked_for_destruction?
+ end
+
+ date_ranges_will_change!
+ end
+
+ before_validation :fill_date_ranges
+
+ def fill_date_ranges
+ if @periods
+ self.date_ranges = @periods.map(&:range).compact.sort_by(&:begin)
+ end
+ end
+
+ after_save :clear_periods
+
+ def clear_periods
+ @periods = nil
+ end
+
+ private :clear_periods
+
+### dates
+
+ class DateValue
+ include ActiveAttr::Model
+
+ attribute :id, type: Integer
+ attribute :value, type: Date
+
+ validates_presence_of :value
+
+ def self.from_date(index, date)
+ DateValue.new id: index, value: 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_value
+ DateValue.new
+ end
+
+ def date_values
+ @date_values ||= init_date_values
+ end
+
+ def init_date_values
+ if dates
+ dates.each_with_index.map { |d, index| DateValue.from_date(index, d) }
+ else
+ []
+ end
+ end
+ private :init_date_values
+
+ validate :validate_date_values
+
+ def validate_date_values
+ date_values_are_valid = true
+
+ unless date_values.all?(&:valid?)
+ date_values_are_valid = false
+ end
+
+ date_values.each do |date_value|
+ if date_values.count { |d| d.value == date_value.value } > 1
+ date_value.errors.add(:base, I18n.t('activerecord.errors.models.calendar.attributes.dates.date_in_dates'))
+ date_values_are_valid = false
+ end
+ date_ranges.each do |date_range|
+ if date_range.cover? date_value.value
+ date_value.errors.add(:base, I18n.t('activerecord.errors.models.calendar.attributes.dates.date_in_date_ranges'))
+ date_values_are_valid = false
+ end
+ end
+ end
+
+ unless date_values_are_valid
+ errors.add(:date_values, :invalid)
+ end
+ end
+
+ def date_values_attributes=(attributes = {})
+ @date_values = []
+ attributes.each do |index, date_value_attribute|
+ date_value = DateValue.new(date_value_attribute.merge(id: index))
+ @date_values << date_value unless date_value.marked_for_destruction?
+ end
+
+ dates_will_change!
+ end
+
+ before_validation :fill_dates
+
+ def fill_dates
+ if @date_values
+ self.dates = @date_values.map(&:value).compact.sort
+ end
+ end
+
+ after_save :clear_date_values
+
+ def clear_date_values
+ @date_values = nil
+ end
+
+ private :clear_date_values
+
+###
+
+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..1c455f391
--- /dev/null
+++ b/app/policies/calendar_policy.rb
@@ -0,0 +1,31 @@
+class CalendarPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope
+ end
+ end
+
+ def show?
+ organisation_match? || record.shared
+ end
+
+ def new? ; modify? end
+ def create? ; new? end
+
+ def edit? ; modify? end
+ def update? ; edit? end
+
+ def destroy? ; modify? end
+
+ def share?
+ user.organisation_id == 1 # FIXME
+ end
+
+ def modify?
+ organisation_match?
+ end
+
+ def organisation_match?
+ user.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..261052baf
--- /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('.search_no_results')
+
diff --git a/app/views/calendars/_date_value_fields.html.slim b/app/views/calendars/_date_value_fields.html.slim
new file mode 100644
index 000000000..3a9cdb7bd
--- /dev/null
+++ b/app/views/calendars/_date_value_fields.html.slim
@@ -0,0 +1,14 @@
+.nested-fields
+ - if f.object.errors.has_key? :base
+ .row
+ .col-lg-12
+ .alert.alert-danger
+ - f.object.errors[:base].each do |message|
+ p.small = message
+ .row
+ .col-xs-3
+ = f.input :value, as: :date, html5: true, label: t('simple_form.labels.calendar.date_value')
+ .col-xs-1.end.text-right#delete-btn
+ = link_to_remove_association f, class: 'btn btn-danger', 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..a97c16565
--- /dev/null
+++ b/app/views/calendars/_form.html.slim
@@ -0,0 +1,27 @@
+#calendar_form
+ .row
+ .col-xs-8.col-xs-offset-2
+ = simple_form_for @calendar, html: { class: 'form-horizontal' } do |f|
+ = f.input :name
+ = f.input :short_name
+ .form-group
+ = f.label :dates
+ .well
+ = f.simple_fields_for :date_values do |date_value|
+ = render 'date_value_fields', f: date_value
+ .links
+ = link_to_add_association t('simple_form.labels.calendar.add_a_date'), f, :date_values, class: 'btn btn-primary btn-xs'
+ .form-group
+ = f.label :date_ranges
+ .well
+ = f.simple_fields_for :periods do |period|
+ = render 'period_fields', f: period
+ .links
+ = link_to_add_association t('simple_form.labels.calendar.add_a_date_range'), f, :periods, class: 'btn btn-primary btn-xs'
+ - if policy(@calendar).share?
+ = f.input :shared
+ .form-actions
+ = f.button :submit, as: :button, class: 'btn btn-info'
+ = link_to t('cancel'), calendars_path, class: 'btn btn-default'
+
+
diff --git a/app/views/calendars/_period_fields.html.slim b/app/views/calendars/_period_fields.html.slim
new file mode 100644
index 000000000..024d09de2
--- /dev/null
+++ b/app/views/calendars/_period_fields.html.slim
@@ -0,0 +1,16 @@
+.nested-fields
+ - if f.object.errors.has_key? :base
+ .row
+ .col-lg-12
+ .alert.alert-danger
+ - f.object.errors[:base].each do |message|
+ p.small = message
+ .row
+ .col-xs-4
+ = f.input :begin, as: :date, html5: true, label: t('simple_form.labels.calendar.ranges.begin')
+ .col-xs-3
+ = f.input :end, as: :date, html5: true, label: t('simple_form.labels.calendar.ranges.end')
+ .col-xs-1.text-right#delete-btn
+ = link_to_remove_association f, class: 'btn btn-danger', 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..22645cf24
--- /dev/null
+++ b/app/views/calendars/edit.html.slim
@@ -0,0 +1,3 @@
+= title_tag t('.title', calendar: @calendar.name)
+
+= render 'form'
diff --git a/app/views/calendars/index.html.slim b/app/views/calendars/index.html.slim
new file mode 100644
index 000000000..41cd3d70c
--- /dev/null
+++ b/app/views/calendars/index.html.slim
@@ -0,0 +1,29 @@
+= title_tag t('.title')
+
+#calendar_search_form
+ = 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
+ .row
+ .col-md-3
+ = f.label Calendar.human_attribute_name(:short_name)
+ = f.search_field :short_name_cont, class: 'form-control'
+ .col-md-3
+ = f.label Calendar.human_attribute_name(:shared)
+ = f.select :shared_eq, [[t('.shared'), true], [t('.not_shared'), false]], { include_blank: '' }, { class: 'form-control', style: 'width: 100%', 'data-select2ed': 'true', 'data-select2ed-placeholder': t('.all') }
+ .col-md-3
+ = f.label Calendar.human_attribute_name(:date)
+ = f.date_field :contains_date, class: 'form-control'
+
+ .col-md-3
+ button.btn.btn-primary#search-btn type='submit'
+ span.fa.fa-search
+
+#calendars
+ = render 'calendars'
+
+- content_for :sidebar do
+ ul.actions
+ li
+ = link_to t('calendars.actions.new'), new_calendar_path, class: 'add'
+ br
diff --git a/app/views/calendars/index.js.slim b/app/views/calendars/index.js.slim
new file mode 100644
index 000000000..936f93e5e
--- /dev/null
+++ b/app/views/calendars/index.js.slim
@@ -0,0 +1 @@
+| $('#calendars').html("#{escape_javascript(render('calendars'))}");
diff --git a/app/views/calendars/new.html.slim b/app/views/calendars/new.html.slim
new file mode 100644
index 000000000..f827e2eb6
--- /dev/null
+++ b/app/views/calendars/new.html.slim
@@ -0,0 +1,3 @@
+= title_tag t('.title')
+
+= render 'form'
diff --git a/app/views/calendars/show.html.slim b/app/views/calendars/show.html.slim
new file mode 100644
index 000000000..c0671fa94
--- /dev/null
+++ b/app/views/calendars/show.html.slim
@@ -0,0 +1,46 @@
+= 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
+
+.row
+ .col-xs-4
+ p
+ label => "#{Calendar.human_attribute_name('dates')} : "
+ table.table.table-condensed
+ tbody
+ - @calendar.dates.each do |date|
+ tr
+ td= l date
+ p
+ label => "#{Calendar.human_attribute_name('date_ranges')} : "
+ table.table.table-condensed
+ thead
+ th= t('simple_form.labels.calendar.ranges.begin')
+ th= t('simple_form.labels.calendar.ranges.end')
+ tbody
+ - @calendar.date_ranges.each do |date_range|
+ tr
+ td= l date_range.begin
+ td= l date_range.end
+
+p
+ label => "#{Calendar.human_attribute_name('shared')} : "
+ = @calendar.shared
+p
+ label => "#{Organisation.model_name.human} : "
+ = @calendar.organisation.name
+
+
+- content_for(:sidebar) do
+ ul.actions
+ - if policy(@calendar).edit?
+ li
+ = link_to t('calendars.actions.edit'), edit_calendar_path(@calendar), class: 'edit'
+ - if policy(@calendar).destroy?
+ li
+ = link_to t('calendars.actions.destroy'), calendar_path(@calendar), method: :delete, data: { confirm: t('calendars.actions.destroy_confirm') }, class: 'remove'
diff --git a/app/views/referentials/_period_fields.html.slim b/app/views/referentials/_period_fields.html.slim
index 9d92f92ce..6658cd4aa 100644
--- a/app/views/referentials/_period_fields.html.slim
+++ b/app/views/referentials/_period_fields.html.slim
@@ -14,3 +14,4 @@
.col-lg-2.col-md-2.col-sm-2.col-xs-2.text-right style='margin-top:23px'
= link_to_remove_association f, class: 'btn btn-danger', data: { confirm: 'Etes-vous sûr(e) ?' } do
span.fa.fa-trash
+
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..90ab12e76
--- /dev/null
+++ b/config/locales/calendars.en.yml
@@ -0,0 +1,48 @@
+en:
+ calendars:
+ actions:
+ new: Add a new calendar
+ edit: Edit this calendar
+ destroy: Remove this calendar
+ destroy_confirm: Are you sure you want destroy this calendar?
+ errors:
+ overlapped_periods: Another period is overlapped with this period
+ index:
+ title: Calendars
+ all: All
+ shared: Shared
+ not_shared: Not shared
+ search_no_results: No calendar matching your query
+ date: Date
+ new:
+ title: Add a new calendar
+ edit:
+ title: Update calendar %{calendar}
+ show:
+ title: Calendar %{calendar}
+ simple_form:
+ labels:
+ calendar:
+ date_value: Date
+ add_a_date: Add a date
+ add_a_date_range: Add a date range
+ ranges:
+ begin: Beginning
+ end: End
+ activerecord:
+ models:
+ calendar: Calendar
+ attributes:
+ calendar:
+ name: Name
+ short_name: Short name
+ date_ranges: Date ranges
+ dates: Dates
+ shared: Shared
+ errors:
+ models:
+ calendar:
+ attributes:
+ dates:
+ date_in_date_ranges: A date can not be in Dates and in Date ranges.
+ date_in_dates: A date can appear only once in the list of dates.
diff --git a/config/locales/calendars.fr.yml b/config/locales/calendars.fr.yml
new file mode 100644
index 000000000..ba45ad074
--- /dev/null
+++ b/config/locales/calendars.fr.yml
@@ -0,0 +1,49 @@
+fr:
+ calendars:
+ actions:
+ new: Ajouter un calendrier
+ edit: Modifier cet calendrier
+ destroy: Supprimer cet calendrier
+ destroy_confirm: Etes vous sûr de supprimer cet calendrier ?
+ errors:
+ overlapped_periods: Une autre période chevauche cette période
+ index:
+ title: Calendriers
+ all: Tous
+ shared: Partagées
+ not_shared: Non partagées
+ search_no_results: Aucun calendrier ne correspond à votre recherche
+ date: Date
+ new:
+ title: Ajouter un calendrier
+ edit:
+ title: Modifier le calendrier %{calendar}
+ show:
+ title: Calendrier %{calendar}
+ simple_form:
+ labels:
+ calendar:
+ date_value: Date
+ add_a_date: Ajouter une date
+ add_a_date_range: Ajouter un intervalle de dates
+ ranges:
+ begin: Début
+ end: Fin
+ activerecord:
+ models:
+ calendar: Calendrier
+ attributes:
+ calendar:
+ name: Nom
+ short_name: Nom court
+ date_ranges: Intervalles de dates
+ dates: Dates
+ shared: Partagé
+ errors:
+ models:
+ calendar:
+ attributes:
+ dates:
+ date_in_date_ranges: Une même date ne peut pas être incluse à la fois dans la liste et dans les intervalles de dates.
+ date_in_dates: Une même date ne peut pas être incluse plusieurs fois dans la liste.
+
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 9d4141198..99effe3e5 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/lib/range_ext.rb b/lib/range_ext.rb
new file mode 100644
index 000000000..5afb44dee
--- /dev/null
+++ b/lib/range_ext.rb
@@ -0,0 +1,7 @@
+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/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/features/calendars_spec.rb b/spec/features/calendars_spec.rb
new file mode 100644
index 000000000..c1701d7c7
--- /dev/null
+++ b/spec/features/calendars_spec.rb
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+require 'spec_helper'
+
+describe 'Calendars', type: :feature do
+ login_user
+
+ let!(:calendars) { Array.new(2) { create :calendar, organisation_id: 1 } }
+ let!(:shared_calendar_other_org) { create :calendar, shared: true }
+ let!(:unshared_calendar_other_org) { create :calendar }
+
+ describe 'index' do
+ before(:each) { visit calendars_path }
+
+ it 'displays calendars of the current organisation and shared calendars' do
+ expect(page).to have_content(calendars.first.short_name)
+ expect(page).to have_content(shared_calendar_other_org.short_name)
+ expect(page).not_to have_content(unshared_calendar_other_org.short_name)
+ end
+
+ context 'filtering' do
+ it 'supports filtering by short name' do
+ fill_in 'q[short_name_cont]', with: calendars.first.short_name
+ click_button 'search-btn'
+ expect(page).to have_content(calendars.first.short_name)
+ expect(page).not_to have_content(calendars.last.short_name)
+ end
+
+ it 'supports filtering by shared' do
+ shared_calendar = create :calendar, organisation_id: 1, shared: true
+ visit calendars_path
+ select I18n.t('calendars.index.shared'), from: 'q[shared_eq]'
+ click_button 'search-btn'
+ expect(page).to have_content(shared_calendar.short_name)
+ expect(page).not_to have_content(calendars.first.short_name)
+ end
+
+ it 'supports filtering by date' do
+ july_calendar = create :calendar, dates: [Date.new(2017, 7, 7)], date_ranges: [Date.new(2017, 7, 15)..Date.new(2017, 7, 30)], organisation_id: 1
+ visit calendars_path
+ fill_in 'q_contains_date', with: '2017/07/07'
+ click_button 'search-btn'
+ expect(page).to have_content(july_calendar.short_name)
+ expect(page).not_to have_content(calendars.first.short_name)
+ fill_in 'q_contains_date', with: '2017/07/18'
+ click_button 'search-btn'
+ expect(page).to have_content(july_calendar.short_name)
+ expect(page).not_to have_content(calendars.first.short_name)
+ end
+ end
+ end
+
+ describe 'show' do
+ it 'displays calendar' do
+ visit calendar_path(calendars.first)
+ expect(page).to have_content(calendars.first.name)
+ end
+ end
+end
+
diff --git a/spec/models/calendar_spec.rb b/spec/models/calendar_spec.rb
new file mode 100644
index 000000000..36981961f
--- /dev/null
+++ b/spec/models/calendar_spec.rb
@@ -0,0 +1,148 @@
+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)
+ end
+
+ it 'validates that there are no duplicates in dates' do
+ calendar = build(:calendar, dates: [Date.yesterday, Date.yesterday], date_ranges: [Date.today..Date.tomorrow])
+ expect {
+ calendar.save!
+ }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+
+ describe 'Period' do
+
+ subject { period }
+
+ def period(attributes = {})
+ return @period if attributes.empty? and @period
+ Calendar::Period.new(attributes).tap do |period|
+ @period = period if attributes.empty?
+ end
+ end
+
+ it 'should support mark_for_destruction (required by cocoon)' do
+ period.mark_for_destruction
+ expect(period).to be_marked_for_destruction
+ end
+
+ it 'should support _destroy attribute (required by coocon)' do
+ period._destroy = true
+ expect(period).to be_marked_for_destruction
+ end
+
+ it 'should support new_record? (required by cocoon)' do
+ expect(Calendar::Period.new).to be_new_record
+ expect(period(id: 42)).not_to be_new_record
+ end
+
+ it 'should cast begin as date attribute' do
+ expect(period(begin: '2016-11-22').begin).to eq(Date.new(2016,11,22))
+ end
+
+ it 'should cast end as date attribute' do
+ expect(period(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(period(begin: '2016-11-21', end: '2016-11-22')).to be_valid
+ expect(period(begin: '2016-11-21', end: '2016-11-21')).to be_valid
+ expect(period(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 = period(begin: '2016-11-01', end: '2016-11-30')
+ mid_november_mid_december = period(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 = period(begin: '2016-11-01', end: '2016-11-30')
+ december = period(begin: '2016-12-01', end: '2016-12-31')
+
+ expect(november.intersect?(december)).to be(false)
+
+ january = period(begin: '2017-01-01', end: '2017-01-31')
+ expect(november.intersect?(december, january)).to be(false)
+ end
+
+ it 'should not intersect itself' do
+ period = period(id: 42, begin: '2016-11-01', end: '2016-11-30')
+ expect(period.intersect?(period)).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::Period.from_range(index, range)
+ end
+ calendar.valid?
+
+ expect(calendar.date_ranges.map { |period| period.begin..period.end }).to eq(expected_ranges)
+ end
+ end
+
+ describe 'DateValue' do
+
+ subject { date_value }
+
+ def date_value(attributes = {})
+ return @date_value if attributes.empty? and @date_value
+ Calendar::DateValue.new(attributes).tap do |date_value|
+ @date_value = date_value if attributes.empty?
+ end
+ end
+
+ it 'should support mark_for_destruction (required by cocoon)' do
+ date_value.mark_for_destruction
+ expect(date_value).to be_marked_for_destruction
+ end
+
+ it 'should support _destroy attribute (required by coocon)' do
+ date_value._destroy = true
+ expect(date_value).to be_marked_for_destruction
+ end
+
+ it 'should support new_record? (required by cocoon)' do
+ expect(Calendar::DateValue.new).to be_new_record
+ expect(date_value(id: 42)).not_to be_new_record
+ end
+
+ it 'should cast value as date attribute' do
+ expect(date_value(value: '2017-01-03').value).to eq(Date.new(2017,01,03))
+ end
+
+ it { is_expected.to validate_presence_of(:value) }
+
+ end
+
+end
+