mongoid_occurrences 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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.travis.yml +19 -0
  4. data/CHANGELOG.md +17 -0
  5. data/Gemfile +11 -0
  6. data/Guardfile +5 -0
  7. data/README.md +147 -0
  8. data/Rakefile +10 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/lib/mongoid_occurrences/aggregations/aggregation.rb +51 -0
  12. data/lib/mongoid_occurrences/aggregations/occurs_between.rb +33 -0
  13. data/lib/mongoid_occurrences/aggregations/occurs_from.rb +32 -0
  14. data/lib/mongoid_occurrences/aggregations/occurs_on.rb +32 -0
  15. data/lib/mongoid_occurrences/aggregations/occurs_until.rb +32 -0
  16. data/lib/mongoid_occurrences/daily_occurrence/has_scopes.rb +12 -0
  17. data/lib/mongoid_occurrences/daily_occurrence.rb +42 -0
  18. data/lib/mongoid_occurrences/has_fields_from_aggregation.rb +25 -0
  19. data/lib/mongoid_occurrences/has_occurrences.rb +51 -0
  20. data/lib/mongoid_occurrences/occurrence/has_daily_occurrences.rb +46 -0
  21. data/lib/mongoid_occurrences/occurrence/has_operators.rb +20 -0
  22. data/lib/mongoid_occurrences/occurrence/has_schedule.rb +54 -0
  23. data/lib/mongoid_occurrences/occurrence.rb +53 -0
  24. data/lib/mongoid_occurrences/queries/occurs_between.rb +34 -0
  25. data/lib/mongoid_occurrences/queries/occurs_from.rb +27 -0
  26. data/lib/mongoid_occurrences/queries/occurs_on.rb +29 -0
  27. data/lib/mongoid_occurrences/queries/occurs_until.rb +27 -0
  28. data/lib/mongoid_occurrences/queries/query.rb +27 -0
  29. data/lib/mongoid_occurrences/version.rb +3 -0
  30. data/lib/mongoid_occurrences.rb +19 -0
  31. data/mongoid_occurrences.gemspec +37 -0
  32. metadata +278 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3eb3d953d69a2671b169b5871a250971b33c948b5d0d9d6a9ada698eb7bfed7
