# ------------------------------------------------------------------------------
# Recurrence
# ------------------------------------------------------------------------------
# Somewhat based upon RFC 5545 RRules, implemented using dateutil.rrule
# Does not support timezones ... and probably never will
# Does not support a frequency of by hour, by minute or by second
#
# See also:
# https://github.com/django-recurrence/django-recurrence
# https://github.com/dakrauth/django-swingtime
import sys
from operator import attrgetter
import calendar
import datetime as dt
from dateutil.rrule import rrule, rrulestr, rrulebase
from dateutil.rrule import DAILY, WEEKLY, MONTHLY, YEARLY
from dateutil.rrule import weekday as rrweekday
from django.utils.translation import gettext as _
from .telltime import dateShortFormat
from .manythings import toOrdinal, toTheOrdinal, toDaysOffsetStr, hrJoin
from .names import (WEEKDAY_NAMES, WEEKDAY_NAMES_PLURAL,
MONTH_NAMES, WRAPPED_MONTH_NAMES)
# ------------------------------------------------------------------------------
[docs]class Weekday(rrweekday):
"""
Represents a day of the week, for every occurence of the week
or for a specific week in the period. e.g. The first Friday of the month.
"""
def __repr__(self):
s = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[self.weekday]
if not self.n:
return s
else:
return "{:+d}{}".format(self.n, s)
def __str__(self):
return self._getWhen(0)
def _getWhen(self, offset, names=WEEKDAY_NAMES):
weekday = names[self.weekday]
if offset == 0:
if not self.n:
return weekday
else:
ordinal = toOrdinal(self.n)
return _("{ordinal} {weekday}").format(**locals())
localWeekday = names[(self.weekday + offset) % 7]
if not self.n:
return localWeekday
else:
theOrdinal = toTheOrdinal(self.n, inTitleCase=False)
if offset < 0:
return _("{localWeekday} before "
"{theOrdinal} {weekday}").format(**locals())
else:
return _("{localWeekday} after "
"{theOrdinal} {weekday}").format(**locals())
def _getPluralWhen(self, offset):
return self._getWhen(offset, WEEKDAY_NAMES_PLURAL)
MO, TU, WE, TH, FR, SA, SU = EVERYWEEKDAY = map(Weekday, range(7))
# ------------------------------------------------------------------------------
[docs]class Recurrence(rrulebase):
"""
Implementation of the recurrence rules somewhat based upon
`RFC 5545 <https://tools.ietf.org/html/rfc5545>`_ RRules,
implemented using dateutil.rrule.
Does not support timezones ... and probably never will.
Does not support a frequency of by-hour, by-minute or by-second.
"""
def __init__(self, *args, **kwargs):
super().__init__()
arg0 = args[0] if len(args) else None
if isinstance(arg0, str):
self.rule = rrulestr(arg0, **kwargs)
if not isinstance(self.rule, rrule):
raise ValueError("Only support simple RRules for now")
elif isinstance(arg0, Recurrence):
self.rule = arg0.rule
elif isinstance(arg0, rrule):
self.rule = arg0
else:
self.rule = rrule(*args, **kwargs)
# expose all rrule properties
#: How often the recurrence repeats. (0,1,2,3)
freq = property(attrgetter("rule._freq"))
#: The interval between each freq iteration.
interval = property(attrgetter("rule._interval"))
#: Limit on the number of occurrences.
count = property(attrgetter("rule._count"))
#: The week numbers to apply the recurrence to.
byweekno = property(attrgetter("rule._byweekno"))
#: The year days to apply the recurrence to.
byyearday = property(attrgetter("rule._byyearday"))
#: An offset from Easter Sunday.
byeaster = property(attrgetter("rule._byeaster"))
#: The nth occurrence of the rule inside the frequency period.
bysetpos = property(attrgetter("rule._bysetpos"))
@property
def dtstart(self):
"""
The recurrence start date.
"""
return self.rule._dtstart.date()
@property
def frequency(self):
"""
How often the recurrence repeats.
("YEARLY", "MONTHLY", "WEEKLY", "DAILY")
"""
freqOptions = ("YEARLY", "MONTHLY", "WEEKLY", "DAILY")
if self.rule._freq < len(freqOptions):
return freqOptions[self.rule._freq]
else:
return "unsupported_frequency_{}".format(self.rule._freq)
@property
def until(self):
"""
The last occurence in the rule is the greatest date that is
less than or equal to the value specified in the until parameter.
"""
if self.rule._until is not None:
return self.rule._until.date()
@property
def wkst(self):
"""
The week start day. The default week start is got from
calendar.firstweekday() which Joyous sets based on the Django
FIRST_DAY_OF_WEEK format.
"""
return Weekday(self.rule._wkst)
@property
def byweekday(self):
"""
The weekdays where the recurrence will be applied. In RFC5545 this is
called BYDAY, but is renamed by dateutil to avoid ambiguity.
"""
retval = []
if self.rule._byweekday:
retval += [Weekday(day) for day in self.rule._byweekday]
if self.rule._bynweekday:
retval += [Weekday(day, n) for day, n in self.rule._bynweekday]
return retval
@property
def bymonthday(self):
"""
The month days where the recurrence will be applied.
"""
retval = []
if self.rule._bymonthday:
retval += self.rule._bymonthday
if self.rule._bynmonthday:
retval += self.rule._bynmonthday
return retval
@property
def bymonth(self):
"""
The months where the recurrence will be applied.
"""
if self.rule._bymonth:
return list(self.rule._bymonth)
else:
return []
def _iter(self):
for occurence in self.rule._iter():
yield occurence.date()
# __len__() introduces a large performance penality.
[docs] def getCount(self):
"""
How many occurrences will be generated.
"""
return self.rule.count()
def __eq__(self, other):
my = self.rule
if isinstance(other, Recurrence):
their = other.rule
elif isinstance(other, rrule):
their = other
else:
return NotImplemented
theirDtstart = their._dtstart.date()
theirUntil = their._until
if theirUntil is not None:
theirUntil = theirUntil.date()
return (my._freq == their._freq and
my._interval == their._interval and
my._count == their._count and
my._byweekno == their._byweekno and
my._byyearday == their._byyearday and
my._byeaster == their._byeaster and
my._bysetpos == their._bysetpos and
self.dtstart == theirDtstart and
self.until == theirUntil and
my._wkst == their._wkst and
my._byweekday == their._byweekday and
my._bynweekday == their._bynweekday and
my._bymonthday == their._bymonthday and
my._bynmonthday == their._bynmonthday and
my._bymonth == their._bymonth)
def __repr__(self):
dtstart = ""
if self.dtstart:
dtstart = "DTSTART:{:%Y%m%d}\n".format(self.dtstart)
rrule = "RRULE:{}".format(self._getRrule())
retval = dtstart + rrule
return retval
def _getRrule(self, untilDt=None):
# untilDt is the UTC datetime version of self.until
if untilDt and untilDt.utcoffset() != dt.timedelta(0):
raise TypeError("untilDt must be a UTC datetime")
parts = ["FREQ={}".format(self.frequency)]
if self.interval and self.interval != 1:
parts.append("INTERVAL={}".format(self.interval))
if self.wkst:
parts.append("WKST={!r}".format(self.wkst))
if self.count:
parts.append("COUNT={}".format(self.count))
if untilDt:
parts.append("UNTIL={:%Y%m%dT%H%M%SZ}".format(untilDt))
elif self.until:
parts.append("UNTIL={:%Y%m%d}".format(self.until))
for name, value in [('BYSETPOS', self.bysetpos),
('BYDAY', self.byweekday),
('BYMONTH', self.bymonth),
('BYMONTHDAY', self.bymonthday),
('BYYEARDAY', self.byyearday),
('BYWEEKNO', self.byweekno)]:
if value:
parts.append("{}={}".format(name,
",".join(repr(v) for v in value)))
return ";".join(parts)
def __str__(self):
return self._getWhen(0)
def _getWhen(self, offset, numDays=1):
retval = ""
if self.freq == DAILY:
retval = self.__getDailyWhen()
elif self.freq == WEEKLY:
retval = self.__getWeeklyWhen(offset)
elif self.freq == MONTHLY:
retval = self.__getMonthlyWhen(offset)
elif self.freq == YEARLY:
retval = self.__getYearlyWhen(offset)
if numDays >= 2:
retval += " "+_("for {n} days").format(n=numDays)
if self.until:
until = self.until + dt.timedelta(days=offset)
retval += " "+_("(until {when})").format(when=dateShortFormat(until))
return retval
def __getDailyWhen(self):
if self.interval > 1:
retval = _("Every {n} days").format(n=self.interval)
else:
retval = _("Daily")
return retval
def __getWeeklyWhen(self, offset):
retval = hrJoin([d._getPluralWhen(offset) for d in self.byweekday])
if self.interval == 2:
retval = _("Fortnightly on {days}").format(days=retval)
elif self.interval > 2:
retval = _("Every {n} weeks on {days}").format(n=self.interval,
days=retval)
return retval
def __getMonthlyWhen(self, offset):
of = " "+_("of the month")
retval = self.__getMonthlyYearlyWhen(offset, of)
if self.interval >= 2:
retval = _("{when}, every {n} months") \
.format(when=retval, n=self.interval)
return retval
def __getYearlyWhen(self, offset):
months = hrJoin([MONTH_NAMES[m] for m in self.bymonth])
of = " "+_("of {months}").format(months=months)
retval = self.__getMonthlyYearlyWhen(offset, of)
if self.interval >= 2:
retval = _("{when}, every {n} years") \
.format(when=retval, n=self.interval)
return retval
def __getMonthlyYearlyWhen(self, offset, of):
if self.byweekday:
retval = self.__getWhenByWeekday(offset, of)
elif len(self.bymonthday) == 1:
retval = self.__getWhenByMonthday(offset, of)
else:
retval = self.__getWhenWithOffsetMonthdays(offset, of)
return retval
def __getWhenByWeekday(self, offset, of):
if (len(self.byweekday) == 7 and
all(not day.n for day in self.byweekday)):
retval = _("Everyday")
else:
retval = hrJoin([d._getWhen(offset) for d in self.byweekday])
if not self.byweekday[0].n:
retval = _("Every {when}").format(when=retval)
else:
retval = _("The {when}").format(when=retval)
retval += of
return retval
def __getWhenByMonthday(self, offset, of):
daysOffset = ""
d = self.bymonthday[0]
if d == 1 and offset < 0:
# bump first day to previous month
d = offset
if self.freq != MONTHLY:
months = [WRAPPED_MONTH_NAMES[m-1] for m in self.bymonth]
of = " "+_("of {months}").format(months=hrJoin(months))
elif d == -1 and offset > 0:
# bump last day to next month
d = offset
if self.freq != MONTHLY:
months = [WRAPPED_MONTH_NAMES[m+1] for m in self.bymonth]
of = " "+_("of {months}").format(months=hrJoin(months))
elif 0 < d + offset <= 28:
# adjust within the month
d += offset
else:
# too complicated don't adjust for any offset
daysOffset = toDaysOffsetStr(offset)
theOrdinal = toTheOrdinal(d, inTitleCase=False)
if daysOffset:
retval = _("{DaysOffset} {theOrdinal} day") \
.format(DaysOffset=daysOffset, theOrdinal=theOrdinal)
else:
TheOrdinal = theOrdinal[0].upper() + theOrdinal[1:]
retval = _("{TheOrdinal} day").format(TheOrdinal=TheOrdinal)
retval += of
return retval
def __getWhenWithOffsetMonthdays(self, offset, of):
theOrdinal = hrJoin([toTheOrdinal(d, False) for d in self.bymonthday])
if offset != 0:
retval = _("{DaysOffset} {theOrdinal} day") \
.format(DaysOffset=toDaysOffsetStr(offset),
theOrdinal=theOrdinal)
else:
TheOrdinal = theOrdinal[0].upper() + theOrdinal[1:]
retval = _("{TheOrdinal} day").format(TheOrdinal=TheOrdinal)
retval += of
return retval
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------