diff --git a/alembic/versions/0002_seed_roles_permissions.py b/alembic/versions/0002_seed_roles_permissions.py index aa9c90e18..9e973e86c 100644 --- a/alembic/versions/0002_seed_roles_permissions.py +++ b/alembic/versions/0002_seed_roles_permissions.py @@ -9,25 +9,25 @@ catalog has a single source of truth. """ -from datetime import datetime -from typing import Sequence, Union + import uuid +from collections.abc import Sequence +from datetime import UTC, datetime -from alembic import op import sqlalchemy as sa +from alembic import op from db.seed import BUILTIN_ROLES, PERMISSIONS, ROLE_PERMISSION_MAP, permission_name - revision: str = "0002_seed_roles_permissions" -down_revision: Union[str, Sequence[str], None] = "0001_initial" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "0001_initial" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: bind = op.get_bind() - now = datetime.utcnow() + now = datetime.now(UTC) # Permissions perms_table = sa.table( @@ -94,8 +94,7 @@ def upgrade() -> None: for row in bind.execute(sa.text("SELECT id, name FROM permissions")).fetchall() } role_id_by_name = { - row[1]: row[0] - for row in bind.execute(sa.text("SELECT id, name FROM roles")).fetchall() + row[1]: row[0] for row in bind.execute(sa.text("SELECT id, name FROM roles")).fetchall() } existing_rp = { (row[0], row[1]) @@ -132,19 +131,22 @@ def downgrade() -> None: perm_names = [permission_name(r, a) for r, a, _ in PERMISSIONS] if role_names: bind.execute( - sa.text("DELETE FROM role_permissions WHERE role_id IN " - "(SELECT id FROM roles WHERE name IN :names)") - .bindparams(sa.bindparam("names", expanding=True)), + sa.text( + "DELETE FROM role_permissions WHERE role_id IN " + "(SELECT id FROM roles WHERE name IN :names)" + ).bindparams(sa.bindparam("names", expanding=True)), {"names": role_names}, ) bind.execute( - sa.text("DELETE FROM roles WHERE name IN :names AND is_system = 1") - .bindparams(sa.bindparam("names", expanding=True)), + sa.text("DELETE FROM roles WHERE name IN :names AND is_system = 1").bindparams( + sa.bindparam("names", expanding=True) + ), {"names": role_names}, ) if perm_names: bind.execute( - sa.text("DELETE FROM permissions WHERE name IN :names") - .bindparams(sa.bindparam("names", expanding=True)), + sa.text("DELETE FROM permissions WHERE name IN :names").bindparams( + sa.bindparam("names", expanding=True) + ), {"names": perm_names}, ) diff --git a/src/api/knowledge_filter.py b/src/api/knowledge_filter.py index c9096007b..1f9e1588d 100644 --- a/src/api/knowledge_filter.py +++ b/src/api/knowledge_filter.py @@ -1,6 +1,6 @@ import json import uuid -from datetime import datetime +from datetime import UTC, datetime from typing import Any from fastapi import Depends, Request @@ -128,8 +128,8 @@ async def create_knowledge_filter( "owner": user.user_id, "allowed_users": body.allowedUsers, "allowed_groups": body.allowedGroups, - "created_at": datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat(), + "created_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), } result = await knowledge_filter_service.create_knowledge_filter( @@ -225,7 +225,7 @@ async def update_knowledge_filter( if body.allowedGroups is not None else existing_filter.get("allowed_groups", []), "created_at": existing_filter["created_at"], - "updated_at": datetime.utcnow().isoformat(), + "updated_at": datetime.now(UTC).isoformat(), } result = await knowledge_filter_service.create_knowledge_filter( @@ -291,7 +291,7 @@ async def subscribe_to_knowledge_filter( "subscription_id": monitor_result["subscription_id"], "monitor_id": monitor_result["monitor_id"], "webhook_url": monitor_result["webhook_url"], - "created_at": datetime.utcnow().isoformat(), + "created_at": datetime.now(UTC).isoformat(), "notification_config": body.notification_config or {}, } @@ -420,7 +420,7 @@ async def knowledge_filter_webhook( "filter_id": filter_id, "subscription_id": subscription_id, "matched_documents": len(matched_documents), - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } ) diff --git a/src/api/settings/helpers.py b/src/api/settings/helpers.py index 6356f7de2..2a8aa13e6 100644 --- a/src/api/settings/helpers.py +++ b/src/api/settings/helpers.py @@ -8,6 +8,7 @@ 1388–1455). No behavior change. """ +from datetime import UTC from typing import Any from fastapi.responses import JSONResponse @@ -205,8 +206,8 @@ async def _create_openrag_docs_filter(knowledge_filter_service, session_manager, "owner": owner_user_id, "allowed_users": [], "allowed_groups": [], - "created_at": datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat(), + "created_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), } result = await knowledge_filter_service.create_knowledge_filter( diff --git a/src/connectors/onedrive/connector.py b/src/connectors/onedrive/connector.py index 4db428ff0..02950fb25 100644 --- a/src/connectors/onedrive/connector.py +++ b/src/connectors/onedrive/connector.py @@ -406,7 +406,7 @@ def _get_subscription_expiry(self) -> str: """Get subscription expiry time (Graph caps duration; often <= 3 days).""" from datetime import datetime, timedelta - expiry = datetime.utcnow() + timedelta(days=3) + expiry = datetime.now(UTC) + timedelta(days=3) return expiry.strftime("%Y-%m-%dT%H:%M:%S.%fZ") async def list_files( diff --git a/src/connectors/sharepoint/connector.py b/src/connectors/sharepoint/connector.py index 3d6b15c86..5ba99cc43 100644 --- a/src/connectors/sharepoint/connector.py +++ b/src/connectors/sharepoint/connector.py @@ -441,7 +441,7 @@ def _get_subscription_expiry(self) -> str: """Get subscription expiry time (max 3 days for Graph API)""" from datetime import datetime, timedelta - expiry = datetime.utcnow() + timedelta(days=3) # 3 days max for Graph + expiry = datetime.now(UTC) + timedelta(days=3) # 3 days max for Graph return expiry.strftime("%Y-%m-%dT%H:%M:%S.%fZ") def _parse_sharepoint_url(self) -> dict[str, str] | None: diff --git a/src/db/migrations_runtime.py b/src/db/migrations_runtime.py index 03126156d..489601288 100644 --- a/src/db/migrations_runtime.py +++ b/src/db/migrations_runtime.py @@ -18,7 +18,7 @@ import json import os from collections.abc import Iterable -from datetime import datetime +from datetime import UTC, datetime from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -53,7 +53,7 @@ async def _already_done(session: AsyncSession, name: str) -> bool: async def _mark_done(session: AsyncSession, name: str, notes: str = "") -> None: - session.add(MigrationStatus(name=name, completed_at=datetime.utcnow(), notes=notes)) + session.add(MigrationStatus(name=name, completed_at=datetime.now(UTC), notes=notes)) await session.flush() @@ -194,6 +194,17 @@ async def migrate_config_yaml_to_db(session: AsyncSession) -> int: return written +def _parse_legacy_dt(value: str | None) -> datetime | None: + """Parse a legacy ISO datetime string, coercing naive values to UTC.""" + if not value: + return None + try: + dt = datetime.fromisoformat(value) + except (TypeError, ValueError): + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) + + async def migrate_chat_history_json_to_db(session: AsyncSession) -> dict[str, int]: """Copy ``data/session_ownership.json`` and ``data/conversations.json`` into the DB. Idempotent — only inserts rows that aren't already present. @@ -214,20 +225,8 @@ async def migrate_chat_history_json_to_db(session: AsyncSession) -> dict[str, in uid = data.get("user_id") if not uid: continue - try: - created = ( - datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None - ) - except Exception: # noqa: BLE001 - created = None - try: - last = ( - datetime.fromisoformat(data["last_accessed"]) - if data.get("last_accessed") - else None - ) - except Exception: # noqa: BLE001 - last = None + created = _parse_legacy_dt(data.get("created_at")) + last = _parse_legacy_dt(data.get("last_accessed")) inserted = await repo.upsert_raw( response_id=str(sid), user_id=str(uid), @@ -249,22 +248,8 @@ async def migrate_chat_history_json_to_db(session: AsyncSession) -> dict[str, in continue if await crepo.get(str(resp_id)) is not None: continue - try: - created = ( - datetime.fromisoformat(meta["created_at"]) - if meta.get("created_at") - else None - ) - except Exception: # noqa: BLE001 - created = None - try: - last = ( - datetime.fromisoformat(meta["last_activity"]) - if meta.get("last_activity") - else None - ) - except Exception: # noqa: BLE001 - last = None + created = _parse_legacy_dt(meta.get("created_at")) + last = _parse_legacy_dt(meta.get("last_activity")) await crepo.upsert( response_id=str(resp_id), user_id=str(uid), diff --git a/src/db/models/api_key.py b/src/db/models/api_key.py index ff824aca5..88c556a30 100644 --- a/src/db/models/api_key.py +++ b/src/db/models/api_key.py @@ -4,8 +4,7 @@ OpenSearch-backed APIKeyService is unchanged. """ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel @@ -19,10 +18,10 @@ class ApiKey(SQLModel, table=True): name: str = Field(max_length=128) key_hash: str = Field(max_length=128, unique=True, index=True) key_prefix: str = Field(max_length=32) - scope_role_ids: Optional[list] = Field( + scope_role_ids: list | None = Field( default=None, sa_column=Column("scope_role_ids", JSON, nullable=True) ) - last_used_at: Optional[datetime] = Field(default=None) - created_at: datetime = Field(default_factory=datetime.utcnow) - revoked_at: Optional[datetime] = Field(default=None) + last_used_at: datetime | None = Field(default=None) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + revoked_at: datetime | None = Field(default=None) revoked: bool = Field(default=False) diff --git a/src/db/models/audit_log.py b/src/db/models/audit_log.py index 1bd405348..d6d0a2617 100644 --- a/src/db/models/audit_log.py +++ b/src/db/models/audit_log.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel @@ -9,16 +8,16 @@ class AuditLog(SQLModel, table=True): __tablename__ = "audit_log" id: str = Field(primary_key=True, max_length=64) - ts: datetime = Field(default_factory=datetime.utcnow, index=True) - actor_user_id: Optional[str] = Field( + ts: datetime = Field(default_factory=lambda: datetime.now(UTC), index=True) + actor_user_id: str | None = Field( default=None, foreign_key="users.id", max_length=64, index=True ) - actor_api_key_id: Optional[str] = Field(default=None, max_length=64) + actor_api_key_id: str | None = Field(default=None, max_length=64) event: str = Field(max_length=128, index=True) - target_type: Optional[str] = Field(default=None, max_length=64) - target_id: Optional[str] = Field(default=None, max_length=128) - audit_metadata: Optional[dict] = Field( + target_type: str | None = Field(default=None, max_length=64) + target_id: str | None = Field(default=None, max_length=128) + audit_metadata: dict | None = Field( default=None, sa_column=Column("metadata", JSON, nullable=True) ) - ip: Optional[str] = Field(default=None, max_length=64) - user_agent: Optional[str] = Field(default=None, max_length=512) + ip: str | None = Field(default=None, max_length=64) + user_agent: str | None = Field(default=None, max_length=512) diff --git a/src/db/models/conversation.py b/src/db/models/conversation.py index f0878a024..5a23c561b 100644 --- a/src/db/models/conversation.py +++ b/src/db/models/conversation.py @@ -9,8 +9,7 @@ ids not in the users table yet. """ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import Index from sqlmodel import Field, SQLModel @@ -18,16 +17,14 @@ class Conversation(SQLModel, table=True): __tablename__ = "conversations" - __table_args__ = ( - Index("ix_conversations_user_recent", "user_id", "last_activity"), - ) + __table_args__ = (Index("ix_conversations_user_recent", "user_id", "last_activity"),) response_id: str = Field(primary_key=True, max_length=64) user_id: str = Field(max_length=64, index=True) - title: Optional[str] = Field(default=None, max_length=512) - endpoint: Optional[str] = Field(default=None, max_length=64) - previous_response_id: Optional[str] = Field(default=None, max_length=64) - filter_id: Optional[str] = Field(default=None, max_length=128) + title: str | None = Field(default=None, max_length=512) + endpoint: str | None = Field(default=None, max_length=64) + previous_response_id: str | None = Field(default=None, max_length=64) + filter_id: str | None = Field(default=None, max_length=128) total_messages: int = Field(default=0) - created_at: datetime = Field(default_factory=datetime.utcnow) - last_activity: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/src/db/models/migration_status.py b/src/db/models/migration_status.py index 28eb55095..ed8b6fbff 100644 --- a/src/db/models/migration_status.py +++ b/src/db/models/migration_status.py @@ -1,6 +1,6 @@ """Tracks one-shot runtime migrations (e.g. JSON->DB).""" -from datetime import datetime +from datetime import UTC, datetime from sqlmodel import Field, SQLModel @@ -9,5 +9,5 @@ class MigrationStatus(SQLModel, table=True): __tablename__ = "migration_status" name: str = Field(primary_key=True, max_length=128) - completed_at: datetime = Field(default_factory=datetime.utcnow) + completed_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) notes: str = Field(default="", max_length=2048) diff --git a/src/db/models/role.py b/src/db/models/role.py index 783efc91d..e20f66720 100644 --- a/src/db/models/role.py +++ b/src/db/models/role.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlmodel import Field, SQLModel @@ -9,7 +8,7 @@ class Role(SQLModel, table=True): id: str = Field(primary_key=True, max_length=64) name: str = Field(max_length=64, unique=True, index=True) - description: Optional[str] = Field(default=None, max_length=512) + description: str | None = Field(default=None, max_length=512) is_system: bool = Field(default=False) - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/src/db/models/session_ownership.py b/src/db/models/session_ownership.py index 14e0dd94d..eebc6d83a 100644 --- a/src/db/models/session_ownership.py +++ b/src/db/models/session_ownership.py @@ -9,8 +9,7 @@ violations. """ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlmodel import Field, SQLModel @@ -20,5 +19,5 @@ class SessionOwnership(SQLModel, table=True): response_id: str = Field(primary_key=True, max_length=64) user_id: str = Field(max_length=64, index=True) - created_at: datetime = Field(default_factory=datetime.utcnow) - last_accessed: Optional[datetime] = Field(default=None) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + last_accessed: datetime | None = Field(default=None) diff --git a/src/db/models/user.py b/src/db/models/user.py index 0b76dca07..29f3a961b 100644 --- a/src/db/models/user.py +++ b/src/db/models/user.py @@ -5,8 +5,7 @@ constraint and exact-match lookup despite the encrypted blob. """ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import Column, UniqueConstraint from sqlmodel import Field, SQLModel @@ -16,30 +15,24 @@ class User(SQLModel, table=True): __tablename__ = "users" - __table_args__ = ( - UniqueConstraint("oauth_provider", "oauth_subject", name="uq_users_oauth"), - ) + __table_args__ = (UniqueConstraint("oauth_provider", "oauth_subject", name="uq_users_oauth"),) id: str = Field(primary_key=True, max_length=64) oauth_provider: str = Field(max_length=32, index=True) oauth_subject: str = Field(max_length=255, index=True) - email: Optional[str] = Field( + email: str | None = Field( default=None, sa_column=Column("email", EncryptedString(tenant_id="user_pii"), nullable=True), ) - email_lookup_hash: Optional[str] = Field( - default=None, max_length=64, unique=True, index=True - ) - display_name: Optional[str] = Field( + email_lookup_hash: str | None = Field(default=None, max_length=64, unique=True, index=True) + display_name: str | None = Field( default=None, - sa_column=Column( - "display_name", EncryptedString(tenant_id="user_pii"), nullable=True - ), + sa_column=Column("display_name", EncryptedString(tenant_id="user_pii"), nullable=True), ) - picture_url: Optional[str] = Field(default=None, max_length=2048) + picture_url: str | None = Field(default=None, max_length=2048) is_active: bool = Field(default=True) - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - last_login: Optional[datetime] = Field(default=None) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + last_login: datetime | None = Field(default=None) diff --git a/src/db/models/user_role.py b/src/db/models/user_role.py index 7bbc44cf8..db3281bd3 100644 --- a/src/db/models/user_role.py +++ b/src/db/models/user_role.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from sqlmodel import Field, SQLModel @@ -17,7 +16,5 @@ class UserRole(SQLModel, table=True): primary_key=True, max_length=64, ) - granted_by: Optional[str] = Field( - default=None, foreign_key="users.id", max_length=64 - ) - granted_at: datetime = Field(default_factory=datetime.utcnow) + granted_by: str | None = Field(default=None, foreign_key="users.id", max_length=64) + granted_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/src/db/models/workspace_config.py b/src/db/models/workspace_config.py index 8b637d107..3f5b81c7e 100644 --- a/src/db/models/workspace_config.py +++ b/src/db/models/workspace_config.py @@ -11,8 +11,8 @@ other fields (embedding model, prompt, etc.) are not secrets. """ -from datetime import datetime -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel @@ -22,11 +22,9 @@ class WorkspaceConfig(SQLModel, table=True): __tablename__ = "workspace_config" section: str = Field(primary_key=True, max_length=64) - value: Optional[dict[str, Any]] = Field( + value: dict[str, Any] | None = Field( default_factory=dict, sa_column=Column("value", JSON, nullable=False), ) - updated_at: datetime = Field(default_factory=datetime.utcnow) - updated_by: Optional[str] = Field( - default=None, foreign_key="users.id", max_length=64 - ) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_by: str | None = Field(default=None, foreign_key="users.id", max_length=64) diff --git a/src/db/repositories/api_key_repo.py b/src/db/repositories/api_key_repo.py index cac0188ca..06d6a4e39 100644 --- a/src/db/repositories/api_key_repo.py +++ b/src/db/repositories/api_key_repo.py @@ -4,10 +4,11 @@ remains the source of truth until Phase 2 migrates keys here. """ -from typing import Optional +from datetime import UTC from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col from db.models import ApiKey @@ -16,16 +17,14 @@ class ApiKeyRepo: def __init__(self, session: AsyncSession): self.session = session - async def get_by_hash(self, key_hash: str) -> Optional[ApiKey]: + async def get_by_hash(self, key_hash: str) -> ApiKey | None: result = await self.session.execute( - select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.revoked.is_(False)) + select(ApiKey).where(col(ApiKey.key_hash) == key_hash, col(ApiKey.revoked).is_(False)) ) return result.scalar_one_or_none() async def list_for_user(self, user_id: str) -> list[ApiKey]: - result = await self.session.execute( - select(ApiKey).where(ApiKey.user_id == user_id) - ) + result = await self.session.execute(select(ApiKey).where(col(ApiKey.user_id) == user_id)) return list(result.scalars().all()) async def add(self, api_key: ApiKey) -> ApiKey: @@ -35,9 +34,10 @@ async def add(self, api_key: ApiKey) -> ApiKey: async def revoke(self, key_id: str) -> None: from datetime import datetime + row = await self.session.get(ApiKey, key_id) if row: row.revoked = True - row.revoked_at = datetime.utcnow() + row.revoked_at = datetime.now(UTC) self.session.add(row) await self.session.flush() diff --git a/src/db/repositories/conversation_repo.py b/src/db/repositories/conversation_repo.py index 3790fc17c..8675cae78 100644 --- a/src/db/repositories/conversation_repo.py +++ b/src/db/repositories/conversation_repo.py @@ -1,10 +1,11 @@ """Async CRUD over the ``conversations`` table — chat-history metadata.""" -from datetime import datetime -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col from db.models import Conversation @@ -13,16 +14,14 @@ class ConversationRepo: def __init__(self, session: AsyncSession): self.session = session - async def get(self, response_id: str) -> Optional[Conversation]: + async def get(self, response_id: str) -> Conversation | None: return await self.session.get(Conversation, response_id) - async def list_for_user( - self, user_id: str, limit: int = 200 - ) -> list[Conversation]: + async def list_for_user(self, user_id: str, limit: int = 200) -> list[Conversation]: result = await self.session.execute( select(Conversation) - .where(Conversation.user_id == user_id) - .order_by(Conversation.last_activity.desc()) + .where(col(Conversation.user_id) == user_id) + .order_by(col(Conversation.last_activity).desc()) .limit(limit) ) return list(result.scalars().all()) @@ -32,15 +31,15 @@ async def upsert( *, response_id: str, user_id: str, - title: Optional[str] = None, - endpoint: Optional[str] = None, - previous_response_id: Optional[str] = None, - filter_id: Optional[str] = None, + title: str | None = None, + endpoint: str | None = None, + previous_response_id: str | None = None, + filter_id: str | None = None, total_messages: int = 0, - created_at: Optional[datetime] = None, - last_activity: Optional[datetime] = None, + created_at: datetime | None = None, + last_activity: datetime | None = None, ) -> Conversation: - now = datetime.utcnow() + now = datetime.now(UTC) existing = await self.get(response_id) if existing is None: row = Conversation( diff --git a/src/db/repositories/workspace_config_repo.py b/src/db/repositories/workspace_config_repo.py index 47ddf734d..44f6b8b5c 100644 --- a/src/db/repositories/workspace_config_repo.py +++ b/src/db/repositories/workspace_config_repo.py @@ -5,8 +5,8 @@ to/from ``OpenRAGConfig`` is the service's job. """ -from datetime import datetime -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +21,7 @@ class WorkspaceConfigRepo: def __init__(self, session: AsyncSession): self.session = session - async def get_section(self, section: str) -> Optional[dict[str, Any]]: + async def get_section(self, section: str) -> dict[str, Any] | None: row = await self.session.get(WorkspaceConfig, section) return None if row is None else (row.value or {}) @@ -33,21 +33,21 @@ async def upsert( self, section: str, value: dict[str, Any], - actor_user_id: Optional[str] = None, + actor_user_id: str | None = None, ) -> WorkspaceConfig: existing = await self.session.get(WorkspaceConfig, section) if existing is None: row = WorkspaceConfig( section=section, value=value, - updated_at=datetime.utcnow(), + updated_at=datetime.now(UTC), updated_by=actor_user_id, ) self.session.add(row) await self.session.flush() return row existing.value = value - existing.updated_at = datetime.utcnow() + existing.updated_at = datetime.now(UTC) if actor_user_id is not None: existing.updated_by = actor_user_id self.session.add(existing) diff --git a/src/services/api_key_service.py b/src/services/api_key_service.py index 561931381..b2f568bab 100644 --- a/src/services/api_key_service.py +++ b/src/services/api_key_service.py @@ -5,7 +5,7 @@ import hashlib import hmac import secrets -from datetime import datetime +from datetime import UTC, datetime from typing import Any from config.settings import API_KEYS_INDEX_NAME @@ -113,7 +113,7 @@ async def create_key( # Create a unique key_id key_id = secrets.token_urlsafe(16) - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() # Create the document to store key_doc = { @@ -209,7 +209,7 @@ async def validate_key(self, api_key: str) -> dict[str, Any] | None: # Update last_used_at and opportunistically migrate legacy hashes. try: write_client = self._get_write_opensearch_client() - update_doc = {"last_used_at": datetime.utcnow().isoformat()} + update_doc = {"last_used_at": datetime.now(UTC).isoformat()} if matched_hash != key_hash: update_doc["key_hash"] = key_hash await write_client.update( diff --git a/src/services/auth_service.py b/src/services/auth_service.py index 929b42ac6..59fdd72c8 100644 --- a/src/services/auth_service.py +++ b/src/services/auth_service.py @@ -2,7 +2,7 @@ import logging import os import uuid -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import httpx from fastapi import HTTPException @@ -304,7 +304,7 @@ async def handle_oauth_callback( # Add expiry if provided if token_data.get("expires_in"): - expiry = datetime.utcnow() + timedelta(seconds=int(token_data["expires_in"])) + expiry = datetime.now(UTC) + timedelta(seconds=int(token_data["expires_in"])) token_file_data["expiry"] = expiry.isoformat() # Save tokens to file diff --git a/src/services/knowledge_filter_service.py b/src/services/knowledge_filter_service.py index 30f291e47..8937a041c 100644 --- a/src/services/knowledge_filter_service.py +++ b/src/services/knowledge_filter_service.py @@ -1,3 +1,4 @@ +from datetime import UTC from typing import Any KNOWLEDGE_FILTERS_INDEX_NAME = "knowledge_filters" @@ -355,7 +356,7 @@ async def remove_subscription( update_body = { "doc": { "subscriptions": updated_subscriptions, - "updated_at": datetime.utcnow().isoformat(), + "updated_at": datetime.now(UTC).isoformat(), } } diff --git a/src/services/session_ownership_service.py b/src/services/session_ownership_service.py index 73c290c3c..a0dcd25ff 100644 --- a/src/services/session_ownership_service.py +++ b/src/services/session_ownership_service.py @@ -14,11 +14,11 @@ from __future__ import annotations -import asyncio import json import os -from datetime import datetime -from typing import Any, Callable, Dict, List, Optional +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any from config.paths import get_data_file from config.storage_mode import ( @@ -34,24 +34,22 @@ class SessionOwnershipService: """Tracks which user owns which session.""" - def __init__(self, session_factory: Optional[Callable] = None): + def __init__(self, session_factory: Callable | None = None): self.ownership_file = get_data_file("session_ownership.json") os.makedirs(os.path.dirname(self.ownership_file), exist_ok=True) self._session_factory = session_factory # JSON cache — eagerly loaded so `files` and `hybrid` modes # behave like the legacy implementation. - self.ownership_data: Dict[str, Dict[str, Any]] = ( - self._load_ownership_data() - ) + self.ownership_data: dict[str, dict[str, Any]] = self._load_ownership_data() # ------------------------------------------------------------------ # JSON helpers (legacy + hybrid fallback) # ------------------------------------------------------------------ - def _load_ownership_data(self) -> Dict[str, Dict[str, Any]]: + def _load_ownership_data(self) -> dict[str, dict[str, Any]]: if os.path.exists(self.ownership_file): try: - with open(self.ownership_file, "r") as f: + with open(self.ownership_file) as f: return json.load(f) except Exception as exc: # noqa: BLE001 logger.error(f"Error loading session ownership data: {exc}") @@ -71,7 +69,7 @@ def _save_ownership_data(self) -> None: async def claim_session(self, user_id: str, session_id: str) -> None: if file_writes_enabled(): - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() if session_id not in self.ownership_data: self.ownership_data[session_id] = { "user_id": user_id, @@ -85,7 +83,7 @@ async def claim_session(self, user_id: str, session_id: str) -> None: if db_writes_enabled(): await self._db_claim(user_id, session_id) - async def get_session_owner(self, session_id: str) -> Optional[str]: + async def get_session_owner(self, session_id: str) -> str | None: mode = get_storage_mode() if mode != "files": owner = await self._db_get_owner(session_id) @@ -97,7 +95,7 @@ async def get_session_owner(self, session_id: str) -> Optional[str]: data = self.ownership_data.get(session_id) return data.get("user_id") if data else None - async def get_user_sessions(self, user_id: str) -> List[str]: + async def get_user_sessions(self, user_id: str) -> list[str]: mode = get_storage_mode() if mode != "files": db_sessions = await self._db_list_for_user(user_id) @@ -105,26 +103,16 @@ async def get_user_sessions(self, user_id: str) -> List[str]: return db_sessions # hybrid: union with JSON-only entries json_sessions = [ - sid - for sid, data in self.ownership_data.items() - if data.get("user_id") == user_id + sid for sid, data in self.ownership_data.items() if data.get("user_id") == user_id ] return list(dict.fromkeys(db_sessions + json_sessions)) # files mode - return [ - sid - for sid, data in self.ownership_data.items() - if data.get("user_id") == user_id - ] - - async def is_session_owned_by_user( - self, session_id: str, user_id: str - ) -> bool: + return [sid for sid, data in self.ownership_data.items() if data.get("user_id") == user_id] + + async def is_session_owned_by_user(self, session_id: str, user_id: str) -> bool: return (await self.get_session_owner(session_id)) == user_id - async def filter_sessions_for_user( - self, session_ids: List[str], user_id: str - ) -> List[str]: + async def filter_sessions_for_user(self, session_ids: list[str], user_id: str) -> list[str]: owned = set(await self.get_user_sessions(user_id)) return [sid for sid in session_ids if sid in owned] @@ -138,8 +126,7 @@ async def release_session(self, user_id: str, session_id: str) -> bool: released = True else: logger.warning( - f"User {user_id} tried to release session " - f"{session_id} they don't own (json)" + f"User {user_id} tried to release session {session_id} they don't own (json)" ) if db_writes_enabled(): @@ -148,14 +135,10 @@ async def release_session(self, user_id: str, session_id: str) -> bool: return released - async def get_ownership_stats(self) -> Dict[str, Any]: + async def get_ownership_stats(self) -> dict[str, Any]: mode = get_storage_mode() if mode == "files": - users = { - d.get("user_id") - for d in self.ownership_data.values() - if d.get("user_id") - } + users = {d.get("user_id") for d in self.ownership_data.values() if d.get("user_id")} return { "total_tracked_sessions": len(self.ownership_data), "unique_users": len(users), @@ -166,22 +149,23 @@ async def get_ownership_stats(self) -> Dict[str, Any]: } # db / hybrid — best-effort summary from DB only try: - from db.models import SessionOwnership from sqlalchemy import select + from db.models import SessionOwnership + sess_factory = self._resolve_session_factory() if sess_factory is None: return {"total_tracked_sessions": 0, "unique_users": 0} async with sess_factory() as session: result = await session.execute(select(SessionOwnership)) rows = result.scalars().all() - users: Dict[str, int] = {} + user_counts: dict[str, int] = {} for r in rows: - users[r.user_id] = users.get(r.user_id, 0) + 1 + user_counts[r.user_id] = user_counts.get(r.user_id, 0) + 1 return { "total_tracked_sessions": len(rows), - "unique_users": len(users), - "sessions_per_user": users, + "unique_users": len(user_counts), + "sessions_per_user": user_counts, } except Exception as exc: # noqa: BLE001 logger.warning("ownership stats DB read failed", error=str(exc)) @@ -197,6 +181,7 @@ def _resolve_session_factory(self): # Lazy: try the module-level SessionLocal try: from db.engine import SessionLocal + return SessionLocal except Exception: # noqa: BLE001 return None @@ -214,7 +199,7 @@ async def _db_claim(self, user_id: str, session_id: str) -> None: except Exception as exc: # noqa: BLE001 logger.error("DB claim_session failed", error=str(exc)) - async def _db_get_owner(self, session_id: str) -> Optional[str]: + async def _db_get_owner(self, session_id: str) -> str | None: from db.repositories import SessionOwnershipRepo sess_factory = self._resolve_session_factory() @@ -228,7 +213,7 @@ async def _db_get_owner(self, session_id: str) -> Optional[str]: logger.debug("DB get_session_owner failed", error=str(exc)) return None - async def _db_list_for_user(self, user_id: str) -> List[str]: + async def _db_list_for_user(self, user_id: str) -> list[str]: from db.repositories import SessionOwnershipRepo sess_factory = self._resolve_session_factory() diff --git a/src/session_manager.py b/src/session_manager.py index b3b85b4f0..faf963c70 100644 --- a/src/session_manager.py +++ b/src/session_manager.py @@ -2,7 +2,7 @@ import os import time from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any import httpx @@ -221,7 +221,7 @@ def _create_signed_jwt_token( expires_delta: timedelta, ) -> str: # Create JWT token with OIDC-compliant claims - now = datetime.utcnow() + now = datetime.now(UTC) roles = ["openrag_user"] token_payload = { # OIDC standard claims