4
+ data.tar.gz: 0317f7a065c2dabbed6a4febd03e5920850d3b554044d3e2cc04fdf406c69987
5
+ SHA512:
6
+ metadata.gz: 506b446a5afc98f7308d89b4e64fb756c3ce3264243a6620daebd5f1e92d2df85c3d9ae3de0255529d623ebe7340be7e99fbffbe197d635299545015daaec0a5
7
+ data.tar.gz: 61ee2fe086a87f5e7f57e4edf7f34032a9fa62e75f94fd642b78201ded701870aab0c4d38590f384ab6a0eecaed8befff2f0e6f84bbabd7f3cb81b5d4af5c0f4
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
data/.travis.yml ADDED
@@ -0,0 +1,19 @@
1
+ language: ruby
2
+ cache: bundler
3
+ script: 'bundle exec rake'
4
+ rvm:
5
+ - 2.5.1
6
+ services:
7
+ - mongodb
8
+
9
+ notifications:
10
+ email:
11
+ recipients:
12
+ - tomas.celizna@gmail.com
13
+ on_failure: change
14
+ on_success: never
15
+
16
+ matrix:
17
+ include:
18
+ - rvm: 2.5.1
19
+ env: MONGOID_VERSION=7
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # CHANGELOG
2
+
3
+ ## 1.0.0
4
+
5
+ * [PR#4](https://github.com/tomasc/mongoid_occurrences/pull/4) Refactor to replace views with aggregations
6
+
7
+ ## 0.2.0
8
+
9
+ * [PR#3](https://github.com/tomasc/mongoid_occurrences/pull/3) `all_day` becomes a field
10
+
11
+ ## 0.1.1
12
+
13
+ * [PR#2](https://github.com/tomasc/mongoid_occurrences/pull/2) allow :assign_daily_occurrences to be triggerred manually
14
+
15
+ ## 0.1.0
16
+
17
+ * initial release
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in mongoid_occurrences.gemspec
6
+ gemspec
7
+
8
+ case version = ENV['MONGOID_VERSION'] || '~> 7.0'
9
+ when /7/ then gem 'mongoid', '~> 7.0'
10
+ else gem 'mongoid', version
11
+ end
data/Guardfile ADDED
@@ -0,0 +1,5 @@
1
+ guard :minitest do
2
+ watch(%r{^lib/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" }
3
+ watch(%r{^test/.+_test\.rb$})
4
+ watch(%r{^test/test_helper\.rb$}) { 'test' }
5
+ end
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Mongoid Occurrences
2
+
3
+ [![Build Status](https://travis-ci.org/tomasc/mongoid_occurrences.svg)](https://travis-ci.org/tomasc/mongoid_occurrences) [![Gem Version](https://badge.fury.io/rb/mongoid_occurrences.svg)](http://badge.fury.io/rb/mongoid_occurrences) [![Coverage Status](https://img.shields.io/coveralls/tomasc/mongoid_occurrences.svg)](https://coveralls.io/r/tomasc/mongoid_occurrences)
4
+
5
+ Facilitates aggregations for events with multiple occurrences or a recurring schedule.
6
+
7
+ ## Requirements
8
+
9
+ * Mongoid 7+
10
+ * MongoDB 3.4+
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem 'mongoid_occurrences'
18
+ ```
19
+
20
+ And then execute:
21
+
22
+ $ bundle
23
+
24
+ Or install it yourself as:
25
+
26
+ $ gem install mongoid_occurrences
27
+
28
+ ## Usage
29
+
30
+ ### Event
31
+
32
+ Define a Mongoid document that will embed occurrence definitions.
33
+
34
+ ```ruby
35
+ class Event
36
+ include Mongoid::Document
37
+ include MongoidOccurrences::HasOccurrences
38
+
39
+ embeds_many_occurrences class_name: "Occurrence"
40
+ end
41
+ ```
42
+
43
+ This document will gain the `#assign_daily_occurrences!` method (automatically triggered `:after_validation`), which expands all occurrence definitions in the embedded relation called `#daily_occurrences`.
44
+
45
+ The class will also gain the following scopes, useful for querying for events based on the `#daily_occurrences`:
46
+
47
+ * `.occurs_between(dtstart, dtend)`
48
+ * `.occurs_from(dtstart)`
49
+ * `.occurs_on(day)`
50
+ * `.occurs_until(dtend)`
51
+
52
+ ### Occurrence
53
+
54
+ Define a Mongoid document that defines the occurrence.
55
+
56
+ ```ruby
57
+ class Occurrence
58
+ include Mongoid::Document
59
+ include MongoidOccurrences::Occurrence
60
+
61
+ embedded_in_event class_name: 'Event'
62
+ end
63
+ ```
64
+
65
+ This document will gain the `#dtstart`, `#dtend` and `#all_day` fields to define individual occurrences.
66
+
67
+ Recurring schedule is handled via the [IceCube](https://github.com/seejohnrun/ice_cube) and [MongoidIceCubeExtension](https://github.com/tomasc/mongoid_ice_cube_extension) gems. The model gains `#schedule`, and `#schedule_dtstart`, `#schedule_dtend` fields (with default values for schedule 1 year from now), along with `#recurrence_rule=` writer method.
68
+
69
+ It is possible to influence the way the occurrences are expanded into `#daily_occurrences` using the `#operator` enum field, which accepts the following values:
70
+
71
+ * `:append` – appends to the list of `#daily_occurrences` (default)
72
+ * `:replace` – replaces all occurrences that happen on the same day with itself
73
+ * `:remove` - removes all occurrences that happen on the same day
74
+
75
+ ### Indexes
76
+
77
+ To optimize the performance of the above scope queries, you might want to add the following indexes:
78
+
79
+ ```ruby
80
+ index :'daily_occurrences.ds' => 1
81
+ index :'daily_occurrences.de' => 1
82
+ ```
83
+
84
+ ### Aggregations
85
+
86
+ It is possible to aggregate (unwind) the events so that they are multiplied per daily occurrences. Example aggregations are included:
87
+
88
+ * `MongoidOccurrences::Aggregations::OccursBetween.instantiate(Event.criteria, dtstart, dtend)`
89
+ * `MongoidOccurrences::Aggregations::OccursFrom.instantiate(Event.criteria, dtstart)`
90
+ * `MongoidOccurrences::Aggregations::OccursOn.instantiate(Event.criteria, day)`
91
+ * `MongoidOccurrences::Aggregations::OccursUntil.instantiate(Event.criteria, dtend)`
92
+
93
+ The aggregations will add `#_dtstart`, `#_dtend` fields to the unwound documents. For easier access, mixin the `MongoidOccurrences::HasFieldsFromAggregation` to your `Event` class:
94
+
95
+ ```ruby
96
+ class Event
97
+ include Mongoid::Document
98
+ include MongoidOccurrences::HasFieldsFromAggregation
99
+ include MongoidOccurrences::HasOccurrences
100
+
101
+ embeds_many_occurrences class_name: "Occurrence"
102
+ end
103
+ ```
104
+
105
+ This will automatically add `#dtstart` and `#dtend` fields with correct (demongoized) values, as well as the `#all_day?` method.
106
+
107
+ ### Embedded events
108
+
109
+ If your events are itself embedded:
110
+
111
+ ```ruby
112
+ class EventParent
113
+ embeds_many :events, class_name: 'Event'
114
+ end
115
+ ```
116
+
117
+ Simply add the following scopes on the parent document:
118
+
119
+ ```ruby
120
+ class EventParent
121
+ scope :occurs_between, ->(dtstart, dtend) { elem_match(events: Event.occurs_between(dtstart, dtend).selector) }
122
+ scope :occurs_from, ->(date_time) { elem_match(events: Event.occurs_from(date_time).selector) }
123
+ scope :occurs_on, ->(date_time) { elem_match(events: Event.occurs_on(date_time).selector) }
124
+ scope :occurs_until, ->(date_time) { elem_match(events: Event.occurs_until(date_time).selector) }
125
+ end
126
+ ```
127
+
128
+ You will then need to write your own aggregations with an extra step to `$unwind` the embedded `#events`, and your indexes will need to be adjusted as follows:
129
+
130
+ ```ruby
131
+ index :'events.daily_occurrences.ds' => 1
132
+ index :'events.daily_occurrences.de' => 1
133
+ ```
134
+
135
+ ## Development
136
+
137
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
138
+
139
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
140
+
141
+ ## Contributing
142
+
143
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tomasc/mongoid_occurrences.
144
+
145
+ ## License
146
+
147
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "mongoid_occurrences"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,51 @@
1
+ module MongoidOccurrences
2
+ module Aggregations
3
+ class Aggregation
4
+ def self.option(name, default_value=nil)
5
+ define_method(name) do
6
+ HashWithIndifferentAccess[options].fetch(name, default_value)
7
+ end
8
+ end
9
+
10
+ def self.instantiate(*args)
11
+ new(*args).instantiate
12
+ end
13
+
14
+ option :allow_disk_use, true
15
+ option :sort_key, :_dtstart
16
+ option :sort_order, :asc
17
+
18
+ def initialize(base_criteria, options = {})
19
+ @base_criteria = base_criteria
20
+ @options = options
21
+ end
22
+
23
+ def aggregation
24
+ base_criteria.klass
25
+ .collection
26
+ .aggregate(
27
+ (selectors + pipeline),
28
+ allow_disk_use: allow_disk_use
29
+ )
30
+ end
31
+
32
+ def instantiate
33
+ aggregation.map do |doc|
34
+ base_criteria.klass.instantiate(doc)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def selectors
41
+ [
42
+ { '$match' => { '$and' => [criteria.selector] } },
43
+ { '$sort' => criteria.options[:sort] },
44
+ { '$limit' => criteria.options[:limit] }
45
+ ].map { |i| i.delete_if { |_, v| v.blank? } }.reject(&:blank?)
46
+ end
47
+
48
+ attr_reader :base_criteria, :options
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ require 'mongoid_occurrences/aggregations/aggregation'
2
+
3
+ module MongoidOccurrences
4
+ module Aggregations
5
+ class OccursBetween < Aggregation
6
+ def initialize(base_criteria, dtstart, dtend, options = {})
7
+ @base_criteria = base_criteria
8
+ @dtstart = dtstart
9
+ @dtend = dtend
10
+ @options = options
11
+ end
12
+
13
+ private
14
+
15
+ def criteria
16
+ base_criteria.occurs_between(dtstart, dtend)
17
+ end
18
+
19
+ def pipeline
20
+ [
21
+ { '$addFields' => { '_daily_occurrences' => '$daily_occurrences' } },
22
+ { '$unwind' => { 'path' => '$_daily_occurrences' } },
23
+ { '$addFields' => { '_dtstart' => '$_daily_occurrences.ds', '_dtend' => '$_daily_occurrences.de' } },
24
+ { '$project' => { '_daily_occurrences' => 0 } },
25
+ { '$match' => Queries::OccursBetween.criteria(base_criteria, dtstart, dtend, dtstart_field: '_dtstart', dtend_field: '_dtend').selector },
26
+ { '$sort' => { sort_key => { asc: 1, desc: -1 }[sort_order] } }
27
+ ]
28
+ end
29
+
30
+ attr_reader :dtstart, :dtend, :options
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ require 'mongoid_occurrences/aggregations/aggregation'
2
+
3
+ module MongoidOccurrences
4
+ module Aggregations
5
+ class OccursFrom < Aggregation
6
+ def initialize(base_criteria, date_time, options = {})
7
+ @base_criteria = base_criteria
8
+ @date_time = date_time
9
+ @options = options
10
+ end
11
+
12
+ private
13
+
14
+ def criteria
15
+ base_criteria.occurs_from(date_time)
16
+ end
17
+
18
+ def pipeline
19
+ [
20
+ { '$addFields' => { '_daily_occurrences' => '$daily_occurrences' } },
21
+ { '$unwind' => { 'path' => '$_daily_occurrences' } },
22
+ { '$addFields' => { '_dtstart' => '$_daily_occurrences.ds', '_dtend' => '$_daily_occurrences.de' } },
23
+ { '$project' => { '_daily_occurrences' => 0 } },
24
+ { '$match' => Queries::OccursFrom.criteria(base_criteria, date_time, dtstart_field: '_dtstart').selector },
25
+ { '$sort' => { sort_key => { asc: 1, desc: -1 }[sort_order] } }
26
+ ]
27
+ end
28
+
29
+ attr_reader :date_time, :options
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ require 'mongoid_occurrences/aggregations/aggregation'
2
+
3
+ module MongoidOccurrences
4
+ module Aggregations
5
+ class OccursOn < Aggregation
6
+ def initialize(base_criteria, date_time, options = {})
7
+ @base_criteria = base_criteria
8
+ @date_time = date_time
9
+ @options = options
10
+ end
11
+
12
+ private
13
+
14
+ def criteria
15
+ base_criteria.occurs_on(date_time)
16
+ end
17
+
18
+ def pipeline
19
+ [
20
+ { '$addFields' => { '_daily_occurrences' => '$daily_occurrences' } },
21
+ { '$unwind' => { 'path' => '$_daily_occurrences' } },
22
+ { '$addFields' => { '_dtstart' => '$_daily_occurrences.ds', '_dtend' => '$_daily_occurrences.de' } },
23
+ { '$project' => { '_daily_occurrences' => 0 } },
24
+ { '$match' => Queries::OccursOn.criteria(base_criteria, date_time, dtstart_field: '_dtstart', dtend_field: '_dtend').selector },
25
+ { '$sort' => { sort_key => { asc: 1, desc: -1 }[sort_order] } }
26
+ ]
27
+ end
28
+
29
+ attr_reader :date_time, :options
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ require 'mongoid_occurrences/aggregations/aggregation'
2
+
3
+ module MongoidOccurrences
4
+ module Aggregations
5
+ class OccursUntil < Aggregation
6
+ def initialize(base_criteria, date_time, options = {})
7
+ @base_criteria = base_criteria
8
+ @date_time = date_time
9
+ @options = options
10
+ end
11
+
12
+ private
13
+
14
+ def criteria
15
+ base_criteria.occurs_until(date_time)
16
+ end
17
+
18
+ def pipeline
19
+ [
20
+ { '$addFields' => { '_daily_occurrences' => '$daily_occurrences' } },
21
+ { '$unwind' => { 'path' => '$_daily_occurrences' } },
22
+ { '$addFields' => { '_dtstart' => '$_daily_occurrences.ds', '_dtend' => '$_daily_occurrences.de' } },
23
+ { '$project' => { '_daily_occurrences' => 0 } },
24
+ { '$match' => Queries::OccursUntil.criteria(base_criteria, date_time, dtend_field: '_dtend').selector },
25
+ { '$sort' => { sort_key => { asc: 1, desc: -1 }[sort_order] } }
26
+ ]
27
+ end
28
+
29
+ attr_reader :date_time, :options
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ module MongoidOccurrences
2
+ class DailyOccurrence
3
+ module HasScopes
4
+ def self.included(base)
5
+ base.scope :occurs_between, ->(dtstart, dtend) { Queries::OccursBetween.criteria(criteria, dtstart, dtend) }
6
+ base.scope :occurs_from, ->(dtstart) { Queries::OccursFrom.criteria(criteria, dtstart) }
7
+ base.scope :occurs_on, ->(day) { Queries::OccursOn.criteria(criteria, day) }
8
+ base.scope :occurs_until, ->(dtend) { Queries::OccursUntil.criteria(criteria, dtend) }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,42 @@
1
+ require 'mongoid_occurrences/daily_occurrence/has_scopes'
2
+
3
+ module MongoidOccurrences
4
+ class DailyOccurrence
5
+ include Mongoid::Document
6
+ include HasScopes
7
+
8
+ attr_accessor :operator
9
+
10
+ field :ds, as: :dtstart, type: DateTime
11
+ field :de, as: :dtend, type: DateTime
12
+
13
+ validates :dtstart, presence: true
14
+ validates :dtend, presence: true
15
+
16
+ def operator
17
+ @operator ||= :append
18
+ end
19
+
20
+ def all_day
21
+ dtstart.to_i == dtstart.beginning_of_day.to_i &&
22
+ dtend.to_i == dtend.end_of_day.to_i
23
+ end
24
+ alias all_day? all_day
25
+
26
+ def <=>(other)
27
+ sort_key <=> other.sort_key
28
+ end
29
+
30
+ def sort_key
31
+ [dtstart, dtend]
32
+ end
33
+
34
+ def to_range
35
+ (dtstart..dtend)
36
+ end
37
+
38
+ def overlaps?(occurrence)
39
+ to_range.overlaps?(occurrence.to_range)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ module MongoidOccurrences
2
+ module HasFieldsFromAggregation
3
+ def dtstart
4
+ @dtstart ||= DateTime.demongoize(
5
+ self['_dtstart'] ||
6
+ daily_occurrences.unscoped.order(dtstart: :asc).limit(1).pluck(:dtstart).first
7
+ )
8
+ end
9
+
10
+ def dtend
11
+ @dtend ||= DateTime.demongoize(
12
+ self['_dtend'] ||
13
+ daily_occurrences.unscoped.order(dtend: :desc).limit(1).pluck(:dtend).first
14
+ )
15
+ end
16
+
17
+ def all_day
18
+ return unless dtstart && dtend
19
+
20
+ dtstart.to_i == dtstart.beginning_of_day.to_i &&
21
+ dtend.to_i == dtend.end_of_day.to_i
22
+ end
23
+ alias all_day? all_day
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ module MongoidOccurrences
2
+ module HasOccurrences
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ def embeds_many_occurrences(options = {})
9
+ field :_previous_occurences_cache_key, type: String
10
+
11
+ embeds_many :occurrences, class_name: options.fetch(:class_name)
12
+ accepts_nested_attributes_for :occurrences, allow_destroy: true, reject_if: :all_blank
13
+
14
+ embeds_many :daily_occurrences, class_name: 'MongoidOccurrences::DailyOccurrence', order: :dtstart.asc
15
+
16
+ after_validation :assign_occurrences_cache_key!
17
+ after_validation :assign_daily_occurrences!, if: :_previous_occurences_cache_key_changed?
18
+
19
+ scope :occurs_between, ->(dtstart, dtend) { elem_match(daily_occurrences: DailyOccurrence.occurs_between(dtstart, dtend).selector) }
20
+ scope :occurs_from, ->(dtstart) { elem_match(daily_occurrences: DailyOccurrence.occurs_from(dtstart).selector) }
21
+ scope :occurs_on, ->(day) { elem_match(daily_occurrences: DailyOccurrence.occurs_on(day).selector) }
22
+ scope :occurs_until, ->(dtend) { elem_match(daily_occurrences: DailyOccurrence.occurs_until(dtend).selector) }
23
+ end
24
+ end
25
+
26
+ def occurences_cache_key
27
+ last_timestamp = occurrences.unscoped.order(updated_at: :desc).limit(1).pluck(:updated_at).first
28
+ "#{occurrences.unscoped.size}-#{last_timestamp.to_i}"
29
+ end
30
+
31
+ def assign_daily_occurrences!
32
+ self.daily_occurrences = begin
33
+ res = occurrences.with_operators(:append).flat_map(&:daily_occurrences)
34
+
35
+ occurrences.with_operators(%i[remove replace]).flat_map(&:daily_occurrences).each do |occurrence|
36
+ res = res.reject { |res_occurrence| res_occurrence.overlaps?(occurrence) }
37
+ end
38
+
39
+ res += occurrences.with_operators(%i[replace]).flat_map(&:daily_occurrences)
40
+
41
+ res.sort
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def assign_occurrences_cache_key!
48
+ self._previous_occurences_cache_key = occurences_cache_key
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ module MongoidOccurrences
2
+ module Occurrence
3
+ module HasDailyOccurrences
4
+ def daily_occurrences
5
+ adjust_dates_for_all_day!
6
+
7
+ daily_occurrences_from_schedule +
8
+ daily_occurrences_from_date_range
9
+ end
10
+
11
+ private
12
+
13
+ def daily_occurrences_from_schedule
14
+ return [] unless dtstart? && dtend?
15
+ return [] unless recurring?
16
+
17
+ schedule.occurrences(schedule_dtend).map do |occurrence|
18
+ MongoidOccurrences::DailyOccurrence.new(
19
+ dtstart: occurrence.start_time.change(hour: dtstart.hour, min: dtstart.minute),
20
+ dtend: occurrence.end_time.change(hour: dtend.hour, min: dtend.minute),
21
+ operator: operator
22
+ )
23
+ end
24
+ end
25
+
26
+ def daily_occurrences_from_date_range
27
+ return [] unless dtstart? && dtend?
28
+ return [] if recurring?
29
+
30
+ date_range = Range.new(dtstart.to_date, dtend.to_date)
31
+ is_single_day = (date_range.first == date_range.last)
32
+
33
+ date_range.map do |date|
34
+ occurence_dtstart = is_single_day || date == date_range.first ? dtstart : date.beginning_of_day
35
+ occurence_dtend = is_single_day || date == date_range.last ? dtend : date.end_of_day
36
+
37
+ MongoidOccurrences::DailyOccurrence.new(
38
+ dtstart: occurence_dtstart,
39
+ dtend: occurence_dtend,
40
+ operator: operator
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ require 'mongoid/enum_attribute'
2
+
3
+ module MongoidOccurrences
4
+ module Occurrence
5
+ module HasOperators
6
+ def self.prepended(base)
7
+ base.scope :with_operators, ->(operators) { criteria.in(_operator: Array(operators)) }
8
+ end
9
+
10
+ module ClassMethods
11
+ def embedded_in_event(options = {})
12
+ super(options)
13
+
14
+ include Mongoid::EnumAttribute
15
+ enum :operator, %i[append replace remove], default: :append
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ module MongoidOccurrences
2
+ module Occurrence
3
+ module HasSchedule
4
+ SCHEDULE_DURATION = 1.year
5
+
6
+ module ClassMethods
7
+ def embedded_in_event(options = {})
8
+ super(options)
9
+
10
+ field :schedule, type: MongoidIceCubeExtension::Schedule
11
+ field :schedule_dtstart, type: Time
12
+ field :schedule_dtend, type: Time
13
+
14
+ before_validation :nil_schedule, unless: :recurring?
15
+ end
16
+ end
17
+
18
+ def schedule_dtstart
19
+ read_attribute(:schedule_dtstart) ||
20
+ (dtstart.try(:to_time) || Time.now)
21
+ end
22
+
23
+ def schedule_dtend
24
+ read_attribute(:schedule_dtend) ||
25
+ (schedule_dtstart + SCHEDULE_DURATION)
26
+ end
27
+
28
+ def recurrence_rule
29
+ schedule&.recurrence_rules&.first
30
+ end
31
+
32
+ def recurrence_rule=(value)
33
+ case value
34
+ when NilClass, 'null'
35
+ @recurrence_rule = nil
36
+ self.schedule = nil
37
+ else
38
+ @recurrence_rule = IceCube::Rule.from_hash(JSON.parse(value))
39
+ self.schedule = IceCube::Schedule.new(schedule_dtstart) { |s| s.add_recurrence_rule(@recurrence_rule) }
40
+ end
41
+ end
42
+
43
+ def recurring?
44
+ schedule.present?
45
+ end
46
+
47
+ private
48
+
49
+ def nil_schedule
50
+ self.schedule = nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,53 @@
1
+ require 'mongoid_occurrences/occurrence/has_daily_occurrences'
2
+ require 'mongoid_occurrences/occurrence/has_operators'
3
+ require 'mongoid_occurrences/occurrence/has_schedule'
4
+
5
+ module MongoidOccurrences
6
+ module Occurrence
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+
10
+ base.include Mongoid::Timestamps::Updated
11
+
12
+ base.include HasDailyOccurrences
13
+
14
+ base.prepend HasOperators
15
+ base.singleton_class.prepend HasOperators::ClassMethods
16
+
17
+ base.prepend HasSchedule
18
+ base.singleton_class.prepend HasSchedule::ClassMethods
19
+ end
20
+
21
+ module ClassMethods
22
+ def embedded_in_event(options = {})
23
+ field :dtstart, type: DateTime
24
+ field :dtend, type: DateTime
25
+ field :all_day, type: Boolean
26
+
27
+ embedded_in :event, class_name: options.fetch(:class_name, nil), inverse_of: :occurrences
28
+
29
+ after_validation :adjust_dates_for_all_day!, if: :changed?
30
+
31
+ validates :dtstart, presence: true
32
+ validates :dtend, presence: true
33
+ end
34
+ end
35
+
36
+ def all_day
37
+ return super unless dtstart.present? && dtend.present?
38
+ return super unless super.nil?
39
+
40
+ dtstart.to_i == dtstart.beginning_of_day.to_i &&
41
+ dtend.to_i == dtend.end_of_day.to_i
42
+ end
43
+ alias all_day? all_day
44
+
45
+ def adjust_dates_for_all_day!
46
+ return unless all_day?
47
+ return unless dtstart? && dtend?
48
+
49
+ self.dtstart = dtstart.beginning_of_day
50
+ self.dtend = dtend.end_of_day
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ require 'mongoid_occurrences/queries/query'
2
+
3
+ module MongoidOccurrences
4
+ module Queries
5
+ class OccursBetween < Query
6
+ option :dtstart_field, :dtstart
7
+ option :dtend_field, :dtend
8
+
9
+ def initialize(base_criteria, dtstart, dtend, options = {})
10
+ @base_criteria = base_criteria
11
+ @dtstart = dtstart
12
+ @dtend = dtend
13
+ @options = options
14
+ end
15
+
16
+ def criteria
17
+ base_criteria.lte(dtstart_field => adjusted_dtend)
18
+ .gte(dtend_field => adjusted_dtstart)
19
+ end
20
+
21
+ private
22
+
23
+ def adjusted_dtstart
24
+ dtstart.instance_of?(Date) ? dtstart.beginning_of_day : dtstart
25
+ end
26
+
27
+ def adjusted_dtend
28
+ dtend.instance_of?(Date) ? dtend.beginning_of_day : dtend
29
+ end
30
+
31
+ attr_reader :dtstart, :dtend, :options
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ require 'mongoid_occurrences/queries/query'
2
+
3
+ module MongoidOccurrences
4
+ module Queries
5
+ class OccursFrom < Query
6
+ option :dtstart_field, :dtstart
7
+
8
+ def initialize(base_criteria, date_time, options = {})
9
+ @base_criteria = base_criteria
10
+ @date_time = date_time
11
+ @options = options
12
+ end
13
+
14
+ def criteria
15
+ base_criteria.gte(dtstart_field => adjusted_date_time)
16
+ end
17
+
18
+ private
19
+
20
+ def adjusted_date_time
21
+ date_time.instance_of?(Date) ? date_time.beginning_of_day : date_time
22
+ end
23
+
24
+ attr_reader :date_time, :options
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ require 'mongoid_occurrences/queries/query'
2
+
3
+ module MongoidOccurrences
4
+ module Queries
5
+ class OccursOn < Query
6
+ def initialize(base_criteria, date_time, options = {})
7
+ @base_criteria = base_criteria
8
+ @date_time = date_time
9
+ @options = options
10
+ end
11
+
12
+ def criteria
13
+ OccursBetween.criteria(base_criteria, adjusted_dtstart, adjusted_dtend, options)
14
+ end
15
+
16
+ private
17
+
18
+ def adjusted_dtstart
19
+ date_time.instance_of?(Date) ? date_time.beginning_of_day : date_time
20
+ end
21
+
22
+ def adjusted_dtend
23
+ date_time.instance_of?(Date) ? date_time.end_of_day : date_time
24
+ end
25
+
26
+ attr_reader :date_time, :options
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ require 'mongoid_occurrences/queries/query'
2
+
3
+ module MongoidOccurrences
4
+ module Queries
5
+ class OccursUntil < Query
6
+ option :dtend_field, :dtend
7
+
8
+ def initialize(base_criteria, date_time, options = {})
9
+ @base_criteria = base_criteria
10
+ @date_time = date_time
11
+ @options = options
12
+ end
13
+
14
+ def criteria
15
+ base_criteria.lte(dtend_field => adjusted_date_time)
16
+ end
17
+
18
+ private
19
+
20
+ def adjusted_date_time
21
+ date_time.instance_of?(Date) ? date_time.end_of_day : date_time
22
+ end
23
+
24
+ attr_reader :date_time, :options
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ module MongoidOccurrences
2
+ module Queries
3
+ class Query
4
+ def initialize(base_criteria)
5
+ @base_criteria = base_criteria
6
+ end
7
+
8
+ def self.criteria(*args)
9
+ new(*args).criteria
10
+ end
11
+
12
+ def self.option(name, default_value=nil)
13
+ define_method(name) do
14
+ HashWithIndifferentAccess[options].fetch(name, default_value)
15
+ end
16
+ end
17
+
18
+ def criteria
19
+ raise NotImplementedError
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :base_criteria
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,3 @@
1
+ module MongoidOccurrences
2
+ VERSION = '1.0.0'.freeze
3
+ end
@@ -0,0 +1,19 @@
1
+ require 'mongoid_occurrences/version'
2
+
3
+ require 'mongoid'
4
+ require 'mongoid_ice_cube_extension'
5
+
6
+ require 'mongoid_occurrences/daily_occurrence'
7
+ require 'mongoid_occurrences/has_fields_from_aggregation'
8
+ require 'mongoid_occurrences/has_occurrences'
9
+ require 'mongoid_occurrences/occurrence'
10
+
11
+ require 'mongoid_occurrences/aggregations/occurs_between'
12
+ require 'mongoid_occurrences/aggregations/occurs_from'
13
+ require 'mongoid_occurrences/aggregations/occurs_on'
14
+ require 'mongoid_occurrences/aggregations/occurs_until'
15
+
16
+ require 'mongoid_occurrences/queries/occurs_between'
17
+ require 'mongoid_occurrences/queries/occurs_from'
18
+ require 'mongoid_occurrences/queries/occurs_on'
19
+ require 'mongoid_occurrences/queries/occurs_until'
@@ -0,0 +1,37 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'mongoid_occurrences/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'mongoid_occurrences'
7
+ spec.version = MongoidOccurrences::VERSION
8
+ spec.authors = ['Tomas Celizna', 'Asger Behncke Jacobsen']
9
+ spec.email = ['tomas.celizna@gmail.com', 'a@asgerbehnckejacobsen.dk']
10
+
11
+ spec.summary = 'Facilitates aggregations for events with multiple occurrences or a recurring schedule.'
12
+ spec.homepage = 'https://github.com/tomasc/mongoid_occurrences'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = 'exe'
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'ice_cube'
23
+ spec.add_dependency 'mongoid', '>= 7.0.2', '< 8'
24
+ spec.add_dependency 'mongoid-enum_attribute'
25
+ spec.add_dependency 'mongoid_ice_cube_extension', '>= 0.1.1'
26
+
27
+ spec.add_development_dependency 'activesupport', '> 3.0'
28
+ spec.add_development_dependency 'bundler'
29
+ spec.add_development_dependency 'coveralls'
30
+ spec.add_development_dependency 'database_cleaner'
31
+ spec.add_development_dependency 'factory_bot', '~> 4.0'
32
+ spec.add_development_dependency 'guard'
33
+ spec.add_development_dependency 'guard-minitest'
34
+ spec.add_development_dependency 'minitest', '~> 5.0'
35
+ spec.add_development_dependency 'minitest-implicit-subject'
36
+ spec.add_development_dependency 'rake', '~> 10.0'
37
+ end
metadata ADDED
@@ -0,0 +1,278 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mongoid_occurrences
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomas Celizna
8
+ - Asger Behncke Jacobsen
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2019-01-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: ice_cube
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: mongoid
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: 7.0.2
35
+ - - "<"
36
+ - !ruby/object:Gem::Version
37
+ version: '8'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 7.0.2
45
+ - - "<"
46
+ - !ruby/object:Gem::Version
47
+ version: '8'
48
+ - !ruby/object:Gem::Dependency
49
+ name: mongoid-enum_attribute
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: mongoid_ice_cube_extension
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 0.1.1
69
+ type: :runtime
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 0.1.1
76
+ - !ruby/object:Gem::Dependency
77
+ name: activesupport
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ - !ruby/object:Gem::Dependency
91
+ name: bundler
92
+ requirement: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ type: :development
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ - !ruby/object:Gem::Dependency
105
+ name: coveralls
106
+ requirement: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ type: :development
112
+ prerelease: false
113
+ version_requirements: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ - !ruby/object:Gem::Dependency
119
+ name: database_cleaner
120
+ requirement: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ type: :development
126
+ prerelease: false
127
+ version_requirements: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ - !ruby/object:Gem::Dependency
133
+ name: factory_bot
134
+ requirement: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '4.0'
139
+ type: :development
140
+ prerelease: false
141
+ version_requirements: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '4.0'
146
+ - !ruby/object:Gem::Dependency
147
+ name: guard
148
+ requirement: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ type: :development
154
+ prerelease: false
155
+ version_requirements: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ - !ruby/object:Gem::Dependency
161
+ name: guard-minitest
162
+ requirement: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ type: :development
168
+ prerelease: false
169
+ version_requirements: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ - !ruby/object:Gem::Dependency
175
+ name: minitest
176
+ requirement: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '5.0'
181
+ type: :development
182
+ prerelease: false
183
+ version_requirements: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '5.0'
188
+ - !ruby/object:Gem::Dependency
189
+ name: minitest-implicit-subject
190
+ requirement: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ type: :development
196
+ prerelease: false
197
+ version_requirements: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ - !ruby/object:Gem::Dependency
203
+ name: rake
204
+ requirement: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '10.0'
209
+ type: :development
210
+ prerelease: false
211
+ version_requirements: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - "~>"
214
+ - !ruby/object:Gem::Version
215
+ version: '10.0'
216
+ description:
217
+ email:
218
+ - tomas.celizna@gmail.com
219
+ - a@asgerbehnckejacobsen.dk
220
+ executables: []
221
+ extensions: []
222
+ extra_rdoc_files: []
223
+ files:
224
+ - ".gitignore"
225
+ - ".travis.yml"
226
+ - CHANGELOG.md
227
+ - Gemfile
228
+ - Guardfile
229
+ - README.md
230
+ - Rakefile
231
+ - bin/console
232
+ - bin/setup
233
+ - lib/mongoid_occurrences.rb
234
+ - lib/mongoid_occurrences/aggregations/aggregation.rb
235
+ - lib/mongoid_occurrences/aggregations/occurs_between.rb
236
+ - lib/mongoid_occurrences/aggregations/occurs_from.rb
237
+ - lib/mongoid_occurrences/aggregations/occurs_on.rb
238
+ - lib/mongoid_occurrences/aggregations/occurs_until.rb
239
+ - lib/mongoid_occurrences/daily_occurrence.rb
240
+ - lib/mongoid_occurrences/daily_occurrence/has_scopes.rb
241
+ - lib/mongoid_occurrences/has_fields_from_aggregation.rb
242
+ - lib/mongoid_occurrences/has_occurrences.rb
243
+ - lib/mongoid_occurrences/occurrence.rb
244
+ - lib/mongoid_occurrences/occurrence/has_daily_occurrences.rb
245
+ - lib/mongoid_occurrences/occurrence/has_operators.rb
246
+ - lib/mongoid_occurrences/occurrence/has_schedule.rb
247
+ - lib/mongoid_occurrences/queries/occurs_between.rb
248
+ - lib/mongoid_occurrences/queries/occurs_from.rb
249
+ - lib/mongoid_occurrences/queries/occurs_on.rb
250
+ - lib/mongoid_occurrences/queries/occurs_until.rb
251
+ - lib/mongoid_occurrences/queries/query.rb
252
+ - lib/mongoid_occurrences/version.rb
253
+ - mongoid_occurrences.gemspec
254
+ homepage: https://github.com/tomasc/mongoid_occurrences
255
+ licenses:
256
+ - MIT
257
+ metadata: {}
258
+ post_install_message:
259
+ rdoc_options: []
260
+ require_paths:
261
+ - lib
262
+ required_ruby_version: !ruby/object:Gem::Requirement
263
+ requirements:
264
+ - - ">="
265
+ - !ruby/object:Gem::Version
266
+ version: '0'
267
+ required_rubygems_version: !ruby/object:Gem::Requirement
268
+ requirements:
269
+ - - ">="
270
+ - !ruby/object:Gem::Version
271
+ version: '0'
272
+ requirements: []
273
+ rubygems_version: 3.0.1
274
+ signing_key:
275
+ specification_version: 4
276
+ summary: Facilitates aggregations for events with multiple occurrences or a recurring
277
+ schedule.
278
+ test_files: []