solventum-image-metadata/backend/app/core/database.py
SamoilenkoVadym 563d476a94 feat(backend): migrate from Flask to FastAPI with Redis sessions
- Create FastAPI application with async I/O
- Implement Redis session storage (fixes session loss on restart)
- Add JWT authentication with refresh tokens
- Add Microsoft SSO support via MSAL
- Copy all processors from src/ (100% reused, no changes)
- Create file upload/download endpoints
- Create metadata update endpoints
- Create template CRUD endpoints
- Add SQLAlchemy async database models
- Add Docker Compose configuration with Redis

Solves critical issues:
- Session management: Redis replaces in-memory dicts
- Scalability: Async FastAPI + microservices architecture
- File handling: Persistent storage with auto-cleanup

Key files:
- backend/app/main.py - FastAPI entry point
- backend/app/core/redis_client.py - Session store
- backend/app/core/auth.py - JWT authentication
- backend/app/api/* - All REST endpoints
- backend/app/processors/ - Reused from src/

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-02-09 13:14:37 +00:00

229 lines
7.1 KiB
Python

"""
Database Models and Session Management
Uses SQLAlchemy async ORM for database operations.
Keeps existing schema: users, audit_log tables.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Integer, Boolean, DateTime, Text, func, select
from datetime import datetime
from typing import Optional
import os
# Database URL from environment
DATABASE_URL = os.getenv(
"DATABASE_URL",
"sqlite+aiosqlite:///./oliver_metadata.db"
)
# Create async engine
engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("DEBUG") == "true", # Log SQL queries in debug mode
future=True
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False
)
# Base class for models
class Base(DeclarativeBase):
pass
# ===== Models =====
class User(Base):
"""User model - keeps existing schema from Flask app"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Nullable for SSO users
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
auth_method: Mapped[str] = mapped_column(String(20), default="local", nullable=False) # 'local' or 'sso'
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self):
"""Convert model to dict for JSON serialization"""
return {
"id": self.id,
"username": self.username,
"email": self.email,
"full_name": self.full_name,
"auth_method": self.auth_method,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
}
class AuditLog(Base):
"""Audit log model - tracks user actions"""
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
details: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
def to_dict(self):
"""Convert model to dict for JSON serialization"""
return {
"id": self.id,
"user_id": self.user_id,
"action": self.action,
"details": self.details,
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
}
# ===== Database Initialization =====
async def init_db():
"""
Initialize database - create tables if they don't exist.
Called on application startup.
"""
async with engine.begin() as conn:
# Create all tables
await conn.run_sync(Base.metadata.create_all)
# ===== Database Session Dependency =====
async def get_db() -> AsyncSession:
"""
FastAPI dependency to get database session.
Use as: db: AsyncSession = Depends(get_db)
"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
# ===== Database Helper Functions =====
class UserRepository:
"""Repository pattern for User operations"""
@staticmethod
async def get_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
"""Get user by ID"""
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
@staticmethod
async def get_by_username(db: AsyncSession, username: str) -> Optional[User]:
"""Get user by username"""
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@staticmethod
async def get_by_email(db: AsyncSession, email: str) -> Optional[User]:
"""Get user by email"""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
@staticmethod
async def create_user(
db: AsyncSession,
username: str,
password_hash: Optional[str],
email: Optional[str],
full_name: Optional[str],
auth_method: str = "local"
) -> User:
"""Create new user"""
user = User(
username=username,
password_hash=password_hash,
email=email,
full_name=full_name,
auth_method=auth_method,
is_active=True
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@staticmethod
async def update_last_login(db: AsyncSession, user_id: int):
"""Update user's last login timestamp"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user:
user.last_login = datetime.utcnow()
await db.commit()
@staticmethod
async def get_all_users(db: AsyncSession) -> list[User]:
"""Get all users"""
result = await db.execute(select(User))
return list(result.scalars().all())
class AuditLogRepository:
"""Repository pattern for AuditLog operations"""
@staticmethod
async def log_action(
db: AsyncSession,
user_id: int,
action: str,
details: Optional[str] = None
) -> AuditLog:
"""Create audit log entry"""
log_entry = AuditLog(
user_id=user_id,
action=action,
details=details
)
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
return log_entry
@staticmethod
async def get_user_activity(
db: AsyncSession,
user_id: int,
limit: int = 100
) -> list[AuditLog]:
"""Get user activity logs"""
result = await db.execute(
select(AuditLog)
.where(AuditLog.user_id == user_id)
.order_by(AuditLog.timestamp.desc())
.limit(limit)
)
return list(result.scalars().all())
@staticmethod
async def get_all_activity(
db: AsyncSession,
limit: int = 1000
) -> list[AuditLog]:
"""Get all activity logs"""
result = await db.execute(
select(AuditLog)
.order_by(AuditLog.timestamp.desc())
.limit(limit)
)
return list(result.scalars().all())