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")