reportable 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ v1.0.0
2
+ ------
3
+
4
+ * Initial release of the new Reportable gem (formerly known as the ReportsAsSparkline plugin)
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008-2010 Marco Otte-Witte, Martin Kavalar
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Reportable
2
+ ==========
3
+
4
+ Reportable allows for the easy creation of reports based on `ActiveRecord` models.
5
+
6
+
7
+ Usage
8
+ -----
9
+
10
+ Usage is pretty easy. To declare a report on a model, simply define that the model provides a report:
11
+
12
+ class User < ActiveRecord::Base
13
+
14
+ reportable :registrations, :aggregation => :count
15
+
16
+ end
17
+
18
+ The `reportable` method takes a bunch more options which are described in the [API docs](http://rdoc.info/projects/saulabs/reportable). For example you could generate a report on
19
+ the number of updated users records per second or the number of registrations of users that have a last name that starts with `'A'` per month:
20
+
21
+ class User < ActiveRecord::Base
22
+
23
+ reportable :last_name_starting_with_a_registrations, :aggregation => :count, :grouping => :month, :conditions => ["last_name LIKE 'A%'"]
24
+
25
+ reportable :updated_per_second, :aggregation => :count, :grouping => :second, :date_column => :updated_at
26
+
27
+ end
28
+
29
+ For every declared report a method is generated on the model that returns the date:
30
+
31
+ User.registrations_report
32
+
33
+ User.last_name_starting_with_a_registrations_report
34
+
35
+ User.updated_per_second_report
36
+
37
+
38
+ Working with the data
39
+ ---------------------
40
+
41
+ The data is returned as an `Array` of `Array`s of `DateTime`s and `Float`s, e.g.:
42
+
43
+ [
44
+ [DateTime.now, 1.0],
45
+ [DateTime.now - 1.day, 2.0],
46
+ [DateTime.now - 2.days, 3.0]
47
+ ]
48
+
49
+ Reportable provides a helper method to generate a sparkline image from this data that you can use in your views:
50
+
51
+ <%= sparkline_tag(User.registrations_report) %>
52
+
53
+
54
+ Installation
55
+ ------------
56
+
57
+ To install Reportable, simply run
58
+
59
+ [sudo] gem install reportable
60
+
61
+ and add it to your application's dependencies in your `environment.rb`:
62
+
63
+ config.gem 'reportable', :lib => 'saulabs/reportable'
64
+
65
+ When you installed the gem, you have to generate the migration that creates Reportable's cache table:
66
+
67
+ ./script/generate reportable_migration create_reportable_cache
68
+
69
+ and migrate:
70
+
71
+ rake db:migrate
72
+
73
+
74
+ Plans
75
+ -----
76
+
77
+ * add support for Oracle and MSSQL
78
+ * add support for DataMapper
79
+ * add more options to generate graphs from the data
80
+ * add the option to generate textual reports on the command line
81
+
82
+
83
+ Authors
84
+ -------
85
+
86
+ © 2008-2010 Marco Otte-Witte (<http://simplabs.com>), Martin Kavalar (<http://www.sauspiel.de>)
87
+
88
+ Released under the MIT license
@@ -0,0 +1,29 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'bundler'
4
+
5
+ Bundler.setup
6
+ Bundler.require
7
+
8
+ require 'spec/rake/spectask'
9
+ require 'simplabs/excellent/rake'
10
+
11
+ desc 'Default: run specs.'
12
+ task :default => :spec
13
+
14
+ desc 'Run the specs'
15
+ Spec::Rake::SpecTask.new(:spec) do |t|
16
+ t.rcov_opts << '--exclude "gems/*,spec/*,init.rb"'
17
+ t.rcov = true
18
+ t.rcov_dir = 'doc/coverage'
19
+ t.spec_files = FileList['spec/**/*_spec.rb']
20
+ end
21
+
22
+ YARD::Rake::YardocTask.new(:doc) do |t|
23
+ t.files = ['lib/**/*.rb', '-', 'HISTORY.md']
24
+ t.options = ['--no-private', '--title', 'Reportable Documentation']
25
+ end
26
+
27
+ Simplabs::Excellent::Rake::ExcellentTask.new(:excellent) do |t|
28
+ t.paths = %w(lib)
29
+ end
@@ -0,0 +1,9 @@
1
+ class ReportableMigrationGenerator < Rails::Generator::NamedBase
2
+
3
+ def manifest
4
+ record do |m|
5
+ m.migration_template 'migration.erb', 'db/migrate'
6
+ end
7
+ end
8
+
9
+ end
@@ -0,0 +1,40 @@
1
+ class <%= class_name %> < ActiveRecord::Migration
2
+
3
+ def self.up
4
+ create_table :reportable_cache, :force => true do |t|
5
+ t.string :model_name, :null => false
6
+ t.string :report_name, :null => false
7
+ t.string :grouping, :null => false
8
+ t.string :aggregation, :null => false
9
+ t.string :condition, :null => false
10
+ t.float :value, :null => false, :default => 0
11
+ t.datetime :reporting_period, :null => false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :reportable_cache, [
17
+ :model_name,
18
+ :report_name,
19
+ :grouping,
20
+ :aggregation,
21
+ :condition
22
+ ], :name => :name_model_grouping_agregation
23
+ add_index :reportable_cache, [
24
+ :model_name,
25
+ :report_name,
26
+ :grouping,
27
+ :aggregation,
28
+ :condition,
29
+ :reporting_period
30
+ ], :unique => true, :name => :name_model_grouping_aggregation_period
31
+ end
32
+
33
+ def self.down
34
+ remove_index :reportable_cache, :name => :name_model_grouping_agregation
35
+ remove_index :reportable_cache, :name => :name_model_grouping_aggregation_period
36
+
37
+ drop_table :reportable_cache
38
+ end
39
+
40
+ end
@@ -0,0 +1,63 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # Extends the {Saulabs::Reportable::ClassMethods#reportable} method into +base+.
6
+ #
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ end
10
+
11
+ module ClassMethods
12
+
13
+ # Generates a report on a model. That report can then be executed via the new method +<name>_report+ (see documentation of {Saulabs::Reportable::Report#run}).
14
+ #
15
+ # @param [String] name
16
+ # the name of the report, also defines the name of the generated report method (+<name>_report+)
17
+ # @param [Hash] options
18
+ # the options to generate the reports with
19
+ #
20
+ # @option options [Symbol] :date_column (created_at)
21
+ # the name of the date column over that the records are aggregated
22
+ # @option options [String, Symbol] :value_column (:id)
23
+ # the name of the column that holds the values to aggregate when using a calculation aggregation like +:sum+
24
+ # @option options [Symbol] :aggregation (:count)
25
+ # the aggregation to use (one of +:count+, +:sum+, +:minimum+, +:maximum+ or +:average+); when using anything other than +:count+, +:value_column+ must also be specified
26
+ # @option options [Symbol] :grouping (:day)
27
+ # the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); <b>Beware that <tt>reportable</tt> treats weeks as starting on monday!</b>
28
+ # @option options [Fixnum] :limit (100)
29
+ # the number of reporting periods to get (see +:grouping+)
30
+ # @option options [Hash] :conditions ({})
31
+ # conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
32
+ # @option options [Boolean] :live_data (false)
33
+ # specifies whether data for the current reporting period is to be read; <b>if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone</b>
34
+ # @option options [DateTime, Boolean] :end_date (false)
35
+ # when specified, the report will only include data for the +:limit+ reporting periods until this date.
36
+ #
37
+ # @example Declaring reports on a model
38
+ #
39
+ # class User < ActiveRecord::Base
40
+ # reportable :registrations, :aggregation => :count
41
+ # reportable :activations, :aggregation => :count, :date_column => :activated_at
42
+ # reportable :total_users, :cumulate => true
43
+ # reportable :rake, :aggregation => :sum, :value_column => :profile_visits
44
+ # end
45
+ def reportable(name, options = {})
46
+ (class << self; self; end).instance_eval do
47
+ define_method "#{name.to_s}_report".to_sym do |*args|
48
+ if options.delete(:cumulate)
49
+ report = Saulabs::Reportable::CumulatedReport.new(self, name, options)
50
+ else
51
+ report = Saulabs::Reportable::Report.new(self, name, options)
52
+ end
53
+ raise ArgumentError.new unless args.length == 0 || (args.length == 1 && args[0].is_a?(Hash))
54
+ report.run(args.length == 0 ? {} : args[0])
55
+ end
56
+ end
57
+ end
58
+
59
+ end
60
+
61
+ end
62
+
63
+ end
@@ -0,0 +1,42 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # A special report class that cumulates all data (see {Saulabs::Reportable::Report})
6
+ #
7
+ # @example Cumulated reports as opposed to regular reports
8
+ #
9
+ # [[<DateTime today>, 1], [<DateTime yesterday>, 2], etc.]
10
+ # [[<DateTime today>, 3], [<DateTime yesterday>, 2], etc.]
11
+ #
12
+ class CumulatedReport < Report
13
+
14
+ # Runs the report (see {Saulabs::Reportable::Report#run})
15
+ #
16
+ def run(options = {})
17
+ cumulate(super, options_for_run(options))
18
+ end
19
+
20
+ private
21
+
22
+ def cumulate(data, options)
23
+ first_reporting_period = ReportingPeriod.first(options[:grouping], options[:limit], options[:end_date])
24
+ acc = initial_cumulative_value(first_reporting_period.date_time, options)
25
+ result = []
26
+ data.each do |element|
27
+ acc += element[1].to_f
28
+ result << [element[0], acc]
29
+ end
30
+ result
31
+ end
32
+
33
+ def initial_cumulative_value(date, options)
34
+ conditions = setup_conditions(nil, date, options[:conditions])
35
+ @klass.send(@aggregation, @value_column, :conditions => conditions)
36
+ end
37
+
38
+ end
39
+
40
+ end
41
+
42
+ end
@@ -0,0 +1,140 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # The grouping specifies which records are grouped into one {Saulabs::Reportable::ReportingPeriod}.
6
+ #
7
+ class Grouping
8
+
9
+ # Initializes a new grouping.
10
+ #
11
+ # @param [Symbol] identifier
12
+ # the identifier of the grouping (one of +:hour+, +:day+, +:week+ or +:month+)
13
+ #
14
+ def initialize(identifier)
15
+ raise ArgumentError.new("Invalid grouping #{identifier}") unless [:hour, :day, :week, :month].include?(identifier)
16
+ @identifier = identifier
17
+ end
18
+
19
+ # Gets the identifier of the grouping.
20
+ #
21
+ # @return [Symbol]
22
+ # the identifier of the grouping.
23
+ #
24
+ def identifier
25
+ @identifier
26
+ end
27
+
28
+ # Gets an array of date parts from a DB string.
29
+ #
30
+ # @param [String] db_string
31
+ # the DB string to get the date parts from
32
+ #
33
+ # @return [Array<Fixnum>]
34
+ # array of numbers that represent the values of the date
35
+ #
36
+ def date_parts_from_db_string(db_string)
37
+ case ActiveRecord::Base.connection.adapter_name
38
+ when /mysql/i
39
+ from_mysql_db_string(db_string)
40
+ when /sqlite/i
41
+ from_sqlite_db_string(db_string)
42
+ when /postgres/i
43
+ from_postgresql_db_string(db_string)
44
+ end
45
+ end
46
+
47
+ # Converts the grouping into a DB specific string that can be used to group records.
48
+ #
49
+ # @param [String] date_column
50
+ # the name of the DB column that holds the date
51
+ #
52
+ def to_sql(date_column)
53
+ case ActiveRecord::Base.connection.adapter_name
54
+ when /mysql/i
55
+ mysql_format(date_column)
56
+ when /sqlite/i
57
+ sqlite_format(date_column)
58
+ when /postgres/i
59
+ postgresql_format(date_column)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def from_mysql_db_string(db_string)
66
+ if @identifier == :week
67
+ parts = [db_string[0..3], db_string[4..5]].map(&:to_i)
68
+ else
69
+ db_string.split('/').map(&:to_i)
70
+ end
71
+ end
72
+
73
+ def from_sqlite_db_string(db_string)
74
+ if @identifier == :week
75
+ parts = db_string.split('-').map(&:to_i)
76
+ date = Date.new(parts[0], parts[1], parts[2])
77
+ return [date.cwyear, date.cweek]
78
+ end
79
+ db_string.split('/').map(&:to_i)
80
+ end
81
+
82
+ def from_postgresql_db_string(db_string)
83
+ case @identifier
84
+ when :hour
85
+ return (db_string[0..9].split('-') + [db_string[11..12]]).map(&:to_i)
86
+ when :day
87
+ return db_string[0..9].split('-').map(&:to_i)
88
+ when :week
89
+ parts = db_string[0..9].split('-').map(&:to_i)
90
+ date = Date.new(parts[0], parts[1], parts[2])
91
+ return [date.cwyear, date.cweek]
92
+ when :month
93
+ return db_string[0..6].split('-')[0..1].map(&:to_i)
94
+ end
95
+ end
96
+
97
+ def mysql_format(date_column)
98
+ case @identifier
99
+ when :hour
100
+ "DATE_FORMAT(#{date_column}, '%Y/%m/%d/%H')"
101
+ when :day
102
+ "DATE_FORMAT(#{date_column}, '%Y/%m/%d')"
103
+ when :week
104
+ "YEARWEEK(#{date_column}, 3)"
105
+ when :month
106
+ "DATE_FORMAT(#{date_column}, '%Y/%m')"
107
+ end
108
+ end
109
+
110
+ def sqlite_format(date_column)
111
+ case @identifier
112
+ when :hour
113
+ "strftime('%Y/%m/%d/%H', #{date_column})"
114
+ when :day
115
+ "strftime('%Y/%m/%d', #{date_column})"
116
+ when :week
117
+ "date(#{date_column}, 'weekday 0')"
118
+ when :month
119
+ "strftime('%Y/%m', #{date_column})"
120
+ end
121
+ end
122
+
123
+ def postgresql_format(date_column)
124
+ case @identifier
125
+ when :hour
126
+ "date_trunc('hour', #{date_column})"
127
+ when :day
128
+ "date_trunc('day', #{date_column})"
129
+ when :week
130
+ "date_trunc('week', #{date_column})"
131
+ when :month
132
+ "date_trunc('month', #{date_column})"
133
+ end
134
+ end
135
+
136
+ end
137
+
138
+ end
139
+
140
+ end
@@ -0,0 +1,165 @@
1
+ module Saulabs
2
+
3
+ module Reportable
4
+
5
+ # The Report class that does all the data retrieval and calculations.
6
+ #
7
+ class Report
8
+
9
+ # the model the report works on (This is the class you invoke {Saulabs::Reportable::ClassMethods#reportable} on)
10
+ #
11
+ attr_reader :klass
12
+
13
+ # the name of the report (as in {Saulabs::Reportable::ClassMethods#reportable})
14
+ #
15
+ attr_reader :name
16
+
17
+ # the name of the date column over that the records are aggregated
18
+ #
19
+ attr_reader :date_column
20
+
21
+ # the name of the column that holds the values to aggregate when using a calculation aggregation like +:sum+
22
+ #
23
+ attr_reader :value_column
24
+
25
+ # the aggregation to use (one of +:count+, +:sum+, +:minimum+, +:maximum+ or +:average+); when using anything other than +:count+, +:value_column+ must also be specified
26
+ #
27
+ attr_reader :aggregation
28
+
29
+ # options for the report
30
+ #
31
+ attr_reader :options
32
+
33
+ # Initializes a new {Saulabs::Reportable::Report}
34
+ #
35
+ # @param [Class] klass
36
+ # the model the report works on (This is the class you invoke {Saulabs::Reportable::ClassMethods#reportable} on)
37
+ # @param [String] name
38
+ # the name of the report (as in {Saulabs::Reportable::ClassMethods#reportable})
39
+ # @param [Hash] options
40
+ # options for the report creation
41
+ #
42
+ # @option options [Symbol] :date_column (created_at)
43
+ # the name of the date column over that the records are aggregated
44
+ # @option options [String, Symbol] :value_column (:id)
45
+ # the name of the column that holds the values to aggregate when using a calculation aggregation like +:sum+
46
+ # @option options [Symbol] :aggregation (:count)
47
+ # the aggregation to use (one of +:count+, +:sum+, +:minimum+, +:maximum+ or +:average+); when using anything other than +:count+, +:value_column+ must also be specified
48
+ # @option options [Symbol] :grouping (:day)
49
+ # the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); <b>Beware that <tt>reportable</tt> treats weeks as starting on monday!</b>
50
+ # @option options [Fixnum] :limit (100)
51
+ # the number of reporting periods to get (see +:grouping+)
52
+ # @option options [Hash] :conditions ({})
53
+ # conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
54
+ # @option options [Boolean] :live_data (false)
55
+ # specifies whether data for the current reporting period is to be read; <b>if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone</b>
56
+ # @option options [DateTime, Boolean] :end_date (false)
57
+ # when specified, the report will only include data for the +:limit+ reporting periods until this date.
58
+ #
59
+ def initialize(klass, name, options = {})
60
+ ensure_valid_options(options)
61
+ @klass = klass
62
+ @name = name
63
+ @date_column = (options[:date_column] || 'created_at').to_s
64
+ @aggregation = options[:aggregation] || :count
65
+ @value_column = (options[:value_column] || (@aggregation == :count ? 'id' : name)).to_s
66
+ @options = {
67
+ :limit => options[:limit] || 100,
68
+ :conditions => options[:conditions] || [],
69
+ :grouping => Grouping.new(options[:grouping] || :day),
70
+ :live_data => options[:live_data] || false,
71
+ :end_date => options[:end_date] || false
72
+ }
73
+ @options.merge!(options)
74
+ @options.freeze
75
+ end
76
+
77
+ # Runs the report and returns an array of array of DateTimes and Floats
78
+ #
79
+ # @param [Hash] options
80
+ # options to run the report with
81
+ #
82
+ # @option options [Symbol] :grouping (:day)
83
+ # the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); <b>Beware that <tt>reportable</tt> treats weeks as starting on monday!</b>
84
+ # @option options [Fixnum] :limit (100)
85
+ # the number of reporting periods to get (see +:grouping+)
86
+ # @option options [Hash] :conditions ({})
87
+ # conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
88
+ # @option options [Boolean] :live_data (false)
89
+ # specifies whether data for the current reporting period is to be read; <b>if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone</b>
90
+ # @option options [DateTime, Boolean] :end_date (false)
91
+ # when specified, the report will only include data for the +:limit+ reporting periods until this date.
92
+ #
93
+ # @return [Array<Array<DateTime, Float>>]
94
+ # the result of the report as pairs of {DateTime}s and {Float}s
95
+ #
96
+ def run(options = {})
97
+ options = options_for_run(options)
98
+ ReportCache.process(self, options) do |begin_at, end_at|
99
+ read_data(begin_at, end_at, options)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def options_for_run(options = {})
106
+ options = options.dup
107
+ ensure_valid_options(options, :run)
108
+ options.reverse_merge!(@options)
109
+ options[:grouping] = Grouping.new(options[:grouping]) unless options[:grouping].is_a?(Grouping)
110
+ return options
111
+ end
112
+
113
+ def read_data(begin_at, end_at, options)
114
+ conditions = setup_conditions(begin_at, end_at, options[:conditions])
115
+ @klass.send(@aggregation,
116
+ @value_column,
117
+ :conditions => conditions,
118
+ :group => options[:grouping].to_sql(@date_column),
119
+ :order => "#{options[:grouping].to_sql(@date_column)} ASC",
120
+ :limit => options[:limit]
121
+ )
122
+ end
123
+
124
+ def setup_conditions(begin_at, end_at, custom_conditions = [])
125
+ conditions = [@klass.send(:sanitize_sql_for_conditions, custom_conditions) || '']
126
+ conditions[0] += "#{(conditions[0].blank? ? '' : ' AND ')}#{ActiveRecord::Base.connection.quote_table_name(@klass.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(@date_column.to_s)} "
127
+ conditions[0] += if begin_at && end_at
128
+ 'BETWEEN ? AND ?'
129
+ elsif begin_at
130
+ '>= ?'
131
+ elsif end_at
132
+ '<= ?'
133
+ else
134
+ raise ArgumentError.new('You must pass either begin_at, end_at or both to setup_conditions.')
135
+ end
136
+ conditions << begin_at if begin_at
137
+ conditions << end_at if end_at
138
+ conditions
139
+ end
140
+
141
+ def ensure_valid_options(options, context = :initialize)
142
+ case context
143
+ when :initialize
144
+ options.each_key do |k|
145
+ raise ArgumentError.new("Invalid option #{k}!") unless [:limit, :aggregation, :grouping, :date_column, :value_column, :conditions, :live_data, :end_date].include?(k)
146
+ end
147
+ raise ArgumentError.new("Invalid aggregation #{options[:aggregation]}!") if options[:aggregation] && ![:count, :sum, :maximum, :minimum, :average].include?(options[:aggregation])
148
+ raise ArgumentError.new('The name of the column holding the value to sum has to be specified for aggregation :sum!') if [:sum, :maximum, :minimum, :average].include?(options[:aggregation]) && !options.key?(:value_column)
149
+ when :run
150
+ options.each_key do |k|
151
+ raise ArgumentError.new("Invalid option #{k}!") unless [:limit, :conditions, :grouping, :live_data, :end_date].include?(k)
152
+ end
153
+ end
154
+ raise ArgumentError.new('Options :live_data and :end_date may not both be specified!') if options[:live_data] && options[:end_date]
155
+ raise ArgumentError.new("Invalid grouping #{options[:grouping]}!") if options[:grouping] && ![:hour, :day, :week, :month].include?(options[:grouping])
156
+ raise ArgumentError.new("Invalid conditions: #{options[:conditions].inspect}!") if options[:conditions] && !options[:conditions].is_a?(Array) && !options[:conditions].is_a?(Hash)
157
+ raise ArgumentError.new("Invalid end date: #{options[:end_date].inspect}; must be a DateTime!") if options[:end_date] && !options[:end_date].is_a?(DateTime) && !options[:end_date].is_a?(Time)
158
+ raise ArgumentError.new('End date may not be in the future!') if options[:end_date] && options[:end_date] > DateTime.now
159
+ end
160
+
161
+ end
162
+
163
+ end
164
+
165
+ end