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
+98
View File
@@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_admin_user
from app.models.user import User, UserRole
from app.schemas.user import UserOut, AdminUserUpdate
router = APIRouter()
@router.get("/queue", response_model=list[UserOut])
async def get_queue(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
result = await db.execute(select(User).where(User.role == UserRole.PENDING))
return result.scalars().all()
@router.get("/users", response_model=list[UserOut])
async def list_users(
role: str | None = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
query = select(User)
if role:
query = query.where(User.role == role)
result = await db.execute(query)
return result.scalars().all()
@router.post("/users/{user_id}/approve", response_model=UserOut)
async def approve_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = UserRole.APPROVED
await db.commit()
await db.refresh(user)
return user
@router.post("/users/{user_id}/deny", response_model=UserOut)
async def deny_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = False
await db.commit()
await db.refresh(user)
return user
@router.patch("/users/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
data: AdminUserUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=204)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_admin_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await db.delete(user)
await db.commit()
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.models.user import User, UserRole
from app.schemas.user import UserRegister, UserLogin, TokenOut, RefreshRequest, UserOut
router = APIRouter()
@router.post("/register", response_model=UserOut, status_code=201)
async def register(data: UserRegister, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == data.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=data.email,
hashed_password=hash_password(data.password),
display_name=data.display_name,
role=UserRole.PENDING,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=TokenOut)
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account deactivated")
return TokenOut(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenOut)
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
user_id = decode_token(data.refresh_token)
if not user_id:
raise HTTPException(status_code=401, detail="Invalid refresh 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=401, detail="User not found")
return TokenOut(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
+129
View File
@@ -0,0 +1,129 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from app.core.database import get_db
from app.core.deps import get_approved_user
from app.models.user import User
from app.models.collection import CollectionCard
from app.schemas.collection import CollectionCardOut, CollectionStatsOut, PaginatedCollection
from app.services.imports import archidekt, manabox
from app.services.imports.enrichment import enrich_and_upsert
router = APIRouter()
@router.post("/import/archidekt", status_code=201)
async def import_archidekt(
file: UploadFile = File(...),
replace: bool = False,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
raw = await file.read()
rows = archidekt.parse(raw)
if replace:
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
await db.commit()
imported = await enrich_and_upsert(rows, user.id, db)
return {"imported": imported}
@router.post("/import/manabox", status_code=201)
async def import_manabox(
file: UploadFile = File(...),
replace: bool = False,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
raw = await file.read()
rows = manabox.parse(raw)
if replace:
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
await db.commit()
imported = await enrich_and_upsert(rows, user.id, db)
return {"imported": imported}
@router.get("/", response_model=PaginatedCollection)
async def list_collection(
search: str = "",
page: int = 1,
page_size: int = 50,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
query = select(CollectionCard).where(CollectionCard.owner_id == user.id)
if search:
query = query.where(CollectionCard.card_name.ilike(f"%{search}%"))
total_result = await db.execute(
select(func.count()).select_from(query.subquery())
)
total = total_result.scalar_one()
result = await db.execute(
query.order_by(CollectionCard.card_name)
.offset((page - 1) * page_size)
.limit(page_size)
)
items = result.scalars().all()
return PaginatedCollection(
items=items, total=total, page=page, page_size=page_size,
pages=max(1, -(-total // page_size)),
)
@router.get("/stats", response_model=CollectionStatsOut)
async def collection_stats(
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CollectionCard).where(CollectionCard.owner_id == user.id)
)
cards = result.scalars().all()
total = sum(c.quantity + c.foil_quantity for c in cards)
unique = len(cards)
foil = sum(c.foil_quantity for c in cards)
value = None
prices = []
for c in cards:
if c.scryfall_data:
p = c.scryfall_data.get("prices", {}).get("usd")
if p:
prices.append(float(p) * (c.quantity + c.foil_quantity))
if prices:
value = sum(prices)
return CollectionStatsOut(
total_cards=total, unique_cards=unique, foil_cards=foil, estimated_value=value
)
@router.delete("/", status_code=204)
async def clear_collection(
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
await db.commit()
@router.delete("/{card_id}", status_code=204)
async def delete_card(
card_id: int,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CollectionCard).where(CollectionCard.id == card_id, CollectionCard.owner_id == user.id)
)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
await db.delete(card)
await db.commit()
+110
View File
@@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.deps import get_approved_user
from app.models.user import User
from app.models.deck import Deck, DeckCard
from app.schemas.deck import DeckOut, DeckSummaryOut, GenerateRequest, CompleteRequest, CullRequest
from app.services.ai.deck_service import generate_deck, complete_deck, cull_deck
router = APIRouter()
@router.post("/generate", response_model=DeckOut, status_code=201)
async def api_generate(
req: GenerateRequest,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
return await generate_deck(req, user.id, db)
@router.post("/complete", response_model=DeckOut, status_code=201)
async def api_complete(
req: CompleteRequest,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
return await complete_deck(req, user.id, db)
@router.post("/cull", response_model=DeckOut, status_code=201)
async def api_cull(
req: CullRequest,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
return await cull_deck(req, user.id, db)
@router.get("/", response_model=dict)
async def list_decks(
page: int = 1,
page_size: int = 20,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(
select(func.count(Deck.id)).where(Deck.owner_id == user.id)
)
total = total_result.scalar_one()
result = await db.execute(
select(Deck)
.where(Deck.owner_id == user.id)
.order_by(Deck.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
decks = result.scalars().all()
items = []
for deck in decks:
count_result = await db.execute(
select(func.count(DeckCard.id)).where(DeckCard.deck_id == deck.id)
)
items.append(DeckSummaryOut(
id=deck.id, name=deck.name, commander=deck.commander,
mode=deck.mode, playstyle=deck.playstyle, created_at=deck.created_at,
card_count=count_result.scalar_one(),
))
return {
"items": [i.model_dump() for i in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": max(1, -(-total // page_size)),
}
@router.get("/{deck_id}", response_model=DeckOut)
async def get_deck(
deck_id: int,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Deck).where(Deck.id == deck_id, Deck.owner_id == user.id))
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
cards_result = await db.execute(select(DeckCard).where(DeckCard.deck_id == deck_id))
deck.cards = cards_result.scalars().all()
return deck
@router.delete("/{deck_id}", status_code=204)
async def delete_deck(
deck_id: int,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Deck).where(Deck.id == deck_id, Deck.owner_id == user.id))
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
await db.delete(deck)
await db.commit()
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_approved_user
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserOut, UserUpdate
router = APIRouter()
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_approved_user)):
return user
@router.patch("/me", response_model=UserOut)
async def update_me(
data: UserUpdate,
user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
if data.display_name is not None:
user.display_name = data.display_name
if data.password:
user.hashed_password = hash_password(data.password)
await db.commit()
await db.refresh(user)
return user