diff --git a/.env.example b/.env.example index c63e971..b60902a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ MARIADB_PASSWORD=change-me LOG_LEVEL=INFO +# --- Timezone (IANA-Format) --- +TIMEZONE=Europe/Berlin + # --- Geburtstags-Mailer --- MAILER_ENABLED=true SMTP_HOST=smtp.example.de diff --git a/src/api/main.py b/src/api/main.py index c16674f..5cffa6a 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -10,7 +10,7 @@ Benutzernamen im Remote-User-Header mitschickt.""" import json import logging import secrets -from datetime import date +from datetime import date, datetime from urllib.parse import quote_plus from fastapi import Depends, FastAPI, Query, Request @@ -22,7 +22,7 @@ from starlette.middleware.sessions import SessionMiddleware import db from api.auth import get_current_user, resolve_account_for_user from api.schemas import ContactListResponse, ContactOut, SyncRunOut -from config import Config +from config import TIMEZONE, Config from mailer import build_message, fetch_birthdays_for_date, send_message logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") @@ -35,6 +35,15 @@ templates = Jinja2Templates(directory="api/templates") templates.env.filters["urlquote"] = lambda s: quote_plus(s or "") +def _fmt_ts(dt) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + from datetime import timezone + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(TIMEZONE).strftime("%d.%m.%Y %H:%M:%S") + + def _row_to_contact_out(row: dict) -> dict: row = dict(row) for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]: @@ -42,7 +51,7 @@ def _row_to_contact_out(row: dict) -> dict: row[field] = json.loads(raw) if raw else [] if not row.get("full_name"): row["full_name"] = db._build_full_name(row) - row["updated_at"] = str(row["updated_at"]) + row["updated_at"] = _fmt_ts(row["updated_at"]) return row @@ -128,7 +137,7 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)): @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) def birthdays_today(current_user: str = Depends(get_current_user)): account_name, is_admin = resolve_account_for_user(current_user) - today = date.today() + today = datetime.now(TIMEZONE).date() with db.get_connection() as conn: where_clause, params = _account_filter_clause(account_name) @@ -184,8 +193,8 @@ def list_sync_runs(current_user: str = Depends(get_current_user)): rows = cur.fetchall() for r in rows: - r["started_at"] = str(r["started_at"]) - r["finished_at"] = str(r["finished_at"]) if r["finished_at"] else None + r["started_at"] = _fmt_ts(r["started_at"]) + r["finished_at"] = _fmt_ts(r["finished_at"]) return rows @@ -224,11 +233,11 @@ def web_dashboard( last_sync_with_changes = cur.fetchone() if last_sync: - last_sync["started_at"] = str(last_sync["started_at"]) - last_sync["finished_at"] = str(last_sync["finished_at"]) if last_sync["finished_at"] else None + last_sync["started_at"] = _fmt_ts(last_sync["started_at"]) + last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"]) if last_sync_with_changes: - last_sync_with_changes["started_at"] = str(last_sync_with_changes["started_at"]) + last_sync_with_changes["started_at"] = _fmt_ts(last_sync_with_changes["started_at"]) if last_sync and last_sync["started_at"] == last_sync_with_changes["started_at"]: last_sync_with_changes = None @@ -244,8 +253,8 @@ def web_dashboard( "upcoming_birthdays": upcoming_birthdays, "last_sync": last_sync, "last_sync_with_changes": last_sync_with_changes, - "current_year": date.today().year, - "today": date.today(), + "current_year": datetime.now(TIMEZONE).date().year, + "today": datetime.now(TIMEZONE).date(), }, ) diff --git a/src/api/schemas.py b/src/api/schemas.py index c61e165..b739658 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) # Licensed under the MIT License. See LICENSE file in project root for details. from datetime import date + from pydantic import BaseModel diff --git a/src/config.py b/src/config.py index 989ac61..57f32a3 100644 --- a/src/config.py +++ b/src/config.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. See LICENSE file in project root for details. import json import os +from zoneinfo import ZoneInfo from dotenv import load_dotenv @@ -29,6 +30,8 @@ class Config: ACCOUNTS_CONFIG_PATH = os.environ.get("ACCOUNTS_CONFIG_PATH", "/app/config/accounts.json") + TIMEZONE = ZoneInfo(os.environ.get("TIMEZONE", "Europe/Berlin")) + LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") SOURCE_NAME = "icloud" diff --git a/src/db.py b/src/db.py index 6be6db8..6e6e012 100644 --- a/src/db.py +++ b/src/db.py @@ -5,12 +5,12 @@ import logging import os import uuid from contextlib import contextmanager -from datetime import date, datetime +from datetime import datetime, timezone import pymysql from pymysql.cursors import DictCursor -from config import Config +from config import TIMEZONE, Config logger = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def finish_sync_run(conn, run_id: str, status: str, upserted: int = None, delete def upsert_contacts(conn, contacts: list[dict], run_id: str): if not contacts: return - now = datetime.now() + now = datetime.now(timezone.utc) with conn.cursor() as cur: for c in contacts: c["sync_run_id"] = run_id @@ -219,7 +219,7 @@ def search_contacts_without_social(conn, account: str | None) -> list[dict]: def get_upcoming_birthdays(conn, account: str | None, days: int = 7) -> list[dict]: where_clause, params = _account_filter_clause(account) - today = date.today() + today = datetime.now(TIMEZONE).date() with conn.cursor() as cur: cur.execute( f"""SELECT id, full_name, given_name, middle_name, family_name, diff --git a/src/mailer.py b/src/mailer.py index 55f1bed..7e240b3 100644 --- a/src/mailer.py +++ b/src/mailer.py @@ -8,11 +8,11 @@ die Tabelle birthday_mail_log.""" import logging import smtplib import sys -from datetime import date +from datetime import date, datetime from email.message import EmailMessage import db -from config import Config +from config import TIMEZONE, Config logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("mailer") @@ -33,18 +33,18 @@ def fetch_birthdays_for_date(conn, target_date: date) -> list[dict]: def fetch_todays_birthdays(conn) -> list[dict]: - return fetch_birthdays_for_date(conn, date.today()) + return fetch_birthdays_for_date(conn, datetime.now(TIMEZONE).date()) def already_sent_today(conn) -> bool: - today = date.today() + today = datetime.now(TIMEZONE).date() with conn.cursor() as cur: cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,)) return cur.fetchone() is not None def log_sent(conn, count: int): - today = date.today() + today = datetime.now(TIMEZONE).date() with conn.cursor() as cur: cur.execute( "INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)", @@ -54,7 +54,7 @@ def log_sent(conn, count: int): def build_message(birthdays: list[dict], target_date: date | None = None) -> EmailMessage: - today = target_date or date.today() + today = target_date or datetime.now(TIMEZONE).date() msg = EmailMessage() msg["From"] = Config.MAIL_FROM msg["To"] = Config.MAIL_TO diff --git a/src/scheduler.py b/src/scheduler.py index 6aa95ff..f3a4ff6 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -19,6 +19,7 @@ import time from datetime import datetime, timedelta import db +from config import TIMEZONE logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), @@ -62,7 +63,7 @@ def run_sync(): run_script("Sync", "/app/sync.py") try: with open("/tmp/last_sync_ok", "w") as f: - f.write(datetime.now().isoformat()) + f.write(datetime.now(TIMEZONE).isoformat()) except OSError: pass @@ -74,7 +75,7 @@ def run_mailer(): def next_run_time(hour: int) -> datetime: - now = datetime.now() + now = datetime.now(TIMEZONE) target = now.replace(hour=hour, minute=0, second=0, microsecond=0) if target <= now: target += timedelta(days=1) @@ -103,11 +104,11 @@ def main(): logger.info("Starte ersten Sync-Lauf...") run_sync() - last_sync = datetime.now() + last_sync = datetime.now(TIMEZONE) next_mailer = next_run_time(MAIL_SEND_HOUR) while not _shutdown: - now = datetime.now() + now = datetime.now(TIMEZONE) if now >= last_sync + timedelta(minutes=SYNC_INTERVAL_MINUTES): run_sync()