From af616f5ba00eac4a7c7531831e73ce84acd737aa Mon Sep 17 00:00:00 2001 From: Baiheng Xie <874256269@qq.com> Date: Fri, 9 Jan 2026 22:07:04 +0800 Subject: [PATCH 1/2] fix: can't subtract offset-naive and offset-aware datetimes --- src/shared/mixins.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/shared/mixins.py b/src/shared/mixins.py index 78d2e30..a68b493 100644 --- a/src/shared/mixins.py +++ b/src/shared/mixins.py @@ -1,11 +1,32 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +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)) + + +def now() -> datetime: + """Get current datetime with configured timezone""" + return datetime.now(get_timezone()) + + class TimestampMixin: - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field( + default_factory=now, + sa_type=DateTime(timezone=True), + ) updated_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), - sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}, + default_factory=now, + sa_type=DateTime(timezone=True), + sa_column_kwargs={"onupdate": now}, ) From 03a647ecfa1d12a5feb942cfcf3b53020521a079 Mon Sep 17 00:00:00 2001 From: Baiheng Xie <874256269@qq.com> Date: Fri, 9 Jan 2026 23:07:04 +0800 Subject: [PATCH 2/2] fix: timezone config and altered occurred_at --- .env-example | 1 + migrations/env.py | 2 + ...c0b2_update_timestamp_to_timezone_aware.py | 51 ++++ ...d_update_timestamp_fields_with_timezone.py | 277 ++++++++++++++++++ src/audit/schemas.py | 9 +- src/config/app.py | 1 + src/session.py | 2 - 7 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 migrations/versions/22abd37dc0b2_update_timestamp_to_timezone_aware.py create mode 100644 migrations/versions/dcaa4d1b381d_update_timestamp_fields_with_timezone.py diff --git a/.env-example b/.env-example index c54f427..9bccd5a 100644 --- a/.env-example +++ b/.env-example @@ -3,6 +3,7 @@ APP__VERSION=0.1.0 APP__DEBUG=false APP__HOST=0.0.0.0 APP__PORT=8000 +APP__TIMEZONE=8 LOG__LEVEL=INFO LOG__JSON_LOGS=false diff --git a/migrations/env.py b/migrations/env.py index fbc32e8..9027b01 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -8,6 +8,8 @@ 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 new file mode 100644 index 0000000..ed40e48 --- /dev/null +++ b/migrations/versions/22abd37dc0b2_update_timestamp_to_timezone_aware.py @@ -0,0 +1,51 @@ +"""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 new file mode 100644 index 0000000..9a3bd64 --- /dev/null +++ b/migrations/versions/dcaa4d1b381d_update_timestamp_fields_with_timezone.py @@ -0,0 +1,277 @@ +"""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 ed0b522..7f75bbd 100644 --- a/src/audit/schemas.py +++ b/src/audit/schemas.py @@ -1,10 +1,12 @@ -from datetime import UTC, datetime +from datetime import datetime from enum import StrEnum from typing import Any -from sqlalchemy import JSON, Column, Text +from sqlalchemy import JSON, Column, DateTime, Text from sqlmodel import Field, SQLModel +from src.shared.mixins import now + class AuditAction(StrEnum): LOGIN = "login" @@ -31,7 +33,8 @@ class AuditLog(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) occurred_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), + default_factory=now, + sa_type=DateTime(timezone=True), nullable=False, index=True, ) diff --git a/src/config/app.py b/src/config/app.py index feeb93c..bb06677 100644 --- a/src/config/app.py +++ b/src/config/app.py @@ -9,3 +9,4 @@ 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") diff --git a/src/session.py b/src/session.py index 319035e..d59ec91 100644 --- a/src/session.py +++ b/src/session.py @@ -17,8 +17,6 @@ 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()