Fix naive-vs-aware datetime crash from 0.2.1 timezone migration

The timezone fix (ed36eaf) replaced datetime.utcnow() with
datetime.now(timezone.utc) across ~80 call sites, but SQLAlchemy's
SQLite DateTime columns strip tzinfo on read, returning naive datetimes.
Any Python-side comparison (subtraction, <, >) between an aware "now"
and a naive DB value raises TypeError.

Add `if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc)`
guards at all 8 affected comparison sites:
- maintenance.py: last_performed_at subtraction (500 on /maintenance/overview)
- auth.py: API key expires_at check (2 locations)
- print_scheduler.py: scheduled_time comparison
- smart_plug_manager.py: auto_off_pending_since elapsed calc
- smart_plugs.py: power_alert_last_triggered cooldown
- main.py: last_runtime_update elapsed calc
- archives.py: timelapse completed_at fallback
This commit is contained in:
maziggy
2026-02-27 17:39:32 +01:00
parent c928be1e36
commit fe5bb1fd90
8 changed files with 39 additions and 12 deletions
+1
View File
@@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.1] - 2026-02-27
### Fixed
- **Timezone-Aware Datetime Comparisons Crash With SQLite** — The 0.2.1 timezone fix (`datetime.now(timezone.utc)`) produced aware datetimes, but SQLAlchemy's SQLite `DateTime` columns return naive datetimes on read. Any Python-side comparison between the two raised `TypeError: can't subtract offset-naive and offset-aware datetimes`, crashing the maintenance overview endpoint and potentially 7 other code paths (API key expiration, smart plug auto-off, power alert cooldown, runtime tracking, print scheduling, and timelapse matching). Added `tzinfo is None` guards before all database datetime comparisons.
- **FTP Proxy Cannot Bind to Port 990 in Docker** — The `cap_add: NET_BIND_SERVICE` in docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (`user:` directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile via `setcap`, which the kernel honors regardless of runtime configuration.
- **AMS History Chart Shows Wrong Time Range** ([#535](https://github.com/maziggy/bambuddy/issues/535)) — The AMS temperature/humidity chart X axis was fitted to only the data points present (`dataMin`/`dataMax`), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line.
- **"Clear Plate & Start Next" Ignores Filament Override Color** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When a print was queued to "any printer" with a filament color override (e.g., white PETG), the "Clear Plate & Start Next" button appeared on all printers of the matching model that had the correct filament *type*, regardless of *color*. A printer with blue PETG would show the button for a white PETG job. The backend scheduler already correctly rejected color mismatches, but the frontend `PrinterQueueWidget` only checked `required_filament_types` (type only) and ignored `filament_overrides` (type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's `_count_override_color_matches()` logic.
+2
View File
@@ -1442,6 +1442,8 @@ async def scan_timelapse(
archive_completed = archive.completed_at or archive.created_at
if archive_completed:
if archive_completed.tzinfo is None:
archive_completed = archive_completed.replace(tzinfo=timezone.utc)
time_since_completion = datetime.now(timezone.utc) - archive_completed
# If archive was completed within the last hour, assume the single timelapse is for it
if time_since_completion < timedelta(hours=1):
+3
View File
@@ -349,6 +349,9 @@ async def _get_printer_maintenance_internal(
if interval_type == "days":
# Time-based: calculate days since last performed
if last_performed_at:
# DB stores naive datetimes; treat as UTC for comparison
if last_performed_at.tzinfo is None:
last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)
days_since = (now - last_performed_at).total_seconds() / 86400.0
else:
# Never performed - consider it due
+4 -1
View File
@@ -756,7 +756,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
# Cooldown: don't alert more than once per 5 minutes
cooldown_minutes = 5
if plug.power_alert_last_triggered:
time_since_last = datetime.now(timezone.utc) - plug.power_alert_last_triggered
last_triggered = plug.power_alert_last_triggered
if last_triggered.tzinfo is None:
last_triggered = last_triggered.replace(tzinfo=timezone.utc)
time_since_last = datetime.now(timezone.utc) - last_triggered
if time_since_last < timedelta(minutes=cooldown_minutes):
return
+15 -7
View File
@@ -235,8 +235,12 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
for api_key in api_keys:
if verify_password(api_key_value, api_key.key_hash):
# Check expiration
if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
return None # Expired
if api_key.expires_at:
expires = api_key.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
return None # Expired
# Update last_used timestamp
api_key.last_used = datetime.now(timezone.utc)
await db.commit()
@@ -451,11 +455,15 @@ async def get_api_key(
# Check if key matches (verify against hash)
if verify_password(api_key_value, api_key.key_hash):
# Check expiration
if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired",
)
if api_key.expires_at:
expires = api_key.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired",
)
# Update last_used timestamp
api_key.last_used = datetime.now(timezone.utc)
await db.commit()
+4 -1
View File
@@ -3125,7 +3125,10 @@ async def track_printer_runtime():
if state.state in ("RUNNING", "PAUSE"):
# Calculate time since last update
if printer.last_runtime_update:
elapsed = (now - printer.last_runtime_update).total_seconds()
last_update = printer.last_runtime_update
if last_update.tzinfo is None:
last_update = last_update.replace(tzinfo=timezone.utc)
elapsed = (now - last_update).total_seconds()
if elapsed > 0:
printer.runtime_seconds += int(elapsed)
updated_count += 1
+6 -2
View File
@@ -80,8 +80,12 @@ class PrintScheduler:
for item in items:
# Check scheduled time first (scheduled_time is stored in UTC from ISO string)
if item.scheduled_time and item.scheduled_time > datetime.now(timezone.utc):
continue
if item.scheduled_time:
sched = item.scheduled_time
if sched.tzinfo is None:
sched = sched.replace(tzinfo=timezone.utc)
if sched > datetime.now(timezone.utc):
continue
# Skip items that require manual start
if item.manual_start:
+4 -1
View File
@@ -455,7 +455,10 @@ class SmartPlugManager:
for plug in pending_plugs:
# Check how long it's been pending (timeout after 2 hours)
if plug.auto_off_pending_since:
elapsed = (datetime.now(timezone.utc) - plug.auto_off_pending_since).total_seconds()
pending_since = plug.auto_off_pending_since
if pending_since.tzinfo is None:
pending_since = pending_since.replace(tzinfo=timezone.utc)
elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
if elapsed > 7200: # 2 hours
logger.warning(
f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "