Merge branch 'recurrent'

This commit is contained in:
echarp 2016-09-11 17:47:39 +02:00
commit dfc18d452d
23 changed files with 432 additions and 52 deletions

View File

@ -2,6 +2,8 @@ source 'https://rubygems.org'
# The central piece of this application: the month calendar view
gem 'simple_calendar'
# The recurrence management library
gem 'ice_cube'
gem 'rails'
gem 'has_scope'

View File

@ -210,6 +210,7 @@ GEM
http_accept_language (2.0.5)
http_parser.rb (0.6.0)
i18n (0.7.0)
ice_cube (0.14.0)
inherited_resources (1.6.0)
actionpack (>= 3.2, < 5)
has_scope (~> 0.6.0.rc)
@ -445,6 +446,7 @@ DEPENDENCIES
has_scope
http_accept_language
i18n-active_record!
ice_cube
jbuilder
jquery-rails (< 4.1)
jquery-sparkline-rails!

View File

@ -1,4 +1,14 @@
$(document).on 'turbolinks:load', ->
# Quick mechanism so that the ice cube rule only appears when useful
if $('#event_repeat').val() == '0'
$('.field.rule').hide()
$('#event_repeat').change ->
if $(this).val() > 0
$('.field.rule').show()
else
$('.field.rule').hide()
# Manage event tags edition
$('#event_tags').each ->
elt = $(this)

View File

@ -52,6 +52,10 @@
content: $fa-var-toggle-on
.field.end_time label:before
content: $fa-var-toggle-off
.field.repeat label:before
content: $fa-var-repeat
.field.rule > label:before
content: $fa-var-calculator
.field.description label:before
content: $fa-var-pencil-square-o
.field.place_name label:before

View File

@ -102,9 +102,9 @@ class EventsController < ApplicationController
# through
def event_params
params.require(:event)
.permit :lock_version, :title, :start_time, :end_time, :description,
:place_name, :address, :city, :region_id, :locality, :url,
:contact, :submitter, :tags
.permit :lock_version, :title, :start_time, :end_time, :repeat, :rule,
:description, :place_name, :address, :city, :region_id,
:locality, :url, :contact, :submitter, :tags
end
def locked

View File

@ -65,9 +65,9 @@ class ModerationsController < ApplicationController
# through.
def moderation_params
params.require(:event)
.permit :lock_version, :title, :start_time, :end_time, :description,
:place_name, :address, :city, :region_id, :locality, :url,
:contact, :submitter, :tags
.permit :lock_version, :title, :start_time, :end_time, :repeat, :rule,
:description, :place_name, :address, :city, :region_id,
:locality, :url, :contact, :submitter, :tags
end
# Useful to manage absolute url in mails

View File

@ -2,14 +2,20 @@
class Event < ActiveRecord::Base
extend SimpleCalendar
strip_attributes
has_paper_trail ignore: [:last_updated, :secret, :submitter, :decision_time,
:lock_version, :latitude, :longitude]
has_paper_trail ignore: [:last_updated, :lock_version, :secret,
:submitter, :decision_time,
:latitude, :longitude]
belongs_to :region
# This is the scheduled first event
belongs_to :event
has_many :notes, dependent: :destroy
has_many :events, dependent: :destroy
validates :title, presence: true
validate :end_after_start
RULES = %w(daily weekly monthly).freeze
validates :rule, inclusion: RULES
validates :description, presence: true
validates :city, presence: true
validates :region, presence: true
@ -25,8 +31,14 @@ class Event < ActiveRecord::Base
# Mechanism to store some reason which can be used when sending notifications
attr_accessor :reason
before_validation EventCallbacks
before_create EventCallbacks
after_create EventCallbacks
before_update EventCallbacks
after_update EventCallbacks
after_destroy EventCallbacks
scope :moderated, ->(*) { where moderated: true }
@ -55,11 +67,6 @@ class Event < ActiveRecord::Base
scope :tag, ->(tag) { where 'tags like ?', "%#{tag}%" }
scope :geo, -> { where 'latitude is not null and longitude is not null' }
before_validation do
# Tags are always downcased
self.tags = tags.mb_chars.downcase if tags
end
before_validation on: :create do
self.submission_time = Time.zone.now
self.decision_time = Time.zone.now
@ -73,22 +80,11 @@ class Event < ActiveRecord::Base
self.longitude = nil if address_changed?
end
before_create do
self.secret = SecureRandom.urlsafe_base64(32)[0...32]
self.moderator_mail_id = SecureRandom.urlsafe_base64(32)[0...32]
self.submitter_mail_id = SecureRandom.urlsafe_base64(32)[0...32]
end
before_update do
self.decision_time = Time.zone.now if moderated? && moderated_changed?
end
def as_json(_options = {})
{ type: 'Feature', properties: {
id: id, name: title, start_time: start_time, end_time: end_time,
place_name: place_name, address: address, city: city, locality: locality,
tags: tags,
popupContent: "<a href=\"/events/#{id}\">#{self}</a>"
tags: tags, popupContent: "<a href=\"/events/#{id}\">#{self}</a>"
}, geometry: { type: 'Point', coordinates: [longitude, latitude] } }
end
@ -98,6 +94,12 @@ class Event < ActiveRecord::Base
[address, city].compact.join ', '
end
def schedule
IceCube::Schedule.new(start_time, end_time: end_time) do |s|
s.add_recurrence_rule IceCube::Rule.send(rule).count(repeat + 1)
end
end
def hashtags
tags.split.map { |tag| "##{tag.tr('-', '_').camelize :lower}" }
end

