1import datetime
2
3from django.conf import settings
4from django.core.exceptions import ImproperlyConfigured
5from django.db import models
6from django.http import Http404
7from django.utils import timezone
8from django.utils.functional import cached_property
9from django.utils.translation import gettext as _
10from django.views.generic.base import View
11from django.views.generic.detail import (
12 BaseDetailView,
13 SingleObjectTemplateResponseMixin,
14)
15from django.views.generic.list import (
16 MultipleObjectMixin,
17 MultipleObjectTemplateResponseMixin,
18)
19
20
21class YearMixin:
22 """Mixin for views manipulating year-based data."""
23
24 year_format = "%Y"
25 year = None
26
27 def get_year_format(self):
28 """
29 Get a year format string in strptime syntax to be used to parse the
30 year from url variables.
31 """
32 return self.year_format
33
34 def get_year(self):
35 """Return the year for which this view should display data."""
36 year = self.year
37 if year is None:
38 try:
39 year = self.kwargs["year"]
40 except KeyError:
41 try:
42 year = self.request.GET["year"]
43 except KeyError:
44 raise Http404(_("No year specified"))
45 return year
46
47 def get_next_year(self, date):
48 """Get the next valid year."""
49 return _get_next_prev(self, date, is_previous=False, period="year")
50
51 def get_previous_year(self, date):
52 """Get the previous valid year."""
53 return _get_next_prev(self, date, is_previous=True, period="year")
54
55 def _get_next_year(self, date):
56 """
57 Return the start date of the next interval.
58
59 The interval is defined by start date <= item date < next start date.
60 """
61 try:
62 return date.replace(year=date.year + 1, month=1, day=1)
63 except ValueError:
64 raise Http404(_("Date out of range"))
65
66 def _get_current_year(self, date):
67 """Return the start date of the current interval."""
68 return date.replace(month=1, day=1)
69
70
71class MonthMixin:
72 """Mixin for views manipulating month-based data."""
73
74 month_format = "%b"
75 month = None
76
77 def get_month_format(self):
78 """
79 Get a month format string in strptime syntax to be used to parse the
80 month from url variables.
81 """
82 return self.month_format
83
84 def get_month(self):
85 """Return the month for which this view should display data."""
86 month = self.month
87 if month is None:
88 try:
89 month = self.kwargs["month"]
90 except KeyError:
91 try:
92 month = self.request.GET["month"]
93 except KeyError:
94 raise Http404(_("No month specified"))
95 return month
96
97 def get_next_month(self, date):
98 """Get the next valid month."""
99 return _get_next_prev(self, date, is_previous=False, period="month")
100
101 def get_previous_month(self, date):
102 """Get the previous valid month."""
103 return _get_next_prev(self, date, is_previous=True, period="month")
104
105 def _get_next_month(self, date):
106 """
107 Return the start date of the next interval.
108
109 The interval is defined by start date <= item date < next start date.
110 """
111 if date.month == 12:
112 try:
113 return date.replace(year=date.year + 1, month=1, day=1)
114 except ValueError:
115 raise Http404(_("Date out of range"))
116 else:
117 return date.replace(month=date.month + 1, day=1)
118
119 def _get_current_month(self, date):
120 """Return the start date of the previous interval."""
121 return date.replace(day=1)
122
123
124class DayMixin:
125 """Mixin for views manipulating day-based data."""
126
127 day_format = "%d"
128 day = None
129
130 def get_day_format(self):
131 """
132 Get a day format string in strptime syntax to be used to parse the day
133 from url variables.
134 """
135 return self.day_format
136
137 def get_day(self):
138 """Return the day for which this view should display data."""
139 day = self.day
140 if day is None:
141 try:
142 day = self.kwargs["day"]
143 except KeyError:
144 try:
145 day = self.request.GET["day"]
146 except KeyError:
147 raise Http404(_("No day specified"))
148 return day
149
150 def get_next_day(self, date):
151 """Get the next valid day."""
152 return _get_next_prev(self, date, is_previous=False, period="day")
153
154 def get_previous_day(self, date):
155 """Get the previous valid day."""
156 return _get_next_prev(self, date, is_previous=True, period="day")
157
158 def _get_next_day(self, date):
159 """
160 Return the start date of the next interval.
161
162 The interval is defined by start date <= item date < next start date.
163 """
164 return date + datetime.timedelta(days=1)
165
166 def _get_current_day(self, date):
167 """Return the start date of the current interval."""
168 return date
169
170
171class WeekMixin:
172 """Mixin for views manipulating week-based data."""
173
174 week_format = "%U"
175 week = None
176
177 def get_week_format(self):
178 """
179 Get a week format string in strptime syntax to be used to parse the
180 week from url variables.
181 """
182 return self.week_format
183
184 def get_week(self):
185 """Return the week for which this view should display data."""
186 week = self.week
187 if week is None:
188 try:
189 week = self.kwargs["week"]
190 except KeyError:
191 try:
192 week = self.request.GET["week"]
193 except KeyError:
194 raise Http404(_("No week specified"))
195 return week
196
197 def get_next_week(self, date):
198 """Get the next valid week."""
199 return _get_next_prev(self, date, is_previous=False, period="week")
200
201 def get_previous_week(self, date):
202 """Get the previous valid week."""
203 return _get_next_prev(self, date, is_previous=True, period="week")
204
205 def _get_next_week(self, date):
206 """
207 Return the start date of the next interval.
208
209 The interval is defined by start date <= item date < next start date.
210 """
211 try:
212 return date + datetime.timedelta(days=7 - self._get_weekday(date))
213 except OverflowError:
214 raise Http404(_("Date out of range"))
215
216 def _get_current_week(self, date):
217 """Return the start date of the current interval."""
218 return date - datetime.timedelta(self._get_weekday(date))
219
220 def _get_weekday(self, date):
221 """
222 Return the weekday for a given date.
223
224 The first day according to the week format is 0 and the last day is 6.
225 """
226 week_format = self.get_week_format()
227 if week_format in {"%W", "%V"}: # week starts on Monday
228 return date.weekday()
229 elif week_format == "%U": # week starts on Sunday
230 return (date.weekday() + 1) % 7
231 else:
232 raise ValueError("unknown week format: %s" % week_format)
233
234
235class DateMixin:
236 """Mixin class for views manipulating date-based data."""
237
238 date_field = None
239 allow_future = False
240
241 def get_date_field(self):
242 """Get the name of the date field to be used to filter by."""
243 if self.date_field is None:
244 raise ImproperlyConfigured(
245 "%s.date_field is required." % self.__class__.__name__
246 )
247 return self.date_field
248
249 def get_allow_future(self):
250 """
251 Return `True` if the view should be allowed to display objects from
252 the future.
253 """
254 return self.allow_future
255
256 # Note: the following three methods only work in subclasses that also
257 # inherit SingleObjectMixin or MultipleObjectMixin.
258
259 @cached_property
260 def uses_datetime_field(self):
261 """
262 Return `True` if the date field is a `DateTimeField` and `False`
263 if it's a `DateField`.
264 """
265 model = self.get_queryset().model if self.model is None else self.model
266 field = model._meta.get_field(self.get_date_field())
267 return isinstance(field, models.DateTimeField)
268
269 def _make_date_lookup_arg(self, value):
270 """
271 Convert a date into a datetime when the date field is a DateTimeField.
272
273 When time zone support is enabled, `date` is assumed to be in the
274 current time zone, so that displayed items are consistent with the URL.
275 """
276 if self.uses_datetime_field:
277 value = datetime.datetime.combine(value, datetime.time.min)
278 if settings.USE_TZ:
279 value = timezone.make_aware(value)
280 return value
281
282 def _make_single_date_lookup(self, date):
283 """
284 Get the lookup kwargs for filtering on a single date.
285
286 If the date field is a DateTimeField, we can't just filter on
287 date_field=date because that doesn't take the time into account.
288 """
289 date_field = self.get_date_field()
290 if self.uses_datetime_field:
291 since = self._make_date_lookup_arg(date)
292 until = self._make_date_lookup_arg(date + datetime.timedelta(days=1))
293 return {
294 "%s__gte" % date_field: since,
295 "%s__lt" % date_field: until,
296 }
297 else:
298 # Skip self._make_date_lookup_arg, it's a no-op in this branch.
299 return {date_field: date}
300
301
302class BaseDateListView(MultipleObjectMixin, DateMixin, View):
303 """
304 Base class for date-based views displaying a list of objects.
305
306 This requires subclassing to provide a response mixin.
307 """
308
309 allow_empty = False
310 date_list_period = "year"
311
312 def get(self, request, *args, **kwargs):
313 self.date_list, self.object_list, extra_context = self.get_dated_items()
314 context = self.get_context_data(
315 object_list=self.object_list, date_list=self.date_list, **extra_context
316 )
317 return self.render_to_response(context)
318
319 def get_dated_items(self):
320 """Obtain the list of dates and items."""
321 raise NotImplementedError(
322 "A DateView must provide an implementation of get_dated_items()"
323 )
324
325 def get_ordering(self):
326 """
327 Return the field or fields to use for ordering the queryset; use the
328 date field by default.
329 """
330 return "-%s" % self.get_date_field() if self.ordering is None else self.ordering
331
332 def get_dated_queryset(self, **lookup):
333 """
334 Get a queryset properly filtered according to `allow_future` and any
335 extra lookup kwargs.
336 """
337 qs = self.get_queryset().filter(**lookup)
338 date_field = self.get_date_field()
339 allow_future = self.get_allow_future()
340 allow_empty = self.get_allow_empty()
341 paginate_by = self.get_paginate_by(qs)
342
343 if not allow_future:
344 now = timezone.now() if self.uses_datetime_field else timezone_today()
345 qs = qs.filter(**{"%s__lte" % date_field: now})
346
347 if not allow_empty:
348 # When pagination is enabled, it's better to do a cheap query
349 # than to load the unpaginated queryset in memory.
350 is_empty = not qs if paginate_by is None else not qs.exists()
351 if is_empty:
352 raise Http404(
353 _("No %(verbose_name_plural)s available")
354 % {
355 "verbose_name_plural": qs.model._meta.verbose_name_plural,
356 }
357 )
358
359 return qs
360
361 def get_date_list_period(self):
362 """
363 Get the aggregation period for the list of dates: 'year', 'month', or
364 'day'.
365 """
366 return self.date_list_period
367
368 def get_date_list(self, queryset, date_type=None, ordering="ASC"):
369 """
370 Get a date list by calling `queryset.dates/datetimes()`, checking
371 along the way for empty lists that aren't allowed.
372 """
373 date_field = self.get_date_field()
374 allow_empty = self.get_allow_empty()
375 if date_type is None:
376 date_type = self.get_date_list_period()
377
378 if self.uses_datetime_field:
379 date_list = queryset.datetimes(date_field, date_type, ordering)
380 else:
381 date_list = queryset.dates(date_field, date_type, ordering)
382 if date_list is not None and not date_list and not allow_empty:
383 raise Http404(
384 _("No %(verbose_name_plural)s available")
385 % {
386 "verbose_name_plural": queryset.model._meta.verbose_name_plural,
387 }
388 )
389
390 return date_list
391
392
393class BaseArchiveIndexView(BaseDateListView):
394 """
395 Base view for archives of date-based items.
396
397 This requires subclassing to provide a response mixin.
398 """
399
400 context_object_name = "latest"
401
402 def get_dated_items(self):
403 """Return (date_list, items, extra_context) for this request."""
404 qs = self.get_dated_queryset()
405 date_list = self.get_date_list(qs, ordering="DESC")
406
407 if not date_list:
408 qs = qs.none()
409
410 return (date_list, qs, {})
411
412
413class ArchiveIndexView(MultipleObjectTemplateResponseMixin, BaseArchiveIndexView):
414 """Top-level archive of date-based items."""
415
416 template_name_suffix = "_archive"
417
418
419class BaseYearArchiveView(YearMixin, BaseDateListView):
420 """
421 Base view for a list of objects published in a given year.
422
423 This requires subclassing to provide a response mixin.
424 """
425
426 date_list_period = "month"
427 make_object_list = False
428
429 def get_dated_items(self):
430 """Return (date_list, items, extra_context) for this request."""
431 year = self.get_year()
432
433 date_field = self.get_date_field()
434 date = _date_from_string(year, self.get_year_format())
435
436 since = self._make_date_lookup_arg(date)
437 until = self._make_date_lookup_arg(self._get_next_year(date))
438 lookup_kwargs = {
439 "%s__gte" % date_field: since,
440 "%s__lt" % date_field: until,
441 }
442
443 qs = self.get_dated_queryset(**lookup_kwargs)
444 date_list = self.get_date_list(qs)
445
446 if not self.get_make_object_list():
447 # We need this to be a queryset since parent classes introspect it
448 # to find information about the model.
449 qs = qs.none()
450
451 return (
452 date_list,
453 qs,
454 {
455 "year": date,
456 "next_year": self.get_next_year(date),
457 "previous_year": self.get_previous_year(date),
458 },
459 )
460
461 def get_make_object_list(self):
462 """
463 Return `True` if this view should contain the full list of objects in
464 the given year.
465 """
466 return self.make_object_list
467
468
469class YearArchiveView(MultipleObjectTemplateResponseMixin, BaseYearArchiveView):
470 """List of objects published in a given year."""
471
472 template_name_suffix = "_archive_year"
473
474
475class BaseMonthArchiveView(YearMixin, MonthMixin, BaseDateListView):
476 """
477 Base view for a list of objects published in a given month.
478
479 This requires subclassing to provide a response mixin.
480 """
481
482 date_list_period = "day"
483
484 def get_dated_items(self):
485 """Return (date_list, items, extra_context) for this request."""
486 year = self.get_year()
487 month = self.get_month()
488
489 date_field = self.get_date_field()
490 date = _date_from_string(
491 year, self.get_year_format(), month, self.get_month_format()
492 )
493
494 since = self._make_date_lookup_arg(date)
495 until = self._make_date_lookup_arg(self._get_next_month(date))
496 lookup_kwargs = {
497 "%s__gte" % date_field: since,
498 "%s__lt" % date_field: until,
499 }
500
501 qs = self.get_dated_queryset(**lookup_kwargs)
502 date_list = self.get_date_list(qs)
503
504 return (
505 date_list,
506 qs,
507 {
508 "month": date,
509 "next_month": self.get_next_month(date),
510 "previous_month": self.get_previous_month(date),
511 },
512 )
513
514
515class MonthArchiveView(MultipleObjectTemplateResponseMixin, BaseMonthArchiveView):
516 """List of objects published in a given month."""
517
518 template_name_suffix = "_archive_month"
519
520
521class BaseWeekArchiveView(YearMixin, WeekMixin, BaseDateListView):
522 """
523 Base view for a list of objects published in a given week.
524
525 This requires subclassing to provide a response mixin.
526 """
527
528 def get_dated_items(self):
529 """Return (date_list, items, extra_context) for this request."""
530 year = self.get_year()
531 week = self.get_week()
532
533 date_field = self.get_date_field()
534 week_format = self.get_week_format()
535 week_choices = {"%W": "1", "%U": "0", "%V": "1"}
536 try:
537 week_start = week_choices[week_format]
538 except KeyError:
539 raise ValueError(
540 "Unknown week format %r. Choices are: %s"
541 % (
542 week_format,
543 ", ".join(sorted(week_choices)),
544 )
545 )
546 year_format = self.get_year_format()
547 if week_format == "%V" and year_format != "%G":
548 raise ValueError(
549 "ISO week directive '%s' is incompatible with the year "
550 "directive '%s'. Use the ISO year '%%G' instead."
551 % (
552 week_format,
553 year_format,
554 )
555 )
556 date = _date_from_string(year, year_format, week_start, "%w", week, week_format)
557 since = self._make_date_lookup_arg(date)
558 until = self._make_date_lookup_arg(self._get_next_week(date))
559 lookup_kwargs = {
560 "%s__gte" % date_field: since,
561 "%s__lt" % date_field: until,
562 }
563
564 qs = self.get_dated_queryset(**lookup_kwargs)
565
566 return (
567 None,
568 qs,
569 {
570 "week": date,
571 "next_week": self.get_next_week(date),
572 "previous_week": self.get_previous_week(date),
573 },
574 )
575
576
577class WeekArchiveView(MultipleObjectTemplateResponseMixin, BaseWeekArchiveView):
578 """List of objects published in a given week."""
579
580 template_name_suffix = "_archive_week"
581
582
583class BaseDayArchiveView(YearMixin, MonthMixin, DayMixin, BaseDateListView):
584 """
585 Base view for a list of objects published on a given day.
586
587 This requires subclassing to provide a response mixin.
588 """
589
590 def get_dated_items(self):
591 """Return (date_list, items, extra_context) for this request."""
592 year = self.get_year()
593 month = self.get_month()
594 day = self.get_day()
595
596 date = _date_from_string(
597 year,
598 self.get_year_format(),
599 month,
600 self.get_month_format(),
601 day,
602 self.get_day_format(),
603 )
604
605 return self._get_dated_items(date)
606
607 def _get_dated_items(self, date):
608 """
609 Do the actual heavy lifting of getting the dated items; this accepts a
610 date object so that TodayArchiveView can be trivial.
611 """
612 lookup_kwargs = self._make_single_date_lookup(date)
613 qs = self.get_dated_queryset(**lookup_kwargs)
614
615 return (
616 None,
617 qs,
618 {
619 "day": date,
620 "previous_day": self.get_previous_day(date),
621 "next_day": self.get_next_day(date),
622 "previous_month": self.get_previous_month(date),
623 "next_month": self.get_next_month(date),
624 },
625 )
626
627
628class DayArchiveView(MultipleObjectTemplateResponseMixin, BaseDayArchiveView):
629 """List of objects published on a given day."""
630
631 template_name_suffix = "_archive_day"
632
633
634class BaseTodayArchiveView(BaseDayArchiveView):
635 """
636 Base view for a list of objects published today.
637
638 This requires subclassing to provide a response mixin.
639 """
640
641 def get_dated_items(self):
642 """Return (date_list, items, extra_context) for this request."""
643 return self._get_dated_items(datetime.date.today())
644
645
646class TodayArchiveView(MultipleObjectTemplateResponseMixin, BaseTodayArchiveView):
647 """List of objects published today."""
648
649 template_name_suffix = "_archive_day"
650
651
652class BaseDateDetailView(YearMixin, MonthMixin, DayMixin, DateMixin, BaseDetailView):
653 """
654 Base detail view for a single object on a single date; this differs from the
655 standard DetailView by accepting a year/month/day in the URL.
656
657 This requires subclassing to provide a response mixin.
658 """
659
660 def get_object(self, queryset=None):
661 """Get the object this request displays."""
662 year = self.get_year()
663 month = self.get_month()
664 day = self.get_day()
665 date = _date_from_string(
666 year,
667 self.get_year_format(),
668 month,
669 self.get_month_format(),
670 day,
671 self.get_day_format(),
672 )
673
674 # Use a custom queryset if provided
675 qs = self.get_queryset() if queryset is None else queryset
676
677 if not self.get_allow_future() and date > datetime.date.today():
678 raise Http404(
679 _(
680 "Future %(verbose_name_plural)s not available because "
681 "%(class_name)s.allow_future is False."
682 )
683 % {
684 "verbose_name_plural": qs.model._meta.verbose_name_plural,
685 "class_name": self.__class__.__name__,
686 }
687 )
688
689 # Filter down a queryset from self.queryset using the date from the
690 # URL. This'll get passed as the queryset to DetailView.get_object,
691 # which'll handle the 404
692 lookup_kwargs = self._make_single_date_lookup(date)
693 qs = qs.filter(**lookup_kwargs)
694
695 return super().get_object(queryset=qs)
696
697
698class DateDetailView(SingleObjectTemplateResponseMixin, BaseDateDetailView):
699 """
700 Detail view of a single object on a single date; this differs from the
701 standard DetailView by accepting a year/month/day in the URL.
702 """
703
704 template_name_suffix = "_detail"
705
706
707def _date_from_string(
708 year, year_format, month="", month_format="", day="", day_format="", delim="__"
709):
710 """
711 Get a datetime.date object given a format string and a year, month, and day
712 (only year is mandatory). Raise a 404 for an invalid date.
713 """
714 format = year_format + delim + month_format + delim + day_format
715 datestr = str(year) + delim + str(month) + delim + str(day)
716 try:
717 return datetime.datetime.strptime(datestr, format).date()
718 except ValueError:
719 raise Http404(
720 _("Invalid date string “%(datestr)s” given format “%(format)s”")
721 % {
722 "datestr": datestr,
723 "format": format,
724 }
725 )
726
727
728def _get_next_prev(generic_view, date, is_previous, period):
729 """
730 Get the next or the previous valid date. The idea is to allow links on
731 month/day views to never be 404s by never providing a date that'll be
732 invalid for the given view.
733
734 This is a bit complicated since it handles different intervals of time,
735 hence the coupling to generic_view.
736
737 However in essence the logic comes down to:
738
739 * If allow_empty and allow_future are both true, this is easy: just
740 return the naive result (just the next/previous day/week/month,
741 regardless of object existence.)
742
743 * If allow_empty is true, allow_future is false, and the naive result
744 isn't in the future, then return it; otherwise return None.
745
746 * If allow_empty is false and allow_future is true, return the next
747 date *that contains a valid object*, even if it's in the future. If
748 there are no next objects, return None.
749
750 * If allow_empty is false and allow_future is false, return the next
751 date that contains a valid object. If that date is in the future, or
752 if there are no next objects, return None.
753 """
754 date_field = generic_view.get_date_field()
755 allow_empty = generic_view.get_allow_empty()
756 allow_future = generic_view.get_allow_future()
757
758 get_current = getattr(generic_view, "_get_current_%s" % period)
759 get_next = getattr(generic_view, "_get_next_%s" % period)
760
761 # Bounds of the current interval
762 start, end = get_current(date), get_next(date)
763
764 # If allow_empty is True, the naive result will be valid
765 if allow_empty:
766 if is_previous:
767 result = get_current(start - datetime.timedelta(days=1))
768 else:
769 result = end
770
771 if allow_future or result <= timezone_today():
772 return result
773 else:
774 return None
775
776 # Otherwise, we'll need to go to the database to look for an object
777 # whose date_field is at least (greater than/less than) the given
778 # naive result
779 else:
780 # Construct a lookup and an ordering depending on whether we're doing
781 # a previous date or a next date lookup.
782 if is_previous:
783 lookup = {"%s__lt" % date_field: generic_view._make_date_lookup_arg(start)}
784 ordering = "-%s" % date_field
785 else:
786 lookup = {"%s__gte" % date_field: generic_view._make_date_lookup_arg(end)}
787 ordering = date_field
788
789 # Filter out objects in the future if appropriate.
790 if not allow_future:
791 # Fortunately, to match the implementation of allow_future,
792 # we need __lte, which doesn't conflict with __lt above.
793 if generic_view.uses_datetime_field:
794 now = timezone.now()
795 else:
796 now = timezone_today()
797 lookup["%s__lte" % date_field] = now
798
799 qs = generic_view.get_queryset().filter(**lookup).order_by(ordering)
800
801 # Snag the first object from the queryset; if it doesn't exist that
802 # means there's no next/previous link available.
803 try:
804 result = getattr(qs[0], date_field)
805 except IndexError:
806 return None
807
808 # Convert datetimes to dates in the current time zone.
809 if generic_view.uses_datetime_field:
810 if settings.USE_TZ:
811 result = timezone.localtime(result)
812 result = result.date()
813
814 # Return the first day of the period.
815 return get_current(result)
816
817
818def timezone_today():
819 """Return the current date in the current time zone."""
820 if settings.USE_TZ:
821 return timezone.localdate()
822 else:
823 return datetime.date.today()