From e43a17216c953d93124cd3c80f28e872c93ac12a Mon Sep 17 00:00:00 2001 From: James Murty Date: Tue, 18 Oct 2016 22:49:21 +1100 Subject: [PATCH] Improve generation and handling of repeating all-day events - make RRULE UNTIL constraint for recurrences the last microsecond of the repeat end date, so the repeat end date is included in the generated occurrence set - ensure all-day events span a full day (less one microsecond) based on their start & end times - improve `time_range_string` description for all-day events spanning just the single day - add unit test for generating repeat occurrences for every day in Oct - minor bug and formatting fixes. --- icekit_events/models.py | 39 +++++++++++++++++++++--------- icekit_events/tests/tests.py | 47 ++++++++++++++++++++++++------------ 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/icekit_events/models.py b/icekit_events/models.py index 7ba540d..4041cd5 100644 --- a/icekit_events/models.py +++ b/icekit_events/models.py @@ -49,9 +49,8 @@ def zero_datetime(dt, tz=None): if dt is None: return None if tz is None: - tz = pytz.utc - return dt.replace( - hour=0, minute=0, second=0, microsecond=0, tzinfo=pytz.utc) + tz = get_current_timezone() + return coerce_naive(dt).replace(hour=0, minute=0, second=0, microsecond=0) def default_starts(): @@ -453,6 +452,7 @@ def start_times_set(self): def get_absolute_url(self): return reverse('icekit_events_eventbase_detail', args=(self.slug,)) + class GeneratorException(Exception): pass @@ -489,10 +489,12 @@ class EventRepeatsGenerator(AbstractBaseModel): 'event repeats.'), null=True, ) - start = models.DateTimeField('first start', + start = models.DateTimeField( + 'first start', default=default_starts, db_index=True) - end = models.DateTimeField('first end', + end = models.DateTimeField( + 'first end', default=default_ends, db_index=True) is_all_day = models.BooleanField( @@ -567,9 +569,17 @@ def _build_complete_rrule(self, start_dt=None, until=None): rrule_spec += "\nRDATE:%s" % format_naive_ical_dt(start_dt) else: rrule_spec += "\nRRULE:%s" % self.recurrence_rule - # Apply this event's end repeat - rrule_spec += ";UNTIL=%s" % format_naive_ical_dt( - until - timedelta(seconds=1)) + # Apply this event's end repeat date as an *exclusive* UNTIL + # constraint. UNTIL in RRULE specs is inclusive by default, so we + # fake exclusivity by adjusting the end time by a microsecond. + if self.is_all_day: + # For all-day generator, make the UNTIL constraint the last + # microsecond of the repeat end date to ensure the end date is + # included in the generated set as users expect. + until += timedelta(days=1, microseconds=-1) + else: + until -= timedelta(microseconds=1) + rrule_spec += ";UNTIL=%s" % format_naive_ical_dt(until) return rrule_spec def save(self, *args, **kwargs): @@ -615,7 +625,8 @@ def save(self, *args, **kwargs): # UTC timezone when we save an all-day occurrence if self.is_all_day: self.start = zero_datetime(self.start) - self.end = zero_datetime(self.end) + self.end = zero_datetime(self.end) \ + + timedelta(days=1, microseconds=-1) super(EventRepeatsGenerator, self).save(*args, **kwargs) @@ -738,9 +749,13 @@ class Meta: def time_range_string(self): if self.is_all_day: - return u"""{0} - {1}, all day""".format( - datefilter(self.local_start, DATE_FORMAT), - datefilter(self.local_end, DATE_FORMAT)) + if self.duration <= timedelta(days=1): + return u"""{0}, all day""".format( + datefilter(self.local_start, DATE_FORMAT)) + else: + return u"""{0} - {1}, all day""".format( + datefilter(self.local_start, DATE_FORMAT), + datefilter(self.local_end, DATE_FORMAT)) else: return u"""{0} - {1}""".format( datefilter(self.local_start, DATETIME_FORMAT), diff --git a/icekit_events/tests/tests.py b/icekit_events/tests/tests.py index de4846d..a6ac52b 100644 --- a/icekit_events/tests/tests.py +++ b/icekit_events/tests/tests.py @@ -152,17 +152,17 @@ def test_event_with_eventrepeatsgenerators(self): 13 * 2, daily_wend_occurrences.count()) self.assertEqual( 5, # Saturday - daily_wend_occurrences[0].start.weekday()) + coerce_naive(daily_wend_occurrences[0].start).weekday()) self.assertEqual( 6, # Sunday - daily_wend_occurrences[1].start.weekday()) + coerce_naive(daily_wend_occurrences[1].start).weekday()) # Start and end dates of all-day occurrences are zeroed self.assertEqual( - models.zero_datetime(daily_wend_occurrences[0].start), - daily_wend_occurrences[0].start) + time(0, 0), + daily_wend_occurrences[0].start.astimezone(djtz.get_current_timezone()).time()) self.assertEqual( - models.zero_datetime(daily_wend_occurrences[0].end), - daily_wend_occurrences[0].end) + time(0, 0), + daily_wend_occurrences[0].end.astimezone(djtz.get_current_timezone()).time()) ####################################################################### # Delete "Daily" repeat generator ####################################################################### @@ -220,17 +220,15 @@ def test_event_with_user_modified_occurrences(self): self.assertEqual(2, event.occurrences.count()) all_day_occurrence = event.occurrences.all()[1] self.assertTrue(timed_occurrence.is_user_modified) - self.assertEqual( - models.zero_datetime(all_day_start), all_day_occurrence.start) - self.assertEqual( - models.zero_datetime(all_day_start), all_day_occurrence.end) # Start and end dates of all-day occurrences are zeroed self.assertEqual( - models.zero_datetime(all_day_occurrence.start), - all_day_occurrence.start) + time(0, 0), + all_day_occurrence.start.astimezone( + djtz.get_current_timezone()).time()) self.assertEqual( - models.zero_datetime(all_day_occurrence.end), - all_day_occurrence.end) + time(0, 0), + all_day_occurrence.end.astimezone( + djtz.get_current_timezone()).time()) ####################################################################### # Cancel first (timed) event ####################################################################### @@ -863,7 +861,7 @@ def test_duration(self): ).duration ) self.assertEquals( - timedelta(), + timedelta(days=1, microseconds=-1), G( models.EventRepeatsGenerator, is_all_day=True, @@ -946,6 +944,25 @@ def test_unlimited_daily_repeating_generator(self): (self.naive_start + timedelta(days=91), self.naive_end + timedelta(days=91)), next(start_and_end_times)) + def test_daily_repeating_every_day_in_month(self): + start = djtz.datetime(2016,10,1, 0,0) + end = djtz.datetime(2016,10,1, 0,0) + repeat_end = djtz.datetime(2016,10,31, 0,0) + generator = G( + models.EventRepeatsGenerator, + start=start, + end=end, + is_all_day=True, + recurrence_rule='FREQ=DAILY', + repeat_end=repeat_end, + ) + # Repeating generator has expected date entries in its RRULESET + rruleset = generator.get_rruleset() + self.assertTrue(31, rruleset.count()) + self.assertTrue(coerce_naive(start) in rruleset) + self.assertTrue( + coerce_naive(djtz.datetime(2016,10,31, 0,0)) in rruleset) + class TestEventOccurrences(TestCase):