From b97eb0404c26bc8a9eb12c80c6b65d3d7e0c6018 Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sat, 24 Jan 2026 02:27:57 +0100 Subject: [PATCH] Improve timezone handling, db models convert naive/timezone-aware --- src/ddns_service/__init__.py | 90 +++++++++++++++++++++++++++--------- src/ddns_service/cleanup.py | 4 +- src/ddns_service/models.py | 19 +++++++- src/ddns_service/server.py | 4 +- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/src/ddns_service/__init__.py b/src/ddns_service/__init__.py index 2342549..25bb677 100644 --- a/src/ddns_service/__init__.py +++ b/src/ddns_service/__init__.py @@ -10,26 +10,20 @@ import datetime __version__ = "1.0.0" __author__ = "Thomas Oettli " -# DynDNS-compatible response statuses -STATUS_GOOD = "good" -STATUS_NOCHG = "nochg" -STATUS_BADAUTH = "badauth" -STATUS_NOHOST = "nohost" -STATUS_DNSERR = "dnserr" -STATUS_ABUSE = "abuse" -STATUS_BADIP = "badip" - __all__ = [ "app", "cleanup", "cli", "config", + "datetime_aware_utc", + "datetime_naive_utc", "datetime_str", "dns", "email", "logging", "main", "models", + "now_utc" "ratelimit", "server", "STATUS_GOOD", @@ -41,13 +35,75 @@ __all__ = [ "STATUS_BADIP", ] +# DynDNS-compatible response statuses +STATUS_GOOD = "good" +STATUS_NOCHG = "nochg" +STATUS_BADAUTH = "badauth" +STATUS_NOHOST = "nohost" +STATUS_DNSERR = "dnserr" +STATUS_ABUSE = "abuse" +STATUS_BADIP = "badip" + DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z" # Datetime convention: -# All datetime objects in this codebase are naive UTC to match database storage. -# - utc_now(): returns naive UTC datetime -# - datetime_str(): converts naive UTC to display string (adds tzinfo for formatting) +# All datetime objects in this codebase are timezone-aware. +# - now_utc(): returns timezone-aware UTC datetime +# - datetime_str(): converts naive UTC (adds tzinfo for formatting) +# or timezone-aware datetime to display string # - Database stores/returns naive datetimes (always UTC by convention) +# - Database models automatically convert between naive/timezone-aware datetimes + + +def now_utc(): + """ + Get current date and time in UTC. + + Returns: + Timezone-aware datetime object in UTC. + """ + return datetime.datetime.now(datetime.UTC) + + +def datetime_naive_utc(dt): + """ + Convert datetime to naive UTC datetime. + + Args: + dt: Datetime object (naive UTC or timezone-aware or None). + + Returns: + Naive datetime object in UTC or None if dt is not a datetime. + """ + if not isinstance(dt, datetime.datetime): + return None + + if not dt.tzinfo: + return dt + + return dt.astimezone(datetime.UTC).replace(tzinfo=None) + + +def datetime_aware_utc(dt): + """ + Convert datetime to UTC datetime. + + Args: + dt: Datetime object (naive UTC or timezone-aware or None). + + Returns: + Timzone-aware datetime object in UTC or None if dt is not a datetime. + """ + if not isinstance(dt, datetime.datetime): + return None + + if not dt.tzinfo: + return dt.replace(tzinfo=datetime.UTC) + + if dt.tzinfo == datetime.UTC: + return dt + + return dt.astimezone(datetime.UTC) def datetime_str(dt, utc=False): @@ -72,13 +128,3 @@ def datetime_str(dt, utc=False): return aware_dt.strftime(DATETIME_FORMAT) else: return aware_dt.astimezone().strftime(DATETIME_FORMAT) - - -def utc_now(): - """ - Get current time as naive UTC datetime. - - Returns naive datetime to match database storage behavior. - All naive datetimes in this codebase are assumed to be UTC. - """ - return datetime.datetime.now(datetime.UTC).replace(tzinfo=None) diff --git a/src/ddns_service/cleanup.py b/src/ddns_service/cleanup.py index 007b398..40c81c9 100644 --- a/src/ddns_service/cleanup.py +++ b/src/ddns_service/cleanup.py @@ -3,7 +3,7 @@ import logging import threading -from . import utc_now +from . import now_utc from .models import Hostname, User from datetime import timedelta @@ -18,7 +18,7 @@ def cleanup_expired(app): Returns: Number of expired hostnames processed. """ - now = utc_now() + now = now_utc() expired_count = 0 for hostname in Hostname.select().join(User).where( diff --git a/src/ddns_service/models.py b/src/ddns_service/models.py index a18ec70..c575112 100644 --- a/src/ddns_service/models.py +++ b/src/ddns_service/models.py @@ -3,7 +3,7 @@ import logging import os -from . import utc_now +from . import datetime_naive_utc, datetime_aware_utc, now_utc from .dns import encode_dnsname, EncodingError from peewee import ( AutoField, @@ -78,11 +78,19 @@ class User(BaseModel): username = CharField(max_length=64, unique=True) password_hash = CharField(max_length=128) email = CharField(max_length=255) - created_at = DateTimeField(default=utc_now) + created_at = DateTimeField(default=now_utc) class Meta: table_name = "users" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.created_at = datetime_aware_utc(self.created_at) + + def save(self, *args, **kwargs): + self.created_at = datetime_naive_utc(self.created_at) + return super().save(*args, **kwargs) + class Hostname(BaseModel): """Hostname model for DNS records.""" @@ -104,12 +112,19 @@ class Hostname(BaseModel): (('hostname', 'zone'), True), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.last_ipv4_update = datetime_aware_utc(self.last_ipv4_update) + self.last_ipv6_update = datetime_aware_utc(self.last_ipv6_update) + def save(self, *args, **kwargs): """Validate and encode hostname/zone before saving.""" if self.hostname: self.hostname = encode_dnsname(self.hostname) if self.zone: self.zone = encode_dnsname(self.zone) + self.last_ipv4_update = datetime_naive_utc(self.last_ipv4_update) + self.last_ipv6_update = datetime_naive_utc(self.last_ipv6_update) return super().save(*args, **kwargs) diff --git a/src/ddns_service/server.py b/src/ddns_service/server.py index 8888f21..36974fe 100644 --- a/src/ddns_service/server.py +++ b/src/ddns_service/server.py @@ -12,8 +12,8 @@ import ssl import threading from . import ( + now_utc, datetime_str, - utc_now, STATUS_GOOD, STATUS_NOCHG, STATUS_BADAUTH, @@ -373,7 +373,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): except ValueError: return (400, STATUS_BADIP, {}) - now = utc_now() + now = now_utc() ipv4_changed = False ipv6_changed = False