View File

@ -1,28 +1,66 @@
# All the mail and tweet related callbacks to event's lifecycle
# also the scheduled events
class EventCallbacks
def self.before_validation(event)
# Tags are always downcased
event.tags = event.tags.mb_chars.downcase if event.tags
end
def self.before_create(event)
event.secret = SecureRandom.urlsafe_base64(32)[0...32]
event.moderator_mail_id = SecureRandom.urlsafe_base64(32)[0...32]
event.submitter_mail_id = SecureRandom.urlsafe_base64(32)[0...32]
end
def self.after_create(event)
EventMailer.create(event).deliver_now!
ModerationMailer.create(event).deliver_now!
end
def self.before_update(event)
if event.moderated_changed? && event.moderated?
event.decision_time = Time.zone.now
create_repeats event if event.repeat > 0
end
end
def self.after_update(event)
if event.moderated_changed?
tweet(event)
tweet event
# Send an acceptation mail to its author
EventMailer.accept(event).deliver_now
if ActionMailer::Base.default_url_options[:host]
# Send an acceptation mail to its author
EventMailer.accept(event).deliver_now
# Send an acceptation mail to moderators
ModerationMailer.accept(event).deliver_now
else
# Send an acceptation mail to moderators
ModerationMailer.accept(event).deliver_now
end
elsif ActionMailer::Base.default_url_options[:host]
# Send an update mail to moderators
ModerationMailer.update(event).deliver_now
end
end
def self.after_destroy(event)
EventMailer.destroy(event).deliver_now
ModerationMailer.destroy(event).deliver_now
if ActionMailer::Base.default_url_options[:host]
EventMailer.destroy(event).deliver_now
ModerationMailer.destroy(event).deliver_now
end
end
# Create multiple events corresponding to a repetition
def self.create_repeats(event)
event.schedule.last(event.repeat).each do |schedule|
event.events.build create_sub_event(event, schedule)
end
end
def self.create_sub_event(event, schedule)
att = event.attributes.reject { |a| a == 'id' || a == 'lock_version' }
att[:start_time] = schedule.start_time
att[:end_time] = schedule.end_time
att
end
# Tweet this event, if configured using apache/system variables!

View File

@ -7,6 +7,13 @@
= t '.delete_link'
= cancel_event_url @event, secret: @event.secret
\
- if @event.repeat > 0 && !@event.event_id
= t '.repeat_helper', count: @event.repeat
- @event.events.each do |e|
= e
= edit_event_url e, secret: e.secret
= cancel_event_url e, secret: e.secret
\
= render file: '/events/show'
\
= t '.signature'

View File

@ -6,3 +6,5 @@
= link_to event do
%strong.city{ title: event.address }= event.city
= event.title
- if event.repeat > 0
%em.fa.fa-repeat(title="#{event.repeat} - #{t event.rule, scope: 'activerecord.attributes.event.rule_values'}")

View File

@ -12,13 +12,30 @@
.field.title
= f.label :title
= f.text_field :title, required: true, placeholder: "#{t '.title_helper'}"
= f.text_field :title, required: true, placeholder: t('.title_helper')
.field.start_time
= f.label :start_time
= f.datetime_local_field :start_time, required: true
.field.end_time
= f.label :end_time
= f.datetime_local_field :end_time, required: true
- unless @event.moderated?
.field.repeat
= f.label :repeat
= f.number_field :repeat, in: 0..40, maxlength: 2, size: 2
.field.rule
.helper
:markdown
#{t '.rule_helper'}
= f.label :rule
%span.radios
- Event::RULES.each do |rule|
= f.radio_button :rule, rule
= f.label "rule_#{rule}",
t(rule, scope: 'activerecord.attributes.event.rule_values')
.field.description
.helper
:markdown
@ -46,7 +63,8 @@
%option= city
.field.region
= f.label :region
= f.collection_select :region_id, Region.all, :id, :name, { include_blank: true }
= f.collection_select :region_id, Region.all, :id, :name,
include_blank: true
.field.locality
= f.label :locality
%span.radios

