Add super admin panel and update README
- Admin account bootstrapped from ADMIN_EMAIL/ADMIN_PASSWORD env vars on startup - Admin panel: list users, view content, reset passwords, disable/delete accounts - is_admin and is_disabled columns on users table - Disabled accounts blocked at login - README updated with admin setup instructions and panel docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,3 +45,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
if not user:
|
||||
raise exc
|
||||
return user
|
||||
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -7,10 +8,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from database import SessionLocal
|
||||
from models import Settings, NotificationLog
|
||||
from models import Settings, NotificationLog, User
|
||||
from routers import varieties, batches, dashboard, settings, notifications
|
||||
from routers import auth as auth_router
|
||||
from routers import auth as auth_router, admin as admin_router
|
||||
from routers.notifications import build_daily_summary, send_ntfy
|
||||
from auth import hash_password
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("sproutly")
|
||||
@@ -49,8 +51,37 @@ def get_notification_schedule(db) -> tuple[int, int]:
|
||||
return 7, 0
|
||||
|
||||
|
||||
def bootstrap_admin(retries: int = 5, delay: float = 2.0):
|
||||
import time
|
||||
admin_email = os.environ.get("ADMIN_EMAIL")
|
||||
admin_password = os.environ.get("ADMIN_PASSWORD")
|
||||
if not admin_email or not admin_password:
|
||||
return
|
||||
for attempt in range(retries):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin = db.query(User).filter(User.email == admin_email).first()
|
||||
if admin:
|
||||
admin.hashed_password = hash_password(admin_password)
|
||||
admin.is_admin = True
|
||||
admin.is_disabled = False
|
||||
else:
|
||||
admin = User(email=admin_email, hashed_password=hash_password(admin_password), is_admin=True)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
logger.info(f"Admin user ready: {admin_email}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Admin bootstrap attempt {attempt + 1} failed: {e}")
|
||||
time.sleep(delay)
|
||||
finally:
|
||||
db.close()
|
||||
logger.error("Admin bootstrap failed after all retries")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
bootstrap_admin()
|
||||
db = SessionLocal()
|
||||
hour, minute = get_notification_schedule(db)
|
||||
db.close()
|
||||
@@ -77,6 +108,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(admin_router.router)
|
||||
app.include_router(varieties.router)
|
||||
app.include_router(batches.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
@@ -41,6 +41,8 @@ class User(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False)
|
||||
is_disabled = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
88
backend/routers/admin.py
Normal file
88
backend/routers/admin.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from auth import get_admin_user, hash_password
|
||||
from database import get_db
|
||||
from models import Batch, User, Variety
|
||||
from schemas import AdminResetPassword, AdminUserOut, BatchOut, VarietyOut
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _user_stats(db: Session, user_id: int) -> dict:
|
||||
return {
|
||||
"variety_count": db.query(Variety).filter(Variety.user_id == user_id).count(),
|
||||
"batch_count": db.query(Batch).filter(Batch.user_id == user_id).count(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[AdminUserOut])
|
||||
def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||
users = db.query(User).order_by(User.created_at).all()
|
||||
return [
|
||||
AdminUserOut(
|
||||
id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled,
|
||||
created_at=u.created_at, **_user_stats(db, u.id)
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/varieties", response_model=List[VarietyOut])
|
||||
def user_varieties(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||
_require_user(db, user_id)
|
||||
return db.query(Variety).filter(Variety.user_id == user_id).order_by(Variety.category, Variety.name).all()
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/batches", response_model=List[BatchOut])
|
||||
def user_batches(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||
from sqlalchemy.orm import joinedload
|
||||
_require_user(db, user_id)
|
||||
return (
|
||||
db.query(Batch)
|
||||
.options(joinedload(Batch.variety))
|
||||
.filter(Batch.user_id == user_id)
|
||||
.order_by(Batch.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
def reset_password(user_id: int, data: AdminResetPassword, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
user = _require_user(db, user_id)
|
||||
if not data.new_password or len(data.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
user.hashed_password = hash_password(data.new_password)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/disable")
|
||||
def toggle_disable(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
user = _require_user(db, user_id)
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot disable your own account")
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot disable another admin account")
|
||||
user.is_disabled = not user.is_disabled
|
||||
db.commit()
|
||||
return {"is_disabled": user.is_disabled}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=204)
|
||||
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
user = _require_user(db, user_id)
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete another admin account")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _require_user(db: Session, user_id: int) -> User:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
@@ -46,6 +46,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == data.email).first()
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
if user.is_disabled:
|
||||
raise HTTPException(status_code=403, detail="Account has been disabled")
|
||||
return {"access_token": create_access_token(user.id), "token_type": "bearer"}
|
||||
|
||||
|
||||
|
||||
@@ -13,18 +13,34 @@ class UserCreate(BaseModel):
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
is_admin: bool = False
|
||||
is_disabled: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
is_admin: bool
|
||||
is_disabled: bool
|
||||
created_at: Optional[datetime]
|
||||
variety_count: int
|
||||
batch_count: int
|
||||
|
||||
|
||||
class AdminResetPassword(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
Reference in New Issue
Block a user