From 124ab4b14cec58f972b80fdcb331ee8cdb8d6b31 Mon Sep 17 00:00:00 2001 From: zahra Date: Thu, 30 Apr 2026 13:23:57 +0930 Subject: [PATCH 1/5] Add temporal utility as optional template variant Makes the timezone-aware datetime utilities optional through a new use_temporal configuration flag. When disabled, the utils folders and related dependencies are excluded from generated projects. The temporal utility now includes only universally useful datetime helpers: `now()`, `today()`, `yesterday()`, `tomorrow()`, `midnight()`, `make_tz_aware()`, `DatetimeRange`, `extract_datetime_from_string()`, `get_weekdays()`, and `get_weekdays_between()`. --- template/pyproject.toml.jinja | 3 + template/src/tests/system/utils/__init__.py | 0 .../tests/system/utils/test_temporal.py.jinja | 244 ++++++++++++++++++ .../{{ project_module }}/utils/__init__.py | 0 .../{{ project_module }}/utils/temporal.py | 168 ++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 template/src/tests/system/utils/__init__.py create mode 100644 template/src/tests/system/utils/test_temporal.py.jinja create mode 100644 template/src/{{ project_module }}/utils/__init__.py create mode 100644 template/src/{{ project_module }}/utils/temporal.py diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index f8c194d..7409957 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] 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..060517e --- /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( + file_name: 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, file_name) + 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 '{file_name}'") + + +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)) From fce1f05beb870d3a51fa6d3a8a5c846c65651782 Mon Sep 17 00:00:00 2001 From: zahra Date: Thu, 30 Apr 2026 17:04:40 +0930 Subject: [PATCH 2/5] Add a linter error in case of naive time usage --- template/pyproject.toml.jinja | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 7409957..6ff3513 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -110,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 From 7cc80bab54e74c4dface2e34919feded4c4439f9 Mon Sep 17 00:00:00 2001 From: zahra Date: Thu, 30 Apr 2026 17:05:24 +0930 Subject: [PATCH 3/5] Add a doc reference about temporal utility and corresponding linter check --- template/docs/reference/temporal.md | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 template/docs/reference/temporal.md diff --git a/template/docs/reference/temporal.md b/template/docs/reference/temporal.md new file mode 100644 index 0000000..e08b614 --- /dev/null +++ b/template/docs/reference/temporal.md @@ -0,0 +1,151 @@ +# Temporal Utilities + +The `{{ project_module }}.utils.temporal` module provides timezone-aware datetime +utilities to prevent common datetime bugs in Django applications. + +## Why? + +Django best practice is to always use timezone-aware datetimes. Using naive datetimes +(without timezone information) can lead to subtle bugs, especially when handling data +across different timezones or daylight saving time boundaries. + +This project enforces timezone-aware datetime usage through linting (DTZ rules). If you +see errors like: + +``` +DTZ005 `datetime.datetime.now()` called without a `tz` argument +``` + +**Use the temporal utility instead of standard datetime methods.** + +## Common Usage + +Replace standard datetime calls with temporal equivalents: + +| ❌ Don't use | ✅ Use instead | +|--------------|----------------| +| `datetime.datetime.now()` | `temporal.now()` | +| `datetime.datetime.today()` | `temporal.today()` | +| `datetime.datetime.utcnow()` | `temporal.now()` | +| `datetime.date.today()` | `temporal.today()` | + +## Available Functions + +### `now() -> datetime` +Returns the current datetime in the timezone set in Django settings (`TIME_ZONE`). + +```python +from {{ project_module }}.utils import temporal + +current_time = temporal.now() +``` + +### `today() -> date` +Returns the current date in the system timezone. + +```python +current_date = temporal.today() +``` + +### `yesterday() -> date` / `tomorrow() -> date` +Returns yesterday's or tomorrow's date in the system timezone. + +```python +yesterday_date = temporal.yesterday() +tomorrow_date = temporal.tomorrow() +``` + +### `make_tz_aware(value: date | datetime) -> datetime` +Converts a naive datetime or date to a timezone-aware datetime using the Django +`TIME_ZONE` setting. + +```python +naive_dt = datetime.datetime(2026, 4, 30, 12, 0) +aware_dt = temporal.make_tz_aware(naive_dt) +``` + +### `midnight(dt: date | datetime) -> datetime` +Returns midnight for the given date in the system timezone. + +```python +start_of_day = temporal.midnight(temporal.today()) +``` + +### `extract_datetime_from_string(text: str) -> datetime | None` +Attempts to parse a datetime from a string (useful for parsing dates from filenames or +user input). + +```python +dt = temporal.extract_datetime_from_string("report_2026-04-30.pdf") +# Returns: datetime(2026, 4, 30, 0, 0, tzinfo=...) +``` + +### Business Day Utilities + +#### `get_weekdays(start: date, num_days: int) -> list[date]` +Returns a list of weekdays (Monday-Friday) starting from a given date. + +```python +weekdays = temporal.get_weekdays(temporal.today(), 5) +# Returns next 5 business days +``` + +#### `get_weekdays_between(start: date, end: date) -> list[date]` +Returns all weekdays between two dates (inclusive). + +```python +weekdays = temporal.get_weekdays_between( + temporal.today(), + temporal.today() + datetime.timedelta(days=14) +) +``` + +### Date Ranges + +#### `DatetimeRange` +A dataclass for representing datetime ranges with comparison operators. + +```python +from {{ project_module }}.utils.temporal import DatetimeRange + +range1 = DatetimeRange( + start=temporal.now(), + end=temporal.now() + datetime.timedelta(days=7) +) + +# Check if a datetime is in the range +if some_datetime in range1: + print("In range!") + +# Compare ranges +range2 = DatetimeRange(start=..., end=...) +if range1 < range2: + print("Range1 is entirely before range2") + +# Get the last day +last_day = range1.last_day +``` + +## Testing + +In tests, use `time-machine` to freeze time: + +```python +import time_machine +from {{ project_module }}.utils import temporal + +@time_machine.travel("2026-04-30 10:00:00", tick=False) +def test_something(): + assert temporal.now().day == 30 +``` + +## Configuration + +The timezone is configured in `settings.py`: + +```python +TIME_ZONE = "UTC" +USE_TZ = True +``` + +All temporal functions respect this setting. From 1edfaee665e4bcdadef3e18317436f31f2945320 Mon Sep 17 00:00:00 2001 From: zahra Date: Mon, 4 May 2026 10:30:26 +0930 Subject: [PATCH 4/5] Choose a more descriptive param name --- template/src/{{ project_module }}/utils/temporal.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/template/src/{{ project_module }}/utils/temporal.py b/template/src/{{ project_module }}/utils/temporal.py index 060517e..b0c9cb3 100644 --- a/template/src/{{ project_module }}/utils/temporal.py +++ b/template/src/{{ project_module }}/utils/temporal.py @@ -43,7 +43,7 @@ def last_day(self) -> datetime.date: def extract_datetime_from_string( - file_name: str, delimiters: abc.Iterable[str] = (".", "_") + value: str, delimiters: abc.Iterable[str] = (".", "_") ) -> datetime.datetime: """ Attempt to extract the datetime from a string. @@ -56,7 +56,7 @@ def extract_datetime_from_string( tzinfo = zoneinfo.ZoneInfo(settings.TIME_ZONE) - segments = re.split(split_pattern, file_name) + segments = re.split(split_pattern, value) file_date: datetime.datetime | None = None for segment in segments: try: @@ -65,7 +65,7 @@ def extract_datetime_from_string( except parser.ParserError, OverflowError: pass - raise ValueError(f"Could not extract datetime from filename '{file_name}'") + raise ValueError(f"Could not extract datetime from filename '{value}'") def make_tz_aware(value: datetime.datetime | datetime.date) -> datetime.datetime: From 2b454ccd473d7a89eb8cf6cead9787e2205c1fe7 Mon Sep 17 00:00:00 2001 From: zahra Date: Mon, 4 May 2026 10:32:41 +0930 Subject: [PATCH 5/5] Convert the document to a datetime how-to --- template/docs/how-tos/datetimes.md.jinja | 188 +++++++++++++++++++++++ template/docs/reference/temporal.md | 151 ------------------ 2 files changed, 188 insertions(+), 151 deletions(-) create mode 100644 template/docs/how-tos/datetimes.md.jinja delete mode 100644 template/docs/reference/temporal.md 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/docs/reference/temporal.md b/template/docs/reference/temporal.md deleted file mode 100644 index e08b614..0000000 --- a/template/docs/reference/temporal.md +++ /dev/null @@ -1,151 +0,0 @@ -# Temporal Utilities - -The `{{ project_module }}.utils.temporal` module provides timezone-aware datetime -utilities to prevent common datetime bugs in Django applications. - -## Why? - -Django best practice is to always use timezone-aware datetimes. Using naive datetimes -(without timezone information) can lead to subtle bugs, especially when handling data -across different timezones or daylight saving time boundaries. - -This project enforces timezone-aware datetime usage through linting (DTZ rules). If you -see errors like: - -``` -DTZ005 `datetime.datetime.now()` called without a `tz` argument -``` - -**Use the temporal utility instead of standard datetime methods.** - -## Common Usage - -Replace standard datetime calls with temporal equivalents: - -| ❌ Don't use | ✅ Use instead | -|--------------|----------------| -| `datetime.datetime.now()` | `temporal.now()` | -| `datetime.datetime.today()` | `temporal.today()` | -| `datetime.datetime.utcnow()` | `temporal.now()` | -| `datetime.date.today()` | `temporal.today()` | - -## Available Functions - -### `now() -> datetime` -Returns the current datetime in the timezone set in Django settings (`TIME_ZONE`). - -```python -from {{ project_module }}.utils import temporal - -current_time = temporal.now() -``` - -### `today() -> date` -Returns the current date in the system timezone. - -```python -current_date = temporal.today() -``` - -### `yesterday() -> date` / `tomorrow() -> date` -Returns yesterday's or tomorrow's date in the system timezone. - -```python -yesterday_date = temporal.yesterday() -tomorrow_date = temporal.tomorrow() -``` - -### `make_tz_aware(value: date | datetime) -> datetime` -Converts a naive datetime or date to a timezone-aware datetime using the Django -`TIME_ZONE` setting. - -```python -naive_dt = datetime.datetime(2026, 4, 30, 12, 0) -aware_dt = temporal.make_tz_aware(naive_dt) -``` - -### `midnight(dt: date | datetime) -> datetime` -Returns midnight for the given date in the system timezone. - -```python -start_of_day = temporal.midnight(temporal.today()) -``` - -### `extract_datetime_from_string(text: str) -> datetime | None` -Attempts to parse a datetime from a string (useful for parsing dates from filenames or -user input). - -```python -dt = temporal.extract_datetime_from_string("report_2026-04-30.pdf") -# Returns: datetime(2026, 4, 30, 0, 0, tzinfo=...) -``` - -### Business Day Utilities - -#### `get_weekdays(start: date, num_days: int) -> list[date]` -Returns a list of weekdays (Monday-Friday) starting from a given date. - -```python -weekdays = temporal.get_weekdays(temporal.today(), 5) -# Returns next 5 business days -``` - -#### `get_weekdays_between(start: date, end: date) -> list[date]` -Returns all weekdays between two dates (inclusive). - -```python -weekdays = temporal.get_weekdays_between( - temporal.today(), - temporal.today() + datetime.timedelta(days=14) -) -``` - -### Date Ranges - -#### `DatetimeRange` -A dataclass for representing datetime ranges with comparison operators. - -```python -from {{ project_module }}.utils.temporal import DatetimeRange - -range1 = DatetimeRange( - start=temporal.now(), - end=temporal.now() + datetime.timedelta(days=7) -) - -# Check if a datetime is in the range -if some_datetime in range1: - print("In range!") - -# Compare ranges -range2 = DatetimeRange(start=..., end=...) -if range1 < range2: - print("Range1 is entirely before range2") - -# Get the last day -last_day = range1.last_day -``` - -## Testing - -In tests, use `time-machine` to freeze time: - -```python -import time_machine -from {{ project_module }}.utils import temporal - -@time_machine.travel("2026-04-30 10:00:00", tick=False) -def test_something(): - assert temporal.now().day == 30 -``` - -## Configuration - -The timezone is configured in `settings.py`: - -```python -TIME_ZONE = "UTC" -USE_TZ = True -``` - -All temporal functions respect this setting.