Restructure into full project layout

This commit is contained in:
2026-06-16 23:06:16 -06:00
parent de4862b2d1
commit 57765496a6
74 changed files with 4441 additions and 3 deletions
View File
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.core.security import hash_password
from app.core.config import settings
from app.models.user import User, UserRole
async def bootstrap_admin():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.email == settings.ADMIN_EMAIL))
if result.scalar_one_or_none():
return
admin = User(
email=settings.ADMIN_EMAIL,
hashed_password=hash_password(settings.ADMIN_PASSWORD),
display_name="Admin",
role=UserRole.ADMIN,
is_active=True,
)
db.add(admin)
await db.commit()
print(f"Admin user created: {settings.ADMIN_EMAIL}")
+26
View File
@@ -0,0 +1,26 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
ANTHROPIC_API_KEY: str
ANTHROPIC_MODEL: str = "claude-sonnet-4-6"
ADMIN_EMAIL: str
ADMIN_PASSWORD: str
DATABASE_URL: str
REDIS_URL: str = "redis://cache:6379"
SCRYFALL_RATE_LIMIT_RPS: int = 8
SCRYFALL_CACHE_TTL_SECONDS: int = 86400
class Config:
env_file = ".env"
settings = Settings()
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
+37
View File
@@ -0,0 +1,37 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User, UserRole
bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
db: AsyncSession = Depends(get_db),
) -> User:
user_id = decode_token(credentials.credentials)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def get_approved_user(user: User = Depends(get_current_user)) -> User:
if user.role == UserRole.PENDING:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account pending approval")
return user
async def get_admin_user(user: User = Depends(get_approved_user)) -> User:
if user.role != UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
+11
View File
@@ -0,0 +1,11 @@
import redis.asyncio as redis
from app.core.config import settings
_redis = None
async def get_redis():
global _redis
if _redis is None:
_redis = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": str(subject), "exp": expire, "type": "access"},
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
def create_refresh_token(subject: int) -> str:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode(
{"sub": str(subject), "exp": expire, "type": "refresh"},
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
def decode_token(token: str) -> Optional[int]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return int(payload["sub"])
except (JWTError, KeyError, ValueError):
return None