- 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>
229 lines
7.1 KiB
Python
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())
|