├── .circleci └── config.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── eventtools ├── __init__.py ├── _version.py └── models.py ├── renovate.json ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── models.py ├── test_settings.py └── tests.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | python38: 4 | working_directory: ~/django-eventtools 5 | docker: 6 | - image: circleci/python:3.8 7 | steps: 8 | - checkout 9 | - run: sudo pip install tox 10 | - run: TOXENV=py38-{dj20,dj22,dj30} tox 11 | 12 | python37: 13 | working_directory: ~/django-eventtools 14 | docker: 15 | - image: circleci/python:3.7 16 | steps: 17 | - checkout 18 | - run: sudo pip install tox 19 | - run: TOXENV=py37-{dj11,dj20,dj21,dj22,dj30} tox 20 | 21 | python36: 22 | working_directory: ~/django-eventtools 23 | docker: 24 | - image: circleci/python:3.6 25 | steps: 26 | - checkout 27 | - run: sudo pip install tox 28 | - run: TOXENV=py36-{dj11,dj20,dj21,dj22,dj30} tox 29 | 30 | python35: 31 | working_directory: ~/django-eventtools 32 | docker: 33 | - image: circleci/python:3.5 34 | steps: 35 | - checkout 36 | - run: sudo pip install tox 37 | - run: TOXENV=py35-{dj11,dj20,dj21,dj22} tox 38 | 39 | python27: 40 | working_directory: ~/django-eventtools 41 | docker: 42 | - image: circleci/python:2.7 43 | steps: 44 | - checkout 45 | - run: sudo pip install tox 46 | - run: TOXENV=py27-{dj18,dj11} tox 47 | 48 | meta: 49 | working_directory: ~/django-eventtools 50 | docker: 51 | - image: circleci/python:3.7 52 | steps: 53 | - checkout 54 | - run: sudo pip install tox coverage flake8 55 | - run: sudo python ./setup.py install 56 | - run: flake8 eventtools 57 | - run: coverage run --source eventtools ./runtests.py 58 | - run: bash <(curl -s https://codecov.io/bash) 59 | 60 | workflows: 61 | version: 2 62 | build: 63 | jobs: 64 | - python38 65 | - python37 66 | - python36 67 | - python35 68 | - python27 69 | - meta 70 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Greg Brown 2 | [gregbrown.co.nz](http://gregbrown.co.nz) 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions 7 | are met: 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 25 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-eventtools is a lightweight library designed to handle repeating and 2 | one-off event occurrences for display on a website. 3 | 4 | [![Circle CI](https://circleci.com/gh/gregplaysguitar/django-eventtools.svg?style=svg)](https://circleci.com/gh/gregplaysguitar/django-eventtools) 5 | [![codecov](https://codecov.io/gh/gregplaysguitar/django-eventtools/branch/master/graph/badge.svg)](https://codecov.io/gh/gregplaysguitar/django-eventtools) 6 | [![Latest Version](https://img.shields.io/pypi/v/django-eventtools.svg?style=flat)](https://pypi.python.org/pypi/django-eventtools/) 7 | 8 | 9 | ## Installation 10 | 11 | Download the source from https://pypi.python.org/pypi/django-eventtools/ 12 | and run `python setup.py install`, or: 13 | 14 | > pip install django-eventtools 15 | 16 | Django 1.8 or higher is required. 17 | 18 | 19 | ## Setup 20 | 21 | Given the following models: 22 | 23 | ```python 24 | from django.db import models 25 | 26 | from eventtools.models import BaseEvent, BaseOccurrence 27 | 28 | 29 | class MyEvent(BaseEvent): 30 | title = models.CharField(max_length=100) 31 | 32 | 33 | class MyOccurrence(BaseOccurrence): 34 | event = models.ForeignKey(MyEvent) 35 | ``` 36 | 37 | ## Usage 38 | 39 | Create a sample event & occurrences 40 | 41 | >>> from datetime import datetime 42 | >>> from myapp.models import MyEvent 43 | >>> event = MyEvent.objects.create(title='Test event') 44 | >>> once_off = MyOccurrence.objects.create( 45 | event=event, 46 | start=datetime(2016, 1, 1, 12, 0), 47 | end=datetime(2016, 1, 1, 2, 0)) 48 | >>> christmas = MyOccurrence.objects.create( 49 | event=event, 50 | start=datetime(2015, 12, 25, 7, 0), 51 | end=datetime(2015, 12, 25, 22, 0), 52 | repeat='RRULE:FREQ=YEARLY') 53 | >>> daily = MyOccurrence.objects.create( 54 | event=event, 55 | start=datetime(2016, 1, 1, 7, 0), 56 | end=datetime(2016, 1, 1, 8, 0), 57 | repeat='RRULE:FREQ=DAILY') 58 | 59 | Event and Occurrence instances, and their associated querysets, all support 60 | the `all_occurrences` method, which takes two optional arguments - `from_date` 61 | and `to_date`, which may be dates or datetimes. `from_date` and `to_date` 62 | default to `None`. The method returns a python generator 63 | yielding tuples in the format `(start, end, instance)` - for example: 64 | 65 | >>> MyEvent.objects.all().all_occurrences() 66 | >>> event.all_occurrences(from_date=datetime(2015, 1, 1, 10, 0)) 67 | >>> event.occurrence_set.all().all_occurrences(to_date=date(2016, 1, 1)) 68 | >>> occurrence.all_occurrences(from_date=date(2016, 1, 1), 69 | to_date=date(2016, 12, 31)) 70 | 71 | `instance` is an instance of the corresponding BaseOccurrence subclass. 72 | 73 | A `next_occurrence` method is also provided, taking the same arguments, 74 | but returning a single occurrence tuple. 75 | 76 | >>> event.next_occurrence() 77 | >>> event.next_occurrence(from_date=date(2016, 1, 1)) 78 | 79 | The method `first_occurrence` also returns a single occurrence tuple, but 80 | takes no arguments. 81 | 82 | ### Queryset filtering 83 | 84 | Event and Occurrence querysets can be filtered, but note that a `from_date` 85 | filtered queryset may contain false positives because it's not possible to tell 86 | for sure if a event will happen _after_ a certain date without evaluating 87 | repetition rules, meaning it can't be part of a database query. If you need a 88 | queryset filtered exactly, pass `exact=True` - this will filter the queryset by 89 | id, based on generated occurrences. Be careful with this option though as it 90 | may be very slow and/or CPU-hungry. For example 91 | 92 | >>> MyEvent.objects.for_period(from_date=date(2015, 1, 1), 93 | to_date=date(2015, 12, 31)) 94 | >>> event.occurrence_set.for_period(from_date=date(2015, 1, 1), exact=True) 95 | 96 | Note `to_date` filtering is always accurate, because the query only needs to 97 | consider the event's first occurrence. 98 | 99 | ### Sorting querysets 100 | 101 | Event and Occurrence querysets can also be sorted by their next occurrence 102 | using the `sort_by_next` method. By default this sorts instances by their 103 | first occurrence; the optional `from_date` argument will sort by the next 104 | occurrence after `from_date`. For example 105 | 106 | >>> MyEvent.objects.all().sort_by_next() 107 | >>> event.occurrence_set.for_period(from_date=date(2015, 1, 1)) \ 108 | >>> .sort_by_next(date(2015, 1, 1)) 109 | 110 | Note that this method returns a sorted list, not a queryset. 111 | 112 | ## Custom repeat intervals 113 | 114 | Occurrences can repeat using any interval that can be expressed as an 115 | [rrulestr](https://labix.org/python-dateutil#head-e987b581aebacf25c7276d3e9214385a12a091f2). 116 | To customise the available options, set `EVENTTOOLS_REPEAT_CHOICES` in 117 | your django settings. The default value is 118 | 119 | ```python 120 | EVENTTOOLS_REPEAT_CHOICES = ( 121 | ("RRULE:FREQ=DAILY", 'Daily'), 122 | ("RRULE:FREQ=WEEKLY", 'Weekly'), 123 | ("RRULE:FREQ=MONTHLY", 'Monthly'), 124 | ("RRULE:FREQ=YEARLY", 'Yearly'), 125 | ) 126 | ``` 127 | 128 | Set `EVENTTOOLS_REPEAT_CHOICES = None` to make repeat a plain-text field. 129 | 130 | ## Occurrence cancellations or modifications 131 | 132 | Cancelling or modifying a single occurrence repetition is not currently supported, but can be implemented by overriding a couple of methods. For example, the following allows cancellations or one-off modifications to the start time of a repetition: 133 | 134 | ```python 135 | from eventtools.models import (BaseEvent, BaseOccurrence, default_naive) 136 | from django.db import models 137 | 138 | 139 | class MyEvent(BaseEvent): 140 | pass 141 | 142 | 143 | class MyEventOccurrence(BaseOccurrence): 144 | event = models.ForeignKey(MyEvent) 145 | overrides = models.ManyToManyField('MyEventOccurrenceOverride', blank=True) 146 | 147 | def get_repeater(self): 148 | rule = super().get_repeater() # gets rruleset from parent method 149 | ruleset.rrule(rule) 150 | for override in self.overrides.all(): 151 | ruleset.exdate(default_naive(override.start)) # remove occurrence 152 | if override.modified_start: # reschedule occurrence if defined 153 | ruleset.rdate(default_naive(override.modified_start)) 154 | return ruleset 155 | 156 | 157 | class MyEventOccurrenceOverride(models.Model): 158 | start = models.DateTimeField() # must match targeted repetition exactly 159 | # new start, leave blank to cancel 160 | modified_start = models.DateTimeField(blank=True, null=True) 161 | ``` 162 | 163 | Note that start times must match exactly, so if the MyEventOccurrence start is changed, any previously-matching overrides will no longer be applied. 164 | 165 | ## Running tests 166 | 167 | Use tox (): 168 | 169 | > pip install tox 170 | > cd path-to/django-eventtools 171 | > tox 172 | -------------------------------------------------------------------------------- /eventtools/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _version 2 | __version__ = _version.__version__ 3 | -------------------------------------------------------------------------------- /eventtools/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.3' 2 | -------------------------------------------------------------------------------- /eventtools/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dateutil import rrule 4 | from datetime import date, datetime, timedelta 5 | 6 | from django.conf import settings 7 | from django.db import models 8 | from django.db.models import Q, Case, When, Value 9 | from django.core.exceptions import ValidationError 10 | 11 | from django.utils.timezone import make_aware, is_naive, make_naive, is_aware 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | from six import python_2_unicode_compatible 15 | 16 | 17 | # set EVENTTOOLS_REPEAT_CHOICES = None to make this a plain textfield 18 | REPEAT_CHOICES = getattr(settings, 'EVENTTOOLS_REPEAT_CHOICES', ( 19 | ("RRULE:FREQ=DAILY", 'Daily'), 20 | ("RRULE:FREQ=WEEKLY", 'Weekly'), 21 | ("RRULE:FREQ=MONTHLY", 'Monthly'), 22 | ("RRULE:FREQ=YEARLY", 'Yearly'), 23 | )) 24 | REPEAT_MAX = 200 25 | 26 | 27 | def max_future_date(): 28 | return datetime(date.today().year + 10, 1, 1, 0, 0) 29 | 30 | 31 | def first_item(gen): 32 | try: 33 | return next(gen) 34 | except StopIteration: 35 | return None 36 | 37 | 38 | def default_aware(dt): 39 | """Convert a naive datetime argument to a tz-aware datetime, if tz support 40 | is enabled. """ 41 | 42 | if settings.USE_TZ and is_naive(dt): 43 | return make_aware(dt) 44 | 45 | # if timezone support disabled, assume only naive datetimes are used 46 | return dt 47 | 48 | 49 | def default_naive(dt): 50 | """Convert an aware datetime argument to naive, if tz support 51 | is enabled. """ 52 | 53 | if settings.USE_TZ and is_aware(dt): 54 | return make_naive(dt) 55 | 56 | # if timezone support disabled, assume only naive datetimes are used 57 | return dt 58 | 59 | 60 | def as_datetime(d, end=False): 61 | """Normalise a date/datetime argument to a datetime for use in filters 62 | 63 | If a date is passed, it will be converted to a datetime with the time set 64 | to 0:00, or 23:59:59 if end is True.""" 65 | 66 | if type(d) is date: 67 | date_args = tuple(d.timetuple())[:3] 68 | if end: 69 | time_args = (23, 59, 59) 70 | else: 71 | time_args = (0, 0, 0) 72 | new_value = datetime(*(date_args + time_args)) 73 | return default_aware(new_value) 74 | # otherwise assume it's a datetime 75 | return default_aware(d) 76 | 77 | 78 | def combine_occurrences(generators, limit): 79 | """Merge the occurrences in two or more generators, in date order. 80 | 81 | Returns a generator. """ 82 | 83 | count = 0 84 | grouped = [] 85 | for gen in generators: 86 | try: 87 | next_date = next(gen) 88 | except StopIteration: 89 | pass 90 | else: 91 | grouped.append({'generator': gen, 'next': next_date}) 92 | 93 | while limit is None or count < limit: 94 | # all generators must have finished if there are no groups 95 | if not len(grouped): 96 | return 97 | 98 | # work out which generator will yield the earliest date (based on 99 | # start - end is ignored) 100 | next_group = None 101 | for group in grouped: 102 | if not next_group or group['next'][0] < next_group['next'][0]: 103 | next_group = group 104 | 105 | # yield the next (start, end) pair, with occurrence data 106 | yield next_group['next'] 107 | count += 1 108 | 109 | # update the group's next item, so we don't keep yielding the same date 110 | try: 111 | next_group['next'] = next(next_group['generator']) 112 | except StopIteration: 113 | # remove the group if there's none left 114 | grouped.remove(next_group) 115 | 116 | 117 | def filter_invalid(approx_qs, from_date, to_date): 118 | """Filter out any results from the queryset which do not have an occurrence 119 | within the given range. """ 120 | 121 | # work out what to exclude based on occurrences 122 | exclude_pks = [] 123 | for obj in approx_qs: 124 | if not obj.next_occurrence(from_date=from_date, to_date=to_date): 125 | exclude_pks.append(obj.pk) 126 | 127 | # and then apply the filtering to the queryset itself 128 | return approx_qs.exclude(pk__in=exclude_pks) 129 | 130 | 131 | def filter_from(qs, from_date, q_func=Q): 132 | """Filter a queryset by from_date. May still contain false positives due to 133 | uncertainty with repetitions. """ 134 | 135 | from_date = as_datetime(from_date) 136 | return qs.filter( 137 | q_func(end__isnull=False, end__gte=from_date) | 138 | q_func(start__gte=from_date) | 139 | (~q_func(repeat='') & (q_func(repeat_until__gte=from_date) | 140 | q_func(repeat_until__isnull=True)))).distinct() 141 | 142 | 143 | class OccurrenceMixin(object): 144 | """Class mixin providing common occurrence-related functionality. """ 145 | 146 | def all_occurrences(self, from_date=None, to_date=None): 147 | raise NotImplementedError() 148 | 149 | def next_occurrence(self, from_date=None, to_date=None): 150 | """Return next occurrence as a (start, end) tuple for this instance, 151 | between from_date and to_date, taking repetition into account. """ 152 | if not from_date: 153 | from_date = datetime.now() 154 | return first_item( 155 | self.all_occurrences(from_date=from_date, to_date=to_date)) 156 | 157 | def first_occurrence(self): 158 | """Return first occurrence as a (start, end) tuple for this instance. 159 | """ 160 | return first_item(self.all_occurrences()) 161 | 162 | 163 | class BaseQuerySet(models.QuerySet, OccurrenceMixin): 164 | """Base QuerySet for models which have occurrences. """ 165 | 166 | def for_period(self, from_date=None, to_date=None, exact=False): 167 | # subclasses should implement this 168 | raise NotImplementedError() 169 | 170 | def sort_by_next(self, from_date=None): 171 | """Sort the queryset by next_occurrence. 172 | 173 | Note that this method necessarily returns a list, not a queryset. """ 174 | 175 | def sort_key(obj): 176 | occ = obj.next_occurrence(from_date=from_date) 177 | return occ[0] if occ else None 178 | return sorted([e for e in self if sort_key(e)], key=sort_key) 179 | 180 | def all_occurrences(self, from_date=None, to_date=None, limit=None): 181 | """Return a generator yielding a (start, end) tuple for all occurrence 182 | dates in the queryset, taking repetition into account, up to a 183 | maximum limit if specified. """ 184 | 185 | # winnow out events which are definitely invalid 186 | qs = self.for_period(from_date, to_date) 187 | 188 | return combine_occurrences( 189 | (obj.all_occurrences(from_date, to_date) for obj in qs), limit) 190 | 191 | 192 | class BaseModel(models.Model, OccurrenceMixin): 193 | """Abstract model providing common occurrence-related functionality. """ 194 | 195 | class Meta: 196 | abstract = True 197 | 198 | 199 | class EventQuerySet(BaseQuerySet): 200 | """QuerySet for BaseEvent subclasses. """ 201 | 202 | def for_period(self, from_date=None, to_date=None, exact=False): 203 | """Filter by the given dates, returning a queryset of Occurrence 204 | instances with occurrences falling within the range. 205 | 206 | Due to uncertainty with repetitions, from_date filtering is only an 207 | approximation. If exact results are needed, pass exact=True - this 208 | will use occurrences to exclude invalid results, but may be very 209 | slow, especially for large querysets. """ 210 | 211 | filtered_qs = self 212 | prefix = self.model.occurrence_filter_prefix() 213 | 214 | def wrap_q(**kwargs): 215 | """Prepend the related model name to the filter keys. """ 216 | 217 | return Q(**{'%s__%s' % (prefix, k): v for k, v in kwargs.items()}) 218 | 219 | # to_date filtering is accurate 220 | if to_date: 221 | to_date = as_datetime(to_date, True) 222 | filtered_qs = filtered_qs.filter( 223 | wrap_q(start__lte=to_date)).distinct() 224 | 225 | if from_date: 226 | # but from_date isn't, due to uncertainty with repetitions, so 227 | # just winnow down as much as possible via queryset filtering 228 | filtered_qs = filter_from(filtered_qs, from_date, wrap_q) 229 | 230 | # filter out invalid results if requested 231 | if exact: 232 | filtered_qs = filter_invalid(filtered_qs, from_date, to_date) 233 | 234 | return filtered_qs 235 | 236 | 237 | class EventManager(models.Manager.from_queryset(EventQuerySet)): 238 | use_for_related_fields = True 239 | 240 | 241 | class BaseEvent(BaseModel): 242 | """Abstract model providing occurrence-related methods for events. 243 | 244 | Subclasses should have a related BaseOccurrence subclass. """ 245 | 246 | objects = EventManager() 247 | 248 | @classmethod 249 | def get_occurrence_relation(cls): 250 | """Get the occurrence relation for this class - use the first if 251 | there's more than one. """ 252 | 253 | # get all related occurrence fields 254 | relations = [rel for rel in cls._meta.get_fields() 255 | if isinstance(rel, models.ManyToOneRel) and 256 | issubclass(rel.related_model, BaseOccurrence)] 257 | 258 | # assume there's only one 259 | return relations[0] 260 | 261 | @classmethod 262 | def occurrence_filter_prefix(cls): 263 | rel = cls.get_occurrence_relation() 264 | return rel.name 265 | 266 | def get_related_occurrences(self): 267 | rel = self.get_occurrence_relation() 268 | return getattr(self, rel.get_accessor_name()).all() 269 | 270 | def all_occurrences(self, from_date=None, to_date=None, limit=None): 271 | """Return a generator yielding a (start, end) tuple for all dates 272 | for this event, taking repetition into account. """ 273 | 274 | return self.get_related_occurrences().all_occurrences( 275 | from_date, to_date, limit=limit) 276 | 277 | class Meta: 278 | abstract = True 279 | 280 | 281 | class OccurrenceQuerySet(BaseQuerySet): 282 | """QuerySet for BaseOccurrence subclasses. """ 283 | 284 | def for_period(self, from_date=None, to_date=None, exact=False): 285 | """Filter by the given dates, returning a queryset of Occurrence 286 | instances with occurrences falling within the range. 287 | 288 | Due to uncertainty with repetitions, from_date filtering is only an 289 | approximation. If exact results are needed, pass exact=True - this 290 | will use occurrences to exclude invalid results, but may be very 291 | slow, especially for large querysets. """ 292 | 293 | filtered_qs = self 294 | 295 | # to_date filtering is accurate 296 | if to_date: 297 | to_date = as_datetime(to_date, True) 298 | filtered_qs = filtered_qs.filter(Q(start__lte=to_date)).distinct() 299 | 300 | if from_date: 301 | # but from_date isn't, due to uncertainty with repetitions, so 302 | # just winnow down as much as possible via queryset filtering 303 | filtered_qs = filter_from(filtered_qs, from_date) 304 | 305 | # filter out invalid results if requested 306 | if exact: 307 | filtered_qs = filter_invalid(filtered_qs, from_date, to_date) 308 | 309 | return filtered_qs 310 | 311 | 312 | class OccurrenceManager(models.Manager.from_queryset(OccurrenceQuerySet)): 313 | use_for_related_fields = True 314 | 315 | def migrate_integer_repeat(self): 316 | self.update(repeat=Case( 317 | When(repeat=rrule.YEARLY, 318 | then=Value("RRULE:FREQ=YEARLY")), 319 | When(repeat=rrule.MONTHLY, 320 | then=Value("RRULE:FREQ=MONTHLY")), 321 | When(repeat=rrule.WEEKLY, 322 | then=Value("RRULE:FREQ=WEEKLY")), 323 | When(repeat=rrule.DAILY, 324 | then=Value("RRULE:FREQ=DAILY")), 325 | default=Value(""), 326 | )) 327 | 328 | 329 | class ChoiceTextField(models.TextField): 330 | """Textfield which uses a Select widget if it has choices specified. """ 331 | 332 | def formfield(self, **kwargs): 333 | if self.choices: 334 | # this overrides the TextField's preference for a Textarea widget, 335 | # allowing the ModelForm to decide which field to use 336 | kwargs['widget'] = None 337 | return super(ChoiceTextField, self).formfield(**kwargs) 338 | 339 | 340 | @python_2_unicode_compatible 341 | class BaseOccurrence(BaseModel): 342 | """Abstract model providing occurrence-related methods for occurrences. 343 | 344 | Subclasses will usually have a ForeignKey pointing to a BaseEvent 345 | subclass. """ 346 | 347 | start = models.DateTimeField(db_index=True, verbose_name=_('start')) 348 | end = models.DateTimeField( 349 | db_index=True, null=True, blank=True, verbose_name=_('end')) 350 | 351 | repeat = ChoiceTextField( 352 | choices=REPEAT_CHOICES, default='', blank=True, 353 | verbose_name=_('repeat')) 354 | repeat_until = models.DateField( 355 | null=True, blank=True, verbose_name=_('repeat_until')) 356 | 357 | def clean(self): 358 | if self.start and self.end and self.start >= self.end: 359 | msg = u"End must be after start" 360 | raise ValidationError(msg) 361 | 362 | if self.repeat_until and not self.repeat: 363 | msg = u"Select a repeat interval, or remove the " \ 364 | u"'repeat until' date" 365 | raise ValidationError(msg) 366 | 367 | if self.start and self.repeat_until and \ 368 | self.repeat_until < self.start.date(): 369 | msg = u"'Repeat until' cannot be before the first occurrence" 370 | raise ValidationError(msg) 371 | 372 | objects = OccurrenceManager() 373 | 374 | def all_occurrences(self, from_date=None, to_date=None, limit=REPEAT_MAX): 375 | """Return a generator yielding a (start, end) tuple for all dates 376 | for this occurrence, taking repetition into account. """ 377 | 378 | if not self.start: 379 | return 380 | 381 | from_date = from_date and as_datetime(from_date) 382 | to_date = to_date and as_datetime(to_date, True) 383 | 384 | if not self.repeat: 385 | if (not from_date or self.start >= from_date or 386 | (self.end and self.end >= from_date)) and \ 387 | (not to_date or self.start <= to_date): 388 | yield (self.start, self.end, self.occurrence_data) 389 | else: 390 | delta = (self.end - self.start) if self.end else timedelta(0) 391 | repeater = self.get_repeater() 392 | 393 | # start from the first occurrence at the earliest 394 | if not from_date or from_date < self.start: 395 | from_date = self.start 396 | 397 | # look until the last occurrence, up to an arbitrary maximum date 398 | if self.repeat_until and ( 399 | not to_date or 400 | as_datetime(self.repeat_until, True) < to_date): 401 | to_date = as_datetime(self.repeat_until, True) 402 | elif not to_date: 403 | to_date = default_aware(max_future_date()) 404 | 405 | # start is used for the filter, so modify from_date to take the 406 | # occurrence length into account 407 | from_date -= delta 408 | 409 | # always send naive datetimes to the repeater 410 | repeater = repeater.between(default_naive(from_date), 411 | default_naive(to_date), inc=True) 412 | 413 | count = 0 414 | for occ_start in repeater: 415 | count += 1 416 | if count > limit: 417 | return 418 | 419 | # make naive results aware 420 | occ_start = default_aware(occ_start) 421 | yield (occ_start, occ_start + delta, self.occurrence_data) 422 | 423 | def get_repeater(self): 424 | """Get rruleset instance representing this occurrence's repetitions. 425 | 426 | Subclasses may override this method for custom repeat behaviour. 427 | """ 428 | 429 | ruleset = rrule.rruleset() 430 | rule = rrule.rrulestr(self.repeat, dtstart=default_naive(self.start)) 431 | ruleset.rrule(rule) 432 | return ruleset 433 | 434 | @property 435 | def occurrence_data(self): 436 | return self 437 | 438 | class Meta: 439 | ordering = ('start', 'end') 440 | abstract = True 441 | 442 | def __str__(self): 443 | return u"%s" % (self.start) 444 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | if __name__ == "__main__": 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | failures = test_runner.run_tests(["tests"]) 16 | 17 | # Return failures 18 | sys.exit(bool(failures)) 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf8 3 | 4 | import os 5 | 6 | from setuptools import setup, find_packages 7 | 8 | # if there's a converted readme, use it, otherwise fall back to markdown 9 | if os.path.exists('README.rst'): 10 | readme_path = 'README.rst' 11 | else: 12 | readme_path = 'README.md' 13 | 14 | # avoid importing the module 15 | exec(open('eventtools/_version.py').read()) 16 | 17 | setup( 18 | name='django-eventtools', 19 | version=__version__, 20 | description='Recurring event tools for django', 21 | long_description=open(readme_path).read(), 22 | author='Greg Brown', 23 | author_email='greg@gregbrown.co.nz', 24 | url='https://github.com/gregplaysguitar/django-eventtools', 25 | packages=find_packages(exclude=('tests', )), 26 | license='BSD License', 27 | zip_safe=False, 28 | platforms='any', 29 | install_requires=['Django>=1.8', 'python-dateutil>=2.1', 'six>=1.14.0'], 30 | include_package_data=True, 31 | package_data={}, 32 | classifiers=[ 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Environment :: Web Environment', 35 | 'Intended Audience :: Developers', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.4', 42 | 'Programming Language :: Python :: 3.5', 43 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 44 | 'Framework :: Django', 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregplaysguitar/django-eventtools/f7c977494d15fd7044a5ae7d6a9de82d27d4bd43/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from six import python_2_unicode_compatible 3 | 4 | from eventtools.models import BaseEvent, BaseOccurrence 5 | 6 | 7 | @python_2_unicode_compatible 8 | class MyEvent(BaseEvent): 9 | title = models.CharField(max_length=100) 10 | 11 | def __str__(self): 12 | return self.title 13 | 14 | 15 | class MyOccurrence(BaseOccurrence): 16 | event = models.ForeignKey(MyEvent, on_delete=models.CASCADE) 17 | 18 | 19 | class MyOtherOccurrence(BaseOccurrence): 20 | event = models.ForeignKey(MyEvent, on_delete=models.CASCADE) 21 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | 2 | DATABASES = { 3 | 'default': { 4 | 'ENGINE': 'django.db.backends.sqlite3', 5 | 'NAME': 'test.sqlite', 6 | } 7 | } 8 | 9 | SECRET_KEY = '1' 10 | 11 | INSTALLED_APPS = [ 12 | "tests", 13 | ] 14 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date, timedelta 2 | from dateutil import rrule 3 | from dateutil.relativedelta import relativedelta 4 | 5 | import pytz 6 | from django.utils import timezone 7 | from django.test import TestCase, override_settings 8 | from django.utils.timezone import get_default_timezone, make_aware 9 | from django.conf import settings 10 | from django.core.exceptions import ValidationError 11 | from eventtools.models import REPEAT_MAX 12 | 13 | from .models import MyEvent, MyOccurrence 14 | 15 | 16 | class EventToolsTestCase(TestCase): 17 | 18 | def setUp(self): 19 | self.christmas = MyEvent.objects.create(title='Christmas') 20 | MyOccurrence.objects.create( 21 | event=self.christmas, 22 | start=datetime(2000, 12, 25, 7, 0), 23 | end=datetime(2000, 12, 25, 22, 0), 24 | repeat="RRULE:FREQ=YEARLY") 25 | 26 | self.weekends = MyEvent.objects.create(title='Weekends 9-10am') 27 | # Saturday 28 | MyOccurrence.objects.create( 29 | event=self.weekends, 30 | start=datetime(2015, 1, 3, 9, 0), 31 | end=datetime(2015, 1, 3, 10, 0), 32 | repeat="RRULE:FREQ=WEEKLY") 33 | # Sunday 34 | MyOccurrence.objects.create( 35 | event=self.weekends, 36 | start=datetime(2015, 1, 4, 9, 0), 37 | end=datetime(2015, 1, 4, 10, 0), 38 | repeat="RRULE:FREQ=WEEKLY") 39 | 40 | self.daily = MyEvent.objects.create(title='Daily 7am') 41 | MyOccurrence.objects.create( 42 | event=self.daily, 43 | start=datetime(2015, 1, 1, 7, 0), 44 | end=None, 45 | repeat="RRULE:FREQ=DAILY") 46 | 47 | self.past = MyEvent.objects.create(title='Past event') 48 | MyOccurrence.objects.create( 49 | event=self.past, 50 | start=datetime(2014, 1, 1, 7, 0), 51 | end=datetime(2014, 1, 1, 8, 0)) 52 | 53 | self.future = MyEvent.objects.create(title='Future event') 54 | MyOccurrence.objects.create( 55 | event=self.future, 56 | start=datetime(2016, 1, 1, 7, 0), 57 | end=datetime(2016, 1, 1, 8, 0)) 58 | 59 | self.monthly = MyEvent.objects.create(title='Monthly until Dec 2017') 60 | MyOccurrence.objects.create( 61 | event=self.monthly, 62 | start=datetime(2016, 1, 1, 7, 0), 63 | end=datetime(2016, 1, 1, 8, 0), 64 | repeat="RRULE:FREQ=MONTHLY", 65 | repeat_until=date(2017, 12, 31)) 66 | 67 | # fake "today" so tests always work 68 | self.today = date(2015, 6, 1) 69 | self.first_of_year = date(2015, 1, 1) 70 | self.last_of_year = date(2015, 12, 31) 71 | 72 | def test_occurrence_validation(self): 73 | with self.assertRaises(ValidationError): 74 | MyOccurrence( 75 | start=datetime(2016, 1, 1, 7, 0), 76 | end=datetime(2016, 1, 1, 6, 0), 77 | ).clean() 78 | 79 | with self.assertRaises(ValidationError): 80 | MyOccurrence( 81 | start=datetime(2016, 1, 1, 7, 0), 82 | repeat_until=date(2017, 12, 31), 83 | ).clean() 84 | 85 | with self.assertRaises(ValidationError): 86 | MyOccurrence( 87 | start=datetime(2016, 1, 1, 7, 0), 88 | repeat="RRULE:FREQ=MONTHLY", 89 | repeat_until=date(2015, 12, 31), 90 | ).clean() 91 | 92 | def test_single_occurrence(self): 93 | occ = self.christmas.get_related_occurrences().get() 94 | 95 | # using date() arguments 96 | dates = list(occ.all_occurrences( 97 | from_date=date(2015, 12, 1), 98 | to_date=date(2015, 12, 31),)) 99 | self.assertEqual(len(dates), 1) 100 | 101 | # check it works as expected when from/to equal the occurrence date 102 | dates = list(occ.all_occurrences( 103 | from_date=date(2015, 12, 25), 104 | to_date=date(2015, 12, 25), )) 105 | self.assertEqual(len(dates), 1) 106 | 107 | # using datetime() arguments 108 | dates = list(occ.all_occurrences( 109 | from_date=datetime(2015, 12, 25, 6, 0, 0), 110 | to_date=datetime(2015, 12, 25, 23, 0, 0), )) 111 | self.assertEqual(len(dates), 1) 112 | 113 | # using tz-aware datetime() arguments, if appropriate 114 | if settings.USE_TZ: 115 | tz = get_default_timezone() 116 | dates = list(occ.all_occurrences( 117 | from_date=datetime(2015, 12, 25, 6, 0, 0, 0, tz), 118 | to_date=datetime(2015, 12, 25, 23, 0, 0, 0, tz), )) 119 | self.assertEqual(len(dates), 1) 120 | 121 | # date range intersecting with occurrence time 122 | dates = list(occ.all_occurrences( 123 | from_date=datetime(2015, 12, 25, 10, 0, 0), 124 | to_date=datetime(2015, 12, 25, 23, 0, 0), )) 125 | self.assertEqual(len(dates), 1) 126 | dates = list(occ.all_occurrences( 127 | from_date=datetime(2015, 12, 25, 6, 0, 0), 128 | to_date=datetime(2015, 12, 25, 10, 0, 0), )) 129 | self.assertEqual(len(dates), 1) 130 | 131 | # date range within occurrence time 132 | dates = list(occ.all_occurrences( 133 | from_date=datetime(2015, 12, 25, 12, 0, 0), 134 | to_date=datetime(2015, 12, 25, 13, 0, 0), )) 135 | self.assertEqual(len(dates), 1) 136 | 137 | # date range outside occurrence time 138 | dates = list(occ.all_occurrences( 139 | from_date=datetime(2015, 12, 24, 12, 0, 0), 140 | to_date=datetime(2015, 12, 26, 13, 0, 0), )) 141 | self.assertEqual(len(dates), 1) 142 | 143 | # date range before occurrence time 144 | dates = list(occ.all_occurrences( 145 | from_date=datetime(2015, 12, 24, 12, 0, 0), 146 | to_date=datetime(2015, 12, 24, 13, 0, 0), )) 147 | self.assertEqual(len(dates), 0) 148 | 149 | # date range after occurrence time 150 | dates = list(occ.all_occurrences( 151 | from_date=datetime(2015, 12, 25, 23, 0, 0), 152 | to_date=datetime(2015, 12, 25, 23, 30, 0), )) 153 | self.assertEqual(len(dates), 0) 154 | 155 | # check next_occurence method for non-repeating occurrences 156 | occ = self.past.get_related_occurrences().get() \ 157 | .next_occurrence(from_date=self.today) 158 | self.assertEqual(occ, None) 159 | 160 | occ = self.future.get_related_occurrences().get() \ 161 | .next_occurrence(from_date=self.today) 162 | self.assertEqual(occ[0].timetuple()[:5], 163 | datetime(2016, 1, 1, 7, 0).timetuple()[:5]) 164 | 165 | # and for repeating 166 | occ = self.daily.get_related_occurrences().get() \ 167 | .next_occurrence(from_date=self.today) 168 | self.assertEqual(occ[0].date(), self.today) 169 | 170 | # test next_occurrence for querysets 171 | occ = self.daily.get_related_occurrences().all() \ 172 | .next_occurrence(from_date=self.today) 173 | self.assertEqual(occ[0].date(), self.today) 174 | 175 | @override_settings(USE_TZ=True) 176 | def test_single_occurrence_tz(self): 177 | self.test_single_occurrence() 178 | 179 | def test_occurrence_qs(self): 180 | events = [self.christmas, self.past, self.future] 181 | occs = MyOccurrence.objects.filter(event__in=events) 182 | 183 | # two christmases and the future event 184 | dates = list(occs.all_occurrences( 185 | from_date=date(2015, 1, 1), 186 | to_date=date(2016, 12, 31),)) 187 | self.assertEqual(len(dates), 3) 188 | 189 | # one christmas and the past event 190 | dates = list(occs.all_occurrences( 191 | from_date=date(2014, 1, 1), 192 | to_date=date(2014, 12, 31),)) 193 | self.assertEqual(len(dates), 2) 194 | 195 | # test queryset filtering 196 | qs = occs.for_period(from_date=date(2015, 1, 1), exact=True) 197 | self.assertEqual(qs.count(), 2) 198 | 199 | qs = occs.for_period(to_date=date(2010, 1, 1), exact=True) 200 | self.assertEqual(qs.get().event, self.christmas) 201 | 202 | qs = occs.for_period(from_date=date(2017, 1, 1), 203 | to_date=date(2017, 12, 31), 204 | exact=True) 205 | self.assertEqual(qs.get().event, self.christmas) 206 | 207 | @override_settings(USE_TZ=True) 208 | def test_occurrence_qs_tz(self): 209 | self.test_occurrence_qs() 210 | 211 | def test_single_event(self): 212 | # one christmas per year 213 | for i in range(0, 10): 214 | count = len(list(self.christmas.all_occurrences( 215 | from_date=self.first_of_year + relativedelta(years=i), 216 | to_date=self.first_of_year + relativedelta(years=i + 1)))) 217 | self.assertEqual(count, 1) 218 | 219 | # but none in the first half of the year 220 | count = len(list(self.christmas.all_occurrences( 221 | from_date=self.first_of_year + relativedelta(years=1), 222 | to_date=self.first_of_year + relativedelta(months=6)))) 223 | self.assertEqual(count, 0) 224 | 225 | # check the daily event happens on some arbitrary dates 226 | for days in (10, 30, 50, 80, 100): 227 | from_date = self.first_of_year + timedelta(days) 228 | count = len(list(self.daily.all_occurrences( 229 | from_date=from_date, 230 | to_date=from_date 231 | ))) 232 | self.assertEqual(count, 1) 233 | 234 | # check the the weekend event occurs as expected in a series of 2 day 235 | # periods 236 | for days in range(1, 50): 237 | from_date = self.first_of_year + timedelta(days) 238 | 239 | if from_date.weekday() == 5: 240 | expected = 2 # whole weekend 241 | elif from_date.weekday() in (4, 6): 242 | expected = 1 # one weekend day 243 | else: 244 | expected = 0 # no weekend days 245 | 246 | occs = list(self.weekends.all_occurrences( 247 | from_date=from_date, 248 | to_date=from_date + timedelta(1) 249 | )) 250 | self.assertEqual(len(occs), expected) 251 | 252 | @override_settings(USE_TZ=True) 253 | def test_single_event_tz(self): 254 | self.test_single_event() 255 | 256 | def test_event_queryset(self): 257 | # one christmas per year 258 | christmas_qs = MyEvent.objects.filter(pk=self.christmas.pk) 259 | for i in range(0, 10): 260 | occs = list(christmas_qs.all_occurrences( 261 | from_date=self.first_of_year + relativedelta(years=i), 262 | to_date=self.first_of_year + relativedelta(years=i + 1))) 263 | self.assertEqual(len(occs), 1) 264 | 265 | # but none in the first half of the year 266 | occs = list(christmas_qs.all_occurrences( 267 | from_date=self.first_of_year + relativedelta(years=1), 268 | to_date=self.first_of_year + relativedelta(months=6))) 269 | self.assertEqual(len(occs), 0) 270 | 271 | def sorted_events(events): 272 | return sorted(events, key=lambda obj: obj.pk) 273 | 274 | def expected(for_date): 275 | """Return ids of events expected to occur on a given date. """ 276 | 277 | events = [self.daily] 278 | if for_date.month == 12 and for_date.day == 25: 279 | events.append(self.christmas) 280 | if for_date.weekday() in (5, 6): 281 | events.append(self.weekends) 282 | return sorted_events(events) 283 | 284 | # check the number of events for some arbitrary dates 285 | for days in (8, 16, 24, 32, 40, 48, 56): 286 | from_date = self.first_of_year + timedelta(days) 287 | qs = MyEvent.objects.for_period( 288 | from_date=from_date, 289 | to_date=from_date, 290 | exact=True, 291 | ).distinct() 292 | 293 | events = sorted_events(list(qs)) 294 | self.assertEqual(events, expected(from_date)) 295 | 296 | # test queryset filtering 297 | events = MyEvent.objects.filter( 298 | pk__in=(self.christmas.pk, self.future.pk, self.past.pk)) 299 | 300 | qs = events.for_period(from_date=date(2015, 1, 1), exact=True) 301 | self.assertEqual(qs.count(), 2) 302 | 303 | qs = events.for_period(to_date=date(2010, 1, 1), exact=True) 304 | self.assertEqual(qs.get(), self.christmas) 305 | 306 | qs = events.for_period(from_date=date(2017, 1, 1), 307 | to_date=date(2017, 12, 31), 308 | exact=True) 309 | self.assertEqual(qs.get(), self.christmas) 310 | 311 | @override_settings(USE_TZ=True) 312 | def test_event_queryset_tz(self): 313 | self.test_event_queryset() 314 | 315 | def test_occurrence_data(self): 316 | occ = self.christmas.get_related_occurrences().get() 317 | self.assertEqual(occ.next_occurrence()[2], occ.occurrence_data) 318 | 319 | def test_repeat_until(self): 320 | # check repeating event when to_date is less than repeat_until 321 | occs = self.monthly.all_occurrences(from_date=date(2016, 4, 1), 322 | to_date=date(2016, 4, 30)) 323 | self.assertEqual(len(list(occs)), 1) 324 | 325 | def test_occurrence_limit(self): 326 | test_objs = [ 327 | self.daily, 328 | MyEvent.objects.filter(pk=self.daily.pk), 329 | self.daily.get_related_occurrences().all(), 330 | self.daily.get_related_occurrences().get(), 331 | ] 332 | for obj in test_objs: 333 | self.assertEqual(len(list(obj.all_occurrences(limit=20))), 20) 334 | self.assertEqual(len(list(obj.all_occurrences())), REPEAT_MAX) 335 | 336 | def test_non_repeating_intersection(self): 337 | occ = self.past.get_related_occurrences().get() 338 | 339 | dates = list(occ.all_occurrences( 340 | from_date=datetime(2014, 1, 1, 7, 30), 341 | to_date=datetime(2014, 1, 1, 8, 30))) 342 | self.assertEqual(len(dates), 1) 343 | dates = list(occ.all_occurrences( 344 | from_date=datetime(2014, 1, 1, 6, 30), 345 | to_date=datetime(2014, 1, 1, 7, 30))) 346 | self.assertEqual(len(dates), 1) 347 | 348 | def test_integer_rules_can_be_migrated(self): 349 | yearly = MyOccurrence.objects.create( 350 | event=self.christmas, 351 | start=datetime(2000, 12, 25, 7, 0), 352 | end=datetime(2000, 12, 25, 22, 0), 353 | repeat=rrule.YEARLY) 354 | monthly = MyOccurrence.objects.create( 355 | event=self.past, 356 | start=datetime(2014, 1, 1, 7, 0), 357 | end=datetime(2014, 1, 1, 8, 0), 358 | repeat=rrule.MONTHLY) 359 | weekly = MyOccurrence.objects.create( 360 | event=self.weekends, 361 | start=datetime(2015, 1, 4, 9, 0), 362 | end=datetime(2015, 1, 4, 10, 0), 363 | repeat=rrule.WEEKLY) 364 | daily = MyOccurrence.objects.create( 365 | event=self.daily, 366 | start=datetime(2015, 1, 1, 7, 0), 367 | end=datetime(2015, 1, 1, 8, 0), 368 | repeat=rrule.DAILY) 369 | 370 | MyOccurrence.objects.migrate_integer_repeat() 371 | yearly.refresh_from_db() 372 | self.assertEqual(yearly.repeat, 'RRULE:FREQ=YEARLY') 373 | monthly.refresh_from_db() 374 | self.assertEqual(monthly.repeat, 'RRULE:FREQ=MONTHLY') 375 | weekly.refresh_from_db() 376 | self.assertEqual(weekly.repeat, 'RRULE:FREQ=WEEKLY') 377 | daily.refresh_from_db() 378 | self.assertEqual(daily.repeat, 'RRULE:FREQ=DAILY') 379 | 380 | def test_queryset_filtering(self): 381 | event1 = MyEvent.objects.create(title='Jan 1st 2000') 382 | MyOccurrence.objects.create( 383 | event=event1, 384 | start=datetime(2000, 1, 1, 7, 0), 385 | end=datetime(2000, 1, 1, 8, 0)) 386 | event2 = MyEvent.objects.create(title='Jan 1st 2001') 387 | MyOccurrence.objects.create( 388 | event=event2, 389 | start=datetime(2001, 1, 1, 7, 0), 390 | end=datetime(2001, 1, 1, 8, 0)) 391 | events = MyEvent.objects.filter(pk__in=[event1.pk, event2.pk]) 392 | occs = MyOccurrence.objects.filter(event__pk__in=[event1.pk, event2.pk]) 393 | 394 | # 1 in 2000 395 | self.assertEqual( 396 | 1, occs.for_period(date(2000, 1, 1), date(2000, 12, 31)).count()) 397 | self.assertEqual( 398 | 1, events.for_period(date(2000, 1, 1), date(2000, 12, 31)).count()) 399 | 400 | # 1 after 1st Jan 2001 401 | self.assertEqual(1, occs.for_period(date(2001, 1, 1)).count()) 402 | self.assertEqual(1, events.for_period(date(2001, 1, 1)).count()) 403 | 404 | # 2 between 2000-1-1 and 2001-1-1 (inclusive) 405 | self.assertEqual( 406 | 2, occs.for_period(date(2000, 1, 1), date(2001, 1, 1)).count()) 407 | self.assertEqual( 408 | 2, events.for_period(date(2000, 1, 1), date(2001, 1, 1)).count()) 409 | 410 | # none in 1999 411 | self.assertEqual( 412 | 0, occs.for_period(date(1999, 1, 1), date(1999, 12, 31)).count()) 413 | self.assertEqual( 414 | 0, events.for_period(date(1999, 1, 1), date(1999, 12, 31)).count()) 415 | 416 | # none in 2002 417 | self.assertEqual( 418 | 0, occs.for_period(date(2002, 1, 1), date(2002, 12, 31)).count()) 419 | self.assertEqual( 420 | 0, events.for_period(date(2002, 1, 1), date(2002, 12, 31)).count()) 421 | 422 | # add another past event with yearly repetition 423 | event3 = MyEvent.objects.create(title='Jun 1st 1998, yearly') 424 | MyOccurrence.objects.create( 425 | event=event3, 426 | start=datetime(1998, 6, 1, 7, 0), 427 | end=datetime(1998, 6, 1, 8, 0), 428 | repeat='RRULE:FREQ=YEARLY') 429 | events = events | MyEvent.objects.filter(pk=event3.pk) 430 | occs = occs | MyOccurrence.objects.filter(event__pk=event3.pk) 431 | 432 | # Jan 2001 now contains a false positive 433 | self.assertEqual( 434 | 2, occs.for_period(date(2001, 1, 1), date(2001, 1, 31)).count()) 435 | self.assertEqual( 436 | 2, events.for_period(date(2001, 1, 1), date(2001, 1, 31)).count()) 437 | # exact=True removes it 438 | self.assertEqual(1, occs.for_period( 439 | date(2001, 1, 1), date(2001, 1, 31), exact=True).count()) 440 | self.assertEqual(1, events.for_period( 441 | date(2001, 1, 1), date(2001, 1, 31), exact=True).count()) 442 | 443 | @override_settings(USE_TZ=True, TIME_ZONE='America/New_York') 444 | def test_dst_boundary(self): 445 | # Check that event start times are consistent across daylight saving 446 | # changes - on an EST5EDT system, daylight saving ends on 5/11/2016 447 | event = MyEvent.objects.create(title='Test') 448 | start = make_aware(datetime(2016, 11, 5, 10, 0)) 449 | MyOccurrence.objects.create(event=event, start=start, 450 | repeat="RRULE:FREQ=WEEKLY") 451 | 452 | occs = list(event.all_occurrences(from_date=start, limit=2)) 453 | self.assertEqual(occs[0][0], start) 454 | self.assertEqual(occs[1][0], make_aware(datetime(2016, 11, 12, 10, 0))) 455 | 456 | @override_settings(USE_TZ=True, TIME_ZONE='Pacific/Auckland') 457 | def test_dst_boundary_nz(self): 458 | # NZ DST commences on 25/9/2016 459 | event = MyEvent.objects.create(title='Test') 460 | start = make_aware(datetime(2016, 9, 20, 10, 0)) 461 | MyOccurrence.objects.create(event=event, start=start, 462 | repeat="RRULE:FREQ=WEEKLY") 463 | 464 | occs = list(event.all_occurrences(from_date=start.date(), limit=2)) 465 | self.assertEqual(occs[0][0], start) 466 | self.assertEqual(occs[1][0], make_aware(datetime(2016, 9, 27, 10, 0))) 467 | 468 | def test_sort_by_next(self): 469 | qs = MyEvent.objects.filter(pk__in=[self.christmas.pk, self.weekends.pk]) 470 | 471 | # Christmas 2015 fell on a Friday 472 | christmas_first = qs.sort_by_next(from_date=date(2015, 12, 24)) 473 | self.assertEqual(christmas_first, [self.christmas, self.weekends]) 474 | 475 | weekend_first = qs.sort_by_next(from_date=date(2015, 12, 20)) 476 | self.assertEqual(weekend_first, [self.weekends, self.christmas]) 477 | 478 | @override_settings(USE_TZ=True, TIME_ZONE='Asia/Singapore') 479 | def test_sg_timezone(self): 480 | sg_tz = timezone.get_current_timezone() 481 | occ = MyOccurrence( 482 | start=sg_tz.localize(datetime(2017, 12, 24, 1)), 483 | repeat='FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;INTERVAL=1') 484 | 485 | next_occ = occ.next_occurrence( 486 | from_date=datetime(2017, 12, 26, 22, 49, tzinfo=pytz.utc)) 487 | self.assertEqual( 488 | next_occ[0].timetuple()[:5], 489 | (2017, 12, 28, 1, 0)) 490 | 491 | next_occ = occ.next_occurrence( 492 | from_date=datetime(2017, 12, 26, 12, 49, tzinfo=pytz.utc)) 493 | self.assertEqual( 494 | next_occ[0].timetuple()[:5], 495 | (2017, 12, 27, 1, 0)) 496 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | 3 | [testenv] 4 | basepython = 5 | py27: python2.7 6 | py34: python3.4 7 | py35: python3.5 8 | py36: python3.6 9 | py37: python3.7 10 | py38: python3.8 11 | deps = 12 | pytz 13 | dj18: Django>=1.8,<1.9 14 | dj19: Django>=1.9,<1.10 15 | dj10: Django>=1.10,<1.11 16 | dj11: Django>=1.11,<1.12 17 | dj20: Django>=2.0,<2.1 18 | dj21: Django>=2.1,<2.2 19 | dj22: Django>=2.2,<3.0 20 | dj30: Django>=3.0,<3.1 21 | commands=./runtests.py 22 | --------------------------------------------------------------------------------