From ee49751c388a4b9050f0a3f59fc986dc463a41f7 Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 14 Dec 2025 15:48:17 +0800 Subject: [PATCH] fix: add UTC timezone indicator to all datetime serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database stores times in UTC but serialized without timezone info, causing frontend to misinterpret as local time. Now all datetime fields include 'Z' suffix to indicate UTC, enabling proper timezone conversion in the browser. - Add UTCDatetimeBaseModel base class for Pydantic schemas - Update model to_dict() methods to append 'Z' suffix - Affects: tasks, users, sessions, audit logs, translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/models/audit_log.py | 7 +++-- backend/app/models/session.py | 13 +++++---- backend/app/models/task.py | 21 ++++++++++----- backend/app/models/translation_log.py | 7 +++-- backend/app/models/user.py | 9 ++++--- backend/app/schemas/auth.py | 10 +++---- backend/app/schemas/base.py | 38 +++++++++++++++++++++++++++ backend/app/schemas/task.py | 12 +++------ backend/app/schemas/translation.py | 8 +++--- 9 files changed, 88 insertions(+), 37 deletions(-) create mode 100644 backend/app/schemas/base.py diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index 2fb43c4..895f839 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -77,7 +77,10 @@ class AuditLog(Base): return f"" def to_dict(self): - """Convert audit log to dictionary""" + """Convert audit log to dictionary. + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + """ return { "id": self.id, "user_id": self.user_id, @@ -91,5 +94,5 @@ class AuditLog(Base): "success": bool(self.success), "error_message": self.error_message, "extra_data": self.extra_data, - "created_at": self.created_at.isoformat() if self.created_at else None + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None } diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 3ed0a88..c2da18c 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -56,16 +56,19 @@ class Session(Base): return f"" def to_dict(self): - """Convert session to dictionary (excluding sensitive tokens)""" + """Convert session to dictionary (excluding sensitive tokens). + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + """ return { "id": self.id, "user_id": self.user_id, "token_type": self.token_type, - "expires_at": self.expires_at.isoformat() if self.expires_at else None, - "issued_at": self.issued_at.isoformat() if self.issued_at else None, + "expires_at": self.expires_at.isoformat() + 'Z' if self.expires_at else None, + "issued_at": self.issued_at.isoformat() + 'Z' if self.issued_at else None, "ip_address": self.ip_address, - "created_at": self.created_at.isoformat() if self.created_at else None, - "last_accessed_at": self.last_accessed_at.isoformat() if self.last_accessed_at else None + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None, + "last_accessed_at": self.last_accessed_at.isoformat() + 'Z' if self.last_accessed_at else None } @property diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 4fbddb2..346e765 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -66,7 +66,11 @@ class Task(Base): return f"" def to_dict(self): - """Convert task to dictionary""" + """Convert task to dictionary. + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + This ensures proper timezone conversion in the frontend. + """ return { "id": self.id, "task_id": self.task_id, @@ -78,11 +82,11 @@ class Task(Base): "result_pdf_path": self.result_pdf_path, "error_message": self.error_message, "processing_time_ms": self.processing_time_ms, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None, + "updated_at": self.updated_at.isoformat() + 'Z' if self.updated_at else None, + "completed_at": self.completed_at.isoformat() + 'Z' if self.completed_at else None, "file_deleted": self.file_deleted, - "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None + "deleted_at": self.deleted_at.isoformat() + 'Z' if self.deleted_at else None } @@ -116,7 +120,10 @@ class TaskFile(Base): return f"" def to_dict(self): - """Convert task file to dictionary""" + """Convert task file to dictionary. + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + """ return { "id": self.id, "task_id": self.task_id, @@ -125,5 +132,5 @@ class TaskFile(Base): "file_size": self.file_size, "mime_type": self.mime_type, "file_hash": self.file_hash, - "created_at": self.created_at.isoformat() if self.created_at else None + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None } diff --git a/backend/app/models/translation_log.py b/backend/app/models/translation_log.py index 927ebbf..4bbdf22 100644 --- a/backend/app/models/translation_log.py +++ b/backend/app/models/translation_log.py @@ -67,7 +67,10 @@ class TranslationLog(Base): return f"" def to_dict(self): - """Convert translation log to dictionary""" + """Convert translation log to dictionary. + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + """ return { "id": self.id, "user_id": self.user_id, @@ -83,5 +86,5 @@ class TranslationLog(Base): "estimated_cost": self.estimated_cost, "processing_time_seconds": self.processing_time_seconds, "provider": self.provider, - "created_at": self.created_at.isoformat() if self.created_at else None + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None } diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6cfe205..e056dbe 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,12 +39,15 @@ class User(Base): return f"" def to_dict(self): - """Convert user to dictionary""" + """Convert user to dictionary. + + All datetime fields are serialized with 'Z' suffix to indicate UTC timezone. + """ return { "id": self.id, "email": self.email, "display_name": self.display_name, - "created_at": self.created_at.isoformat() if self.created_at else None, - "last_login": self.last_login.isoformat() if self.last_login else None, + "created_at": self.created_at.isoformat() + 'Z' if self.created_at else None, + "last_login": self.last_login.isoformat() + 'Z' if self.last_login else None, "is_active": self.is_active } diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c1f4e08..a841d17 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -6,6 +6,8 @@ from typing import Optional from datetime import datetime from pydantic import BaseModel, Field +from app.schemas.base import UTCDatetimeBaseModel + class LoginRequest(BaseModel): """Login request schema""" @@ -58,7 +60,7 @@ class TokenData(BaseModel): session_id: Optional[int] = None -class UserResponse(BaseModel): +class UserResponse(UTCDatetimeBaseModel): """User response schema""" id: int email: str @@ -66,9 +68,3 @@ class UserResponse(BaseModel): created_at: Optional[datetime] = None last_login: Optional[datetime] = None is_active: bool = True - - class Config: - from_attributes = True - json_encoders = { - datetime: lambda v: v.isoformat() if v else None - } diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 0000000..51e2b87 --- /dev/null +++ b/backend/app/schemas/base.py @@ -0,0 +1,38 @@ +""" +Tool_OCR - Base Schema with UTC Datetime Serialization + +All datetime fields stored in the database are in UTC (using datetime.utcnow()). +This base class ensures proper serialization with 'Z' suffix to indicate UTC, +allowing frontend to correctly convert to local timezone. +""" + +from datetime import datetime +from typing import Any +from pydantic import BaseModel, ConfigDict + + +def serialize_datetime_utc(dt: datetime) -> str: + """Serialize datetime to ISO format with UTC indicator (Z suffix). + + Database stores all times in UTC without timezone info (naive datetime). + This serializer adds the 'Z' suffix to indicate UTC, enabling proper + timezone conversion in the frontend. + """ + if dt is None: + return None + # Ensure ISO format with 'Z' suffix for UTC + return dt.isoformat() + 'Z' + + +class UTCDatetimeBaseModel(BaseModel): + """Base model that serializes all datetime fields with UTC indicator. + + Use this as base class for any response schema containing datetime fields + to ensure consistent timezone handling across the API. + """ + model_config = ConfigDict( + from_attributes=True, + json_encoders={ + datetime: serialize_datetime_utc + } + ) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 2ed2d14..3072313 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -7,6 +7,8 @@ from datetime import datetime from pydantic import BaseModel, Field from enum import Enum +from app.schemas.base import UTCDatetimeBaseModel + class TaskStatusEnum(str, Enum): """Task status enumeration""" @@ -146,7 +148,7 @@ class TaskUpdate(BaseModel): result_pdf_path: Optional[str] = None -class TaskFileResponse(BaseModel): +class TaskFileResponse(UTCDatetimeBaseModel): """Task file response schema""" id: int original_name: Optional[str] = None @@ -156,11 +158,8 @@ class TaskFileResponse(BaseModel): file_hash: Optional[str] = None created_at: datetime - class Config: - from_attributes = True - -class TaskResponse(BaseModel): +class TaskResponse(UTCDatetimeBaseModel): """Task response schema""" id: int user_id: int @@ -178,9 +177,6 @@ class TaskResponse(BaseModel): completed_at: Optional[datetime] = None file_deleted: bool = False - class Config: - from_attributes = True - class TaskDetailResponse(TaskResponse): """Detailed task response with files""" diff --git a/backend/app/schemas/translation.py b/backend/app/schemas/translation.py index 2fd32a9..e255d0b 100644 --- a/backend/app/schemas/translation.py +++ b/backend/app/schemas/translation.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, Field from enum import Enum from dataclasses import dataclass +from app.schemas.base import UTCDatetimeBaseModel + class TranslationStatusEnum(str, Enum): """Translation job status enumeration""" @@ -54,7 +56,7 @@ class TranslationProgress(BaseModel): percentage: float = Field(default=0.0, description="Progress percentage (0-100)") -class TranslationStatusResponse(BaseModel): +class TranslationStatusResponse(UTCDatetimeBaseModel): """Response model for translation status query""" task_id: str = Field(..., description="Task ID") status: TranslationStatusEnum = Field(..., description="Current translation status") @@ -89,7 +91,7 @@ class TranslationStatistics(BaseModel): total_tokens: int = Field(default=0, description="Total API tokens used") -class TranslationResultResponse(BaseModel): +class TranslationResultResponse(UTCDatetimeBaseModel): """Response model for translation result""" schema_version: str = Field(default="1.0.0", description="Schema version") source_document: str = Field(..., description="Source document filename") @@ -104,7 +106,7 @@ class TranslationResultResponse(BaseModel): ) -class TranslationListItem(BaseModel): +class TranslationListItem(UTCDatetimeBaseModel): """Item in translation list response""" target_lang: str = Field(..., description="Target language") translated_at: datetime = Field(..., description="Translation timestamp")