Admin mode. Data driven content sections. Bugfixes.

This commit is contained in:
2026-06-19 17:36:35 +00:00
parent bd65455945
commit 266c2451f1
30 changed files with 1733 additions and 13 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Auth router: login, google OAuth, and current user info."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
create_access_token,
get_current_user,
verify_google_id_token,
)
from app.config import settings
from app.database import get_session
from app.user_models import User
router = APIRouter()
# ── Request/Response schemas ─────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class GoogleLoginRequest(BaseModel):
id_token: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfoResponse(BaseModel):
id: int
email: str
display_name: str
avatar_url: str | None
auth_provider: str
is_admin: bool
model_config = {"from_attributes": True}
# ── Routes ───────────────────────────────────────────────────
@router.post("/login", response_model=TokenResponse)
async def login(payload: LoginRequest):
"""Bootstrap admin login (plaintext credentials from env vars)."""
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Bootstrap admin not configured")
if payload.username != settings.ADMIN_USERNAME or payload.password != settings.ADMIN_PASSWORD:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# This is handled in the auth router to keep it self-contained
from app.database import async_session
async with async_session() as session:
# Find or create the bootstrap admin user
result = await session.execute(
select(User).where(
User.email == f"{settings.ADMIN_USERNAME}@local",
User.auth_provider == "local",
)
)
user = result.scalar_one_or_none()
if user is None:
user = User(
email=f"{settings.ADMIN_USERNAME}@local",
display_name=settings.ADMIN_USERNAME,
auth_provider="local",
is_admin=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
token = create_access_token(user.id, user.is_admin)
return TokenResponse(access_token=token)
@router.post("/google", response_model=TokenResponse)
async def login_with_google(payload: GoogleLoginRequest, session: AsyncSession = Depends(get_session)):
"""Google OAuth sign-in. Accepts an ID token, verifies it, upserts user, returns JWT."""
info = await verify_google_id_token(payload.id_token)
result = await session.execute(select(User).where(User.email == info["email"]))
user = result.scalar_one_or_none()
if user is None:
user = User(
email=info["email"],
display_name=info["name"],
avatar_url=info["picture"] or None,
auth_provider="google",
is_admin=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
token = create_access_token(user.id, user.is_admin)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserInfoResponse)
async def me(current_user: User = Depends(get_current_user)):
"""Return current user info."""
return current_user
+11 -2
View File
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import Event
from app.schemas import EventCreate, EventUpdate, EventResponse
from app.user_models import User
router = APIRouter()
@@ -34,7 +36,9 @@ async def get_event(event_id: int, session: AsyncSession = Depends(get_session))
@router.post("/", response_model=EventResponse, status_code=201)
async def create_event(
payload: EventCreate, session: AsyncSession = Depends(get_session)
payload: EventCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
event = Event(**payload.model_dump(), active=True)
session.add(event)
@@ -48,6 +52,7 @@ async def update_event(
event_id: int,
payload: EventUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
@@ -61,7 +66,11 @@ async def update_event(
@router.delete("/{event_id}", status_code=204)
async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)):
async def delete_event(
event_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
+11 -2
View File
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import Program
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
from app.user_models import User
router = APIRouter()
@@ -39,7 +41,9 @@ async def get_program(program_id: int, session: AsyncSession = Depends(get_sessi
@router.post("/", response_model=ProgramResponse, status_code=201)
async def create_program(
payload: ProgramCreate, session: AsyncSession = Depends(get_session)
payload: ProgramCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
program = Program(**payload.model_dump())
session.add(program)
@@ -53,6 +57,7 @@ async def update_program(
program_id: int,
payload: ProgramUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
@@ -66,7 +71,11 @@ async def update_program(
@router.delete("/{program_id}", status_code=204)
async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)):
async def delete_program(
program_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
+11 -4
View File
@@ -1,12 +1,12 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import DonationTier
from app.schemas import TierCreate, TierUpdate, TierResponse
from app.user_models import User
router = APIRouter()
@@ -30,7 +30,9 @@ async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
@router.post("/", response_model=TierResponse, status_code=201)
async def create_tier(
payload: TierCreate, session: AsyncSession = Depends(get_session)
payload: TierCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
tier = DonationTier(**payload.model_dump())
session.add(tier)
@@ -44,6 +46,7 @@ async def update_tier(
tier_id: int,
payload: TierUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
@@ -57,7 +60,11 @@ async def update_tier(
@router.delete("/{tier_id}", status_code=204)
async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
async def delete_tier(
tier_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier: