From d1b038c5a78edfb175138b015edec0c25b484e4c Mon Sep 17 00:00:00 2001 From: Chrisbr Date: Mon, 7 Jul 2014 14:58:53 +0200 Subject: [PATCH] [Bugfix] Confirmed / Unconfirmed events over time for conference dashboard --- Gemfile | 3 + Gemfile.lock | 5 + .../admin/conference_controller.rb | 19 +- app/models/conference.rb | 161 ++++++++++- app/models/event.rb | 7 + config/schedule.rb | 24 ++ ...23203_add_events_per_week_to_conference.rb | 90 ++++++ db/schema.rb | 1 + spec/models/conference_spec.rb | 266 ++++++++++++++++++ 9 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 config/schedule.rb create mode 100644 db/migrate/20140701123203_add_events_per_week_to_conference.rb diff --git a/Gemfile b/Gemfile index c213901a..6720b8db 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,9 @@ gem 'factory_girl_rails' gem 'ahoy_matey' gem 'activeuuid' +# We use whenever for recurring jobs +gem 'whenever', :require => false + # Use guard and spring for testing in development group :development do # rspec Guard rules diff --git a/Gemfile.lock b/Gemfile.lock index c892b145..fdfd7edd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,7 @@ GEM nio4r (>= 0.5.0) chart-js-rails (0.0.6) railties (> 3.1) + chronic (0.10.2) climate_control (0.0.3) activesupport (>= 3.0) cliver (0.3.2) @@ -373,6 +374,9 @@ GEM warden (1.2.3) rack (>= 1.0) websocket-driver (0.3.3) + whenever (0.9.2) + activesupport (>= 2.3.4) + chronic (>= 0.6.3) xpath (2.0.0) nokogiri (~> 1.3) yajl-ruby (1.2.0) @@ -434,3 +438,4 @@ DEPENDENCIES transitions turbolinks uglifier (>= 1.3.0) + whenever diff --git a/app/controllers/admin/conference_controller.rb b/app/controllers/admin/conference_controller.rb index b4d25a43..36ca67a7 100644 --- a/app/controllers/admin/conference_controller.rb +++ b/app/controllers/admin/conference_controller.rb @@ -126,19 +126,12 @@ class Admin::ConferenceController < ApplicationController @submissions = Conference.get_event_state_line_colors @submissions_data = {} - @cfp_weeks = [0] - @submissions_data['Submitted'] = @conference.get_submissions_per_week - @cfp_weeks.push(@submissions_data['Submitted'].length) - - @submissions_data['Confirmed'] = @conference.get_submissions_per_week_by_status('confirmed') - @cfp_weeks.push(@submissions_data['Confirmed'].length) - - @submissions_data['Unconfirmed'] = @conference.get_submissions_per_week_by_status('unconfirmed') - @cfp_weeks.push(@submissions_data['Unconfirmed'].length) - - @cfp_weeks = @cfp_weeks.max - @submissions_data = normalize_array_length(@submissions_data, @cfp_weeks) - @cfp_weeks = @cfp_weeks > 0 ? (1..@cfp_weeks).to_a : 1 + @submissions_data = @conference.get_submissions_data + @cfp_weeks = 0 + if @submissions_data['Weeks'] + @cfp_weeks = @submissions_data['Weeks'] + @submissions_data = @submissions_data.except('Weeks') + end # Doughnut charts @event_type_distribution = @conference.event_type_distribution diff --git a/app/models/conference.rb b/app/models/conference.rb index 953fb9a4..543f64d0 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -3,6 +3,7 @@ class Conference < ActiveRecord::Base require 'uri' + serialize :events_per_week, Hash attr_accessible :title, :short_title, :social_tag, :contact_email, :timezone, :html_export_path, :start_date, :end_date, :rooms_attributes, :tracks_attributes, @@ -185,18 +186,28 @@ class Conference < ActiveRecord::Base end ## - # Returns an array with the summarized event submissions per week. + # Returns an hash with submitted, confirmed and unconfirmed event submissions + # per week. # # ====Returns - # * +Array+ -> e.g. [0, 3, 3, 5] -> first week 0 events, second week 3 events. - def get_submissions_per_week_by_status(state) - result = [] - + # * +Array+ -> e.g. 'Submitted' => [0, 3, 3, 5] -> first week 0 events, second week 3 events. + def get_submissions_data + result = {} if call_for_papers && events - submissions = events.where('state = ?', state).group(:week).count + result = get_events_per_week_by_state + start_week = call_for_papers.start_week - weeks = call_for_papers.weeks - result = calculate_items_per_week(start_week, weeks, submissions) + end_week = end_date.strftime('%W').to_i + weeks = weeks(start_week, end_week) + + result.each do |state, values| + if state == 'Submitted' + result['Submitted'] = pad_array_left_kumulative(start_week, values) + else + result[state] = pad_array_left_not_kumulative(start_week, values) + end + end + result['Weeks'] = weeks > 0 ? (1..weeks).to_a : 0 end result end @@ -487,8 +498,142 @@ class Conference < ActiveRecord::Base result end + ## + # Writes an snapshot of the actual event distribution to the database + # Triggered each every Sunday 11:55 pm form whenever (config/schedule.rb). + # + def self.write_event_distribution_to_db + week = DateTime.now.end_of_week + + Conference.where('end_date > ?', Date.today).each do |conference| + result = {} + Event.state_machine.states.each do |state| + count = conference.events.where('state = ?', state.name).count + result[state.name] = count + end + + if !conference.events_per_week + conference.events_per_week = {} + end + + # Write to database + conference.events_per_week[week] = result + conference.save! + end + end + private + ## + # Calculates the weeks from a start and a end week. + # + # ====Returns + # * +Fixnum+ -> weeks + def weeks(start_week, end_week) + weeks = end_week - start_week + 1 + weeks_of_year = Date.new(start_date.year, 12, 31).strftime('%W').to_i + weeks < 0 ? weeks + weeks_of_year : weeks + end + + ## + # Returns a Hash with the events with the state confirmend / unconfirmed per week. + # + # ====Returns + # * +Hash+ -> e.g. 'Confirmed' => { 3 => 5, 4 => 6 } + def get_events_per_week_by_state + result = { + 'Submitted' => {}, + 'Confirmed' => {}, + 'Unconfirmed' => {} + } + + # Completed weeks + events_per_week.each do |week, values| + values.each do |state, value| + if [:confirmed, :unconfirmed].include?(state) + if !result[state.to_s.capitalize] + result[state.to_s.capitalize] = {} + end + result[state.to_s.capitalize][week.strftime('%W').to_i] = value + end + end + end + + # Actual week + this_week = Date.today.end_of_week.strftime('%W').to_i + result['Confirmed'][this_week] = events.where('state = ?', :confirmed).count + result['Unconfirmed'][this_week] = events.where('state = ?', :unconfirmed).count + result['Submitted'] = events.group(:week).count + result['Submitted'][this_week] = events.where(week: this_week).count + result + end + + ## + # Returns an array from the hash values with left padding. + # + # ====Returns + # * +Array+ -> [0, 0, 1, 2, 3, 0, 0] + def pad_array_left_not_kumulative(start_week, hash) + hash = assert_keys_are_continuously(hash) + + first_week = hash.keys[0] + left = pad_left(first_week, start_week) + left + hash.values + end + + ## + # Returns an array from the hash values with left padding. + # + # ====Returns + # * +Array+ -> [0, 0, 1, 2, 3, 3, 3] + def pad_array_left_kumulative(start_week, hash) + hash = assert_keys_are_continuously(hash) + result = cumulative_sum(hash.values) + + first_week = hash.keys[0] + left = pad_left(first_week, start_week) + left + result + end + + ## + # Cumulative sums an array. + # + # ====Returns + # * +Array+ -> [1, 2, 3, 4] --> [1, 3, 7, 11] + def cumulative_sum(array) + sum = 0 + array.map { |x| sum += x } + end + + ## + # Returns the left padding. + # + # ====Returns + # * +Array+ + def pad_left(first_week, start_week) + left = [] + if first_week > start_week + left = Array.new(first_week - start_week - 1, 0) + end + left + end + + ## + # Asserts that all keys in the hash are continuously. + # If not, the missing key is inserted with value 0. + # + # ====Returns + # * +Hash+ { 1 => 1, 2 => 0, 3 => 0, 4 => 3 } + def assert_keys_are_continuously(hash) + keys = hash.keys + (keys.min..keys.max).each do |key| + if !hash[key] + hash[key] = 0 + end + end + hash.sort.to_h + end + ## # Returns the progress of the set up conference list in percent # diff --git a/app/models/event.rb b/app/models/event.rb index 078b512e..ae2830e7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,7 @@ class Event < ActiveRecord::Base before_create :generate_guid validate :abstract_limit + validate :before_end_of_conference validate :biography_exists validates :title, presence: true validates :abstract, presence: true @@ -207,4 +208,10 @@ class Event < ActiveRecord::Base self.week = created_at.strftime('%W') save! end + + def before_end_of_conference + errors. + add(:created_at, "can't be after the conference end date!") if conference.end_date && + (Date.today > conference.end_date) + end end diff --git a/config/schedule.rb b/config/schedule.rb new file mode 100644 index 00000000..2454f5a9 --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,24 @@ +# Use this file to easily define all of your cron jobs. +# +# It's helpful, but not entirely necessary to understand cron before proceeding. +# http://en.wikipedia.org/wiki/Cron + +# Example: +# +# set :output, "/path/to/my/cron_log.log" +# +# every 2.hours do +# command "/usr/bin/some_great_command" +# runner "MyModel.some_method" +# rake "some:great:rake:task" +# end +# +# every 4.days do +# runner "AnotherModel.prune_old_records" +# end + +# Learn more: http://github.com/javan/whenever + +every :sunday, at: '23:55 pm' do + runner 'Conference.write_event_distribution_to_db' +end diff --git a/db/migrate/20140701123203_add_events_per_week_to_conference.rb b/db/migrate/20140701123203_add_events_per_week_to_conference.rb new file mode 100644 index 00000000..70110d24 --- /dev/null +++ b/db/migrate/20140701123203_add_events_per_week_to_conference.rb @@ -0,0 +1,90 @@ +class AddEventsPerWeekToConference < ActiveRecord::Migration + class TempVersion < ActiveRecord::Base + self.table_name = 'versions' + serialize :object_changes, HashWithIndifferentAccess + end + + class TempEvent < ActiveRecord::Base + self.table_name = 'events' + end + + class TempConference < ActiveRecord::Base + self.table_name = 'conferences' + serialize :events_per_week, Hash + end + + def up + add_column :conferences, :events_per_week, :text + + TempVersion.where(item_type: 'Event').each do |event_version| + event = TempEvent.find(event_version.item_id) + conference = TempConference.find(event.conference_id) + week = event_version.created_at.end_of_week + + no_events = { + new: 0, + withdrawn: 0, + unconfirmed: 0, + confirmed: 0, + canceled: 0, + rejected: 0, + } + + if !conference.events_per_week + conference.events_per_week = { + week => no_events + } + elsif !conference.events_per_week[week] + conference.events_per_week[week] = no_events + end + + if event_version.object_changes && + event_version.event == 'create' + + # Increment the new state + conference.events_per_week[week][:new] += 1 + elsif event_version.object_changes && + event_version.object_changes[:state] + + prev_state = event_version.object_changes[:state][0].to_sym + next_state = event_version.object_changes[:state][1].to_sym + + # Backward compatibility: deprecated state :review now :new + if prev_state == :review + prev_state = :new + elsif next_state == :review + next_state = :new + end + + # Increment the next state + conference.events_per_week[week][next_state] += 1 + + # Decrement the previous state + conference.events_per_week[week][prev_state] -= 1 + end + conference.save + end + + + # Cumulate the previous weeks to get a snapshot + TempConference.all.each do |conference| + hash = conference.events_per_week.sort.to_h + previous = nil + + hash.each do |week, values| + if previous + values.each do |state, value| + hash[week][state] += previous[state] + end + end + previous = values + end + conference.events_per_week = hash + conference.save + end + end + + def down + remove_column :conferences, :events_per_week + end +end diff --git a/db/schema.rb b/db/schema.rb index e45fb684..d8c8b4dd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -125,6 +125,7 @@ ActiveRecord::Schema.define(version: 20140714141156) do t.datetime "banner_photo_updated_at" t.boolean "include_banner_in_splash", default: false t.string "instagram_url" + t.text "events_per_week" end create_table "conferences_questions", id: false, force: true do |t| diff --git a/spec/models/conference_spec.rb b/spec/models/conference_spec.rb index a039e1d5..af30caa0 100644 --- a/spec/models/conference_spec.rb +++ b/spec/models/conference_spec.rb @@ -6,6 +6,272 @@ describe Conference do let(:subject) { create(:conference) } + describe '#write_event_distribution_to_db' do + + it 'updates pending conferences' do + create(:conference, + start_date: Date.today - 2.weeks, + end_date: Date.today - 1.weeks) + + subject.start_date = Date.today + 1.weeks + subject.end_date = Date.today + 2.weeks + + result = { + DateTime.now.end_of_week => + { + confirmed: 0, + unconfirmed: 0, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + } + + Conference.write_event_distribution_to_db + subject.reload + expect(subject.events_per_week).to eq(result) + end + + it 'does not update past conferences' do + old_conference = create(:conference, + start_date: Date.today - 2.weeks, + end_date: Date.today - 1.weeks) + + Conference.write_event_distribution_to_db + old_conference.reload + expect(old_conference.events_per_week).to eq({}) + end + + it 'computes the correct result' do + subject.email_settings = create(:email_settings) + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today - 3.weeks) + + create(:event, conference: subject, created_at: Date.today) + options = {} + options[:send_mail] = 'false' + + withdrawn = create(:event, conference: subject) + withdrawn.withdraw! + + unconfirmed = create(:event, conference: subject) + unconfirmed.accept!(options) + + rejected = create(:event, conference: subject) + rejected.reject!(options) + + confirmed = create(:event, conference: subject) + confirmed.accept!(options) + confirmed.confirm! + + canceled = create(:event, conference: subject) + canceled.accept!(options) + canceled.cancel! + + Conference.write_event_distribution_to_db + + result = { + DateTime.now.end_of_week => + { + confirmed: 1, + unconfirmed: 1, + new: 1, + withdrawn: 1, + canceled: 1, + rejected: 1 + }, + } + + subject.reload + expect(subject.events_per_week).to eq(result) + end + + it 'does not overwrite old entries' do + subject.email_settings = create(:email_settings) + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + db_data = { + DateTime.now.end_of_week - 2.weeks => + { + confirmed: 1, + unconfirmed: 2, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + DateTime.now.end_of_week - 1.weeks => + { + confirmed: 3, + unconfirmed: 4, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + } + subject.events_per_week = db_data + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today - 3.weeks) + + create(:event, conference: subject, created_at: Date.today) + unconfirmed = create(:event, conference: subject) + confirmed = create(:event, conference: subject) + options = {} + options[:send_mail] = 'false' + unconfirmed.accept!(options) + confirmed.accept!(options) + confirmed.confirm! + + Conference.write_event_distribution_to_db + + result = { + DateTime.now.end_of_week - 2.weeks => + { + confirmed: 1, + unconfirmed: 2, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + DateTime.now.end_of_week - 1.weeks => + { + confirmed: 3, + unconfirmed: 4, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + DateTime.now.end_of_week => + { + confirmed: 1, + unconfirmed: 1, + new: 1, + withdrawn: 0, + canceled: 0, + rejected: 0 + }, + } + + subject.reload + expect(subject.events_per_week).to eq(result) + end + end + + describe '#get_submissions_data' do + it 'returns emtpy hash if there is no cfp or events' do + expect(subject.get_submissions_data).to eq({}) + end + + it 'calculates the correct result with data from database' do + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + + # Inject last two weeks to database + db_data = { + Date.today.end_of_week - 2.weeks => { + confirmed: 1, + unconfirmed: 2, + }, + Date.today.end_of_week - 1.weeks => { + confirmed: 3, + unconfirmed: 4, + } + } + subject.events_per_week = db_data + + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today - 2.weeks) + + create(:event, conference: subject, created_at: Date.today - 2.weeks) + + result = { + 'Submitted' => [1, 1, 1], + 'Confirmed' => [1, 3, 0], + 'Unconfirmed' => [2, 4, 0], + 'Weeks' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + } + expect(subject.get_submissions_data).to eq(result) + end + + it 'calculates the correct result without data from database' do + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today) + create(:event, conference: subject) + + result = { + 'Submitted' => [1], + 'Confirmed' => [0], + 'Unconfirmed' => [0], + 'Weeks' => [1, 2, 3, 4, 5, 6, 7, 8] + } + expect(subject.get_submissions_data).to eq(result) + end + + it 'pads left correct' do + subject.email_settings = create(:email_settings) + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today - 3.weeks) + + create(:event, conference: subject, created_at: Date.today) + unconfirmed = create(:event, conference: subject) + confirmed = create(:event, conference: subject) + options = {} + options[:send_mail] = 'false' + unconfirmed.accept!(options) + confirmed.accept!(options) + confirmed.confirm! + + result = { + 'Submitted' => [0, 0, 3], + 'Confirmed' => [0, 0, 1], + 'Unconfirmed' => [0, 0, 1], + 'Weeks' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + } + expect(subject.get_submissions_data).to eq(result) + end + + it 'calculates correct with missing weeks' do + subject.start_date = Date.today + 6.weeks + subject.end_date = Date.today + 7.weeks + + # Inject last two weeks to database + db_data = { + Date.today.end_of_week - 3.weeks => { + confirmed: 1, + unconfirmed: 2, + }, + Date.today.end_of_week - 1.weeks => { + confirmed: 3, + unconfirmed: 4, + } + } + subject.events_per_week = db_data + + subject.save + subject.call_for_papers = create(:call_for_papers, start_date: Date.today - 3.weeks) + + create(:event, conference: subject, created_at: Date.today - 3.weeks) + + result = { + 'Submitted' => [1, 1, 1, 1], + 'Confirmed' => [1, 0, 3, 0], + 'Unconfirmed' => [2, 0, 4, 0], + 'Weeks' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + } + expect(subject.get_submissions_data).to eq(result) + end + end + describe '#get_top_submitter' do # It is necessary to use bang version of let to build roles before user let!(:organizer_role) { create(:organizer_role) }