View File

@ -77,8 +77,8 @@
"http://fr.wikipedia.org/wiki/#{url_encode @event.region.try :name}"
- if @event.latitude && @event.longitude
.event#map{ data: { url: "#{maps_path format: :json}",
latitude: "#{@event.latitude}", longitude: "#{@event.longitude}" } }
.event#map{ data: { url: maps_path(format: :json),
latitude: @event.latitude, longitude: @event.longitude } }
- elsif controller.action_name != 'show'
%em.fa.fa-compress
@ -114,3 +114,13 @@
%span.label= Event.human_attribute_name :tags
- @event.tags.split.each do |tag|
= link_to tag, tag_path(tag), rel: :tag
- if @event.repeat > 0
%h3
%em.fa.fa-repeat
= t @event.rule, scope: 'activerecord.attributes.event.rule_values'
%ul
%li= link_to_unless_current @event.event || @event, @event.event || @event
- (@event.event || @event).events.each do |e|
%li= link_to_unless_current e, e

View File

@ -2,6 +2,9 @@
#{Event.human_attribute_name(:title).concat(':').ljust 12 } #{@event.title}
#{Event.human_attribute_name(:start_time).concat(':').ljust 12 } #{l @event.start_time, format: :at}
#{Event.human_attribute_name(:end_time).concat(':').ljust 12 } #{l @event.end_time, format: :at}
-if @event.repeat > 0
#{Event.human_attribute_name(:repeat).concat(':').ljust 12 } #{@event.repeat}
#{Event.human_attribute_name(:rule).concat(':').ljust 12 } #{t @event.rule, scope: 'activerecord.attributes.event.rule_values'}
#{Event.human_attribute_name(:place_name).concat(':').ljust 12 } #{@event.place_name}
#{Event.human_attribute_name(:address).concat(':').ljust 12 } #{@event.address}
#{Event.human_attribute_name(:city).concat(':').ljust 12 } #{@event.city}

View File

@ -24,6 +24,18 @@
= @event.to_tweet
- if @event.repeat > 0
%fieldset
%legend
%em.fa.fa-repeat
= Event.human_attribute_name :repeat
%h3= t '.repeat_helper', count: @event.repeat
%p.rule
= Event.human_attribute_name :rule
= t @event.rule, scope: 'activerecord.attributes.event.rule_values'
%fieldset
%legend
%em.fa.fa-calendar

View File

