fix: add UTC timezone indicator to all datetime serialization
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 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,10 @@ class AuditLog(Base):
|
|||||||
return f"<AuditLog(id={self.id}, type='{self.event_type}', user_id={self.user_id})>"
|
return f"<AuditLog(id={self.id}, type='{self.event_type}', user_id={self.user_id})>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
@@ -91,5 +94,5 @@ class AuditLog(Base):
|
|||||||
"success": bool(self.success),
|
"success": bool(self.success),
|
||||||
"error_message": self.error_message,
|
"error_message": self.error_message,
|
||||||
"extra_data": self.extra_data,
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,16 +56,19 @@ class Session(Base):
|
|||||||
return f"<Session(id={self.id}, user_id={self.user_id}, expires_at='{self.expires_at}')>"
|
return f"<Session(id={self.id}, user_id={self.user_id}, expires_at='{self.expires_at}')>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"token_type": self.token_type,
|
"token_type": self.token_type,
|
||||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
"expires_at": self.expires_at.isoformat() + 'Z' if self.expires_at else None,
|
||||||
"issued_at": self.issued_at.isoformat() if self.issued_at else None,
|
"issued_at": self.issued_at.isoformat() + 'Z' if self.issued_at else None,
|
||||||
"ip_address": self.ip_address,
|
"ip_address": self.ip_address,
|
||||||
"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,
|
||||||
"last_accessed_at": self.last_accessed_at.isoformat() if self.last_accessed_at else None
|
"last_accessed_at": self.last_accessed_at.isoformat() + 'Z' if self.last_accessed_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ class Task(Base):
|
|||||||
return f"<Task(id={self.id}, task_id='{self.task_id}', status='{self.status.value}')>"
|
return f"<Task(id={self.id}, task_id='{self.task_id}', status='{self.status.value}')>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"task_id": self.task_id,
|
"task_id": self.task_id,
|
||||||
@@ -78,11 +82,11 @@ class Task(Base):
|
|||||||
"result_pdf_path": self.result_pdf_path,
|
"result_pdf_path": self.result_pdf_path,
|
||||||
"error_message": self.error_message,
|
"error_message": self.error_message,
|
||||||
"processing_time_ms": self.processing_time_ms,
|
"processing_time_ms": self.processing_time_ms,
|
||||||
"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,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() + 'Z' if self.updated_at else None,
|
||||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
"completed_at": self.completed_at.isoformat() + 'Z' if self.completed_at else None,
|
||||||
"file_deleted": self.file_deleted,
|
"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"<TaskFile(id={self.id}, task_id={self.task_id}, original_name='{self.original_name}')>"
|
return f"<TaskFile(id={self.id}, task_id={self.task_id}, original_name='{self.original_name}')>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"task_id": self.task_id,
|
"task_id": self.task_id,
|
||||||
@@ -125,5 +132,5 @@ class TaskFile(Base):
|
|||||||
"file_size": self.file_size,
|
"file_size": self.file_size,
|
||||||
"mime_type": self.mime_type,
|
"mime_type": self.mime_type,
|
||||||
"file_hash": self.file_hash,
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class TranslationLog(Base):
|
|||||||
return f"<TranslationLog(id={self.id}, task_id='{self.task_id}', target_lang='{self.target_lang}', tokens={self.total_tokens})>"
|
return f"<TranslationLog(id={self.id}, task_id='{self.task_id}', target_lang='{self.target_lang}', tokens={self.total_tokens})>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
@@ -83,5 +86,5 @@ class TranslationLog(Base):
|
|||||||
"estimated_cost": self.estimated_cost,
|
"estimated_cost": self.estimated_cost,
|
||||||
"processing_time_seconds": self.processing_time_seconds,
|
"processing_time_seconds": self.processing_time_seconds,
|
||||||
"provider": self.provider,
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,15 @@ class User(Base):
|
|||||||
return f"<User(id={self.id}, email='{self.email}', display_name='{self.display_name}')>"
|
return f"<User(id={self.id}, email='{self.email}', display_name='{self.display_name}')>"
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"display_name": self.display_name,
|
"display_name": self.display_name,
|
||||||
"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,
|
||||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
"last_login": self.last_login.isoformat() + 'Z' if self.last_login else None,
|
||||||
"is_active": self.is_active
|
"is_active": self.is_active
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.base import UTCDatetimeBaseModel
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
"""Login request schema"""
|
"""Login request schema"""
|
||||||
@@ -58,7 +60,7 @@ class TokenData(BaseModel):
|
|||||||
session_id: Optional[int] = None
|
session_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(UTCDatetimeBaseModel):
|
||||||
"""User response schema"""
|
"""User response schema"""
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
@@ -66,9 +68,3 @@ class UserResponse(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
json_encoders = {
|
|
||||||
datetime: lambda v: v.isoformat() if v else None
|
|
||||||
}
|
|
||||||
|
|||||||
38
backend/app/schemas/base.py
Normal file
38
backend/app/schemas/base.py
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -7,6 +7,8 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from app.schemas.base import UTCDatetimeBaseModel
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusEnum(str, Enum):
|
class TaskStatusEnum(str, Enum):
|
||||||
"""Task status enumeration"""
|
"""Task status enumeration"""
|
||||||
@@ -146,7 +148,7 @@ class TaskUpdate(BaseModel):
|
|||||||
result_pdf_path: Optional[str] = None
|
result_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskFileResponse(BaseModel):
|
class TaskFileResponse(UTCDatetimeBaseModel):
|
||||||
"""Task file response schema"""
|
"""Task file response schema"""
|
||||||
id: int
|
id: int
|
||||||
original_name: Optional[str] = None
|
original_name: Optional[str] = None
|
||||||
@@ -156,11 +158,8 @@ class TaskFileResponse(BaseModel):
|
|||||||
file_hash: Optional[str] = None
|
file_hash: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
class TaskResponse(UTCDatetimeBaseModel):
|
||||||
class TaskResponse(BaseModel):
|
|
||||||
"""Task response schema"""
|
"""Task response schema"""
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -178,9 +177,6 @@ class TaskResponse(BaseModel):
|
|||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
file_deleted: bool = False
|
file_deleted: bool = False
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class TaskDetailResponse(TaskResponse):
|
class TaskDetailResponse(TaskResponse):
|
||||||
"""Detailed task response with files"""
|
"""Detailed task response with files"""
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from pydantic import BaseModel, Field
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.schemas.base import UTCDatetimeBaseModel
|
||||||
|
|
||||||
|
|
||||||
class TranslationStatusEnum(str, Enum):
|
class TranslationStatusEnum(str, Enum):
|
||||||
"""Translation job status enumeration"""
|
"""Translation job status enumeration"""
|
||||||
@@ -54,7 +56,7 @@ class TranslationProgress(BaseModel):
|
|||||||
percentage: float = Field(default=0.0, description="Progress percentage (0-100)")
|
percentage: float = Field(default=0.0, description="Progress percentage (0-100)")
|
||||||
|
|
||||||
|
|
||||||
class TranslationStatusResponse(BaseModel):
|
class TranslationStatusResponse(UTCDatetimeBaseModel):
|
||||||
"""Response model for translation status query"""
|
"""Response model for translation status query"""
|
||||||
task_id: str = Field(..., description="Task ID")
|
task_id: str = Field(..., description="Task ID")
|
||||||
status: TranslationStatusEnum = Field(..., description="Current translation status")
|
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")
|
total_tokens: int = Field(default=0, description="Total API tokens used")
|
||||||
|
|
||||||
|
|
||||||
class TranslationResultResponse(BaseModel):
|
class TranslationResultResponse(UTCDatetimeBaseModel):
|
||||||
"""Response model for translation result"""
|
"""Response model for translation result"""
|
||||||
schema_version: str = Field(default="1.0.0", description="Schema version")
|
schema_version: str = Field(default="1.0.0", description="Schema version")
|
||||||
source_document: str = Field(..., description="Source document filename")
|
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"""
|
"""Item in translation list response"""
|
||||||
target_lang: str = Field(..., description="Target language")
|
target_lang: str = Field(..., description="Target language")
|
||||||
translated_at: datetime = Field(..., description="Translation timestamp")
|
translated_at: datetime = Field(..., description="Translation timestamp")
|
||||||
|
|||||||
Reference in New Issue
Block a user