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"