@ -0,0 +1,173 @@
fr:
ice_cube:
pieces_connector: ' / '
not: 'pas %{target}'
not_on: 'pas durant %{target}'
date:
formats:
default: '%d %B %Y'
month_names:
-
- janvier
- février
- mars
- avril
- mai
- juin
- juillet
- août
- septembre
- octobre
- novembre
- décembre
day_names:
- dimanche
- lundi
- mardi
- mercredi
- jeudi
- vendredi
- samedi
times:
other: '%{count} fois'
one: '%{count} fois'
until: "jusqu'au %{date}"
days_of_week: '%{segments} %{day}'
days_of_month:
other: '%{segments} jours du mois'
one: '%{segments} jours du mois'
days_of_year:
other: "%{segments} jours de l'année"
one: "%{segments} jours de l'année"
at_hours_of_the_day:
other: aux %{segments} heures de la journée
one: à %{segments}h
on_minutes_of_hour:
other: aux %{segments} minutes de l'heure
one: à la %{segments} minute de l'heure
at_seconds_of_minute:
other: aux %{segments} secondes
one: à la %{segments} seconde
on_seconds_of_minute:
other: aux %{segments} secondes de la minute
one: à la %{segments} seconde de la minute
each_second:
one: Toutes les secondes
other: Toutes les %{count} secondes
each_minute:
one: Toutes les minutes
other: Toutes les %{count} minutes
each_hour:
one: Toutes les heures
other: Toutes les %{count} heures
each_day:
one: Quotidien
other: Tous les %{count} jours
each_week:
one: Hebdomadaire
other: Toutes les %{count} semaines
each_month:
one: Mensuel
other: Tous les %{count} mois
each_year:
one: Annuel
other: Tous les %{count} ans
'on': les %{sentence}
in: 'en %{target}'
integer:
negative: '%{ordinal} depuis la fin'
literal_ordinals:
-1: derniers
-2: avant-derniers
ordinal: '%{number}%{ordinal}'
ordinals:
default: '°'
1: °
on_weekends: pendant les weekends
on_weekdays: pendant les jours ouvrés
days_on:
- dimanches
- lundis
- mardis
- mercredis
- jeudis
- vendredis
- samedis
on_days: les %{days}
array:
last_word_connector: ', et '
two_words_connector: ' et '
words_connector: ', '
string:
format:
day: '%{rest} %{current}'
day_of_week: '%{rest} %{current}'
day_of_month: '%{rest} %{current}'
day_of_year: '%{rest} %{current}'
hour_of_day: '%{rest} %{current}'
minute_of_hour: '%{rest} %{current}'
until: '%{rest} %{current}'
count: '%{rest} %{current}'
default: '%{rest} %{current}'
date:
abbr_day_names:
- Dim
- Lun
- Mar
- Mer
- Jeu
- Ven
- Sam
abbr_month_names:
-
- Jan
- Fév
- Mar
- Avr
- Mai
- Jun
- Jul
- Aou
- Sep
- Oct
- Nov
- Déc
day_names:
- dimanche
- lundi
- mardi
- mecredi
- jeudi
- vendredi
- samedi
formats:
default: "%d-%m-%Y"
long: "%d %B %Y"
short: "%d %b"
month_names:
-
- janvier
- février
- mars
- avril
- mai
- juin
- juillet
- août
- septembre
- octobre
- novembre
- décembre
order:
- :year
- :month
- :day
time:
am: am
formats:
default: "%a, %d %b %Y %H:%M:%S %z"
long: "%d %B %Y %H:%M"
short: "%d %b %H:%M"
pm: pm

View File

@ -63,6 +63,13 @@ en:
title: Title
start_time: Start
end_time: End
repeat: Repeat
rule: Règle
rule_values:
daily: Daily
weekly: Weekly
monthly: Monthly
yearly: Yearly
description: Description
place_name: Place name
address: Address

View File

@ -63,6 +63,13 @@ fr:
title: Titre
start_time: Début
end_time: Fin
repeat: Répéter
rule: Règle
rule_values:
daily: Journalière
weekly: Hebdomadaire
monthly: Mensuelle
yearly: Annuelle
description: Description
place_name: Nom du lieu
address: Adresse

View File

