Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/projectvote/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions src/projectvote/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +28,7 @@
format_datetime_for_email,
get_app_settings,
get_board_members,
get_now,
)
from projectvote.backend.models import (
Application,
Expand Down Expand Up @@ -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"