Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def test(session: nox.Session, django_ver: str) -> None:
"Australia/LHI",
"Asia/Magadan",
"Antarctica/McMurdo",
"Pacific/Auckland",
"NZ-CHAT",
"Etc/GMT-13",
"Etc/GMT-14",
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "djpress"
version = "0.30.0"
version = "0.30.1"
description = "A blog application for Django sites, inspired by classic WordPress."
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -112,7 +112,7 @@ omit = ["*/tests/*", "*/migrations/*"]
exclude_lines = ["if TYPE_CHECKING:", "# pragma: no cover"]

[tool.bumpver]
current_version = "0.30.0"
current_version = "0.30.1"
version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "👍 bump version {old_version} -> {new_version}"
commit = true
Expand Down
2 changes: 1 addition & 1 deletion src/djpress/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""djpress module."""

__version__ = "0.30.0"
__version__ = "0.30.1"
19 changes: 11 additions & 8 deletions src/djpress/management/commands/djpress_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db.models import QuerySet
from django.utils import timezone

from djpress.models import Media, Post

Expand Down Expand Up @@ -180,8 +181,8 @@ def _export_media_library(self, target_dir: Path) -> int:
"description": media.description,
"media_type": media.media_type,
"uploaded_by": media.uploaded_by.username if media.uploaded_by else None,
"uploaded_at": media.uploaded_at.isoformat() if media.uploaded_at else None,
"updated_at": media.updated_at.isoformat() if media.updated_at else None,
"uploaded_at": timezone.localtime(media.uploaded_at).isoformat() if media.uploaded_at else None,
"updated_at": timezone.localtime(media.updated_at).isoformat() if media.updated_at else None,
"url": media.file.url,
}

Expand Down Expand Up @@ -215,8 +216,10 @@ def _create_directory_structure(self, output_dir: Path, *, include_media: bool =

def _export_post(self, post: Post, output_dir: Path) -> None:
"""Export a single post to flat file format."""
# Create filename based on date and slug
date_str = post.published_at.strftime("%Y-%m-%d")
# Create filename based on date and slug. Use `_date` (the frozen local publish date used
# for the post's canonical URL) rather than `published_at` (stored in UTC), so the exported
# filename's date matches the site's actual date-based routing.
date_str = post._date.strftime("%Y-%m-%d") # noqa: SLF001
filename = f"{date_str}-{post.slug}.md"
filepath = output_dir / "content" / "posts" / filename

Expand Down Expand Up @@ -250,8 +253,8 @@ def _generate_frontmatter(self, content: Post) -> dict[str, Any]:
Frontmatter fields:
- Common:
- "title": content.title
- "date": content.published_at.isoformat()
- "lastmod": content.updated_at.isoformat()
- "date": content.published_at, converted to the local `TIME_ZONE`, isoformat()
- "lastmod": content.updated_at, converted to the local `TIME_ZONE`, isoformat()
- "status": content.status
- "slug": content.slug
- "author": content.author.get_full_name() or content.author.username
Expand All @@ -265,8 +268,8 @@ def _generate_frontmatter(self, content: Post) -> dict[str, Any]:
"""
frontmatter = {
"title": content.title,
"date": content.published_at.isoformat(),
"lastmod": content.updated_at.isoformat(),
"date": timezone.localtime(content.published_at).isoformat(),
"lastmod": timezone.localtime(content.updated_at).isoformat(),
"status": content.status,
"slug": content.slug,
"author": content.author.get_full_name() or content.author.username,
Expand Down
2 changes: 1 addition & 1 deletion src/djpress/models/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
# We only do this if the post is new or if the date has changed. This ensures the _date field doesn't change
# unless the date field has been specifically changed.
if self.pk is None:
self._date = self.published_at.date()
self._date = self.local_datetime.date()
else:
old = self.__class__.admin_objects.filter(pk=self.pk).only("published_at").first()
if old is None or old.published_at != self.published_at:
Expand Down
61 changes: 58 additions & 3 deletions tests/test_management_commands_export.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for management commands."""

import datetime
import os
import pytest
import tempfile
Expand Down Expand Up @@ -158,7 +159,9 @@ def test_basic_export_published_only(self, test_post1, test_page, draft_post):
# Check file naming
post_file = post_files[0]
assert test_post1.slug in post_file.name
assert str(test_post1.published_at.year) in post_file.name
# The exported filename's date comes from `_date` (frozen local publish date), not
# `published_at` (stored in UTC).
assert str(test_post1._date.year) in post_file.name

def test_export_posts_only(self, test_post1, test_page):
"""Test export with posts-only option."""
Expand Down Expand Up @@ -205,6 +208,32 @@ def test_frontmatter_generation_complete(self, post_with_categories_and_tags):
assert len(frontmatter["categories"]) == 1
assert len(frontmatter["tags"]) == 2

def test_frontmatter_dates_use_local_timezone_not_utc(self, user):
"""`date`/`lastmod` in frontmatter must reflect `TIME_ZONE`, not the UTC
value Django stores internally and returns on fetch from the DB.
"""
with override_settings(TIME_ZONE="Pacific/Auckland"): # UTC+12/+13
published_at = datetime.datetime(2026, 4, 20, 23, 0, tzinfo=datetime.timezone.utc)
post = Post.admin_objects.create(
title="Timezone Export Post",
slug="timezone-export-post",
content="Test content.",
author=user,
status="published",
post_type="post",
published_at=published_at,
)
# Re-fetch, since the export command works off a queryset, not the
# in-memory instance that was just created.
post = Post.admin_objects.get(pk=post.pk)

command = Command()
frontmatter = command._generate_frontmatter(post)

assert frontmatter["date"] == timezone.localtime(post.published_at).isoformat()
assert frontmatter["date"].startswith("2026-04-21T11:00:00")
assert frontmatter["lastmod"] == timezone.localtime(post.updated_at).isoformat()

def test_frontmatter_generation_page_with_parent(self, child_page):
"""Test frontmatter generation for page with parent and menu order."""
command = Command()
Expand Down Expand Up @@ -255,8 +284,9 @@ def test_export_post_filename_format(self, test_post1):
assert len(post_files) == 1
filename = post_files[0].name

# Should be in format YYYY-MM-DD-slug.md
date_str = test_post1.published_at.strftime("%Y-%m-%d")
# Should be in format YYYY-MM-DD-slug.md. The filename is built from `_date`, the frozen
# local publish date - not `published_at`, which is stored in UTC.
date_str = test_post1._date.strftime("%Y-%m-%d")
expected_filename = f"{date_str}-{test_post1.slug}.md"
assert filename == expected_filename

Expand Down Expand Up @@ -715,6 +745,31 @@ def test_export_media_metadata_success(self, test_media_file_1, user):
assert "uploaded_at" in item
assert "updated_at" in item

def test_export_media_metadata_dates_use_local_timezone_not_utc(self, test_media_file_1):
"""`uploaded_at`/`updated_at` in the media metadata must reflect `TIME_ZONE`, not the UTC
value Django stores internally and returns on fetch from the DB.
"""
import json

with override_settings(TIME_ZONE="Pacific/Auckland"): # UTC+12/+13
uploaded_at = datetime.datetime(2026, 4, 20, 23, 0, tzinfo=datetime.timezone.utc)
test_media_file_1.uploaded_at = uploaded_at
test_media_file_1.save()
# Re-fetch, since the export command works off a queryset, not the in-memory instance.
media = Media.objects.get(pk=test_media_file_1.pk)

with tempfile.TemporaryDirectory() as temp_dir:
call_command("djpress_export", output=temp_dir, no_zip=True)

metadata_file = Path(temp_dir) / "static" / "metadata.json"
with metadata_file.open("r", encoding="utf-8") as f:
metadata = json.load(f)

item = metadata[media.file.name]
assert item["uploaded_at"] == timezone.localtime(media.uploaded_at).isoformat()
assert item["uploaded_at"].startswith("2026-04-21T11:00:00")
assert item["updated_at"] == timezone.localtime(media.updated_at).isoformat()

def test_export_media_metadata_no_uploaded_by(self, test_media_file_1):
"""Test that media metadata handles null uploaded_by correctly."""
import json
Expand Down
Loading
Loading