diff --git a/ADMIN.md b/ADMIN.md new file mode 100644 index 0000000..334050f --- /dev/null +++ b/ADMIN.md @@ -0,0 +1,179 @@ +# PedsHub — Admin Guide + +## Deployment + +```bash +# Start everything +docker compose up -d + +# Rebuild after code changes (restart alone won't pick up changes) +docker compose build --no-cache backend celery frontend +docker compose up -d backend celery frontend --force-recreate + +# View logs +docker compose logs backend --tail=50 +docker compose logs celery --tail=50 +docker compose logs frontend --tail=50 +``` + +## CLI Management Tool + +All commands run inside the backend container: + +```bash +docker compose exec backend python -m app.cli +``` + +### User Management + +```bash +# List all users +docker compose exec backend python -m app.cli list-users + +# Reset a user's password +docker compose exec backend python -m app.cli reset-password user@example.com newpassword + +# Change user role (admin / moderator / user) +docker compose exec backend python -m app.cli set-role user@example.com moderator + +# Force-verify a user's email (skip email confirmation) +docker compose exec backend python -m app.cli verify-email user@example.com + +# Delete a user (interactive confirmation) +docker compose exec backend python -m app.cli delete-user user@example.com + +# Export users to CSV +docker compose exec backend python -m app.cli export-users > users.csv +``` + +### Documents + +```bash +# List all documents with status +docker compose exec backend python -m app.cli list-docs + +# Fix documents stuck in 'processing' (>30 min) +docker compose exec backend python -m app.cli fix-stuck-docs + +# Requeue a specific document for processing +docker compose exec backend python -m app.cli reprocess-doc 32 +``` + +**Why documents get stuck:** If the Celery worker restarts while processing a PDF, the task is lost and the document stays at "processing" forever. `fix-stuck-docs` resets these to "ready" so they can be reprocessed. + +### Courses & Quizzes + +```bash +# List all courses +docker compose exec backend python -m app.cli list-courses + +# List quizzes (active only) +docker compose exec backend python -m app.cli list-quizzes + +# List all quizzes including deleted +docker compose exec backend python -m app.cli list-quizzes --all +``` + +### Maintenance + +```bash +# Platform statistics +docker compose exec backend python -m app.cli stats + +# Clean up orphaned Redis keys (quiz progress with no expiry) +docker compose exec backend python -m app.cli cleanup-redis +``` + +## Database + +### Direct Access + +```bash +docker compose exec postgres psql -U pedquiz -d pedquiz +``` + +### Backups + +Automated daily backups run via the `db-backup` service (prodrigestivill/postgres-backup-local). Retention: 14 daily, 4 weekly, 6 monthly. Stored in `./backups/`. + +```bash +# Check backup status +docker compose logs db-backup --tail=10 + +# List backups +ls -la backups/ + +# Manual backup +docker compose exec postgres pg_dump -U pedquiz pedquiz > manual-backup.sql + +# Restore from backup +cat backups/daily/pedquiz-YYYYMMDD-HHMMSS.sql.gz | gunzip | \ + docker compose exec -T postgres psql -U pedquiz -d pedquiz +``` + +## User Roles + +| Role | Capabilities | +|------|-------------| +| `user` | Take quizzes, study flashcards, enroll in courses, create quizzes from question bank | +| `moderator` | All user abilities + create courses, upload PDFs, manage documents | +| `admin` | All moderator abilities + admin dashboard, user management, model config, system settings | + +## Common Issues + +### Document stuck in "processing" +```bash +docker compose exec backend python -m app.cli fix-stuck-docs +``` +Cause: Celery worker restarted mid-task. The fix resets status to "ready". + +### DDL race condition on startup +One or two backend workers may fail on startup with `Application startup failed`. This is normal — multiple uvicorn workers race to run ALTER TABLE migrations, and losers get a deadlock error. The surviving workers handle all traffic. Check with: +```bash +docker compose logs backend --tail=10 | grep "startup complete" +``` + +### Celery task not running +```bash +# Check Celery is connected +docker compose logs celery --tail=5 + +# Check if tasks are registered +docker compose exec celery celery -A app.tasks inspect registered + +# Check active tasks (don't restart if tasks are running!) +docker compose exec celery celery -A app.tasks inspect active +``` + +### Password reset without email +```bash +docker compose exec backend python -m app.cli reset-password user@example.com newpassword +``` + +### User can't log in (unverified email) +```bash +docker compose exec backend python -m app.cli verify-email user@example.com +``` + +## Environment + +Key settings in `backend/.env`: + +| Variable | Purpose | +|----------|---------| +| `APP_URL` | Base URL for email links (e.g. `https://pedshub.com`) | +| `SECRET_KEY` | JWT signing key — change in production | +| `MAIL_FROM` | Sender email for verification/reset emails | +| `LITELLM_API_BASE` | LiteLLM proxy URL for AI features | +| `LITELLM_API_KEY` | API key for LiteLLM proxy | +| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection | +| `BBB_SERVER_URL` / `BBB_SECRET` | BigBlueButton integration for live sessions | + +## Security Notes + +- Passwords are hashed with **bcrypt** (one-way, irreversible) +- Registration checks passwords against **Have I Been Pwned** breach database (warns, doesn't block) +- Only the first 5 characters of the SHA-1 hash are sent to HIBP (k-anonymity) +- JWT tokens auto-refresh via sliding expiration (12h age or <1h remaining) +- Rate limiting on login (10 attempts per IP per 15 min) via Redis +- Email verification required for new accounts diff --git a/backend/app/cli.py b/backend/app/cli.py index ba09e56..f5e4e49 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -11,6 +11,9 @@ Commands: stats Show platform statistics list-courses List all courses with status list-quizzes List quizzes (--all includes deleted) + list-docs List all documents with status + fix-stuck-docs Reset stuck 'processing' docs to 'ready' + reprocess-doc Requeue a document for processing cleanup-redis Clear expired/orphaned Redis keys export-users Export users to CSV """ @@ -192,6 +195,72 @@ def list_quizzes(show_all=False): db.close() +def list_docs(): + from app.models.pdf_document import PDFDocument + db = SessionLocal() + try: + docs = db.query(PDFDocument).order_by(PDFDocument.id).all() + print(f"{'ID':<5} {'Filename':<45} {'Pages':<7} {'Status':<12} {'Uploaded':<12}") + print("-" * 81) + for d in docs: + pages = str(d.total_pages) if d.total_pages else "-" + date = d.uploaded_at.strftime("%Y-%m-%d") if d.uploaded_at else "-" + fname = (d.original_filename or "-")[:44] + print(f"{d.id:<5} {fname:<45} {pages:<7} {d.status:<12} {date:<12}") + if d.status == "error" and d.error_message: + print(f" Error: {d.error_message[:70]}") + print(f"\n{len(docs)} document(s)") + finally: + db.close() + + +def fix_stuck_docs(): + from datetime import datetime, timedelta + from app.models.pdf_document import PDFDocument + db = SessionLocal() + try: + # Documents stuck in 'processing' for more than 30 minutes + cutoff = datetime.utcnow() - timedelta(minutes=30) + stuck = db.query(PDFDocument).filter( + PDFDocument.status == "processing", + PDFDocument.uploaded_at < cutoff, + ).all() + if not stuck: + print("No stuck documents found.") + return + for d in stuck: + d.status = "ready" + d.error_message = "Reset by admin — was stuck in processing" + print(f" Reset doc {d.id}: {d.original_filename}") + db.commit() + print(f"\nReset {len(stuck)} stuck document(s) to 'ready'") + finally: + db.close() + + +def reprocess_doc(doc_id: str): + from app.models.pdf_document import PDFDocument + db = SessionLocal() + try: + doc = db.query(PDFDocument).filter(PDFDocument.id == int(doc_id)).first() + if not doc: + print(f"Error: No document with id {doc_id}") + return False + print(f"Document: {doc.original_filename} (status: {doc.status})") + if doc.status == "processing": + print("Warning: Document is currently processing. Resetting first.") + doc.status = "processing" + doc.error_message = None + db.commit() + # Queue the Celery task + from app.tasks.pdf_tasks import process_pdf + process_pdf.delay(doc.id) + print(f"Queued for reprocessing (Celery task dispatched)") + return True + finally: + db.close() + + def cleanup_redis(): from app.config import settings import redis as redis_lib @@ -235,6 +304,9 @@ COMMANDS = { "stats": (stats, 0), "list-courses": (list_courses, 0), "list-quizzes": (lambda args: list_quizzes("--all" in args), 0), + "list-docs": (list_docs, 0), + "fix-stuck-docs": (fix_stuck_docs, 0), + "reprocess-doc": (lambda args: reprocess_doc(args[0]), 1), "cleanup-redis": (cleanup_redis, 0), "export-users": (export_users, 0), } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 82dcfcd..78a5a2b 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -33,7 +33,10 @@ function ProfileSection({ user }) { if (!Object.keys(payload).length) return setError('No changes to save') setLoading(true) try { - await api.put('/auth/me', payload) + const res = await api.put('/auth/me', payload) + if (res.data.password_warning) { + setError(res.data.password_warning) + } setSuccess('Saved successfully') setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') if (payload.name) setTimeout(() => window.location.reload(), 800)