Add day calendar to organizer page (#2100)

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2021-11-19 14:59:35 +01:00
committed by GitHub
parent f6f3bbcce6
commit 34e4f7e0fc
8 changed files with 938 additions and 14 deletions

View File

@@ -31,13 +31,15 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import calendar
import hashlib
import math
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from functools import reduce
from urllib.parse import quote
import dateutil
import isoweek
import pytz
from django.conf import settings
@@ -376,6 +378,10 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView):
cv = CalendarView()
cv.request = request
return cv.get(request, *args, **kwargs)
elif style == "day":
cv = DayCalendarView()
cv.request = request
return cv.get(request, *args, **kwargs)
elif style == "week":
cv = WeekCalendarView()
cv.request = request
@@ -441,6 +447,11 @@ def add_events_for_days(request, baseqs, before, after, ebd, timezones):
)) and event.settings.show_times
else None
),
'time_end_today': (
datetime_to.time().replace(tzinfo=None)
if date_to == d and event.settings.show_times
else None
),
'url': eventreverse(event, 'presale:event.index'),
'timezone': event.settings.timezone,
})
@@ -516,6 +527,11 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n
)) and s.show_times
else None
),
'time_end_today': (
datetime_to.time().replace(tzinfo=None)
if date_to == d and s.show_times
else None
),
'event': se,
'url': (
eventreverse(se.event, 'presale:event.redeem',
@@ -714,6 +730,334 @@ class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView):
return ebd
class DayCalendarView(OrganizerViewMixin, EventListMixin, TemplateView):
template_name = 'pretixpresale/organizers/calendar_day.html'
def _set_date_to_next_event(self):
next_ev = filter_qs_by_attr(Event.objects.using(settings.DATABASE_REPLICA).filter(
Q(date_from__gte=now()) | Q(date_to__isnull=False, date_to__gte=now()),
organizer=self.request.organizer,
live=True,
is_public=True,
date_from__gte=now(),
), self.request).order_by('date_from').first()
next_sev = filter_qs_by_attr(SubEvent.objects.using(settings.DATABASE_REPLICA).filter(
Q(date_from__gte=now()) | Q(date_to__isnull=False, date_to__gte=now()),
event__organizer=self.request.organizer,
event__is_public=True,
event__live=True,
active=True,
is_public=True,
), self.request).select_related('event').order_by('date_from').first()
datetime_from = None
if (next_ev and next_sev and next_sev.date_from < next_ev.date_from) or (next_sev and not next_ev):
datetime_from = next_sev.date_from
next_ev = next_sev.event
elif next_ev:
datetime_from = next_ev.date_from
if datetime_from:
self.tz = pytz.timezone(next_ev.settings.timezone)
self.date = datetime_from.astimezone(self.tz).date()
else:
self.tz = self.request.organizer.timezone
self.date = now().astimezone(self.tz).date()
def _set_date(self):
if 'date' in self.request.GET:
self.tz = self.request.organizer.timezone
try:
self.date = dateutil.parser.parse(self.request.GET.get('date')).date()
except ValueError:
self.date = now().astimezone(self.tz).date()
else:
self._set_date_to_next_event()
def get(self, request, *args, **kwargs):
self._set_date()
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
before = datetime(
self.date.year, self.date.month, self.date.day, 0, 0, 0, tzinfo=UTC
) - timedelta(days=1)
after = datetime(
self.date.year, self.date.month, self.date.day, 0, 0, 0, tzinfo=UTC
) + timedelta(days=1)
ctx['date'] = self.date
ctx['cal_tz'] = self.tz
ctx['before'] = before
ctx['after'] = after
ctx['has_before'], ctx['has_after'] = has_before_after(
self.request.organizer.events.filter(
sales_channels__contains=self.request.sales_channel.identifier
),
SubEvent.objects.filter(
event__organizer=self.request.organizer,
event__is_public=True,
event__live=True,
event__sales_channels__contains=self.request.sales_channel.identifier
),
before,
after,
)
ebd = self._events_by_day(before, after)
if not ebd[self.date]:
return ctx
events = ebd[self.date]
shortest_duration = self._get_shortest_duration(events).total_seconds() // 60
# pick the next biggest tick_duration based on shortest_duration, max. 180 minutes
tick_duration = next((d for d in [5, 10, 15, 30, 60, 120, 180] if d >= shortest_duration), 180)
raster_size = min(self._get_raster_size(events), tick_duration)
events, start, end = self._rasterize_events(events, tick_duration=tick_duration, raster_size=raster_size)
calendar_duration = self._get_time_duration(start, end)
ctx["calendar_duration"] = self._format_duration(calendar_duration)
ctx['time_ticks'] = self._get_time_ticks(start, end, tick_duration)
ctx['start'] = datetime.combine(self.date, start)
ctx['raster_size'] = raster_size
# ctx['end'] = end
# size of each grid-column is based on shortest event duration and raster_size
# raster_size is based on start/end times, so it could happen we have a small raster but long running events
# raster_size will always be smaller or equals tick_duration
ctx['raster_to_shortest_ratio'] = round((8 * raster_size) / shortest_duration)
ctx['events'] = events
events_by_series = self._grid_for_template(events)
ctx['collections'] = events_by_series
ctx['no_headlines'] = not any([series for series, events in events_by_series])
ctx['multiple_timezones'] = self._multiple_timezones
return ctx
def _get_raster_size(self, events):
# get best raster-size for min. # of columns in grid
# due to grid-col-calculations in CSS raster_size cannot be bigger than 60 (minutes)
# all start- and end-times (minute-part) except full hour
times = [
e["time"].minute for e in events if e["time"] and e["time"].minute
] + [
e["time_end_today"].minute for e in events if "time_end_today" in e and e["time_end_today"] and e["time_end_today"].minute
]
if not times:
# no time other than full hour, so raster can be 1 hour/60 minutes
return 60
gcd = reduce(math.gcd, set(times))
return next((d for d in [5, 10, 15, 30, 60] if d >= gcd), 60)
def _get_time_duration(self, start, end):
midnight = time(0, 0)
return datetime.combine(
self.date if end != midnight else self.date + timedelta(days=1),
end
) - datetime.combine(
self.date,
start
)
def _format_duration(self, duration):
return ":".join([
"%02d" % i for i in (
(duration.days * 24) + (duration.seconds // 3600),
(duration.seconds // 60) % 60
)
])
def _floor_time(self, t, raster_size=5):
# raster_size based on minutes, might be factored into a helper class with a timedelta as raster
minutes = t.hour * 60 + t.minute
if minutes % raster_size:
minutes = (minutes // raster_size) * raster_size
return t.replace(hour=minutes // 60, minute=minutes % 60)
return t
def _ceil_time(self, t, raster_size=5):
# raster_size based on minutes, might be factored into a helper class with a timedelta as raster
minutes = t.hour * 60 + t.minute
if not minutes % raster_size:
return t
minutes = math.ceil(minutes / raster_size) * raster_size
minute = minutes % 60
hour = minutes // 60
if hour > 23:
hour = hour % 24
return t.replace(minute=minute, hour=hour)
def _rasterize_events(self, events, tick_duration, raster_size=5):
rastered_events = []
start, end = self._get_time_range(events)
start = self._floor_time(start, raster_size=tick_duration)
end = self._ceil_time(end, raster_size=tick_duration)
midnight = time(0, 0)
for e in events:
e["offset_shift_start"] = 0
if e["continued"]:
e["time_rastered"] = midnight
elif e["time"].minute % raster_size:
e["time_rastered"] = e["time"].replace(minute=(e["time"].minute // raster_size) * raster_size)
e["offset_shift_start"] = e["time"].minute % raster_size
else:
e["time_rastered"] = e["time"]
e["offset_shift_end"] = 0
if "time_end_today" in e and e["time_end_today"]:
if e["time_end_today"].minute % raster_size:
minute = math.ceil(e["time_end_today"].minute / raster_size) * raster_size
hour = e["time_end_today"].hour
if minute > 59:
minute = minute % 60
hour = (hour + 1) % 24
e["time_end_today_rastered"] = e["time_end_today"].replace(minute=minute, hour=hour)
e["offset_shift_end"] = raster_size - e["time_end_today"].minute % raster_size
else:
e["time_end_today_rastered"] = e["time_end_today"]
else:
e["time_end_today"] = e["time_end_today_rastered"] = time(0, 0)
e["duration_rastered"] = self._format_duration(datetime.combine(
self.date if e["time_end_today_rastered"] != midnight else self.date + timedelta(days=1),
e["time_end_today_rastered"]
) - datetime.combine(
self.date,
e['time_rastered']
))
e["offset_rastered"] = datetime.combine(self.date, time(0, 0)) + self._get_time_duration(start, e["time_rastered"])
rastered_events.append(e)
return rastered_events, start, end
def _get_shortest_duration(self, events):
midnight = time(0, 0)
durations = [
datetime.combine(
self.date if e.get('time_end_today') and e['time_end_today'] != midnight else self.date + timedelta(days=1),
e['time_end_today'] if e.get('time_end_today') else time(0, 0)
)
-
datetime.combine(
self.date,
time(0, 0) if e['continued'] else e['time']
)
for e in events
]
return min([d for d in durations])
def _get_time_range(self, events):
if any(e['continued'] for e in events) or any(e['time'] is None for e in events):
starting_at = time(0, 0)
else:
starting_at = min(e['time'] for e in events)
if any(e.get('time_end_today') is None for e in events):
ending_at = time(0, 0)
else:
ending_at = max(e['time_end_today'] for e in events)
return starting_at, ending_at
def _get_time_ticks(self, start, end, tick_duration):
ticks = []
tick_duration = timedelta(minutes=tick_duration)
# convert time to datetime for timedelta calc
start = datetime.combine(self.date, start)
end = datetime.combine(self.date, end)
if end <= start:
end = end + timedelta(days=1)
tick_start = start
offset = datetime.utcfromtimestamp(0)
duration = datetime.utcfromtimestamp(tick_duration.total_seconds())
while tick_start < end:
tick = {
"start": tick_start,
"duration": duration,
"offset": offset,
}
ticks.append(tick)
tick_start += tick_duration
offset += tick_duration
return ticks
def _grid_for_template(self, events):
midnight = time(0, 0)
rows_by_collection = defaultdict(list)
# We sort the events into "collections": all subevents from the same
# event series together and all non-series events into a "None"
# collection. Then, we look if there's already an event in the
# collection that overlaps, in which case we need to split the
# collection into multiple rows.
for counter, e in enumerate(events):
collection = e['event'].event if isinstance(e['event'], SubEvent) else None
placed_in_row = False
for row in rows_by_collection[collection]:
if any(
(e['time_rastered'] < o['time_end_today_rastered'] or o['time_end_today_rastered'] == midnight) and
(o['time_rastered'] < e['time_end_today_rastered'] or e['time_end_today_rastered'] == midnight)
for o in row
):
continue
row.append(e)
placed_in_row = True
break
if not placed_in_row:
rows_by_collection[collection].append([e])
# flatten rows to one stream of events with attribute row
# for better keyboard-tab-order in html
for collection in rows_by_collection:
for i, row in enumerate(rows_by_collection[collection]):
concurrency = i + 1
for e in row:
e["concurrency"] = concurrency
rows_by_collection[collection] = {
"concurrency": len(rows_by_collection[collection]),
"events": sorted([e for row in rows_by_collection[collection] for e in row], key=lambda d: d['time'] or time(0, 0)),
}
def sort_key(c):
collection, row = c
if collection is None:
return ''
else:
return str(collection.name)
return sorted(rows_by_collection.items(), key=sort_key)
def _events_by_day(self, before, after):
ebd = defaultdict(list)
timezones = set()
add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using(
settings.DATABASE_REPLICA
).filter(
sales_channels__contains=self.request.sales_channel.identifier
), before, after, ebd, timezones)
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
event__organizer=self.request.organizer,
event__is_public=True,
event__live=True,
event__sales_channels__contains=self.request.sales_channel.identifier
).prefetch_related(
'event___settings_objects', 'event__organizer___settings_objects'
)), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones)
self._multiple_timezones = len(timezones) > 1
return ebd
@method_decorator(cache_page(300), name='dispatch')
class OrganizerIcalDownload(OrganizerViewMixin, View):
def get(self, request, *args, **kwargs):