Skip to content

Commit

Permalink
add DatePaginator and DatePage (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuadavidthomas authored Jan 24, 2024
1 parent 1bdec5b commit 2a1d306
Show file tree
Hide file tree
Showing 9 changed files with 805 additions and 6 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
-->
## [Unreleased]

### Added

- `DatePaginator` and `DatePage` classes, extending Django's built-in `Paginator` and `Page` classes, respectively. These new classes enable pagination based on a specified date field, making it easier to work with date-based data. Useful for applications that require handling of time-series data or chronological records, such as a blog or an event archive.

## [0.1.1]

Initial release!
Expand Down
10 changes: 6 additions & 4 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ set dotenv-load := true
# DEPENDENCIES #
##################

alias install := bootstrap

bootstrap:
python -m pip install --editable '.[dev]'

Expand All @@ -21,11 +23,11 @@ update:
# TESTING/TYPES #
##################

test:
python -m nox --reuse-existing-virtualenvs --session "test"
test *ARGS:
python -m nox --reuse-existing-virtualenvs --session "test" -- "{{ ARGS }}"

test-all:
python -m nox --reuse-existing-virtualenvs --session "tests"
test-all *ARGS:
python -m nox --reuse-existing-virtualenvs --session "tests" -- "{{ ARGS }}"

coverage:
python -m nox --reuse-existing-virtualenvs --session "coverage"
Expand Down
11 changes: 9 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ def should_skip(python: str, django: str) -> tuple[bool, str | None]:

@nox.session
def test(session):
session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')")
default_test = f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')"
if session.posargs:
session.notify(default_test, posargs=session.posargs)
else:
session.notify(default_test)
session.skip()


Expand All @@ -68,7 +72,10 @@ def tests(session, django):
else:
session.install(f"django=={django}")

session.run("python", "-m", "pytest")
if session.posargs:
session.run("python", "-m", "pytest", *session.posargs)
else:
session.run("python", "-m", "pytest")


@nox.session
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dev = [
"faker",
"hatch",
"mypy",
"model-bakery",
"nox",
"pytest",
"pytest-cov",
Expand Down Expand Up @@ -104,6 +105,14 @@ version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
source = ["src"]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if DEBUG:",
"if not DEBUG:",
"if settings.DEBUG:",
"if TYPE_CHECKING:",
'def __str__\(self\)\s?\-?\>?\s?\w*\:',
]
fail_under = 75

[tool.coverage.run]
Expand Down
235 changes: 235 additions & 0 deletions src/django_twc_toolbox/paginator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
from __future__ import annotations

import datetime
from typing import TYPE_CHECKING

from django.core.paginator import Page
from django.core.paginator import Paginator
from django.db.models.query import QuerySet
from django.utils import timezone
from django.utils.functional import cached_property

if TYPE_CHECKING:
from typing import Any

from django.core.paginator import _SupportsPagination


class DatePaginator(Paginator):
def __init__(
self,
object_list: _SupportsPagination,
date_field: str,
date_range: datetime.timedelta,
**kwargs,
) -> None:
self.date_field = date_field
self.date_range = date_range
super().__init__(
object_list,
1, # per_page is 1 as we paginate by date
**kwargs,
)

@cached_property
def date_segments(self) -> list[tuple[datetime.datetime, datetime.datetime]]:
# Check if the object_list is empty.
# Check for `exists()` first for performance, falling back in case it's
# not a QuerySet.
if isinstance(self.object_list, QuerySet): # type: ignore[misc]
if not self.object_list.exists():
return []
elif not self.object_list:
return []

if isinstance(self.object_list, QuerySet): # type: ignore[misc]
first_obj = self.object_list.first()
last_obj = self.object_list.last()
else:
first_obj = self.object_list[0]
last_obj = self.object_list[-1]

first_date = getattr(first_obj, self.date_field)
last_date = getattr(last_obj, self.date_field)

segments = []
current_start_date = first_date
if self._is_chronological():
# if chronological, we are moving forward in time through the `object_list`
# so any time we need to get the next segment's end date, we need to
# add the date_range. to start we add the date_range to the first date.
current_end_date = first_date + self.date_range
# less than or equal because we are moving forwards in time and dates
# in the future are greater than dates in the past
# yesterday < today < tomorrow
while current_end_date <= last_date:
# keep the tuple ordering consistent with the chronological order
# so start_date < end_date
segments.append((current_start_date, current_end_date))
current_start_date = current_end_date
current_end_date += self.date_range
# Append the last segment to cover any remaining dates not included in the
# previous segments. This is necessary because the date range defined by
# `date_range` might not perfectly divide the total span of dates. This final
# segment captures any dates from the end of the last segment up to and
# including `last_date`. We add one day to `last_date` to ensure the entire
# day is covered.
segments.append(
(current_start_date, last_date + datetime.timedelta(days=1))
)
else:
# if not chronological, it means we are moving backwards in time through
# the `object_list` so this time we subtract the date_range to get the
# next segment's end date. to start we subtract the date_range from the
# first date.
current_end_date = first_date - self.date_range
# again, greater than or equal because we are moving backwards in time
# tomorrow > today > yesterday
while current_end_date >= last_date:
# keep the tuple ordering consistent with the reverse chronological
# order so end_date > start_date
# segments.append((current_end_date, current_start_date))
segments.append((current_start_date, current_end_date))
current_start_date = current_end_date
current_end_date -= self.date_range
# subtract because reverse
# segments.append(
# (last_date - datetime.timedelta(days=1), current_start_date)
# )
segments.append(
(current_start_date, last_date - datetime.timedelta(days=1))
)

return segments

def page(self, number: int | str) -> DatePage:
number = self.validate_number(number)
start_date, end_date = self.date_segments[number - 1]

object_list: QuerySet[Any] | list[Any]

if isinstance(self.object_list, QuerySet): # type: ignore[misc]
# For QuerySet, filter based on date range
if self._is_chronological():
object_list = self.object_list.filter(
**{
f"{self.date_field}__gte": start_date,
f"{self.date_field}__lt": end_date,
}
)
else:
object_list = self.object_list.filter(
**{
f"{self.date_field}__lte": start_date,
f"{self.date_field}__gt": end_date,
}
).order_by(f"-{self.date_field}")
else:
# For non-QuerySet, manually filter and sort
if self._is_chronological():
object_list = [
obj
for obj in self.object_list
# yesterday < today < tomorrow
if start_date <= getattr(obj, self.date_field) < end_date
]
else:
object_list = [
obj
for obj in self.object_list
# tomorrow > today > yesterday
if start_date >= getattr(obj, self.date_field) > end_date
]

# Apply sorting based on the initial order
object_list.sort(
key=lambda obj: getattr(obj, self.date_field),
reverse=not self._is_chronological(),
)

return self._get_page(object_list, number, self, start_date, end_date)

def _is_chronological(self) -> bool:
"""Check if the object_list is ordered in chronological order
Chronological
- oldest to newest
- e.g. [yesterday, today, tomorrow]
- yesterday < tomorrow
- would return True
Reverse chronological
- newest to oldest
- e.g. [tomorrow, today, yesterday]
- tomorrow > yesterday
- would return False
"""
if self.count == 1:
return True

if isinstance(self.object_list, QuerySet): # type: ignore[misc]
first_obj = self.object_list.first()
last_obj = self.object_list.last()
else:
first_obj = self.object_list[0]
last_obj = self.object_list[-1]

first_date = getattr(first_obj, self.date_field)
last_date = getattr(last_obj, self.date_field)

return first_date < last_date

def _get_page(self, *args, **kwargs) -> DatePage:
return DatePage(*args, **kwargs)

@cached_property
def num_pages(self) -> int:
return len(self.date_segments)

def _check_object_list_is_ordered(self):
"""Ensure that the object_list is ordered by date_field"""
if isinstance(self.object_list, QuerySet): # type: ignore[misc]
ordering_fields = self.object_list.query.order_by
if not ordering_fields or not any(
field in [self.date_field, f"-{self.date_field}"]
for field in ordering_fields
):
raise ValueError(
f"Paginator received an unordered object_list: {self.object_list}. "
"DatePaginator only supports ordered object_list instances. "
"DatePaginator only supports object_list instances ordered by "
f"the specified date_field. Please use .order_by('{self.date_field}') "
f"or .order_by('-{self.date_field}') on the queryset."
)
else:
# For lists, check if elements are in ascending or descending order by date_field
is_ascending = all(
getattr(x, self.date_field) <= getattr(y, self.date_field)
for x, y in zip(self.object_list, self.object_list[1:])
)
is_descending = all(
getattr(x, self.date_field) >= getattr(y, self.date_field)
for x, y in zip(self.object_list, self.object_list[1:])
)
if not (is_ascending or is_descending):
raise ValueError(
"Paginator received an unordered list. DatePaginator only supports "
f"lists that are ordered by the specified `date_field` - {self.date_field}."
)


class DatePage(Page):
def __init__(
self,
object_list: _SupportsPagination,
number: int,
paginator: DatePaginator,
start_date: datetime.datetime,
end_date: datetime.datetime,
) -> None:
super().__init__(object_list, number, paginator)
self.start_date = start_date
self.end_date = end_date
self.min_date = self.start_date
self.max_date = self.end_date if number != 1 else timezone.now()
self.date_range = (self.start_date, self.max_date)
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def pytest_configure(config):
},
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django_twc_toolbox",
"tests.dummy",
],
LOGGING_CONFIG=None,
PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"],
Expand Down
Empty file added tests/dummy/__init__.py
Empty file.
11 changes: 11 additions & 0 deletions tests/dummy/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

from django.db import models


class DateOrderableModel(models.Model):
date = models.DateField()


class DateTimeOrderableModel(models.Model):
date = models.DateTimeField()
Loading

0 comments on commit 2a1d306

Please sign in to comment.