Improve timezone handling, db models convert naive/timezone-aware

This commit is contained in:
2026-01-24 02:27:57 +01:00
parent 07e37e525c
commit b97eb0404c
4 changed files with 89 additions and 28 deletions

View File

@@ -10,26 +10,20 @@ import datetime
__version__ = "1.0.0" __version__ = "1.0.0"
__author__ = "Thomas Oettli <spacefreak@noop.ch>" __author__ = "Thomas Oettli <spacefreak@noop.ch>"
# 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__ = [ __all__ = [
"app", "app",
"cleanup", "cleanup",
"cli", "cli",
"config", "config",
"datetime_aware_utc",
"datetime_naive_utc",
"datetime_str", "datetime_str",
"dns", "dns",
"email", "email",
"logging", "logging",
"main", "main",
"models", "models",
"now_utc"
"ratelimit", "ratelimit",
"server", "server",
"STATUS_GOOD", "STATUS_GOOD",
@@ -41,13 +35,75 @@ __all__ = [
"STATUS_BADIP", "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_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
# Datetime convention: # Datetime convention:
# All datetime objects in this codebase are naive UTC to match database storage. # All datetime objects in this codebase are timezone-aware.
# - utc_now(): returns naive UTC datetime # - now_utc(): returns timezone-aware UTC datetime
# - datetime_str(): converts naive UTC to display string (adds tzinfo for formatting) # - 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 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): def datetime_str(dt, utc=False):
@@ -72,13 +128,3 @@ def datetime_str(dt, utc=False):
return aware_dt.strftime(DATETIME_FORMAT) return aware_dt.strftime(DATETIME_FORMAT)
else: else:
return aware_dt.astimezone().strftime(DATETIME_FORMAT) 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)

View File

@@ -3,7 +3,7 @@
import logging import logging
import threading import threading
from . import utc_now from . import now_utc
from .models import Hostname, User from .models import Hostname, User
from datetime import timedelta from datetime import timedelta
@@ -18,7 +18,7 @@ def cleanup_expired(app):
Returns: Returns:
Number of expired hostnames processed. Number of expired hostnames processed.
""" """
now = utc_now() now = now_utc()
expired_count = 0 expired_count = 0
for hostname in Hostname.select().join(User).where( for hostname in Hostname.select().join(User).where(

View File

@@ -3,7 +3,7 @@
import logging import logging
import os import os
from . import utc_now from . import datetime_naive_utc, datetime_aware_utc, now_utc
from .dns import encode_dnsname, EncodingError from .dns import encode_dnsname, EncodingError
from peewee import ( from peewee import (
AutoField, AutoField,
@@ -78,11 +78,19 @@ class User(BaseModel):
username = CharField(max_length=64, unique=True) username = CharField(max_length=64, unique=True)
password_hash = CharField(max_length=128) password_hash = CharField(max_length=128)
email = CharField(max_length=255) email = CharField(max_length=255)
created_at = DateTimeField(default=utc_now) created_at = DateTimeField(default=now_utc)
class Meta: class Meta:
table_name = "users" 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): class Hostname(BaseModel):
"""Hostname model for DNS records.""" """Hostname model for DNS records."""
@@ -104,12 +112,19 @@ class Hostname(BaseModel):
(('hostname', 'zone'), True), (('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): def save(self, *args, **kwargs):
"""Validate and encode hostname/zone before saving.""" """Validate and encode hostname/zone before saving."""
if self.hostname: if self.hostname:
self.hostname = encode_dnsname(self.hostname) self.hostname = encode_dnsname(self.hostname)
if self.zone: if self.zone:
self.zone = encode_dnsname(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) return super().save(*args, **kwargs)

View File

@@ -12,8 +12,8 @@ import ssl
import threading import threading
from . import ( from . import (
now_utc,
datetime_str, datetime_str,
utc_now,
STATUS_GOOD, STATUS_GOOD,
STATUS_NOCHG, STATUS_NOCHG,
STATUS_BADAUTH, STATUS_BADAUTH,
@@ -373,7 +373,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
except ValueError: except ValueError:
return (400, STATUS_BADIP, {}) return (400, STATUS_BADIP, {})
now = utc_now() now = now_utc()
ipv4_changed = False ipv4_changed = False
ipv6_changed = False ipv6_changed = False