فهرست منبع

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
maziggy 2 ماه پیش
والد
کامیت
fe5bb1fd90

+ 1 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1] - 2026-02-27
 ## [0.2.1] - 2026-02-27
 
 
 ### Fixed
 ### 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.
 - **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.
 - **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.
 - **"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 - 0
backend/app/api/routes/archives.py

@@ -1442,6 +1442,8 @@ async def scan_timelapse(
 
 
         archive_completed = archive.completed_at or archive.created_at
         archive_completed = archive.completed_at or archive.created_at
         if archive_completed:
         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
             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 archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
             if time_since_completion < timedelta(hours=1):

+ 3 - 0
backend/app/api/routes/maintenance.py

@@ -349,6 +349,9 @@ async def _get_printer_maintenance_internal(
         if interval_type == "days":
         if interval_type == "days":
             # Time-based: calculate days since last performed
             # Time-based: calculate days since last performed
             if last_performed_at:
             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
                 days_since = (now - last_performed_at).total_seconds() / 86400.0
             else:
             else:
                 # Never performed - consider it due
                 # Never performed - consider it due

+ 4 - 1
backend/app/api/routes/smart_plugs.py

@@ -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: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
     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):
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
             return
 
 

+ 15 - 7
backend/app/core/auth.py

@@ -235,8 +235,12 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
         for api_key in api_keys:
         for api_key in api_keys:
             if verify_password(api_key_value, api_key.key_hash):
             if verify_password(api_key_value, api_key.key_hash):
                 # Check expiration
                 # 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
                 # Update last_used timestamp
                 api_key.last_used = datetime.now(timezone.utc)
                 api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
@@ -451,11 +455,15 @@ async def get_api_key(
         # Check if key matches (verify against hash)
         # Check if key matches (verify against hash)
         if verify_password(api_key_value, api_key.key_hash):
         if verify_password(api_key_value, api_key.key_hash):
             # Check expiration
             # 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
             # Update last_used timestamp
             api_key.last_used = datetime.now(timezone.utc)
             api_key.last_used = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()

+ 4 - 1
backend/app/main.py

@@ -3125,7 +3125,10 @@ async def track_printer_runtime():
                     if state.state in ("RUNNING", "PAUSE"):
                     if state.state in ("RUNNING", "PAUSE"):
                         # Calculate time since last update
                         # Calculate time since last update
                         if printer.last_runtime_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:
                             if elapsed > 0:
                                 printer.runtime_seconds += int(elapsed)
                                 printer.runtime_seconds += int(elapsed)
                                 updated_count += 1
                                 updated_count += 1

+ 6 - 2
backend/app/services/print_scheduler.py

@@ -80,8 +80,12 @@ class PrintScheduler:
 
 
             for item in items:
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 # 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
                 # Skip items that require manual start
                 if item.manual_start:
                 if item.manual_start:

+ 4 - 1
backend/app/services/smart_plug_manager.py

@@ -455,7 +455,10 @@ class SmartPlugManager:
                 for plug in pending_plugs:
                 for plug in pending_plugs:
                     # Check how long it's been pending (timeout after 2 hours)
                     # Check how long it's been pending (timeout after 2 hours)
                     if plug.auto_off_pending_since:
                     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
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                             logger.warning(
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "