# ------------------------------------------------------------------------------
# Joyous events models
# ------------------------------------------------------------------------------
import datetime as dt
from operator import attrgetter
from collections import OrderedDict
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.db import models
from django.db.models import Q
from django.db.models.query import ModelIterable
from django import forms
from django.forms import widgets
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils import translation
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_noop
from wagtail.core.models import Page
from wagtail.core.fields import RichTextField
from wagtail.admin.edit_handlers import (FieldPanel, MultiFieldPanel,
        PageChooserPanel)
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.search import index
from wagtail.admin.forms import WagtailAdminPageForm
from modelcluster.fields import ParentalKey
from ..utils.manythings import hrJoin
from ..utils.mixins import ProxyPageMixin
from ..utils.telltime import (todayUtc, getAwareDatetime, getLocalDatetime,
        getLocalDateAndTime, getLocalDate, getLocalTime, getLocalTimeAtDate)
from ..utils.telltime import getTimeFrom, getTimeTo
from ..utils.telltime import timeFormat, dateFormat
from ..fields import RecurrenceField
from ..forms import FormDefender, BorgPageForm
from ..edit_handlers import (TZDatePanel, ExceptionDatePanel, TimePanel,
        MapFieldPanel)
from ..holidays import Holidays
from .groups import get_group_model_string
from .event_base import (ThisEvent, EventsByDayList,
        EventManager, EventQuerySet, EventPageForm, EventBase)

# ------------------------------------------------------------------------------
# Helper types and constants
# ------------------------------------------------------------------------------
_1day  = dt.timedelta(days=1)
_2days = dt.timedelta(days=2)

# ------------------------------------------------------------------------------
# Event models
# ------------------------------------------------------------------------------
class EventWithHolidaysManager(EventManager):
    def get_queryset(self):
        return self._queryset_class(self.model).live()

    def __call__(self, request, holidays):
        # a shortcut
        return self.get_queryset().auth(request).hols(holidays)

