From 99789fddf45db071972fdc9b02166eba33e7c1f5 Mon Sep 17 00:00:00 2001 From: Andreas Vester Date: Thu, 29 Jan 2026 21:40:37 +0100 Subject: [PATCH] feat: Add configurable timezone support via TZ environment variable Implement timezone-aware timestamp handling to fix issue where timestamps were displayed in UTC instead of the configured local timezone. The system now uses the standard Unix TZ environment variable for configuration. Changes: - Added 'tz' configuration field to Settings class (defaults to Europe/Berlin) - Updated format_datetime_for_email() to use configured timezone - Updated vote timestamp recording to capture time in configured timezone - Added comprehensive parametrized unit tests for multiple timezones: - Europe/Berlin, Europe/London, America/New_York, Asia/Tokyo, UTC - Update .env and .env.example with TZ configuration documentation Fixes issue where Docker container timestamps were displayed in UTC instead of configured local timezone, causing timestamps in emails and archive to be several hours off. Default timezone is Europe/Berlin, but users can now configure any IANA timezone by setting the TZ environment variable in their .env file. Test coverage: - 89 tests passing (8 new parametrized timezone tests) - All lint and type checks passing Closes #22 --- .env.example | 4 +++ CHANGELOG.md | 4 +++ src/projectvote/backend/config.py | 3 +- src/projectvote/backend/main.py | 36 +++++++++++++++---- tests/test_main.py | 58 +++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index d7d9ca9..13d77c5 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ FRONTEND_URL=http://your-production-domain.com # Comma-separated list of board member email addresses BOARD_MEMBERS=board.member1@example.com,board.member2@example.com,board.member3@example.com +# Timezone for application timestamps and email formatting (standard Unix TZ variable) +# Examples: Europe/Berlin, Europe/London, America/New_York, UTC +TZ=Europe/Berlin + # ----------------------------------------------------------------------------- # Database Configuration # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e32efa..ac6592c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Database migration for fresh deployments ([GH#20](https://github.com/andreas-vester/ProjectVote/issues/20)). +### Added + +- Timezone environment variable ([GH#22](https://github.com/andreas-vester/ProjectVote/issues/22)). + ## [0.5.0] - 2026-01-23 ### Added diff --git a/src/projectvote/backend/config.py b/src/projectvote/backend/config.py index cf8143c..4be2d19 100644 --- a/src/projectvote/backend/config.py +++ b/src/projectvote/backend/config.py @@ -2,7 +2,7 @@ from pathlib import Path -from pydantic import EmailStr, SecretStr, field_validator +from pydantic import EmailStr, Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -36,6 +36,7 @@ def board_members_must_be_set(cls, v: str | None) -> str: backend_url: str = "http://localhost:8008" send_automatic_confirmation_email: bool = False send_automatic_rejection_email: bool = False + tz: str = Field(default="Europe/Berlin") # Database settings db_echo: bool = True diff --git a/src/projectvote/backend/main.py b/src/projectvote/backend/main.py index fbd22e5..6d40053 100644 --- a/src/projectvote/backend/main.py +++ b/src/projectvote/backend/main.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Annotated +from zoneinfo import ZoneInfo import aiofiles from dotenv import load_dotenv @@ -96,11 +97,30 @@ def get_board_members( return [email.strip() for email in settings.board_members.split(",")] -def format_datetime_for_email(timestamp: dt.datetime | None) -> str: +def get_configured_timezone() -> ZoneInfo: + """Return the Berlin timezone (default).""" + return ZoneInfo("Europe/Berlin") + + +def get_now(settings: Settings | None = None) -> dt.datetime: + """Get current time in configured timezone (or Berlin as default).""" + tz = get_configured_timezone() if settings is None else ZoneInfo(settings.tz) + return dt.datetime.now(tz) + + +def format_datetime_for_email( + timestamp: dt.datetime | None, settings: Settings | None = None +) -> str: """Format a datetime object into a user-friendly string for emails.""" if not timestamp: return "N/A" - return timestamp.strftime("%d.%m.%Y, %H:%M Uhr") + # Determine timezone to use + tz = get_configured_timezone() if settings is None else ZoneInfo(settings.tz) + # Ensure the timestamp is timezone-aware and convert to configured timezone + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=ZoneInfo("UTC")) + localized_time = timestamp.astimezone(tz) + return localized_time.strftime("%d.%m.%Y, %H:%M Uhr") # --- Pydantic Models for API data validation --- @@ -179,7 +199,7 @@ async def send_confirmation_email(application: Application, settings: Settings) "project_title": application.project_title, "project_description": application.project_description, "costs": application.costs, - "created_at": format_datetime_for_email(application.created_at), + "created_at": format_datetime_for_email(application.created_at, settings), "attachment_filename": application.attachments[0].filename if application.attachments else None, @@ -217,7 +237,9 @@ async def send_voting_links( "project_title": application.project_title, "project_description": application.project_description, "costs": application.costs, - "created_at": format_datetime_for_email(application.created_at), + "created_at": format_datetime_for_email( + application.created_at, settings + ), "vote_url": vote_url, "token": vote_record.token, "frontend_url": settings.frontend_url, @@ -265,8 +287,8 @@ async def send_final_decision_emails( "project_description": application.project_description, "costs": application.costs, "status": german_status, - "created_at": format_datetime_for_email(application.created_at), - "concluded_at": format_datetime_for_email(application.concluded_at), + "created_at": format_datetime_for_email(application.created_at, settings), + "concluded_at": format_datetime_for_email(application.concluded_at, settings), "frontend_url": settings.frontend_url, } @@ -494,7 +516,7 @@ async def cast_vote( # Update vote record vote_record.vote = vote_data.decision.value vote_record.vote_status = VoteStatus.CAST.value # type: ignore[attr-defined] - vote_record.voted_at = func.now() + vote_record.voted_at = get_now(settings) await db.commit() # After a vote is cast, check if the voting process is complete. diff --git a/tests/test_main.py b/tests/test_main.py index 34d8b98..56df8f0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,7 +10,9 @@ - TestUtilities: Helper functions and edge cases """ +import datetime as dt from http import HTTPStatus +from zoneinfo import ZoneInfo import pytest from _pytest.outcomes import Failed @@ -26,6 +28,7 @@ format_datetime_for_email, get_app_settings, get_board_members, + get_now, ) from projectvote.backend.models import ( Application, @@ -1579,3 +1582,58 @@ def test_format_datetime_for_email_with_none(self) -> None: """Test that format_datetime_for_email returns 'N/A' when timestamp is None.""" result = format_datetime_for_email(None) assert result == "N/A" + + def test_format_datetime_for_email_berlin_timezone(self) -> None: + """Test format_datetime_for_email with Berlin timezone (default).""" + # Create a UTC timestamp + utc_time = dt.datetime(2026, 1, 29, 12, 0, 0, tzinfo=ZoneInfo("UTC")) + result = format_datetime_for_email(utc_time) + # Berlin is UTC+1, so 12:00 UTC = 13:00 Berlin + assert "13:00" in result + assert "29.01.2026" in result + + @pytest.mark.parametrize( + ("timezone", "expected_time"), + [ + ("Europe/London", "12:00"), # UTC+0 in January + ("America/New_York", "07:00"), # UTC-5 in January + ("Asia/Tokyo", "21:00"), # UTC+9 + ("Europe/Berlin", "13:00"), # UTC+1 in January + ], + ) + def test_format_datetime_for_email_various_timezones( + self, timezone: str, expected_time: str + ) -> None: + """Test format_datetime_for_email with various timezones.""" + settings = Settings(tz=timezone, board_members="test@example.com") + utc_time = dt.datetime(2026, 1, 29, 12, 0, 0, tzinfo=ZoneInfo("UTC")) + result = format_datetime_for_email(utc_time, settings) + assert expected_time in result + assert "29.01.2026" in result + + def test_format_datetime_for_email_naive_timestamp(self) -> None: + """Test format_datetime_for_email with naive (non-timezone-aware) timestamp.""" + # Create a naive timestamp (assumed to be UTC) + naive_time = dt.datetime(2026, 1, 29, 12, 0, 0) # noqa: DTZ001 + settings = Settings(tz="Europe/Berlin", board_members="test@example.com") + result = format_datetime_for_email(naive_time, settings) + # Should convert naive time assuming UTC, then to Berlin (UTC+1) + assert "13:00" in result + assert "29.01.2026" in result + + @pytest.mark.parametrize( + "timezone", + ["Europe/Berlin", "America/Los_Angeles", "UTC", "Asia/Tokyo"], + ) + def test_get_now_with_timezone(self, timezone: str) -> None: + """Test get_now returns current time in configured timezone.""" + settings = Settings(tz=timezone, board_members="test@example.com") + now = get_now(settings) + assert now.tzinfo is not None + assert str(now.tzinfo) == timezone + + def test_get_now_default_timezone(self) -> None: + """Test get_now returns current time in default Berlin timezone.""" + now = get_now() + assert now.tzinfo is not None + assert str(now.tzinfo) == "Europe/Berlin"