timezone config

This commit is contained in:
2026-08-07 13:06:48 +02:00
parent 8eaac47ef6
commit 04f464c7bb
7 changed files with 42 additions and 25 deletions
+3
View File
@@ -7,6 +7,9 @@ MARIADB_PASSWORD=change-me
LOG_LEVEL=INFO LOG_LEVEL=INFO
# --- Timezone (IANA-Format) ---
TIMEZONE=Europe/Berlin
# --- Geburtstags-Mailer --- # --- Geburtstags-Mailer ---
MAILER_ENABLED=true MAILER_ENABLED=true
SMTP_HOST=smtp.example.de SMTP_HOST=smtp.example.de
+20 -11
View File
@@ -10,7 +10,7 @@ Benutzernamen im Remote-User-Header mitschickt."""
import json import json
import logging import logging
import secrets import secrets
from datetime import date from datetime import date, datetime
from urllib.parse import quote_plus from urllib.parse import quote_plus
from fastapi import Depends, FastAPI, Query, Request from fastapi import Depends, FastAPI, Query, Request
@@ -22,7 +22,7 @@ from starlette.middleware.sessions import SessionMiddleware
import db import db
from api.auth import get_current_user, resolve_account_for_user from api.auth import get_current_user, resolve_account_for_user
from api.schemas import ContactListResponse, ContactOut, SyncRunOut 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 from mailer import build_message, fetch_birthdays_for_date, send_message
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") 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 "") 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: def _row_to_contact_out(row: dict) -> dict:
row = dict(row) row = dict(row)
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]: 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 [] row[field] = json.loads(raw) if raw else []
if not row.get("full_name"): if not row.get("full_name"):
row["full_name"] = db._build_full_name(row) 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 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]) @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
def birthdays_today(current_user: str = Depends(get_current_user)): def birthdays_today(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(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: with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name) 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() rows = cur.fetchall()
for r in rows: for r in rows:
r["started_at"] = str(r["started_at"]) r["started_at"] = _fmt_ts(r["started_at"])
r["finished_at"] = str(r["finished_at"]) if r["finished_at"] else None r["finished_at"] = _fmt_ts(r["finished_at"])
return rows return rows
@@ -224,11 +233,11 @@ def web_dashboard(
last_sync_with_changes = cur.fetchone() last_sync_with_changes = cur.fetchone()
if last_sync: if last_sync:
last_sync["started_at"] = str(last_sync["started_at"]) last_sync["started_at"] = _fmt_ts(last_sync["started_at"])
last_sync["finished_at"] = str(last_sync["finished_at"]) if last_sync["finished_at"] else None last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"])
if last_sync_with_changes: 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"]: if last_sync and last_sync["started_at"] == last_sync_with_changes["started_at"]:
last_sync_with_changes = None last_sync_with_changes = None
@@ -244,8 +253,8 @@ def web_dashboard(
"upcoming_birthdays": upcoming_birthdays, "upcoming_birthdays": upcoming_birthdays,
"last_sync": last_sync, "last_sync": last_sync,
"last_sync_with_changes": last_sync_with_changes, "last_sync_with_changes": last_sync_with_changes,
"current_year": date.today().year, "current_year": datetime.now(TIMEZONE).date().year,
"today": date.today(), "today": datetime.now(TIMEZONE).date(),
}, },
) )
+1
View File
@@ -1,6 +1,7 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) # Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details. # Licensed under the MIT License. See LICENSE file in project root for details.
from datetime import date from datetime import date
from pydantic import BaseModel from pydantic import BaseModel
+3
View File
@@ -2,6 +2,7 @@
# Licensed under the MIT License. See LICENSE file in project root for details. # Licensed under the MIT License. See LICENSE file in project root for details.
import json import json
import os import os
from zoneinfo import ZoneInfo
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -29,6 +30,8 @@ class Config:
ACCOUNTS_CONFIG_PATH = os.environ.get("ACCOUNTS_CONFIG_PATH", "/app/config/accounts.json") 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") LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
SOURCE_NAME = "icloud" SOURCE_NAME = "icloud"
+4 -4
View File
@@ -5,12 +5,12 @@ import logging
import os import os
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime from datetime import datetime, timezone
import pymysql import pymysql
from pymysql.cursors import DictCursor from pymysql.cursors import DictCursor
from config import Config from config import TIMEZONE, Config
logger = logging.getLogger(__name__) 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): def upsert_contacts(conn, contacts: list[dict], run_id: str):
if not contacts: if not contacts:
return return
now = datetime.now() now = datetime.now(timezone.utc)
with conn.cursor() as cur: with conn.cursor() as cur:
for c in contacts: for c in contacts:
c["sync_run_id"] = run_id 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]: def get_upcoming_birthdays(conn, account: str | None, days: int = 7) -> list[dict]:
where_clause, params = _account_filter_clause(account) where_clause, params = _account_filter_clause(account)
today = date.today() today = datetime.now(TIMEZONE).date()
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
f"""SELECT id, full_name, given_name, middle_name, family_name, f"""SELECT id, full_name, given_name, middle_name, family_name,
+6 -6
View File
@@ -8,11 +8,11 @@ die Tabelle birthday_mail_log."""
import logging import logging
import smtplib import smtplib
import sys import sys
from datetime import date from datetime import date, datetime
from email.message import EmailMessage from email.message import EmailMessage
import db import db
from config import Config from config import TIMEZONE, Config
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("mailer") 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]: 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: def already_sent_today(conn) -> bool:
today = date.today() today = datetime.now(TIMEZONE).date()
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,)) cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,))
return cur.fetchone() is not None return cur.fetchone() is not None
def log_sent(conn, count: int): def log_sent(conn, count: int):
today = date.today() today = datetime.now(TIMEZONE).date()
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
"INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)", "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: 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 = EmailMessage()
msg["From"] = Config.MAIL_FROM msg["From"] = Config.MAIL_FROM
msg["To"] = Config.MAIL_TO msg["To"] = Config.MAIL_TO
+5 -4
View File
@@ -19,6 +19,7 @@ import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
import db import db
from config import TIMEZONE
logging.basicConfig( logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"), level=os.environ.get("LOG_LEVEL", "INFO"),
@@ -62,7 +63,7 @@ def run_sync():
run_script("Sync", "/app/sync.py") run_script("Sync", "/app/sync.py")
try: try:
with open("/tmp/last_sync_ok", "w") as f: with open("/tmp/last_sync_ok", "w") as f:
f.write(datetime.now().isoformat()) f.write(datetime.now(TIMEZONE).isoformat())
except OSError: except OSError:
pass pass
@@ -74,7 +75,7 @@ def run_mailer():
def next_run_time(hour: int) -> datetime: 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) target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if target <= now: if target <= now:
target += timedelta(days=1) target += timedelta(days=1)
@@ -103,11 +104,11 @@ def main():
logger.info("Starte ersten Sync-Lauf...") logger.info("Starte ersten Sync-Lauf...")
run_sync() run_sync()
last_sync = datetime.now() last_sync = datetime.now(TIMEZONE)
next_mailer = next_run_time(MAIL_SEND_HOUR) next_mailer = next_run_time(MAIL_SEND_HOUR)
while not _shutdown: while not _shutdown:
now = datetime.now() now = datetime.now(TIMEZONE)
if now >= last_sync + timedelta(minutes=SYNC_INTERVAL_MINUTES): if now >= last_sync + timedelta(minutes=SYNC_INTERVAL_MINUTES):
run_sync() run_sync()