mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 23:40:24 +00:00
timezone config
This commit is contained in:
@@ -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
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user