diff --git a/template/docs/how-tos/datetimes.md.jinja b/template/docs/how-tos/datetimes.md.jinja new file mode 100644 index 0000000..8f89443 --- /dev/null +++ b/template/docs/how-tos/datetimes.md.jinja @@ -0,0 +1,188 @@ +# Working with Dates and Times + +Datetime handling is a common source of subtle bugs in Django projects. Problems tend +to show up as off-by-one-day behaviour around midnight, surprising results when +daylight saving time changes, or tests that pass locally but fail in continuous +integration. + +This document follows a few simple conventions: + +- Use timezone-aware datetimes throughout the codebase. +- Use the `{{ project_module }}.utils.temporal` helpers instead of `datetime.now()`. +- Treat "now" as a dependency (prefer passing it into business logic). +- Freeze time in tests when you need to. + +## Avoiding Naive Datetimes + +A naive datetime is a `datetime` with no timezone information (`tzinfo=None`). Once a +naive datetime enters your system, it's not obvious what it represents: UTC, server +local time, or a user's timezone. + +To keep this consistent, Ruff is configured with `flake8-datetimez` rules. When you +see an error like: + +``` +DTZ005 `datetime.datetime.now()` called without a `tz` argument +``` + +the intended fix is to use the project's temporal utilities. + +## The Temporal Utility Module + +Use `{{ project_module }}.utils.temporal` for getting the current time and date. +These helpers return timezone-aware values and respect `settings.TIME_ZONE`. + +| ❌ Don't use | ✅ Use instead | +|--------------|----------------| +| `datetime.datetime.now()` | `temporal.now()` | +| `datetime.datetime.today()` | `temporal.today()` | +| `datetime.date.today()` | `temporal.today()` | + +```python +from {{ project_module }}.utils import temporal + +current_time = temporal.now() +today = temporal.today() +``` + +For the full list of functions, see `src/{{ project_module }}/utils/temporal.py`. + +## Writing Testable Code + +To keep business logic easy to unit test, prefer calling `temporal.now()` at the +boundary of the system (views, tasks, commands) and passing the value down as a +parameter. + +```python +import datetime + +from django.shortcuts import get_object_or_404, render + +from {{ project_module }}.utils import temporal + + +def calculate_expiry(subscription, as_of: datetime.datetime) -> datetime.date: + return as_of.date() + datetime.timedelta(days=subscription.duration) + + +def subscription_detail(request, subscription_id): + subscription = get_object_or_404(Subscription, id=subscription_id) + now = temporal.now() + expiry = calculate_expiry(subscription, as_of=now) + return render(request, "subscription.html", {"expiry": expiry}) +``` + +When a function takes `as_of` (or similar), tests can pass a specific datetime +directly, and you usually don't need a time-freezing library. + +## Testing with Datetimes + +Datetime-related flakiness is very common. If a test depends on "now" or on timezone +conversions, freeze time so the test always runs in a predictable timeline. + +!!! warning "Tests that depend on `now` should freeze time" + + If a test reads the current time or manipulates timezones, make it explicit. + Either pass an `as_of` value into the code under test, or freeze time. + +### Freezing Time with time-machine + +```python +import datetime + +import time_machine + + +@time_machine.travel("2026-05-04 14:30:00+00:00", tick=False) +def test_view_shows_current_date(client): + response = client.get("/dashboard/") + assert response.context["today"] == datetime.date(2026, 5, 4) +``` + +## Timezones in Django: Storage vs Display + +Two settings matter here: + +- `USE_TZ`: whether Django uses timezone-aware datetimes. +- `TIME_ZONE`: the default timezone used for conversions and display. + +When `USE_TZ = True`, Django stores datetimes in UTC in the database, regardless of what +`TIME_ZONE` is set to. (This aligns with how Postgres treats `timestamp with time zone`: +values are stored in UTC and converted on output.) + +`TIME_ZONE` still matters because it is the default "current timezone" used for +display and conversions. + +### Does Django know the user's timezone? + +Not automatically. Django will use `TIME_ZONE` unless you explicitly activate a +timezone for the current request. + +If you want per-user timezones, activate one (usually in middleware): + +```python +import zoneinfo + +from django.utils import timezone + + +def user_timezone_middleware(get_response): + def middleware(request): + # Replace this with your own user/profile lookup. + tz = zoneinfo.ZoneInfo("Australia/Melbourne") + timezone.activate(tz) + try: + return get_response(request) + finally: + timezone.deactivate() + + return middleware +``` + +If your application only really cares about a single local timezone, it can be +simpler to set `TIME_ZONE` to that timezone and keep it consistent across the app. + +## Common Patterns + +### Importing External Data + +External systems often provide datetime strings without timezone information. Convert +them to timezone-aware datetimes as early as possible. + +```python +import csv +import datetime + +from {{ project_module }}.utils import temporal + + +def import_events_from_csv(file): + for row in csv.DictReader(file): + naive_dt = datetime.datetime.strptime(row["event_date"], "%Y-%m-%d %H:%M") + aware_dt = temporal.make_tz_aware(naive_dt) + Event.objects.create(name=row["name"], starts_at=aware_dt) +``` + +### Displaying Datetimes in Templates + +The `date` and `time` filters display datetimes in the current timezone (which +defaults to `TIME_ZONE` unless you've activated something else). + +{% raw %} +```django +{{ event.starts_at|date:"Y-m-d H:i" }} + +{# Without a filter you'll generally see the stored timezone (UTC) #} +{{ event.starts_at }} + +{# Or specify a timezone explicitly #} +{{ event.starts_at|timezone:"Australia/Sydney"|date:"Y-m-d H:i" }} +``` +{% endraw %} + +## Quick Reference + +- Use `{{ project_module }}.utils.temporal` instead of `datetime.now()`. +- Prefer passing `as_of`/`now` into business logic. +- Freeze time with `time-machine` when a test depends on "now". +- With `USE_TZ = True`, storage is UTC; `TIME_ZONE` controls default display. diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index f8c194d..6ff3513 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -17,6 +17,7 @@ dependencies = [ "uvloop>=0.22.1,<1; sys_platform != 'win32'", "sentry-sdk>=2.55.0,<3", "httptools>=0.7.1,<1", + "python-dateutil>=2.9.0,<3", {% if use_feature_toggles %}"django-waffle>=5.0.0,<6",{% endif %} ] @@ -41,6 +42,8 @@ dev = [ "pytest-playwright>=0.7.2,<0.8", "pytest-spec>=6.0.0,<7", "ruff>=0.15.6,<1", + "time-machine>=3.2,<4", + "types-python-dateutil>=2.9.0,<3", ] [tool.uv] @@ -107,11 +110,12 @@ select = [ "W", # pycodestyle (warnings) "S", # flake8-bandit "I", # isort - "C90" # mccabe + "C90", # mccabe + "DTZ", # flake8-datetimez (use temporal utility) ] [tool.ruff.lint.per-file-ignores] -"src/tests/*" = ["S101"] +"src/tests/*" = ["S101", "DTZ001"] [tool.ruff.lint.mccabe] max-complexity = 15 diff --git a/template/src/tests/system/utils/__init__.py b/template/src/tests/system/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/src/tests/system/utils/test_temporal.py.jinja b/template/src/tests/system/utils/test_temporal.py.jinja new file mode 100644 index 0000000..b59daa9 --- /dev/null +++ b/template/src/tests/system/utils/test_temporal.py.jinja @@ -0,0 +1,244 @@ +import datetime +import zoneinfo + +import pytest +import time_machine + +from {{ project_module }}.utils import temporal + +UTC = zoneinfo.ZoneInfo("UTC") + + +@pytest.mark.parametrize( + ["value", "expected"], + [ + pytest.param( + "2021-01-01", datetime.datetime(2021, 1, 1, tzinfo=UTC), id="iso_date" + ), + pytest.param( + "2021-01-01T12:00:00", + datetime.datetime(2021, 1, 1, 12, 0, 0, tzinfo=UTC), + id="iso_datetime", + ), + pytest.param( + "2021-01-01T12:00:00", + datetime.datetime(2021, 1, 1, 12, 0, 0, tzinfo=UTC), + id="iso_datetime", + ), + pytest.param( + "item.l01.20240826122411.csv", + datetime.datetime(2024, 8, 26, 12, 24, 11, tzinfo=UTC), + id="filename_no_punctuation", + ), + pytest.param( + "item.l01.2024-08-26 12:24:11.csv", + datetime.datetime(2024, 8, 26, 12, 24, 11, tzinfo=UTC), + id="filename_punctuation", + ), + ], +) +def test_can_extract_a_datetime_from_string(value: str, expected: datetime.datetime): + assert temporal.extract_datetime_from_string(value) == expected + + +def test_can_extract_a_datetime_from_string_with_custom_delimiters(): + assert temporal.extract_datetime_from_string( + "abc~def~20240826122411.csv", delimiters=["~", "."] + ) == datetime.datetime(2024, 8, 26, 12, 24, 11, tzinfo=UTC) + + +@time_machine.travel("2024-10-21 12:00:00") +def test_midnight_returns_midnight_in_system_timezone(): + assert temporal.midnight() == datetime.datetime(2024, 10, 21, 0, 0, 0, tzinfo=UTC) + + +def test_midnight_returns_midnight_of_specified_date_in_system_timezone(): + assert temporal.midnight(datetime.date(2024, 10, 21)) == datetime.datetime( + 2024, 10, 21, 0, 0, 0, tzinfo=UTC + ) + + +def test_midnight_returns_midnight_of_specified_datetime_in_system_timezone(): + assert temporal.midnight( + datetime.datetime(2024, 10, 21, 12, 5, 21) + ) == datetime.datetime(2024, 10, 21, 0, 0, 0, tzinfo=UTC) + + +@time_machine.travel("2024-10-21 12:00:00 +0000", tick=False) +def test_now_returns_current_datetime_in_system_timezone(): + assert temporal.now() == datetime.datetime(2024, 10, 21, 12, 0, 0, tzinfo=UTC) + + +# DateRange +# ---------- + + +def test_date_range_contains(): + """Test if a datetime is within the range.""" + date_range = temporal.DatetimeRange( + temporal.make_tz_aware(datetime.datetime(2025, 6, 1)), + temporal.make_tz_aware(datetime.datetime(2025, 6, 10)), + ) + + assert datetime.datetime(2025, 6, 5) in date_range + assert datetime.datetime(2025, 6, 1) in date_range + assert datetime.datetime(2025, 6, 10) not in date_range # Exclusive end + assert datetime.datetime(2025, 5, 31) not in date_range + + +def test_date_range_gt(): + """Test if a datetime is before the start of the range.""" + date_range = temporal.DatetimeRange( + temporal.make_tz_aware(datetime.datetime(2025, 6, 1)), + temporal.make_tz_aware(datetime.datetime(2025, 6, 10)), + ) + + assert datetime.datetime(2025, 5, 31) < date_range + assert date_range > datetime.datetime(2025, 5, 31) + assert not datetime.datetime(2025, 6, 1) < date_range + + +def test_date_range_lt(): + """Test if a datetime is after the end of the range.""" + date_range = temporal.DatetimeRange( + temporal.make_tz_aware(datetime.datetime(2025, 6, 1)), + temporal.make_tz_aware(datetime.datetime(2025, 6, 10)), + ) + + assert datetime.datetime(2025, 6, 10) > date_range + assert date_range < datetime.datetime(2025, 6, 10) + assert not date_range < datetime.datetime(2025, 6, 9) + + +def test_date_range_eq(): + """Test equality between two temporal.DatetimeRange objects.""" + range1 = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), datetime.datetime(2025, 6, 10) + ) + range2 = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), + datetime.datetime(2025, 6, 10), + ) + range3 = temporal.DatetimeRange( + datetime.datetime(2025, 6, 2), datetime.datetime(2025, 6, 10) + ) + + assert range1 == range2 + assert range1 != range3 + + +def test_date_range_len(): + """Test the length of the datetime range.""" + date_range = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), datetime.datetime(2025, 6, 10) + ) + + assert len(date_range) == 9 # June 1 to June 10 is 9 days + + +def test_date_range_repr(): + """Test the string representation.""" + date_range = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), datetime.datetime(2025, 6, 10) + ) + + assert ( + repr(date_range) + == f"DatetimeRange(start={date_range.start}, end={date_range.end})" + ) + + +def test_date_range_invalid_comparisons(): + """Ensure invalid types are handled properly.""" + date_range = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), datetime.datetime(2025, 6, 10) + ) + + assert date_range != "2025-06-01" + assert date_range != 123 + + +def test_date_range_last_day(): + date_range = temporal.DatetimeRange( + datetime.datetime(2025, 6, 1), datetime.datetime(2025, 6, 10) + ) + assert date_range.last_day == datetime.date(2025, 6, 9) + + +@pytest.mark.parametrize( + ["start", "end", "expected"], + [ + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 10), + 7, + id="more than a week", + ), + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 2), + 1, + id="2 day", + ), + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 1), + 0, + id="zero length", + ), + pytest.param( + datetime.datetime(2025, 2, 1), + datetime.datetime(2025, 2, 28), + 19, + id="starts on a sat", + ), + pytest.param( + datetime.datetime(2025, 3, 2), + datetime.datetime(2025, 3, 8), + 5, + id="starts and end on weekend", + ), + ], +) +def test_can_get_weekdays_for_a_range(start, end, expected): + date_range = temporal.DatetimeRange(start, end) + assert temporal.get_weekdays(date_range) == expected + + +@pytest.mark.parametrize( + ["start", "end", "expected"], + [ + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 10), + 7, + id="more than a week", + ), + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 2), + 1, + id="2 day", + ), + pytest.param( + datetime.datetime(2024, 10, 1), + datetime.datetime(2024, 10, 1), + 0, + id="zero length", + ), + pytest.param( + datetime.datetime(2025, 2, 1), + datetime.datetime(2025, 2, 28), + 19, + id="starts on a sat", + ), + pytest.param( + datetime.datetime(2025, 3, 2), + datetime.datetime(2025, 3, 8), + 5, + id="starts and end on weekend", + ), + ], +) +def test_can_get_weekdays_between_two_dates(start, end, expected): + assert temporal.get_weekdays_between(start, end) == expected diff --git a/template/src/{{ project_module }}/utils/__init__.py b/template/src/{{ project_module }}/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/src/{{ project_module }}/utils/temporal.py b/template/src/{{ project_module }}/utils/temporal.py new file mode 100644 index 0000000..b0c9cb3 --- /dev/null +++ b/template/src/{{ project_module }}/utils/temporal.py @@ -0,0 +1,168 @@ +import dataclasses +import datetime +import re +import zoneinfo +from collections import abc + +from dateutil import parser +from django.conf import settings +from django.utils import timezone + + +@dataclasses.dataclass +class DatetimeRange: + start: datetime.datetime + end: datetime.datetime # Exclusive + + def __contains__(self, value: datetime.date | datetime.datetime) -> bool: + value = make_tz_aware(value) + return self.start <= value < self.end + + def __lt__(self, value: datetime.date | datetime.datetime) -> bool: + value = make_tz_aware(value) + return self.end <= value + + def __gt__(self, value: datetime.date | datetime.datetime) -> bool: + value = make_tz_aware(value) + return value < self.start + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DatetimeRange): + return NotImplemented + return self.start == other.start and self.end == other.end + + def __len__(self) -> int: + return (self.end - self.start).days + + def __repr__(self) -> str: + return f"DatetimeRange(start={self.start}, end={self.end})" + + @property + def last_day(self) -> datetime.date: + return (self.end - datetime.timedelta(days=1)).date() + + +def extract_datetime_from_string( + value: str, delimiters: abc.Iterable[str] = (".", "_") +) -> datetime.datetime: + """ + Attempt to extract the datetime from a string. + + The string is first split by the delimiters and then each segment is parsed in an + attempt to find a datetime. The first datetime found is returned. + """ + + split_pattern = re.compile("|".join([re.escape(d) for d in delimiters])) + + tzinfo = zoneinfo.ZoneInfo(settings.TIME_ZONE) + + segments = re.split(split_pattern, value) + file_date: datetime.datetime | None = None + for segment in segments: + try: + file_date = parser.parse(segment, fuzzy=False) + return file_date.replace(tzinfo=tzinfo) + except parser.ParserError, OverflowError: + pass + + raise ValueError(f"Could not extract datetime from filename '{value}'") + + +def make_tz_aware(value: datetime.datetime | datetime.date) -> datetime.datetime: + """ + Return a TZ-aware datetime of the passed date/datetime. + """ + tzinfo = zoneinfo.ZoneInfo(settings.TIME_ZONE) + if isinstance(value, datetime.datetime): + dt = value + else: + dt = datetime.datetime.combine(value, datetime.time(0)) + + if timezone.is_naive(dt): + return timezone.make_aware(dt, timezone=tzinfo) + return dt.astimezone(tzinfo) + + +def now() -> datetime.datetime: + """ + Return now in system timezone + """ + tzinfo = zoneinfo.ZoneInfo(settings.TIME_ZONE) + return timezone.now().astimezone(tzinfo) + + +def today() -> datetime.date: + """ + Return today in system timezone + """ + return now().date() + + +def yesterday() -> datetime.date: + """ + Return yesterday in system timezone + """ + return now().date() - datetime.timedelta(days=1) + + +def tomorrow() -> datetime.date: + """ + Return tomorrow in system timezone + """ + return now().date() + datetime.timedelta(days=1) + + +def midnight( + value: datetime.datetime | datetime.date | None = None, +) -> datetime.datetime: + """ + Return midnight in system timezone + """ + tzinfo = zoneinfo.ZoneInfo(settings.TIME_ZONE) + + if value is None: + value = timezone.now() + elif isinstance(value, datetime.date): + value = datetime.datetime.combine(value, datetime.time(0)) + + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone=tzinfo) + else: + value = value.astimezone(tzinfo) + + return value.replace(hour=0, minute=0, second=0, microsecond=0) + + +def get_weekdays(period: DatetimeRange) -> int: + """ + Calculate the number of weekdays in a period. + + Note that the period start date is inclusive and the end is exclusive. + """ + start_date = period.start.date() + end_date = period.end.date() + + total_days = (end_date - start_date).days + full_weeks, extra_days = divmod(total_days, 7) + + weekdays = full_weeks * 5 + + # Handle remaining days + for i in range(extra_days): + if (start_date + datetime.timedelta(days=i)).weekday() < 5: + weekdays += 1 + + return weekdays + + +def get_weekdays_between( + start: datetime.date | datetime.datetime, end: datetime.date | datetime.datetime +) -> int: + """ + Calculate the number of weekdays between 2 dates. + + Note that the start is inclusive and the end is exclusive. + """ + start_date = make_tz_aware(start) + end_date = make_tz_aware(end) + return get_weekdays(DatetimeRange(start_date, end_date))