diff --git a/.claude/skills/backend-patterns/SKILL.md b/.claude/skills/backend-patterns/SKILL.md index 8ac23b0..7fe0918 100644 --- a/.claude/skills/backend-patterns/SKILL.md +++ b/.claude/skills/backend-patterns/SKILL.md @@ -144,3 +144,29 @@ async def fetch_user(user_id: int): response.raise_for_status() return response.json() ``` + +### Timezone Handling +**Principle**: Store in UTC, query in UTC, convert to local time at presentation time. + +```python +# Storage (models) +from datetime import UTC, datetime +from src.mixins import TimestampMixin + +class Order(SQLModel, TimestampMixin, table=True): + # created_at, updated_at stored in UTC + pass + +# Presentation (serializers) +from datetime import timedelta, timezone +from src.config import get_settings + +class OrderResponse(BaseModel): + created_at: datetime + + @field_serializer('created_at') + def serialize_dt(self, dt: datetime, _info): + tz_offset = get_settings().app.timezone + local_tz = timezone(timedelta(hours=tz_offset)) + return dt.astimezone(local_tz) +``` diff --git a/README.md b/README.md index a9c9b5f..9f1cda3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ This repo also provides a full-featured, best-practiced backend template for bui - **Standardized Responses**: Middleware for consistent, unified JSON response formatting across all endpoints. - **Custom Error Codes**: Flexible handling of business-specific error codes and messages. - **Pagination**: Built-in support for paginating query results using `fastapi-pagination`. +- **Timezone Handling**: UTC storage with presentation-layer conversion - store in UTC, query in UTC, convert to local time at presentation time. ### DDD guidelines diff --git a/migrations/env.py b/migrations/env.py index 9027b01..fbc32e8 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -8,8 +8,6 @@ from alembic import context from src.config import get_settings -from src.audit.schemas import AuditLog # noqa: F401 - config = context.config # Get settings before setting URL diff --git a/migrations/versions/22abd37dc0b2_update_timestamp_to_timezone_aware.py b/migrations/versions/22abd37dc0b2_update_timestamp_to_timezone_aware.py deleted file mode 100644 index ed40e48..0000000 --- a/migrations/versions/22abd37dc0b2_update_timestamp_to_timezone_aware.py +++ /dev/null @@ -1,51 +0,0 @@ -"""update_timestamp_to_timezone_aware - -Revision ID: 22abd37dc0b2 -Revises: 9f5f1dfe3a30 -Create Date: 2026-01-09 22:42:39.007596 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "22abd37dc0b2" -down_revision: Union[str, Sequence[str], None] = "9f5f1dfe3a30" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - bind = op.get_bind() - dialect = bind.dialect.name - - if dialect == "postgresql": - op.alter_column( - "audit_logs", - "occurred_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - -def downgrade() -> None: - """Downgrade schema.""" - bind = op.get_bind() - dialect = bind.dialect.name - - if dialect == "postgresql": - op.alter_column( - "audit_logs", - "occurred_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) diff --git a/migrations/versions/dcaa4d1b381d_update_timestamp_fields_with_timezone.py b/migrations/versions/dcaa4d1b381d_update_timestamp_fields_with_timezone.py deleted file mode 100644 index 9a3bd64..0000000 --- a/migrations/versions/dcaa4d1b381d_update_timestamp_fields_with_timezone.py +++ /dev/null @@ -1,277 +0,0 @@ -"""update_timestamp_fields_with_timezone - -Revision ID: dcaa4d1b381d -Revises: 9f5f1dfe3a30 -Create Date: 2026-01-09 22:15:58.804158 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "dcaa4d1b381d" -down_revision: Union[str, Sequence[str], None] = "9f5f1dfe3a30" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - bind = op.get_bind() - dialect = bind.dialect.name - - if dialect == "sqlite": - pass - elif dialect == "postgresql": - op.alter_column( - "audit_logs", - "occurred_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - op.drop_index(op.f("ix_audit_logs_request_id"), table_name="audit_logs") - op.drop_constraint( - op.f("audit_logs_actor_id_fkey"), "audit_logs", type_="foreignkey" - ) - op.create_foreign_key( - "audit_logs_actor_id_fkey_new", "audit_logs", "users", ["actor_id"], ["id"] - ) - - op.alter_column( - "permissions", - "created_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - op.alter_column( - "permissions", - "updated_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - op.drop_constraint( - op.f("role_permissions_permission_id_fkey"), - "role_permissions", - type_="foreignkey", - ) - op.drop_constraint( - op.f("role_permissions_role_id_fkey"), - "role_permissions", - type_="foreignkey", - ) - op.create_foreign_key( - "role_permissions_role_id_fkey_new", - "role_permissions", - "roles", - ["role_id"], - ["id"], - ) - op.create_foreign_key( - "role_permissions_permission_id_fkey_new", - "role_permissions", - "permissions", - ["permission_id"], - ["id"], - ) - - op.alter_column( - "roles", - "created_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - op.alter_column( - "roles", - "updated_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - op.drop_constraint( - op.f("user_roles_user_id_fkey"), "user_roles", type_="foreignkey" - ) - op.drop_constraint( - op.f("user_roles_role_id_fkey"), "user_roles", type_="foreignkey" - ) - op.create_foreign_key( - "user_roles_role_id_fkey_new", "user_roles", "roles", ["role_id"], ["id"] - ) - op.create_foreign_key( - "user_roles_user_id_fkey_new", "user_roles", "users", ["user_id"], ["id"] - ) - - op.alter_column( - "users", - "created_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - ) - op.alter_column( - "users", - "updated_at", - existing_type=postgresql.TIMESTAMP(), - type_=sa.DateTime(timezone=True), - existing_nullable=False, - ) - op.alter_column( - "users", - "hashed_password", - existing_type=sa.VARCHAR(length=255), - type_=sqlmodel.sql.sqltypes.AutoString(length=1024), - existing_nullable=False, - ) - - -def downgrade() -> None: - """Downgrade schema.""" - bind = op.get_bind() - dialect = bind.dialect.name - - if dialect == "sqlite": - pass - elif dialect == "postgresql": - op.alter_column( - "users", - "hashed_password", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=1024), - type_=sa.VARCHAR(length=255), - existing_nullable=False, - ) - op.alter_column( - "users", - "updated_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - ) - op.alter_column( - "users", - "created_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - ) - - op.drop_constraint( - "user_roles_role_id_fkey_new", "user_roles", type_="foreignkey" - ) - op.drop_constraint( - "user_roles_user_id_fkey_new", "user_roles", type_="foreignkey" - ) - op.create_foreign_key( - op.f("user_roles_role_id_fkey"), - "user_roles", - "roles", - ["role_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_foreign_key( - op.f("user_roles_user_id_fkey"), - "user_roles", - "users", - ["user_id"], - ["id"], - ondelete="CASCADE", - ) - - op.alter_column( - "roles", - "updated_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - op.alter_column( - "roles", - "created_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - op.drop_constraint( - "role_permissions_role_id_fkey_new", "role_permissions", type_="foreignkey" - ) - op.drop_constraint( - "role_permissions_permission_id_fkey_new", - "role_permissions", - type_="foreignkey", - ) - op.create_foreign_key( - op.f("role_permissions_role_id_fkey"), - "role_permissions", - "roles", - ["role_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_foreign_key( - op.f("role_permissions_permission_id_fkey"), - "role_permissions", - "permissions", - ["permission_id"], - ["id"], - ondelete="CASCADE", - ) - - op.alter_column( - "permissions", - "updated_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - op.alter_column( - "permissions", - "created_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) - - op.drop_constraint( - "audit_logs_actor_id_fkey_new", "audit_logs", type_="foreignkey" - ) - op.create_foreign_key( - op.f("audit_logs_actor_id_fkey"), - "audit_logs", - "users", - ["actor_id"], - ["id"], - ondelete="SET NULL", - ) - op.create_index( - op.f("ix_audit_logs_request_id"), "audit_logs", ["request_id"], unique=False - ) - - op.alter_column( - "audit_logs", - "occurred_at", - existing_type=sa.DateTime(timezone=True), - type_=postgresql.TIMESTAMP(), - existing_nullable=False, - existing_server_default=sa.text("CURRENT_TIMESTAMP"), - ) diff --git a/src/audit/schemas.py b/src/audit/schemas.py index 014e318..ed0b522 100644 --- a/src/audit/schemas.py +++ b/src/audit/schemas.py @@ -1,12 +1,10 @@ -from datetime import datetime +from datetime import UTC, datetime from enum import StrEnum from typing import Any -from sqlalchemy import JSON, Column, DateTime, Text +from sqlalchemy import JSON, Column, Text from sqlmodel import Field, SQLModel -from src.mixins import now - class AuditAction(StrEnum): LOGIN = "login" @@ -33,8 +31,7 @@ class AuditLog(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) occurred_at: datetime = Field( - default_factory=now, - sa_type=DateTime(timezone=True), + default_factory=lambda: datetime.now(UTC), nullable=False, index=True, ) diff --git a/src/config/app.py b/src/config/app.py index bb06677..94ae541 100644 --- a/src/config/app.py +++ b/src/config/app.py @@ -9,4 +9,7 @@ class AppSettings(BaseSettings): debug: bool = Field(default=False) host: str = Field(default="0.0.0.0") port: int = Field(default=8000) - timezone: int = Field(default=8, description="UTC offset in hours") + timezone: int = Field( + default=8, + description="UTC offset in hours for display/serialization only. DB stores UTC.", + ) diff --git a/src/mixins.py b/src/mixins.py index a68b493..80ced62 100644 --- a/src/mixins.py +++ b/src/mixins.py @@ -1,32 +1,19 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime -from sqlalchemy import DateTime from sqlmodel import Field -def get_timezone() -> timezone: - """Get configured timezone, defaults to UTC+8 if not configured""" - try: - from src.config import get_settings - - tz_offset = get_settings().app.timezone - return timezone(timedelta(hours=tz_offset)) - except ImportError: - return timezone(timedelta(hours=8)) - +class TimestampMixin: + """Provides UTC timestamp fields for models. -def now() -> datetime: - """Get current datetime with configured timezone""" - return datetime.now(get_timezone()) + Principle: Store in UTC, query in UTC, convert to local time at presentation time. + For display in local timezone, convert using settings.app.timezone + in the presentation layer (Pydantic serializers, API responses). + """ -class TimestampMixin: - created_at: datetime = Field( - default_factory=now, - sa_type=DateTime(timezone=True), - ) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field( - default_factory=now, - sa_type=DateTime(timezone=True), - sa_column_kwargs={"onupdate": now}, + default_factory=lambda: datetime.now(UTC), + sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, ) diff --git a/src/session.py b/src/session.py index d59ec91..319035e 100644 --- a/src/session.py +++ b/src/session.py @@ -17,6 +17,8 @@ async def init_db(url: str, echo: bool = False) -> None: @event.listens_for(engine.sync_engine, "connect") def set_sqlite_pragma(dbapi_conn, connection_record): cursor = dbapi_conn.cursor() + # By default, sqlite disables foreign key constraint enforcement + # see https://www.sqlite.org/foreignkeys.html#fk_enable cursor.execute("PRAGMA foreign_keys=ON") cursor.close()