class EventWithHolidaysQuerySet(EventQuerySet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.holidays = None

    def _clone(self):
        qs = super()._clone()
        qs.holidays = self.holidays
        return qs

    def _fetch_all(self):
        super()._fetchResults()
        # make sure we all have the same holidays
        if self.holidays is not None:
            for item in self._result_cache:
                for event in getattr(item, 'all_events', [item]):
                    page = getattr(event, 'page', event)
                    page.holidays = self.holidays
        super()._filterResults()

    def hols(self, holidays):
        qs = self._clone()
        qs.holidays = holidays
        return qs

class RecurringEventQuerySet(EventWithHolidaysQuerySet):
    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page, url=page.get_url(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

    def byDay(self, fromDate, toDate):
        request  = self.request
        holidays = self.holidays
        class ByDayIterable(ModelIterable):
            def __iter__(self):
                evods = EventsByDayList(fromDate, toDate, holidays)
                for page in super().__iter__():
                    exceptions = self.__getExceptionsFor(page)
                    closedHols = self.__getClosedForHolidays(page)
                    startDelta = dt.timedelta(days=page.num_days + 1)
                    for occurence in page.repeat.between(fromDate - startDelta,
                                                         toDate + _2days,
                                                         inc=True):
                        thisEvent = None
                        exception = exceptions.get(occurence)
                        if exception:
                            if exception.title:
                                thisEvent = exception
                        elif closedHols and closedHols._closedOn(occurence):
                            # ClosedForHolidaysPage still affects the event,
                            # even if the user is not authorized
                            if (closedHols.isAuthorized(request) and
                                closedHols.cancellation_title):
                                thisEvent = ThisEvent(closedHols.cancellation_title,
                                                      closedHols,
                                                      closedHols.get_url(request))
                        else:
                            thisEvent = ThisEvent(page, url=page.get_url(request))
                        if thisEvent:
                            pageFromDate = getLocalDate(occurence,
                                                        page.time_from, page.tz)
                            daysDelta = dt.timedelta(days=page.num_days - 1)
                            pageToDate = getLocalDate(occurence + daysDelta,
                                                      page.time_to, page.tz)
                            evods.add(thisEvent, pageFromDate, pageToDate)
                yield from evods

            def __getExceptionsFor(self, page):
                dateRange = (fromDate - _2days, toDate + _2days)
                exceptions = {}
                for extraInfo in ExtraInfoPage.events(request).child_of(page)\
                                     .filter(except_date__range=dateRange):
                    title = extraInfo.extra_title or page.title
                    exceptDate = extraInfo.except_date
                    exceptions[exceptDate] = ThisEvent(title, extraInfo,
                                                       extraInfo.get_url(request))
                for cancellation in CancellationPage.events.child_of(page)   \
                                     .filter(except_date__range=dateRange):
                    # The cancellation still affects the event, even if the
                    # user is not authorized to view the cancellation.
                    if cancellation.isAuthorized(request):
                        title = cancellation.cancellation_title
                        url = cancellation.getCancellationUrl(request)
                    else:
                        title = None
                        url = None
                    exceptDate = cancellation.except_date
                    exceptions[exceptDate] = ThisEvent(title, cancellation, url)
                # TODO consider storing extended cancellations in an interval tree?
                for shutdown in ExtCancellationPage.events.child_of(page)    \
                           .filter(cancelled_from_date__lte=dateRange[1])    \
                           .filter(Q(cancelled_to_date__gte=dateRange[0]) |
                                   Q(cancelled_to_date__isnull = True)):
                    if shutdown.isAuthorized(request):
                        title = shutdown.cancellation_title
                        url = shutdown.get_url(request)
                    else:
                        title = None
                        url = None
                    thisEvent = ThisEvent(title, shutdown, url)
                    for myDate in shutdown._getMyRawDates(*dateRange):
                        exceptions[myDate] = thisEvent
                return exceptions

            def __getClosedForHolidays(self, page):
                closedHols = ClosedForHolidaysPage.events.hols(holidays)     \
                                                  .child_of(page).first()
                return closedHols

        qs = self._clone()
        qs._iterable_class = ByDayIterable
        return qs

# Panel trickery needed as editing proxy models doesn't work yet :-(
class HiddenNumDaysPanel(FieldPanel):
    class widget(widgets.NumberInput):
        def value_from_datadict(self, data, files, name):
            # validation doesn't like num_days disappearing
            return data.get(name, "1")

    def __init__(self, field_name="num_days", *args, **kwargs):
        super().__init__(field_name, *args, **kwargs)

    def render_as_object(self):
        return super().render_as_object() if self._show() else ""

    def render_as_field(self):
        return super().render_as_field() if self._show() else ""

    def _show(self):
        page = getattr(self, 'instance', None)
        if isinstance(page, (MultidayRecurringEventPage,
                             RescheduleMultidayEventPage)):
            retval = True
        else:
            numDays = getattr(page, 'num_days', 0)
            retval = numDays > 1
        return retval

class RecurringEventPageForm(EventPageForm):
    def _checkStartBeforeEnd(self, cleaned_data):
        numDays = cleaned_data.get('num_days', 1)
        if numDays == 1:
            super()._checkStartBeforeEnd(cleaned_data)

class RecurringEventPage(EventBase, Page, metaclass=FormDefender):
    events = EventWithHolidaysManager.from_queryset(RecurringEventQuerySet)()

    class Meta:
        verbose_name = _("recurring event page")
        verbose_name_plural = _("recurring event pages")
        default_manager_name = "objects"

    parent_page_types = ["joyous.CalendarPage",
                         "joyous.SpecificCalendarPage",
                         "joyous.GeneralCalendarPage",
                         get_group_model_string()]
    subpage_types = ['joyous.ExtraInfoPage',
                     'joyous.CancellationPage',
                     'joyous.ExtCancellationPage',
                     'joyous.ClosedForHolidaysPage',
                     'joyous.PostponementPage']
    base_form_class = RecurringEventPageForm
    MAX_REPEAT_COUNT = 500

    repeat   = RecurrenceField(_("repeat"))
    num_days = models.IntegerField(_("number of days"), default=1,
                                   validators=[MinValueValidator(1),
                                               MaxValueValidator(99)])

    content_panels0 = Page.content_panels + [
        FieldPanel('category'),
        ImageChooserPanel('image'),
        FieldPanel('repeat')]
    content_panels1 = [
        TimePanel('time_from'),
        TimePanel('time_to'),
        FieldPanel('tz'),
        ] + EventBase.content_panels1
    content_panels = content_panels0 + [HiddenNumDaysPanel()] + content_panels1

    # Anything inheriting from models.Model needs its own __init__ or
    # modeltranslation patch_constructor may break it
    def __init__(self, *args, **kwargs):
        self.holidays = kwargs.pop("holidays", None)
        super().__init__(*args, **kwargs)

    @property
    def next_date(self):
        """
        Date when this event is next scheduled to occur in the local time zone
        (Does not include postponements, but does exclude cancellations)
        """
        myNextDt = self.__after(timezone.localtime(timezone=self.tz))
        if myNextDt is not None:
            return getLocalDate(myNextDt.date(), self.time_from,
                                self.tz, dt.time.min)

    @property
    def _current_datetime_from(self):
        """
        The datetime this event will start or did start in the local timezone, or
        None if it is finished.
        """
        myNow = timezone.localtime(timezone=self.tz)
        timeFrom = getTimeFrom(self.time_from)
        timeTo = getTimeTo(self.time_to)
        # Yes this ignores DST transitions and milliseconds
        timeDelta = dt.timedelta(days    = self.num_days - 1,
                                 hours   = timeTo.hour   - timeFrom.hour,
                                 minutes = timeTo.minute - timeFrom.minute,
                                 seconds = timeTo.second - timeFrom.second)
        myNextDt = self.__after(myNow - timeDelta,
                              excludeCancellations=True,
                              excludeExtraInfo=True)
        if myNextDt is not None:
            return getLocalDatetime(myNextDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _future_datetime_from(self):
        """
        The datetime this event next starts in the local timezone, or None if
        in the past.
        """
        myNextDt = self.__after(timezone.localtime(timezone=self.tz),
                                excludeCancellations=True,
                                excludeExtraInfo=True)
        if myNextDt is not None:
            return getLocalDatetime(myNextDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def prev_date(self):
        """
        Date when this event last occurred in the local time zone
        (Does not include postponements, but does exclude cancellations)
        """
        myPrevDt = self.__before(timezone.localtime(timezone=self.tz))
        if myPrevDt is not None:
            return getLocalDate(myPrevDt.date(), self.time_from,
                                self.tz, dt.time.min)

    @property
    def _past_datetime_from(self):
        """
        The datetime this event previously started in the local time zone, or
        None if it never did.
        """
        myPrevDt = self.__before(timezone.localtime(timezone=self.tz),
                                 excludeCancellations=True,
                                 excludeExtraInfo=True)
        # Exclude extra info pages as this is used for sorting and the extra
        # info pages sit alongside
        if myPrevDt is not None:
            return getLocalDatetime(myPrevDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _first_datetime_from(self):
        """
        The datetime this event first started in the local time zone, or None if
        it never did.
        """
        myFromDt = self._getMyFirstDatetimeFrom()
        localTZ = timezone.get_current_timezone()
        return myFromDt.astimezone(localTZ)

    @property
    def status(self):
        """
        The current status of the event (started, finished or pending).
        """
        myNow = timezone.localtime(timezone=self.tz)
        myDaysDelta = dt.timedelta(days=self.num_days - 1)
        # NB: postponements can be created after the until date
        #     so ignore that
        todayStart = getAwareDatetime(myNow.date(), dt.time.min, self.tz)
        eventStart, event = self.__afterOrPostponedTo(todayStart - myDaysDelta)
        # Will miss any postponements that started more than myDaysDelta ago
        # which might still be active if their num_days is longer than that.
        if eventStart is None:
            return "finished"
        eventDaysDelta = dt.timedelta(days=event.num_days - 1)
        eventFinish = getAwareDatetime(eventStart.date() + eventDaysDelta,
                                       event.time_to, self.tz)
        if event.time_from is None:
            eventStart += _1day
        if eventStart < myNow < eventFinish:
            # if there are two occurences on the same day then we may miss
            # that one of them has started
            return "started"
        if (self.repeat.until and eventFinish < myNow and
            self.__afterOrPostponedTo(myNow)[0] is None):
            # only just wound up, the last occurence was earlier today
            return "finished"

    @property
    def status_text(self):
        """
        A text description of the current status of the event.
        """
        status = self.status
        if status == "finished":
            return _("These events have finished.")
        else:
            return super().status_text

    @property
    def when(self):
        """
        A string describing when the event occurs (in the local time zone).
        """
        offset   = 0
        timeFrom = dateFrom = timeTo = dateTo = None
        myFromDt = self.__getMyFromDt()
        if myFromDt is not None:
            offset = timezone.localtime(myFromDt).toordinal() - myFromDt.toordinal()
            dateFrom, timeFrom = getLocalDateAndTime(myFromDt.date(), self.time_from,
                                                     self.tz, dt.time.min)
            daysDelta = dt.timedelta(days=self.num_days - 1)
            dateTo, timeTo = getLocalDateAndTime(myFromDt.date() + daysDelta,
                                                 self.time_to, self.tz)
        if dateFrom == dateTo:
            retval = _("{repeat} {atTime}").format(
                    repeat=self.repeat._getWhen(offset),
                    atTime=timeFormat(timeFrom, timeTo, gettext("at ")))
        else:
            localNumDays = (dateTo - dateFrom).days + 1
            retval = _("{repeat} {startFinishTime}").format(
                    repeat=self.repeat._getWhen(offset, localNumDays),
                    startFinishTime=timeFormat(timeFrom, timeTo,
                                               prefix=gettext("starting at "),
                                               infix=gettext("finishing at")))
        return retval.strip()

    def _getFromTime(self, atDate=None):
        """
        What was the time of this event?  Due to time zones that depends what
        day we are talking about.  If no day is given, assume today.

        :param atDate: day in local timezone for which we want the time_from
        :rtype: time_from in local timezone
        """
        if atDate is None:
            atDate = timezone.localdate()
        return getLocalTimeAtDate(atDate, self.time_from, self.tz)

    def _futureExceptions(self, request):
        """
        Returns all future extra info, cancellations and postponements created
        for this recurring event
        """
        retval = []
        for extraInfo in ExtraInfoPage.events(request).child_of(self)         \
                                      .future():
            retval.append(extraInfo)
        for cancellation in CancellationPage.events(request).child_of(self)   \
                                            .future():
            postponement = getattr(cancellation, "postponementpage", None)
            if postponement:
                retval.append(postponement)
            else:
                retval.append(cancellation)
        for shutdown in ExtCancellationPage.events(request).child_of(self)   \
                                           .future():
            retval.append(shutdown)
        closedHols = ClosedForHolidaysPage.events(request, self.holidays)    \
                                          .child_of(self).first()
        if closedHols is not None:
            retval.append(closedHols)
        retval.sort(key=attrgetter('_future_datetime_from'))
        # notice these are events not ThisEvents
        return retval

    def _nextOn(self, request):
        """
        Formatted date/time of when this event (including any postponements)
        will next be on
        """
        retval = None
        myNow = timezone.localtime(timezone=self.tz)
        myNextDt, event = self.__afterOrPostponedTo(myNow)
        if myNextDt is not None:
            nextDate, nextTime = getLocalDateAndTime(myNextDt.date(), event.time_from,
                                                     self.tz, dt.time.min)
            retval = "{} {}".format(dateFormat(nextDate),
                                    timeFormat(nextTime, prefix=gettext("at ")))
            if event is not self and event.isAuthorized(request):
                retval = format_html('<a class="inline-link" href="{}">{}</a>',
                                     event.url, retval)
        return retval

    def _occursOn(self, myDate):
        """
        Returns true iff an occurence of this event starts on this date
        (given in the event's own timezone).

        (Does not include postponements, but does exclude cancellations.)
        """
        if myDate not in self.repeat:
            return False
        if CancellationPage.events.child_of(self)                            \
                           .filter(except_date=myDate).exists():
            return False
        if ExtCancellationPage.events.child_of(self)                         \
                           .filter(cancelled_from_date__lte=myDate)          \
                           .filter(Q(cancelled_to_date__gte=myDate) |
                                   Q(cancelled_to_date__isnull = True))      \
                           .exists():
            return False
        closedHols = ClosedForHolidaysPage.events.hols(self.holidays)        \
                                          .child_of(self).first()
        if closedHols and closedHols._closedOn(myDate):
            return False
        return True

    def _getMyFirstDatetimeFrom(self):
        """
        The datetime this event first started, or None if it never did.
        """
        myStartDt = getAwareDatetime(self.repeat.dtstart, None,
                                     self.tz, dt.time.min)
        return self.__after(myStartDt, excludeCancellations=False)

    def _getMyFirstDatetimeTo(self, myFirstDt=False):
        """
        The datetime this event first finished, or None if it never did.
        """
        if myFirstDt is False:
            myFirstDt = self._getMyFirstDatetimeFrom()
        if myFirstDt is not None:
            daysDelta = dt.timedelta(days=self.num_days - 1)
            return getAwareDatetime(myFirstDt.date() + daysDelta,
                                    self.time_to,
                                    self.tz, dt.time.max)

    def _getMyNextDate(self):
        """
        Date when this event is next scheduled to occur in my time zone
        (Does not include postponements, but does exclude cancellations)
        """
        myNextDt = self.__after(timezone.localtime(timezone=self.tz))
        if myNextDt is not None:
            return myNextDt.date()

    def __getMyFromDt(self):
        """
        Get the datetime of the next event after or before now in my timezone.
        """
        myNow = timezone.localtime(timezone=self.tz)
        return self.__after(myNow) or self.__before(myNow)

    def __afterOrPostponedTo(self, fromDt):
        after = self.__after(fromDt)
        # We know all postponement exception dates are in the parent time zone
        if after:
            # is there a postponed event before that?
            # nb: range is inclusive
            dateRange = (fromDt.date(), after.date())
            postponements = PostponementPage.events.child_of(self)           \
                                     .filter(date__range=(dateRange))        \
                                     .order_by('date', 'time_from')
            for postponement in postponements:
                postDt = getAwareDatetime(postponement.date,
                                          postponement.time_from,
                                          self.tz, dt.time.min)
                postDtMax = getAwareDatetime(postponement.date,
                                             postponement.time_from,
                                             self.tz, dt.time.max)
                if postDt < after and postDtMax >= fromDt:
                    return (postDt, postponement)
        else:
            # is there a postponed event then?
            postponements = PostponementPage.events.child_of(self)           \
                                     .filter(date__gte=fromDt.date())        \
                                     .order_by('date', 'time_from')
            for postponement in postponements:
                postDt = getAwareDatetime(postponement.date,
                                          postponement.time_from,
                                          self.tz, dt.time.min)
                postDtMax = getAwareDatetime(postponement.date,
                                             postponement.time_from,
                                             self.tz, dt.time.max)
                if postDtMax >= fromDt:
                    return (postDt, postponement)

        if after is not None:
            return (after, self)
        else:
            return (None, None)

    def __after(self, fromDt, excludeCancellations=True, excludeExtraInfo=False):
        fromDate = fromDt.date()
        if self.time_from and self.time_from < fromDt.time():
            fromDate += _1day
        exceptions = set()
        shutdowns  = []
        closedHols = None
        if excludeCancellations:
            for cancelled in CancellationPage.events.child_of(self)          \
                           .filter(except_date__gte=fromDate):
                exceptions.add(cancelled.except_date)
            # TODO consider storing extended cancellations in an interval tree?
            shutdowns = ExtCancellationPage.events.child_of(self) \
                           .filter(Q(cancelled_to_date__gte=fromDate) |
                                   Q(cancelled_to_date__isnull = True))
            closedHols = ClosedForHolidaysPage.events.hols(self.holidays)    \
                                              .child_of(self).first()
        if excludeExtraInfo:
            for info in ExtraInfoPage.events.child_of(self)                  \
                                     .filter(except_date__gte=fromDate)      \
                                     .exclude(extra_title=""):
                exceptions.add(info.except_date)
        for occurence in self.repeat.xafter(fromDate,
                                            count=self.MAX_REPEAT_COUNT,
                                            inc=True):
            if occurence in exceptions:
                continue
            shutdown = next((shutdown for shutdown in shutdowns
                             if shutdown._closedOn(occurence)), None)
            if shutdown is not None:
                if shutdown.cancelled_to_date is None:
                    break
                else:
                    # the next line is run; coverage is wrong
                    continue  # pragma: no cover
            if closedHols and closedHols._closedOn(occurence):
                continue
            return getAwareDatetime(occurence, self.time_from,
                                    self.tz, dt.time.min)

    def __before(self, fromDt, excludeCancellations=True, excludeExtraInfo=False):
        fromDate = fromDt.date()
        if self.time_from and self.time_from > fromDt.time():
            fromDate -= _1day
        exceptions = set()
        shutdowns  = []
        if excludeCancellations:
            for cancelled in CancellationPage.events.child_of(self)          \
                           .filter(except_date__lte=fromDate):
                exceptions.add(cancelled.except_date)
            shutdowns = ExtCancellationPage.events.child_of(self)            \
                           .filter(cancelled_from_date__lte=fromDate)
            closedHols = ClosedForHolidaysPage.events.hols(self.holidays)    \
                                              .child_of(self).first()
        if excludeExtraInfo:
            for info in ExtraInfoPage.events.child_of(self)                  \
                                     .filter(except_date__lte=fromDate)      \
                                     .exclude(extra_title=""):
                exceptions.add(info.except_date)
        last = None
        for occurence in self.repeat:
            if occurence > fromDate:
                break
            if occurence in exceptions:
                continue
            if any(shutdown._closedOn(occurence) for shutdown in shutdowns):
                continue
            if closedHols and closedHols._closedOn(occurence):
                continue
            last = occurence

        if last is not None:
            return getAwareDatetime(last, self.time_from, self.tz, dt.time.min)

# ------------------------------------------------------------------------------
class MultidayRecurringEventPage(ProxyPageMixin, RecurringEventPage):
    """
    A proxy of RecurringEventPage that exposes the hidden num_days field.
    """
    class Meta(ProxyPageMixin.Meta):
        verbose_name = _("multiday recurring event page")
        verbose_name_plural = _("multiday recurring event pages")

    subpage_types = ['joyous.ExtraInfoPage',
                     'joyous.CancellationPage',
                     'joyous.ExtCancellationPage',
                     'joyous.RescheduleMultidayEventPage']
    template = "joyous/recurring_event_page.html"

    content_panels = RecurringEventPage.content_panels0 + [
        FieldPanel('num_days'),
        ] + RecurringEventPage.content_panels1

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

# ------------------------------------------------------------------------------
# EventException models
# ------------------------------------------------------------------------------
class EventExceptionBase(models.Model):
    class Meta:
        abstract = True

    # overrides is also the parent, but parent is not set until the
    # child is saved and added.  (NB: is published version of parent)
    overrides = models.ForeignKey('joyous.RecurringEventPage',
                                  null=True, blank=False,
                                  related_name='+',
                                  verbose_name=_("overrides"),
                                  # can't set to CASCADE, so go with SET_NULL
                                  on_delete=models.SET_NULL)
    overrides.help_text = _("The recurring event that we are updating.")

    # Original properties
    num_days    = property(attrgetter("overrides.num_days"))
    time_from   = property(attrgetter("overrides.time_from"))
    time_to     = property(attrgetter("overrides.time_to"))
    tz          = property(attrgetter("overrides.tz"))
    group       = property(attrgetter("overrides.group"))
    category    = property(attrgetter("overrides.category"))
    image       = property(attrgetter("overrides.image"))
    location    = property(attrgetter("overrides.location"))
    website     = property(attrgetter("overrides.website"))

    # NB: exceptions do not need _first_datetime_from

    # Init these variables to prevent template DEBUG messages
    # Yes, this is very ugly.  An alternative solution would be welcome.
    cancellation_details = None
    extra_information    = None
    postponed_from_when  = None

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def at(self):
        """
        A string describing what time the event starts (in the local time zone).
        """
        return timeFormat(self._getFromTime())

    @property
    def overrides_repeat(self):
        """
        The recurrence rule of the event being overridden.
        """
        return getattr(self.overrides, 'repeat', None)

    def get_context(self, request, *args, **kwargs):
        retval = super().get_context(request, *args, **kwargs)
        retval['overrides'] = self.overrides
        retval['themeCSS'] = getattr(settings, "JOYOUS_THEME_CSS", "")
        return retval

    # TOO CONFUSING.  Stick with English and the event's timezone
    # def get_admin_display_title(self):
    #     if self.has_unpublished_changes:
    #         # WARNING this could be expensive
    #         page = self.get_latest_revision().as_page_object()
    #     else:
    #         page = self
    #     return page.local_title

    def isAuthorized(self, request):
        """
        Is the user authorized for the requested action with this event?
        """
        restrictions = self.get_view_restrictions()
        if restrictions and request is None:
            return False
        else:
            return all(restriction.accept_request(request)
                       for restriction in restrictions)

    def _copyFieldsFromParent(self, parent):
        """
        Copy across field values from the recurring event parent.
        """
        self.overrides = parent

# ------------------------------------------------------------------------------
class DateExceptionQuerySet(EventQuerySet):
    def current(self):
        qs = super().current()
        return qs.filter(except_date__gte = todayUtc() - _1day)

    def future(self):
        qs = super().future()
        return qs.filter(except_date__gte = todayUtc() - _1day)

    def past(self):
        qs = super().past()
        return qs.filter(except_date__lte = todayUtc() + _1day)

class DateExceptionPageForm(BorgPageForm):
    def _checkSlugAvailable(self, cleaned_data, slugName=None):
        if slugName is None:
            slugName = self.instance.slugName
        description = getattr(self, 'description', "a {}".format(slugName))
        exceptDate = cleaned_data.get('except_date', "invalid")
        slug = "{}-{}".format(exceptDate, slugName)
        if not Page._slug_is_available(slug, self.parent_page, self.instance):
            self.add_error('except_date',
                           _("That date already has") + " " + description.lower())

class DateExceptionBase(EventExceptionBase):
    class Meta:
        abstract = True

    events = EventManager.from_queryset(DateExceptionQuerySet)()

    except_date = models.DateField(_("For Date"))
    except_date.help_text = _("For this date")

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def local_title(self):
        """
        Localised version of the human-readable title of the page.
        """
        name = self.title.partition(" for ")[0]
        exceptDate = getLocalDate(self.except_date, self.time_from, self.tz)
        title = _("{exception} for {date}").format(exception=_(name),
                                                   date=dateFormat(exceptDate))
        return title

    @property
    def when(self):
        """
        A string describing when the event occurs (in the local time zone).
        """
        return self._getLocalWhen(self.except_date, self.num_days)

    def full_clean(self, *args, **kwargs):
        """
        Apply fixups that need to happen before per-field validation occurs.
        Sets the page's title.
        """
        name = self.slugName.title()
        # generate the title and slug in English
        # the translation of the title will happen in the property local_title
        with translation.override("en"):
            # FIXME? year may be missing which makes the title ambiguous
            self.title = "{} for {}".format(name, dateFormat(self.except_date))
            self.slug = "{}-{}".format(self.except_date, self.slugName)
        super().full_clean(*args, **kwargs)

    def _getLocalWhen(self, date_from, num_days=1):
        """
        Returns a string describing when the event occurs (in the local time zone).
        """
        dateFrom, timeFrom = getLocalDateAndTime(date_from, self.time_from,
                                                 self.tz, dt.time.min)
        if num_days > 1 or self.time_to is not None:
            daysDelta = dt.timedelta(days=self.num_days - 1)
            dateTo, timeTo = getLocalDateAndTime(date_from + daysDelta,
                                                 self.time_to, self.tz)
        else:
            dateTo = dateFrom
            timeTo = None

        if dateFrom == dateTo:
            retval = _("{date} {atTime}").format(
                    date=dateFormat(dateFrom),
                    atTime=timeFormat(timeFrom, timeTo, gettext("at ")))
        else:
            # Friday the 10th of April for 3 days starting at 1pm finishing at 10am
            # TODO Is "Friday the 10th of April at 1pm to Sunday the 12th of April at 10am" better????
            localNumDays = (dateTo - dateFrom).days + 1
            retval = _("{date} for {n} days {startTime}").format(
                    date=dateFormat(dateFrom),
                    n=localNumDays,
                    startTime=timeFormat(timeFrom, prefix=gettext("starting at ")))
            retval = _("{dateAndStartTime} {finishTime}").format(
                    dateAndStartTime=retval.strip(),
                    finishTime=timeFormat(timeTo, prefix=gettext("finishing at ")))
        return retval.strip()

    def _getFromTime(self, atDate=None):
        """
        Time that the event starts (in the local time zone).
        """
        return getLocalTime(self.except_date, self.time_from, self.tz)

    def _getFromDt(self):
        """
        Datetime that the event starts (in the local time zone).
        """
        return getLocalDatetime(self.except_date, self.time_from, self.tz)

    def _getToDt(self):
        """
        Datetime that the event ends (in the local time zone).
        """
        daysDelta = dt.timedelta(days=self.num_days - 1)
        return getAwareDatetime(self.except_date + daysDelta, self.time_to, self.tz)

    def _copyFieldsFromParent(self, parent):
        """
        Copy across field values from the recurring event parent.
        """
        super()._copyFieldsFromParent(parent)
        self.except_date = parent._getMyNextDate()

# ------------------------------------------------------------------------------
class ExtraInfoQuerySet(DateExceptionQuerySet):
    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page.extra_title, page,
                                    page.get_url(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

class ExtraInfoPageForm(DateExceptionPageForm):
    description = _("Extra Information")

    def clean(self):
        cleaned_data = super().clean()
        self._checkSlugAvailable(cleaned_data)
        return cleaned_data

class ExtraInfoPage(DateExceptionBase, Page, metaclass=FormDefender):
    class Meta:
        verbose_name = _("extra event information")
        verbose_name_plural = _("extra event information")
        default_manager_name = "objects"

    events = EventManager.from_queryset(ExtraInfoQuerySet)()
    parent_page_types = ["joyous.RecurringEventPage",
                         "joyous.MultidayRecurringEventPage"]
    subpage_types = []
    base_form_class = ExtraInfoPageForm
    slugName    = "extra-info"
    gettext_noop("Extra-Info")

    extra_title = models.CharField(_("title"), max_length=255, blank=True)
    extra_title.help_text = _("A more specific title for this occurence (optional)")
    extra_information = RichTextField(_("extra information"), blank=True)
    extra_information.help_text = _("Information just for this date")

    search_fields = Page.search_fields + [
        index.SearchField('extra_title'),
        index.SearchField('extra_information'),
    ]
    # Note title is not displayed
    content_panels = [
        PageChooserPanel('overrides'),
        ExceptionDatePanel('except_date'),
        FieldPanel('extra_title', classname="full title"),
        FieldPanel('extra_information', classname="full"),
        ]
    promote_panels = []

    # Original properties
    details     = property(attrgetter("overrides.details"))

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def status(self):
        """
        The current status of the event (started, finished or pending).
        """
        now = timezone.localtime()
        if self._getToDt() < now:
           return "finished"
        elif self._getFromDt() < now:
           return "started"

    @property
    def status_text(self):
        """
        A text description of the current status of the event.
        """
        status = self.status
        if status == "finished":
            return _("This event has finished.")
        elif status == "started":
            return _("This event has started.")
        else:
            return ""

    @property
    def _current_datetime_from(self):
        """
        The datetime this event will start or did start in the local timezone, or
        None if it is finished.
        """
        # _occursOn means a lookup of CancellationPage and ClosedForHolidaysPage
        # possible performance problem?
        if not self.overrides._occursOn(self.except_date):
            return None
        fromDt = self._getFromDt()
        toDt = self._getToDt()
        return fromDt if toDt >= timezone.localtime() else None

    @property
    def _future_datetime_from(self):
        """
        The datetime this event next starts in the local timezone, or None if
        in the past.
        """
        # possible performance problem?
        if not self.overrides._occursOn(self.except_date):
            return None
        fromDt = self._getFromDt()
        return fromDt if fromDt >= timezone.localtime() else None

    @property
    def _past_datetime_from(self):
        """
        The datetime this event previously started in the local timezone, or
        None if it never did.
        """
        # possible performance problem?
        if not self.overrides._occursOn(self.except_date):
            return None
        fromDt = self._getFromDt()
        return fromDt if fromDt < timezone.localtime() else None

# ------------------------------------------------------------------------------
class CancellationQuerySet(DateExceptionQuerySet):
    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page.cancellation_title, page,
                                    page.getCancellationUrl(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

class CancellationPageForm(DateExceptionPageForm):
    description = _("a cancellation")

    def clean(self):
        cleaned_data = super().clean()
        self._checkSlugAvailable(cleaned_data)
        self._checkSlugAvailable(cleaned_data, "postponement")
        return cleaned_data

class CancellationBase(models.Model):
    class Meta:
        abstract = True

    cancellation_title = models.CharField(_("title"), max_length=255, blank=True)
    cancellation_title.help_text = _("Show in place of cancelled event "
                                     "(Leave empty to show nothing)")
    cancellation_details = RichTextField(_("details"), blank=True)
    cancellation_details.help_text = _("Why was the event cancelled?")

    search_fields = Page.search_fields + [
        index.SearchField('cancellation_title'),
        index.SearchField('cancellation_details'),
    ]

    cancellation_panel = MultiFieldPanel([
            FieldPanel('cancellation_title', classname="full title"),
            FieldPanel('cancellation_details', classname="full")],
            heading=_("Cancellation"))

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def status(self):
        """
        The current status of the event
        """
        return "cancelled"

    @property
    def status_text(self):
        """
        A text description of the current status of the event.
        """
        return _("This event has been cancelled.")

class CancellationPage(CancellationBase, DateExceptionBase, Page):
    class Meta:
        verbose_name = _("cancellation")
        verbose_name_plural = _("cancellations")
        default_manager_name = "objects"

    events = EventManager.from_queryset(CancellationQuerySet)()
    parent_page_types = ["joyous.RecurringEventPage",
                         "joyous.MultidayRecurringEventPage"]
    subpage_types = []
    base_form_class = CancellationPageForm
    slugName = "cancellation"
    gettext_noop("Cancellation")

    search_fields = Page.search_fields + CancellationBase.search_fields

    # Note title is not displayed
    content_panels = [
        PageChooserPanel('overrides'),
        ExceptionDatePanel('except_date'),
        CancellationBase.cancellation_panel,
        ]
    promote_panels = []

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def _current_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if it would have finished by now.
        """
        if self.except_date not in self.overrides.repeat:
            return None
        fromDt = self._getFromDt()
        toDt = self._getToDt()
        return fromDt if toDt >= timezone.localtime() else None

    @property
    def _future_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if that is in the past.
        """
        if self.except_date not in self.overrides.repeat:
            return None
        fromDt = self._getFromDt()
        return fromDt if fromDt >= timezone.localtime() else None

    @property
    def _past_datetime_from(self):
        """
        The datetime of the event that was cancelled in the local timezone, or
        None if it never would have.
        """
        if self.except_date not in self.overrides.repeat:
            return None
        fromDt = self._getFromDt()
        return fromDt if fromDt < timezone.localtime() else None

    def getCancellationUrl(self, request=None):
        """
        The URL to a page describing the cancellation for Postponements and
        plain Cancellations.
        """
        url = self.get_url(request)
        if hasattr(self, "postponementpage"):
            if url[-1] != '/':
                url += "/from"
            else:
                url += "from/"
        return url

    cancellation_url = property(getCancellationUrl)

# ------------------------------------------------------------------------------
class PostponementQuerySet(EventQuerySet):
    def current(self):
        qs = super().current()
        return qs.filter(date__gte = todayUtc() - _1day)

    def future(self):
        qs = super().future()
        return qs.filter(date__gte = todayUtc() - _1day)

    def past(self):
        qs = super().past()
        return qs.filter(date__lte = todayUtc() + _1day)

    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page.postponement_title,
                                    page, page.get_url(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

    def byDay(self, fromDate, toDate):
        request = self.request
        class ByDayIterable(ModelIterable):
            def __iter__(self):
                evods = EventsByDayList(fromDate, toDate)
                for page in super().__iter__():
                    thisEvent = ThisEvent(page.postponement_title,
                                          page, page.get_url(request))
                    pageFromDate = getLocalDate(page.date,
                                                page.time_from, page.tz)
                    daysDelta = dt.timedelta(days=page.num_days - 1)
                    pageToDate = getLocalDate(page.date + daysDelta,
                                              page.time_to, page.tz)
                    evods.add(thisEvent, pageFromDate, pageToDate)
                yield from evods

        qs = self._clone()
        qs._iterable_class = ByDayIterable
        return qs.filter(date__range=(fromDate - _1day, toDate + _1day))

class PostponementPageForm(DateExceptionPageForm):
    description = _("a postponement")

    def clean(self):
        cleaned_data = super().clean()
        self._checkSlugAvailable(cleaned_data)
        self._checkSlugAvailable(cleaned_data, "cancellation")
        self._checkStartBeforeEnd(cleaned_data)
        return cleaned_data

    def _checkStartBeforeEnd(self, cleaned_data):
        numDays   = cleaned_data.get('num_days', 1)
        startTime = getTimeFrom(cleaned_data.get('time_from'))
        endTime   = getTimeTo(cleaned_data.get('time_to'))
        if numDays == 1 and startTime > endTime:
            self.add_error('time_to', _("Event cannot end before it starts"))

class RescheduleEventBase(EventBase):
    """
    This class exists just so that the class attributes defined here are
    picked up by Django before the properties from EventExceptionBase,
    as well as before EventBase.
    """
    class Meta:
        abstract = True

    # num_days from here to come before overrides.num_days
    num_days = models.IntegerField(_("number of days"), default=1,
                                   validators=[MinValueValidator(1),
                                               MaxValueValidator(99)])
    # Original properties
    tz          = property(attrgetter("overrides.tz"))
    group       = property(attrgetter("overrides.group"))
    uid         = property(attrgetter("overrides.uid"))
    group_page  = None
    get_context = DateExceptionBase.get_context

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

class PostponementPage(RoutablePageMixin, RescheduleEventBase, CancellationPage):
    class Meta:
        verbose_name = _("postponement")
        verbose_name_plural = _("postponements")
        default_manager_name = "objects"

    events = EventManager.from_queryset(PostponementQuerySet)()
    parent_page_types = ["joyous.RecurringEventPage"]
    subpage_types = []
    base_form_class = PostponementPageForm
    slugName = "postponement"
    gettext_noop("Postponement")

    postponement_title = models.CharField(_("title"), max_length=255)
    postponement_title.help_text = _("The title for the postponed event")
    date = models.DateField(_("date"))

    search_fields = Page.search_fields + [
        index.SearchField('postponement_title'),
    ]

    postponement_panel0 = [
            FieldPanel('postponement_title', classname="full title"),
            ImageChooserPanel('image'),
            FieldPanel('date')]
    postponement_panel1 = [
            TimePanel('time_from'),
            TimePanel('time_to'),
            FieldPanel('details', classname="full"),
            MapFieldPanel('location'),
            FieldPanel('website')]
    postponement_panel = MultiFieldPanel(
            postponement_panel0 + [HiddenNumDaysPanel()] + postponement_panel1,
            heading=_("Postponed to"))
    content_panels = [
        PageChooserPanel('overrides'),
        ExceptionDatePanel('except_date'),
        CancellationBase.cancellation_panel,
        postponement_panel,
    ]
    promote_panels = []

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @route(r"^from/$")
    def serveCancellation(self, request):
        return TemplateResponse(request,
                                "joyous/postponement_page_from.html",
                                self.get_context(request))

    @property
    def when(self):
        """
        A string describing when the postponement occurs (in the local time zone).
        """
        return DateExceptionBase._getLocalWhen(self, self.date, self.num_days)

    @property
    def postponed_from_when(self):
        """
        A string describing when the event was postponed from (in the local time zone).
        """
        what = self.what
        if what:
            return _("{what} from {when}").format(what=what,
                                                  when=self.cancellationpage.when)

    @property
    def what(self):
        """
        May return a 'postponed' or 'rescheduled' string depending what
        the start and finish time of the event has been changed to.
        """
        originalFromDt = dt.datetime.combine(self.except_date,
                                             getTimeFrom(self.overrides.time_from))
        changedFromDt = dt.datetime.combine(self.date, getTimeFrom(self.time_from))
        originalDaysDelta = dt.timedelta(days=self.overrides.num_days - 1)
        originalToDt = dt.datetime.combine(self.except_date + originalDaysDelta,
                                           getTimeTo(self.overrides.time_to))
        changedDaysDelta = dt.timedelta(days=self.num_days - 1)
        changedToDt = dt.datetime.combine(self.except_date + changedDaysDelta,
                                          getTimeTo(self.time_to))
        if originalFromDt < changedFromDt:
            return _("Postponed")
        elif originalFromDt > changedFromDt or originalToDt != changedToDt:
            return _("Rescheduled")
        else:
            return None

    @property
    def postponed_from(self):
        """
        Date that the event was postponed from (in the local time zone).
        """
        fromDate = getLocalDate(self.except_date, self.time_from, self.tz)
        return dateFormat(fromDate)

    @property
    def postponed_to(self):
        """
        Date that the event was postponed to (in the local time zone).
        """
        toDate = getLocalDate(self.date, self.time_from, self.tz)
        return dateFormat(toDate)

    def _getFromTime(self, atDate=None):
        """
        Time that the postponement starts (in the local time zone).
        """
        return getLocalTime(self.date, self.time_from, self.tz)

    def _getFromDt(self):
        """
        Datetime that the postponement starts (in the local time zone).
        """
        return getLocalDatetime(self.date, self.time_from, self.tz)

    def _getToDt(self):
        """
        Datetime that the postponement ends (in the local time zone).
        """
        daysDelta = dt.timedelta(days=self.num_days - 1)
        return getLocalDatetime(self.date + daysDelta, self.time_to, self.tz)

    def _copyFieldsFromParent(self, parent):
        """
        Copy across field values from the recurring event parent.
        """
        super()._copyFieldsFromParent(parent)
        parentFields = set()
        for panel in parent.content_panels:
            parentFields.update(panel.required_fields())
        pageFields = set()
        for panel in self.content_panels:
            pageFields.update(panel.required_fields())
        commonFields = parentFields & pageFields
        for name in commonFields:
            setattr(self, name, getattr(parent, name))
        if self.except_date:
            self.date = self.except_date + dt.timedelta(days=1)
        self.postponement_title = parent.title

# ------------------------------------------------------------------------------
class RescheduleMultidayEventPage(ProxyPageMixin, PostponementPage):
    """
    A proxy of PostponementPage that exposes the hidden num_days field.
    """
    class Meta(ProxyPageMixin.Meta):
        verbose_name = _("postponement")
        verbose_name_plural = _("postponements")

    parent_page_types = ["joyous.MultidayRecurringEventPage"]
    template = "joyous/postponement_page.html"

    postponement_panel = MultiFieldPanel(
            PostponementPage.postponement_panel0 +
            [FieldPanel('num_days')] +
            PostponementPage.postponement_panel1,
            heading=_("Postponed to"))
    content_panels = [
        PageChooserPanel('overrides'),
        ExceptionDatePanel('except_date'),
        PostponementPage.cancellation_panel,
        postponement_panel,
    ]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

# ------------------------------------------------------------------------------
class ClosedFor(models.Model):
    """The holidays we are closed for."""
    class Meta:
        ordering = ['pk']
        unique_together = ['page', 'name']

    page = ParentalKey('joyous.ClosedForHolidaysPage',
                       on_delete=models.CASCADE,
                       related_name="closed_for")
    name = models.CharField(_("name"), max_length=100)
    # Storing holidays by name is a violation of 1NF, but holidays are
    # defined programmatically not in the database and the definitions
    # may change, so there is no holiday_id which can be used instead.

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    # used by ChoiceWidget.format_value
    def __str__(self):
        return self.name

class ClosedForHolidaysQuerySet(EventWithHolidaysQuerySet):
    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page.cancellation_title, page,
                                    page.get_url(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

class ClosedForHolidaysPageForm(BorgPageForm):
    class Media:
        css = { 'all': ["admin/css/widgets.css",
                        "joyous/css/holidays_admin.css"] }
        js = ["/django-admin/jsi18n",
              "joyous/js/holidays_admin.js"]

    holidays_class = Holidays
    description = _("closed for holidays")
    closed_for = forms.MultipleChoiceField(label=_("These holidays"),
                                           required=False,
                                           widget=FilteredSelectMultiple(
                                                            _("Holidays"),
                                                            is_stacked=False))

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['closed_for'].choices = self._holidayChoices()
        self.initial['closed_for'] = list(self.instance.closed_for.all())

    def save(self, commit=True):
        self._saveChosenHolidays()
        page = super().save(commit)
        return page

    def _holidayChoices(self):
        if self.instance.holidays is not None:
            holidays = self.instance.holidays
        else:
            holidays = self.holidays_class()
        retval = [(name, name) for name in holidays.names()]
        return retval

    def _saveChosenHolidays(self):
        chosen = self.cleaned_data.get('closed_for', [])
        initial = {day.name: day for day in self.initial['closed_for']}
        days = [initial.get(name, ClosedFor(name=name)) for name in chosen]
        self.instance.closed_for.set(days)

class ClosedForHolidaysPage(CancellationBase, EventExceptionBase, Page,
                            metaclass=FormDefender):
    class Meta:
        verbose_name = _("closed for holidays")
        verbose_name_plural = _("closed for holidays")
        default_manager_name = "objects"

    events = EventWithHolidaysManager.from_queryset(ClosedForHolidaysQuerySet)()
    parent_page_types = ["joyous.RecurringEventPage"]
    # TODO add "joyous.MultidayRecurringEventPage" : How would that work?
    # A. If the first day of the event is a holiday then the event is cancelled, or
    # B. If a holiday occurs anywhere in the event the event is cancelled, or
    # C. If every day of the event is a holiday then the event is cancelled?
    subpage_types = []
    base_form_class = ClosedForHolidaysPageForm
    MAX_REPEAT_COUNT = RecurringEventPage.MAX_REPEAT_COUNT
    MAX_DATETIME = timezone.make_aware(dt.datetime.max - _2days)
    MIN_DATETIME = timezone.make_aware(dt.datetime.min + _2days)

    all_holidays = models.BooleanField(default=True)
    all_holidays.help_text = "Cancel any occurence of this event on a public holiday"

    search_fields = Page.search_fields + CancellationBase.search_fields
    # Note title is not displayed
    content_panels = [
        PageChooserPanel('overrides'),
        MultiFieldPanel([
            FieldPanel('all_holidays'),
            FieldPanel('closed_for')],
            heading=_("Closed For")),
        CancellationBase.cancellation_panel,
        ]
    promote_panels = []

    @classmethod
    def can_create_at(cls, parent):
        retval = (super().can_create_at(parent) and
                  not cls.objects.descendant_of(parent).exists())
        return retval

    def __init__(self, *args, **kwargs):
        self.holidays = kwargs.pop("holidays", None)
        super().__init__(*args, **kwargs)
        self.__closedSet = None

    def full_clean(self, *args, **kwargs):
        """
        Apply fixups that need to happen before per-field validation occurs.
        Sets the page's title.
        """
        self.title = "Closed for holidays"
        self.slug  = "closed-for-holidays"
        super().full_clean(*args, **kwargs)

    @property
    def local_title(self):
        """
        Localised version of the human-readable title of the page.
        """
        return _("Closed for holidays")

    @property
    def status_text(self):
        """
        A text description of the current status of the event.
        """
        return _("Closed for holidays.")

    @property
    def when(self):
        """
        A string describing when the event occurs (in the local time zone).
        """
        if self.all_holidays:
            retval = _("Closed on all holidays")
        else:
            retval = _("Closed on {}").format(hrJoin(self.closed))
        return retval

    @property
    def closed(self):
        """
        The list of holidays we are closed for, or "ALL" if we are closed for
        all holidays.
        """
        if self.all_holidays:
            retval = "ALL"
        else:
            retval = [day.name for day in self.closed_for.all()]
        return retval

    @property
    def _current_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if it would have finished by now.
        """
        # TODO this code would need to change if this is going to support
        # MultidayRecurringEventPage
        return self._future_datetime_from

    @property
    def _future_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if that is in the past.
        """
        if self.holidays is None:
            return self.MAX_DATETIME
        myNextDt = self.__after(timezone.localtime(timezone=self.tz))
        if myNextDt is not None:
            return getLocalDatetime(myNextDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _past_datetime_from(self):
        """
        The datetime of the event that was cancelled in the local timezone, or
        None if it never would have.
        """
        if self.holidays is None:
            return self.MIN_DATETIME
        myPrevDt = self.__before(timezone.localtime(timezone=self.tz))
        if myPrevDt is not None:
            return getLocalDatetime(myPrevDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _closed_for_dates(self):
        """
        Return all the dates which we are closed for in the local timezone
        """
        for occurence in self._getMyDates():
            yield getLocalDate(occurence, self.time_from, self.tz)

    def _getMyDates(self):
        """
        Return all the dates which we are closed for in the event timezone
        """
        if self.holidays is not None:
            self._cacheClosedSet()
            if not self.__closedSet:
                return None
            n = 0
            for occurence in self.overrides.repeat:
                n += 1
                if n > self.MAX_REPEAT_COUNT:
                    # that's enough, bailing out
                    return None
                if self._closedOn(occurence):
                    n = 0
                    yield occurence

    def _getFromTime(self, atDate=None):
        """
        Time that the event starts (in the local time zone).
        """
        return self.overrides._getFromTime(atDate)

    def _cacheClosedSet(self):
        """
        Cache the set of names of the holidays we are closed for.
        This needs to be re-called if the page's fields change.
        """
        closed = self.closed
        if closed == "ALL":
            self.__closedSet = "ALL"
        else:
            self.__closedSet = set(self.closed)

    def _closedOn(self, myDate):
        """
        Are we closed for holidays on myDate?
        """
        if self.holidays is None:
            return False
        if self.__closedSet is None:
            self._cacheClosedSet()
        if not self.__closedSet:
            return False
        holiday = self.holidays.get(myDate)
        if holiday:
            if self.all_holidays:
                return True
            names = holiday.split(", ")
            return any(name in self.__closedSet for name in names)
        return False

    def __after(self, fromDt, excludeCancellations=True):
        fromDate = fromDt.date()
        if self.time_from and self.time_from < fromDt.time():
            fromDate += _1day
        self._cacheClosedSet()
        exceptions = set()
        shutdowns  = []
        if excludeCancellations:
            for cancelled in CancellationPage.events.child_of(self.overrides) \
                                     .filter(except_date__gte=fromDate):
                exceptions.add(cancelled.except_date)
            shutdowns = ExtCancellationPage.events.child_of(self.overrides)  \
                           .filter(Q(cancelled_to_date__gte=fromDate) |
                                   Q(cancelled_to_date__isnull = True))
        repeat = self.overrides.repeat
        for n, occurence in enumerate(repeat.xafter(fromDate, inc=True)):
            if n > self.MAX_REPEAT_COUNT:
                # that's enough, bailing out, return two days before max
                # (so we can still do TZ conversions on the result)
                return self.MAX_DATETIME
            if occurence in exceptions:
                continue
            shutdown = next((shutdown for shutdown in shutdowns
                             if shutdown._closedOn(occurence)), None)
            if shutdown is not None:
                if shutdown.cancelled_to_date is None:
                    break
                else:
                    # the next line is run; coverage is wrong
                    continue  # pragma: no cover
            if not self._closedOn(occurence):
                continue
            return getAwareDatetime(occurence, self.time_from,
                                    self.tz, dt.time.min)

    def __before(self, fromDt, excludeCancellations=True):
        fromDate = fromDt.date()
        if self.time_from and self.time_from > fromDt.time():
            fromDate -= _1day
        self._cacheClosedSet()
        exceptions = set()
        shutdowns  = []
        if excludeCancellations:
            for cancelled in CancellationPage.events.child_of(self.overrides) \
                                     .filter(except_date__lte=fromDate):
                exceptions.add(cancelled.except_date)
            shutdowns = ExtCancellationPage.events.child_of(self.overrides)  \
                           .filter(cancelled_from_date__lte=fromDate)
        last = None
        repeat = self.overrides.repeat
        for occurence in repeat:
            if occurence > fromDate:
                break
            if occurence in exceptions:
                continue
            if any(shutdown._closedOn(occurence) for shutdown in shutdowns):
                continue
            if not self._closedOn(occurence):
                continue
            last = occurence
        if last is not None:
            return getAwareDatetime(last, self.time_from, self.tz, dt.time.min)

# ------------------------------------------------------------------------------
class ExtCancellationQuerySet(EventQuerySet):
    def current(self):
        qs = super().current()
        return qs.filter(Q(cancelled_to_date__gte=todayUtc() - _1day) |
                         Q(cancelled_to_date__isnull=True))

    def future(self):
        qs = super().future()
        return qs.filter(Q(cancelled_to_date__gte=todayUtc() - _1day) |
                         Q(cancelled_to_date__isnull=True))

    def past(self):
        qs = super().past()
        return qs.filter(cancelled_from_date__lte=todayUtc() + _1day)

    def this(self):
        request = self.request
        class ThisIterable(ModelIterable):
            def __iter__(self):
                for page in super().__iter__():
                    yield ThisEvent(page.cancellation_title, page,
                                    page.get_url(request))
        qs = self._clone()
        qs._iterable_class = ThisIterable
        return qs

class ExtCancellationPageForm(BorgPageForm):
    class Media:
        css = { 'all': ["joyous/css/recurrence_admin.css"] }

    def clean(self):
        cleaned_data = super().clean()
        self._checkSlugAvailable(cleaned_data)
        return cleaned_data

    def _checkSlugAvailable(self, cleaned_data):
        fromDate = cleaned_data.get('cancelled_from_date', "invalid")
        siblings = self.parent_page.get_children().not_page(self.instance)
        pattern = r"{}-.*-cancellation".format(fromDate)
        if siblings.filter(slug__regex=pattern).exists():
            self.add_error('cancelled_from_date',
                           _("There is already an extended cancellation for then"))

class ExtCancellationPage(CancellationBase, EventExceptionBase, Page,
                          metaclass=FormDefender):
    class Meta:
        verbose_name = _("extended cancellation")
        verbose_name_plural = _("extended cancellations")
        default_manager_name = "objects"

    events = EventManager.from_queryset(ExtCancellationQuerySet)()
    parent_page_types = ["joyous.RecurringEventPage",
                         "joyous.MultidayRecurringEventPage"]
    subpage_types = []
    base_form_class = ExtCancellationPageForm
    FAR_DATE = dt.date(3000,12,31)

    cancelled_from_date = models.DateField(_("From Date"))
    cancelled_from_date.help_text = _("Cancelled from this date")
    cancelled_to_date = models.DateField(_("To Date"), null=True, blank=True)
    cancelled_to_date.help_text = _('Cancelled to this date '
                                    '(Leave empty for "until further notice")')

    search_fields = Page.search_fields + CancellationBase.search_fields
    # Note title is not displayed
    content_panels = [
        PageChooserPanel('overrides'),
        TZDatePanel('cancelled_from_date'),
        TZDatePanel('cancelled_to_date'),
        CancellationBase.cancellation_panel,
        ]
    promote_panels = []

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @property
    def local_title(self):
        """
        Localised version of the human-readable title of the page.
        """
        title = _("Cancellation from {fromDate} {untilWhen}").format(
                            fromDate=dateFormat(self.cancelled_from_date),
                            untilWhen=self.until_when)
        return title

    @property
    def until_when(self):
        """
        A string describing how long we are cancelled for
        """
        if self.cancelled_to_date is not None:
            retval = _("to {}").format(dateFormat(self.cancelled_to_date))
        else:
            retval = _("until further notice")
        return retval

    @property
    def when(self):
        """
        A string describing when the event occurs (in the local time zone).
        """
        retval = _("Cancelled from {fromDate} {untilWhen}").format(
                            fromDate=dateFormat(self.cancelled_from_date),
                            untilWhen=self.until_when)
        return retval

    @property
    def _current_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if it would have finished by now.
        """
        myNow = timezone.localtime(timezone=self.tz)
        timeFrom = getTimeFrom(self.time_from)
        timeTo = getTimeTo(self.time_to)
        # Yes this ignores DST transitions and milliseconds
        timeDelta = dt.timedelta(days    = self.num_days - 1,
                                 hours   = timeTo.hour   - timeFrom.hour,
                                 minutes = timeTo.minute - timeFrom.minute,
                                 seconds = timeTo.second - timeFrom.second)
        myNextDt = self.__after(myNow - timeDelta)
        if myNextDt is not None:
            return getLocalDatetime(myNextDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _future_datetime_from(self):
        """
        The datetime the event that was cancelled would start in the local
        timezone, or None if that is in the past.
        """
        myNextDt = self.__after(timezone.localtime(timezone=self.tz))
        if myNextDt is not None:
            return getLocalDatetime(myNextDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    @property
    def _past_datetime_from(self):
        """
        The datetime of the event that was cancelled in the local timezone, or
        None if it never would have.
        """
        myPrevDt = self.__before(timezone.localtime(timezone=self.tz))
        if myPrevDt is not None:
            return getLocalDatetime(myPrevDt.date(), self.time_from,
                                    self.tz, dt.time.max)

    def full_clean(self, *args, **kwargs):
        """
        Apply fixups that need to happen before per-field validation occurs.
        Sets the page's title.
        """
        # generate the title and slug in English
        # the translation of the title will happen in the property local_title
        with translation.override("en"):
            if self.cancelled_to_date is not None:
                # FIXME? year may be missing which makes the title ambiguous
                titleTo = "to " + dateFormat(self.cancelled_to_date)
                slugTo = str(self.cancelled_to_date)
            else:
                titleTo = "until further notice"
                slugTo = ""

            # FIXME? year may be missing which makes the title ambiguous
            self.title = "Cancellation from {} {}".format(
                                dateFormat(self.cancelled_from_date), titleTo)
            self.slug = "{}-{}-cancellation".format(
                                self.cancelled_from_date, slugTo)
        super().full_clean(*args, **kwargs)

    def _getMyDates(self, fromDate=dt.date.min, toDate=FAR_DATE):
        """
        Return all the dates which we are cancelled for in the event timezone.
        Only if the event occurs on those dates.
        Limited by fromDate and toDate if given.
        """
        if self.cancelled_from_date > fromDate:
            fromDate = self.cancelled_from_date
        if (self.cancelled_to_date is not None and
            self.cancelled_to_date < toDate):
            toDate = self.cancelled_to_date
        repeat = self.overrides.repeat
        yield from repeat.between(fromDate, toDate, inc=True)

    def _getMyRawDates(self, fromDate=dt.date.min, toDate=FAR_DATE):
        """
        Return all the dates which we are cancelled for in the event timezone.
        Without considering if the event occurs on those dates.
        Limited by fromDate and toDate if given.
        """
        if self.cancelled_from_date > fromDate:
            fromDate = self.cancelled_from_date
        if (self.cancelled_to_date is not None and
            self.cancelled_to_date < toDate):
            toDate = self.cancelled_to_date
        fromOrd = fromDate.toordinal()
        toOrd   = toDate.toordinal()
        for ord in range(fromOrd, toOrd+1):
            yield dt.date.fromordinal(ord)

    def _getFromTime(self, atDate=None):
        """
        Time that the event starts (in the local time zone).
        """
        return self.overrides._getFromTime(atDate)

    def _closedOn(self, myDate):
        """
        Are we closed on myDate?
        """
        if self.cancelled_to_date is not None:
            retval = (self.cancelled_from_date <= myDate <=
                      self.cancelled_to_date)
        else:
            retval = self.cancelled_from_date <= myDate
        return retval

    def __after(self, fromDt):
        fromDate = fromDt.date()
        if self.time_from and self.time_from < fromDt.time():
            fromDate += _1day
        if fromDate < self.cancelled_from_date:
            fromDate = self.cancelled_from_date
        occurence = self.overrides.repeat.after(fromDate, inc=True)
        if (self.cancelled_to_date is None or
            occurence <= self.cancelled_to_date):
            return getAwareDatetime(occurence, self.time_from,
                                    self.tz, dt.time.min)

    def __before(self, fromDt):
        fromDate = fromDt.date()
        if self.time_from and self.time_from > fromDt.time():
            fromDate -= _1day
        if (self.cancelled_to_date is not None and
            fromDate > self.cancelled_to_date):
            fromDate = self.cancelled_to_date
        occurence = self.overrides.repeat.before(fromDate, inc=True)
        if occurence >= self.cancelled_from_date:
            return getAwareDatetime(occurence, self.time_from,
                                    self.tz, dt.time.min)

# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