@ -89,6 +89,8 @@ it more readable or agreable.
ok: Your event was updated
form:
title_helper: Less than 5 words, without address or date
rule_helper: Repeated events will be generated during validation. You
will receive by mail edition and cancellation links
description_helper: Describe with as much precision as possible your event
address_helper: Associated to the city and region, it will generate an
[OpenStreetMap](http://www.openstreetmap.org) map, displayed alongside
@ -179,6 +181,10 @@ it more readable or agreable.
ok: Yes
ko: Moderation
tweet_helper: A tweet will be published, here is its content
repeat_helper:
zero:
one: One other event will be generated
other: "%{count} events will be generated"
accept:
ok: Event accepted
refuse:
@ -318,6 +324,10 @@ description."
edit_link: "You can modify this event later to add details at the
address:"
delete_link: "You can can also cancel it at the address:"
repeat_helper:
zero:
one: Another event was genereated, here are the edition and cancellation links
other: "%{count} other events were generated, here are the edition and cancellation links"
signature: Thank you for your contribution and see you soon!
destroy:
subject: "Event '%{subject}' refused"

View File

@ -80,6 +80,8 @@ fr:
ok: Votre événement a été mis à jour
form:
title_helper: Moins de 5 mots, sans lieu ou date
rule_helper: Les événements répétés seront générés lors de la
validation. Vous recevrez par mail les liens d'édition et d'annulation
description_helper: Décrivez de la manière la plus complète possible
votre événement
address_helper: "*Associée à la ville et la région, elle générera une
@ -176,6 +178,10 @@ fr:
ok: Oui
ko: Modération
tweet_helper: Un tweet sera publié, dont voici le contenu
repeat_helper:
zero:
one: Un autre événement sera généré
other: "%{count} autres événements seront générés"
accept:
ok: Événement accepté
refuse:
@ -324,6 +330,10 @@ est maintenant visible à l'adresse:"
ajouter des précisions en vous rendant à l'adresse:"
delete_link: "Vous pouvez également l'annuler en vous rendant à
l'adresse:"
repeat_helper:
zero:
one: Un autre événement a été généré, voici les liens d'édition et annulation
other: "%{count} autres événements ont été générés, voici les liens d'édition et annulation"
signature: Merci de votre contribution et à bientôt!
destroy:
subject: "Événement '%{subject}' refusé"

View File

@ -0,0 +1,9 @@
# Manage a schedule for events, that will let adl create recurring events
class CreateSchedules < ActiveRecord::Migration
def change
add_column :events, :repeat, :integer, default: 0
add_column :events, :rule, :text, default: 'daily'
# This column is there to manage scheduled events
add_reference :events, :event, index: true
end
end

View File

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20160409131029) do
ActiveRecord::Schema.define(version: 20160616190823) do
create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace", limit: 255
@ -59,19 +59,19 @@ ActiveRecord::Schema.define(version: 20160409131029) do
add_index "cities", ["name"], name: "cities_name"
create_table "events", force: :cascade do |t|
t.string "title", limit: 255, default: "", null: false
t.text "description", limit: 65535, null: false
t.datetime "start_time", null: false
t.datetime "end_time", null: false
t.string "title", limit: 255, default: "", null: false
t.text "description", limit: 65535, null: false
t.datetime "start_time", null: false
t.datetime "end_time", null: false
t.string "city", limit: 255, default: ""
t.integer "region_id", limit: 4, default: 0, null: false
t.integer "locality", limit: 4, default: 0, null: false
t.string "url", limit: 255, default: "", null: false
t.string "contact", limit: 255, default: "", null: false
t.string "submitter", limit: 255, default: "", null: false
t.integer "moderated", limit: 4, default: 0, null: false
t.string "tags", limit: 255, default: "", null: false
t.string "secret", limit: 255, default: "", null: false
t.integer "region_id", limit: 4, default: 0, null: false
t.integer "locality", limit: 4, default: 0, null: false
t.string "url", limit: 255, default: "", null: false
t.string "contact", limit: 255, default: "", null: false
t.string "submitter", limit: 255, default: "", null: false
t.integer "moderated", limit: 4, default: 0, null: false
t.string "tags", limit: 255, default: "", null: false
t.string "secret", limit: 255, default: "", null: false
t.datetime "decision_time"
t.datetime "submission_time"
t.string "moderator_mail_id", limit: 32
@ -79,10 +79,15 @@ ActiveRecord::Schema.define(version: 20160409131029) do
t.text "address", limit: 65535
t.float "latitude", limit: 24
t.float "longitude", limit: 24
t.integer "lock_version", limit: 4, default: 0, null: false
t.integer "lock_version", limit: 4, default: 0, null: false
t.string "place_name", limit: 255
t.integer "count", default: 1
t.integer "repeat", default: 0
t.text "rule", default: "daily"
t.integer "event_id"
end
add_index "events", ["event_id"], name: "index_events_on_event_id"
add_index "events", ["start_time", "end_time"], name: "events_date"
create_table "kinds", force: :cascade do |t|

View File

@ -0,0 +1,47 @@
require 'test_helper'
# Test event callbacks
class EventCallbacksTest < ActiveSupport::TestCase
setup do
ActionMailer::Base.default_url_options[:host] = 'localhost:3000'
@event = events :one
end
test 'schedule' do
@event = Event.new(
title: 'hello world',
start_time: Time.zone.now, end_time: Time.zone.now + 1.hour,
description: 'et hop!',
city: City.first, region: Region.first,
url: 'http://example.com',
contact: 'contact@example.com',
tags: 'hello world'
)
assert_difference 'Event.count' do
assert @event.save, @event.errors.messages
end
end
test 'moderation' do
@event = Event.new(
title: 'hello world',
start_time: Time.zone.now + 1.hour, end_time: Time.zone.now + 2.hours,
repeat: 1, rule: 'monthly',
description: 'et hop!',
city: City.first, region: Region.first,
url: 'http://example.com',
contact: 'contact@example.com',
tags: 'hello world'
)
assert @event.save, @event.errors.messages
assert !@event.moderated?
assert_difference 'Event.count' do
@event.update moderated: 1
end
assert @event.moderated?, @event.errors.messages
end
end

View File

@ -21,7 +21,9 @@ class EventTest < ActiveSupport::TestCase
submitter: 'submitter@example.com',
tags: 'hello world'
)
assert @event.save, @event.errors.messages
assert_difference 'Event.count' do
assert @event.save, @event.errors.messages
end
assert_equal 32, @event.secret.size
assert_equal 32, @event.moderator_mail_id.size