diff --git a/CHANGELOG.md b/CHANGELOG.md index 644086e3b..18471b7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.1b4] - Unreleased ### Fixed +- **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time. - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed. - **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions. diff --git a/backend/app/api/routes/ams_history.py b/backend/app/api/routes/ams_history.py index 5896f7dd6..a781a7dd8 100644 --- a/backend/app/api/routes/ams_history.py +++ b/backend/app/api/routes/ams_history.py @@ -1,6 +1,6 @@ """API routes for AMS sensor history.""" -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, Query from pydantic import BaseModel @@ -44,7 +44,7 @@ async def get_ams_history( _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ), ): """Get AMS sensor history for a specific printer and AMS unit.""" - since = datetime.now() - timedelta(hours=hours) + since = datetime.now(timezone.utc) - timedelta(hours=hours) # Get data points result = await db.execute( @@ -108,7 +108,7 @@ async def delete_old_history( _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ), ): """Delete old AMS history data for a printer.""" - cutoff = datetime.now() - timedelta(days=days) + cutoff = datetime.now(timezone.utc) - timedelta(days=days) result = await db.execute( select(func.count(AMSSensorHistory.id)).where( diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index ce77f3eb5..3f0f462c7 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1438,11 +1438,11 @@ async def scan_timelapse( # Strategy 4: If only one timelapse exists and archive was recently completed, use it # This handles cases where printer clock is wrong or timezone issues exist if not matching_file and len(video_files) == 1: - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone archive_completed = archive.completed_at or archive.created_at if archive_completed: - time_since_completion = datetime.now() - 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 time_since_completion < timedelta(hours=1): matching_file = video_files[0] diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index 30fc9a27d..7b5ab7c63 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -1,7 +1,7 @@ """Maintenance tracking API routes.""" import logging -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select @@ -303,7 +303,7 @@ async def _get_printer_maintenance_internal( due_count = 0 warning_count = 0 - now = datetime.utcnow() + now = datetime.now(timezone.utc) for maint_type in all_types: # Skip system types that don't apply to this printer model @@ -591,7 +591,7 @@ async def perform_maintenance( db.add(history) # Update item - item.last_performed_at = datetime.utcnow() + item.last_performed_at = datetime.now(timezone.utc) item.last_performed_hours = current_hours await db.commit() diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index efb0f908e..426ddd002 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -2,7 +2,7 @@ import json import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import delete, desc, func, select @@ -194,11 +194,11 @@ async def test_all_notification_providers( # Update provider status if success: - provider.last_success = datetime.utcnow() + provider.last_success = datetime.now(timezone.utc) success_count += 1 else: provider.last_error = message - provider.last_error_at = datetime.utcnow() + provider.last_error_at = datetime.now(timezone.utc) failed_count += 1 results.append( @@ -248,7 +248,7 @@ async def get_notification_logs( if success is not None: query = query.where(NotificationLog.success == success) if days is not None: - cutoff = datetime.utcnow() - timedelta(days=days) + cutoff = datetime.now(timezone.utc) - timedelta(days=days) query = query.where(NotificationLog.created_at >= cutoff) query = query.offset(offset).limit(limit) @@ -295,7 +295,7 @@ async def get_notification_log_stats( _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ), ): """Get notification log statistics.""" - cutoff = datetime.utcnow() - timedelta(days=days) + cutoff = datetime.now(timezone.utc) - timedelta(days=days) # Total counts total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff)) @@ -341,7 +341,7 @@ async def clear_notification_logs( _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE), ): """Clear old notification logs.""" - cutoff = datetime.utcnow() - timedelta(days=older_than_days) + cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days) result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff)) await db.commit() @@ -446,10 +446,10 @@ async def test_notification_provider( # Update provider status if success: - provider.last_success = datetime.utcnow() + provider.last_success = datetime.now(timezone.utc) else: provider.last_error = message - provider.last_error_at = datetime.utcnow() + provider.last_error_at = datetime.now(timezone.utc) await db.commit() diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 17889e5cd..ab3389c5e 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -3,7 +3,7 @@ import json import logging import zipfile -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import defusedxml.ElementTree as ET @@ -732,7 +732,7 @@ async def cancel_queue_item( raise HTTPException(400, f"Cannot cancel item with status '{item.status}'") item.status = "cancelled" - item.completed_at = datetime.now() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.info("Cancelled queue item %s", item_id) @@ -775,7 +775,7 @@ async def stop_queue_item( # Update queue item status regardless - if printer is off, print is already stopped item.status = "cancelled" - item.completed_at = datetime.now() + item.completed_at = datetime.now(timezone.utc) item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)" await db.commit() diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 096093419..6e908499b 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -1,7 +1,7 @@ """API routes for smart plug management.""" import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Body, Depends, HTTPException from pydantic import BaseModel @@ -605,7 +605,7 @@ async def control_smart_plug( elif expected_state == "OFF" and plug.printer_id: # Mark printer offline immediately for faster UI update printer_manager.mark_printer_offline(plug.printer_id) - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) await db.commit() # Trigger associated scripts if this is a main (non-script) plug @@ -692,7 +692,7 @@ async def get_plug_status( # Update last state in database if is_reachable and data.state: plug.last_state = data.state - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) await db.commit() energy_data = None @@ -727,7 +727,7 @@ async def get_plug_status( # Update last state in database if status["reachable"]: plug.last_state = status["state"] - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) await db.commit() # Fetch energy data if device is reachable @@ -756,7 +756,7 @@ 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.utcnow() - plug.power_alert_last_triggered + time_since_last = datetime.now(timezone.utc) - plug.power_alert_last_triggered if time_since_last < timedelta(minutes=cooldown_minutes): return @@ -777,7 +777,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A threshold = plug.power_alert_low if alert_triggered: - plug.power_alert_last_triggered = datetime.utcnow() + plug.power_alert_last_triggered = datetime.now(timezone.utc) await db.commit() # Send notification diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index a24323b14..3747209e5 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Annotated @@ -108,7 +108,7 @@ SLICER_TOKEN_EXPIRE_MINUTES = 5 def create_slicer_download_token(resource_type: str, resource_id: int) -> str: """Create a short-lived download token for slicer protocol handlers.""" # Cleanup expired tokens - now = datetime.utcnow() + now = datetime.now(timezone.utc) expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now] for k in expired: del _slicer_tokens[k] @@ -125,7 +125,7 @@ def verify_slicer_download_token(token: str, resource_type: str, resource_id: in if not entry: return False resource_key, expiry = entry - if datetime.utcnow() > expiry: + if datetime.now(timezone.utc) > expiry: del _slicer_tokens[token] return False expected_key = f"{resource_type}:{resource_id}" @@ -156,9 +156,9 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s """Create a JWT access token.""" to_encode = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -235,10 +235,10 @@ 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(): + if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc): return None # Expired # Update last_used timestamp - api_key.last_used = datetime.now() + api_key.last_used = datetime.now(timezone.utc) await db.commit() return api_key except Exception as e: @@ -451,13 +451,13 @@ 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(): + 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", ) # Update last_used timestamp - api_key.last_used = datetime.now() + api_key.last_used = datetime.now(timezone.utc) await db.commit() return api_key diff --git a/backend/app/main.py b/backend/app/main.py index d8ed896aa..7ff2e7022 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1262,7 +1262,7 @@ async def on_print_start(printer_id: int, data: dict): if archive: # Update archive status to printing archive.status = "printing" - archive.started_at = datetime.now() + archive.started_at = datetime.now(timezone.utc) await db.commit() # Track as active print @@ -1561,7 +1561,7 @@ async def on_print_start(printer_id: int, data: dict): file_size=0, print_name=print_name, status="printing", - started_at=datetime.now(), + started_at=datetime.now(timezone.utc), extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data}, ) @@ -2212,7 +2212,7 @@ async def on_print_complete(printer_id: int, data: dict): if queue_item: queue_status = data.get("status", "completed") queue_item.status = queue_status - queue_item.completed_at = datetime.now() + queue_item.completed_at = datetime.now(timezone.utc) await db.commit() logger.info("Updated queue item %s status to %s", queue_item.id, queue_status) @@ -2239,7 +2239,7 @@ async def on_print_complete(printer_id: int, data: dict): pending_count = count_result.scalar() or 0 if pending_count == 0: - today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) completed_result = await db.execute( select(sa_func.count(PrintQueueItem.id)).where( PrintQueueItem.status.in_(["completed", "failed", "skipped"]), @@ -2408,7 +2408,7 @@ async def on_print_complete(printer_id: int, data: dict): await service.update_archive_status( archive_id, status=status, - completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None, + completed_at=datetime.now(timezone.utc) if status in ("completed", "failed", "aborted") else None, failure_reason=failure_reason, ) logger.info( @@ -2998,7 +2998,7 @@ async def record_ams_history(): if humidity is not None and humidity > humidity_threshold: cooldown_key = f"{printer.id}:{ams_id}:humidity" last_alarm = _ams_alarm_cooldown.get(cooldown_key) - now = datetime.now() + now = datetime.now(timezone.utc) if ( last_alarm is None or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60 @@ -3024,7 +3024,7 @@ async def record_ams_history(): if temperature is not None and temperature > temp_threshold: cooldown_key = f"{printer.id}:{ams_id}:temperature" last_alarm = _ams_alarm_cooldown.get(cooldown_key) - now = datetime.now() + now = datetime.now(timezone.utc) if ( last_alarm is None or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60 @@ -3062,7 +3062,7 @@ async def record_ams_history(): setting = result.scalar_one_or_none() retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS - cutoff = datetime.now() - timedelta(days=retention_days) + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff)) await db.commit() if result.rowcount > 0: @@ -3118,7 +3118,7 @@ async def track_printer_runtime(): result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) printers = result.scalars().all() - now = datetime.now() + now = datetime.now(timezone.utc) updated_count = 0 needs_commit = False diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 147cbc26f..80dad0232 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -4,7 +4,7 @@ import logging import re import shutil import zipfile -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from defusedxml import ElementTree as ET @@ -893,8 +893,8 @@ class ArchiveService: # Determine status and timestamps status = print_data.get("status", "completed") if print_data else "archived" - started_at = datetime.now() if status == "printing" else None - completed_at = datetime.now() if status in ("completed", "failed", "archived") else None + started_at = datetime.now(timezone.utc) if status == "printing" else None + completed_at = datetime.now(timezone.utc) if status in ("completed", "failed", "archived") else None # Calculate cost based on filament usage and type cost = None diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index e33a9f0a2..35ce86d28 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -5,7 +5,7 @@ Handles authentication and profile management with Bambu Lab's cloud services. """ import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import httpx @@ -42,7 +42,7 @@ class BambuCloudService: """Check if we have a valid token.""" if not self.access_token: return False - return not (self.token_expiry and datetime.now() > self.token_expiry) + return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry) def _get_headers(self) -> dict: """Get headers for authenticated requests.""" @@ -200,9 +200,9 @@ class BambuCloudService: if response.status_code == 200 and access_token: self.access_token = access_token self.refresh_token = data.get("refreshToken") - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone - self.token_expiry = datetime.now() + timedelta(days=30) + self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30) return {"success": True, "message": "Login successful"} # Provide helpful error message @@ -224,12 +224,12 @@ class BambuCloudService: self.access_token = data.get("accessToken") self.refresh_token = data.get("refreshToken") # Token typically valid for ~3 months, but we'll refresh more often - self.token_expiry = datetime.now() + timedelta(days=30) + self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30) def set_token(self, access_token: str): """Set access token directly (for stored tokens).""" self.access_token = access_token - self.token_expiry = datetime.now() + timedelta(days=30) + self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30) def logout(self): """Clear authentication state.""" diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 8e3a0adbc..3e44efd2b 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -16,7 +16,7 @@ import time from collections import deque from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone import paho.mqtt.client as mqtt @@ -460,7 +460,7 @@ class BambuMQTTClient: if self._logging_enabled: self._message_log.append( MQTTLogEntry( - timestamp=datetime.now().isoformat(), + timestamp=datetime.now(timezone.utc).isoformat(), topic=msg.topic, direction="in", payload=payload, @@ -2678,7 +2678,7 @@ class BambuMQTTClient: if self._logging_enabled: self._message_log.append( MQTTLogEntry( - timestamp=datetime.now().isoformat(), + timestamp=datetime.now(timezone.utc).isoformat(), topic=self.topic_publish, direction="out", payload=command, diff --git a/backend/app/services/discovery.py b/backend/app/services/discovery.py index 4571bfc15..d8956cf8b 100644 --- a/backend/app/services/discovery.py +++ b/backend/app/services/discovery.py @@ -17,7 +17,7 @@ import re import socket import struct from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path logger = logging.getLogger(__name__) @@ -304,7 +304,7 @@ class PrinterDiscoveryService: name=name, ip_address=ip_address, model=model, - discovered_at=datetime.now().isoformat(), + discovered_at=datetime.now(timezone.utc).isoformat(), ) self._discovered[serial] = printer @@ -414,7 +414,7 @@ class SubnetScanner: name=name or f"Printer at {ip}", ip_address=ip, model=model, - discovered_at=datetime.now().isoformat(), + discovered_at=datetime.now(timezone.utc).isoformat(), ) self._discovered[ip] = printer @@ -609,7 +609,7 @@ class TasmotaScanner: "name": f"Tasmota ({ip})", "module": None, "state": "UNKNOWN", - "discovered_at": datetime.now().isoformat(), + "discovered_at": datetime.now(timezone.utc).isoformat(), } self._discovered[ip] = device return @@ -627,7 +627,7 @@ class TasmotaScanner: "name": f"Tasmota ({ip})", "module": None, "state": "UNKNOWN", - "discovered_at": datetime.now().isoformat(), + "discovered_at": datetime.now(timezone.utc).isoformat(), } self._discovered[ip] = device return @@ -668,7 +668,7 @@ class TasmotaScanner: "name": device_name, "module": module, "state": power_state, - "discovered_at": datetime.now().isoformat(), + "discovered_at": datetime.now(timezone.utc).isoformat(), } self._discovered[ip] = device diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py index aa810b7b7..348a98464 100644 --- a/backend/app/services/failure_analysis.py +++ b/backend/app/services/failure_analysis.py @@ -1,5 +1,5 @@ from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +30,7 @@ class FailureAnalysisService: Returns: Dictionary with failure analysis results """ - cutoff_date = datetime.utcnow() - timedelta(days=days) + cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) # Build base query base_filter = [PrintArchive.created_at >= cutoff_date] @@ -142,7 +142,7 @@ class FailureAnalysisService: # Failure rate trend (by week) trend_data = [] for i in range(min(days // 7, 12)): # Up to 12 weeks - week_end = datetime.utcnow() - timedelta(weeks=i) + week_end = datetime.now(timezone.utc) - timedelta(weeks=i) week_start = week_end - timedelta(weeks=1) week_filter = base_filter.copy() diff --git a/backend/app/services/mqtt_relay.py b/backend/app/services/mqtt_relay.py index 9987c970e..42da86ee4 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -10,7 +10,7 @@ import logging import ssl import threading import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any import paho.mqtt.client as mqtt @@ -211,7 +211,7 @@ class MQTTRelayService: """Publish BamBuddy status (online/offline).""" self._publish( f"{self.topic_prefix}/status", - {"status": status, "timestamp": datetime.utcnow().isoformat()}, + {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()}, retain=True, ) @@ -257,7 +257,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "printer_serial": printer_serial, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "connected": state.connected, "state": state.state, "progress": state.progress, @@ -294,7 +294,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "printer_serial": printer_serial, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -309,7 +309,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "printer_serial": printer_serial, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -333,7 +333,7 @@ class MQTTRelayService: "printer_serial": printer_serial, "filename": filename, "subtask_name": subtask_name, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -365,7 +365,7 @@ class MQTTRelayService: "filename": filename, "subtask_name": subtask_name, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -387,7 +387,7 @@ class MQTTRelayService: "printer_name": printer_name, "printer_serial": printer_serial, "ams_units": ams_data, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -409,7 +409,7 @@ class MQTTRelayService: "printer_name": printer_name, "printer_serial": printer_serial, "errors": errors, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -435,7 +435,7 @@ class MQTTRelayService: "filename": filename, "printer_id": printer_id, "printer_name": printer_name, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -459,7 +459,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "printer_serial": printer_serial, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -489,7 +489,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -517,7 +517,7 @@ class MQTTRelayService: "maintenance_type": maintenance_type, "current_value": current_value, "threshold": threshold, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -537,7 +537,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "maintenance_type": maintenance_type, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -557,7 +557,7 @@ class MQTTRelayService: "printer_id": printer_id, "printer_name": printer_name, "maintenance_type": maintenance_type, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -583,7 +583,7 @@ class MQTTRelayService: "print_name": print_name, "printer_name": printer_name, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -603,7 +603,7 @@ class MQTTRelayService: "archive_id": archive_id, "print_name": print_name, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -629,7 +629,7 @@ class MQTTRelayService: "spool_name": spool_name, "remaining_weight": remaining_weight, "remaining_percent": remaining_percent, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -659,7 +659,7 @@ class MQTTRelayService: "state": state, "printer_id": printer_id, "printer_name": printer_name, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -683,7 +683,7 @@ class MQTTRelayService: "power_watts": power, "energy_today_kwh": energy_today, "energy_total_kwh": energy_total, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) diff --git a/backend/app/services/mqtt_smart_plug.py b/backend/app/services/mqtt_smart_plug.py index 525ebec55..3d989aa46 100644 --- a/backend/app/services/mqtt_smart_plug.py +++ b/backend/app/services/mqtt_smart_plug.py @@ -8,7 +8,7 @@ import json import logging import threading from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any import paho.mqtt.client as mqtt @@ -262,7 +262,7 @@ class MQTTSmartPlugService: self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) data = self.plug_data[plug_id] - data.last_seen = datetime.utcnow() + data.last_seen = datetime.now(timezone.utc) # Process based on data type if data_type == "power": @@ -473,7 +473,7 @@ class MQTTSmartPlugService: return False timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES) - return datetime.utcnow() - data.last_seen < timeout + return datetime.now(timezone.utc) - data.last_seen < timeout async def disconnect(self, timeout: float = 0): """Disconnect from MQTT broker.""" diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 90a10c821..89f6943dd 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -5,7 +5,7 @@ import json import logging import re import smtplib -from datetime import datetime +from datetime import datetime, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any @@ -501,10 +501,10 @@ class NotificationService: provider = result.scalar_one_or_none() if provider: if success: - provider.last_success = datetime.utcnow() + provider.last_success = datetime.now(timezone.utc) else: provider.last_error = error - provider.last_error_at = datetime.utcnow() + provider.last_error_at = datetime.now(timezone.utc) await db.commit() async def _get_providers_for_event( diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index f80cebab3..46e456cb7 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -4,7 +4,7 @@ import asyncio import json import logging import zipfile -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import defusedxml.ElementTree as ET @@ -80,7 +80,7 @@ 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.utcnow(): + if item.scheduled_time and item.scheduled_time > datetime.now(timezone.utc): continue # Skip items that require manual start @@ -124,7 +124,7 @@ class PrintScheduler: if not await self._check_previous_success(db, item): item.status = "skipped" item.error_message = "Previous print failed or was aborted" - item.completed_at = datetime.now() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.info("Skipped queue item %s - previous print failed", item.id) @@ -201,7 +201,7 @@ class PrintScheduler: if not await self._check_previous_success(db, item): item.status = "skipped" item.error_message = "Previous print failed or was aborted" - item.completed_at = datetime.now() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.info("Skipped queue item %s - previous print failed", item.id) @@ -940,7 +940,7 @@ class PrintScheduler: if not printer: item.status = "failed" item.error_message = "Printer not found" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: Printer %s not found", item.id, item.printer_id) await self._power_off_if_needed(db, item) @@ -950,7 +950,7 @@ class PrintScheduler: if not printer_manager.is_connected(item.printer_id): item.status = "failed" item.error_message = "Printer not connected" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: Printer %s not connected", item.id, item.printer_id) await self._power_off_if_needed(db, item) @@ -969,7 +969,7 @@ class PrintScheduler: if not archive: item.status = "failed" item.error_message = "Archive not found" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: Archive %s not found", item.id, item.archive_id) await self._power_off_if_needed(db, item) @@ -985,7 +985,7 @@ class PrintScheduler: if not library_file: item.status = "failed" item.error_message = "Library file not found" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: Library file %s not found", item.id, item.library_file_id) await self._power_off_if_needed(db, item) @@ -1021,7 +1021,7 @@ class PrintScheduler: # Neither archive nor library file specified item.status = "failed" item.error_message = "No source file specified" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: No archive_id or library_file_id specified", item.id) await self._power_off_if_needed(db, item) @@ -1031,7 +1031,7 @@ class PrintScheduler: if not file_path.exists(): item.status = "failed" item.error_message = "Source file not found on disk" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error("Queue item %s: File not found: %s", item.id, file_path) await self._power_off_if_needed(db, item) @@ -1106,7 +1106,7 @@ class PrintScheduler: ) item.status = "failed" item.error_message = error_msg - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error( f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, " @@ -1146,7 +1146,7 @@ class PrintScheduler: # in "printing" status without actually printing - but that's safer than # accidentally reprinting the same file hours later. item.status = "printing" - item.started_at = datetime.utcnow() + item.started_at = datetime.now(timezone.utc) await db.commit() # Consume the plate-cleared flag now that we're starting a print @@ -1214,7 +1214,7 @@ class PrintScheduler: # Print command failed - revert status item.status = "failed" item.error_message = "Failed to send print command to printer" - item.completed_at = datetime.utcnow() + item.completed_at = datetime.now(timezone.utc) await db.commit() logger.error( f"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - " diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 7a6eb9c9c..9a7ca90b1 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -2,7 +2,7 @@ import asyncio import logging -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from sqlalchemy import select @@ -112,7 +112,7 @@ class SmartPlugManager: success = await service.turn_on(plug) if success: plug.last_state = "ON" - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) self._last_schedule_check[plug.id] = f"on:{current_time}" # Check if we should turn off @@ -123,7 +123,7 @@ class SmartPlugManager: success = await service.turn_off(plug) if success: plug.last_state = "OFF" - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) self._last_schedule_check[plug.id] = f"off:{current_time}" # Mark printer offline if linked if plug.printer_id: @@ -164,7 +164,7 @@ class SmartPlugManager: if success: # Update last state and reset auto_off_executed plug.last_state = "ON" - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) plug.auto_off_executed = False # Reset flag when turning on await db.commit() @@ -391,7 +391,7 @@ class SmartPlugManager: plug = result.scalar_one_or_none() if plug: plug.auto_off_pending = pending - plug.auto_off_pending_since = datetime.utcnow() if pending else None + plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None await db.commit() logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending) except Exception as e: @@ -412,7 +412,7 @@ class SmartPlugManager: plug.auto_off_pending = False # Clear pending state plug.auto_off_pending_since = None plug.last_state = "OFF" - plug.last_checked = datetime.utcnow() + plug.last_checked = datetime.now(timezone.utc) await db.commit() logger.info("Auto-off executed and disabled for plug %s", plug_id) except Exception as e: @@ -455,7 +455,7 @@ 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.utcnow() - plug.auto_off_pending_since).total_seconds() + elapsed = (datetime.now(timezone.utc) - plug.auto_off_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, " diff --git a/backend/tests/unit/services/test_smart_plug_manager.py b/backend/tests/unit/services/test_smart_plug_manager.py index c5e23c4ca..303b4113e 100644 --- a/backend/tests/unit/services/test_smart_plug_manager.py +++ b/backend/tests/unit/services/test_smart_plug_manager.py @@ -4,7 +4,7 @@ These tests specifically target the auto-off behavior and toggle functionality that were identified as common regression points. """ -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -354,7 +354,7 @@ class TestScheduleLoop: mock_now = MagicMock() mock_now.strftime.return_value = "08:00" mock_datetime.now.return_value = mock_now - mock_datetime.utcnow.return_value = datetime.utcnow() + mock_datetime.utcnow.return_value = datetime.now(timezone.utc) # Set up async session mock mock_db = AsyncMock() @@ -395,7 +395,7 @@ class TestScheduleLoop: mock_now = MagicMock() mock_now.strftime.return_value = "22:00" mock_datetime.now.return_value = mock_now - mock_datetime.utcnow.return_value = datetime.utcnow() + mock_datetime.utcnow.return_value = datetime.now(timezone.utc) # Set up async session mock mock_db = AsyncMock() @@ -466,7 +466,7 @@ class TestPendingAutoOffPersistence: mock_plug.password = None mock_plug.printer_id = 1 mock_plug.auto_off_pending = True - mock_plug.auto_off_pending_since = datetime.utcnow() + mock_plug.auto_off_pending_since = datetime.now(timezone.utc) mock_plug.off_delay_mode = "temperature" mock_plug.off_temp_threshold = 70 @@ -497,7 +497,7 @@ class TestPendingAutoOffPersistence: mock_plug.password = None mock_plug.printer_id = 1 mock_plug.auto_off_pending = True - mock_plug.auto_off_pending_since = datetime.utcnow() + mock_plug.auto_off_pending_since = datetime.now(timezone.utc) mock_plug.off_delay_mode = "time" with ( diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx index f780537b6..7409b5441 100644 --- a/frontend/src/components/CalendarView.tsx +++ b/frontend/src/components/CalendarView.tsx @@ -185,7 +185,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
- {ref.timestamp ? new Date(ref.timestamp).toLocaleDateString() : ''} + {ref.timestamp ? parseUTCDate(ref.timestamp)?.toLocaleDateString() ?? '' : ''}