Add admin guide, document CLI commands, consolidate account into settings
- Add list-docs, fix-stuck-docs, reprocess-doc CLI commands - Create ADMIN.md with deployment, CLI, database, and troubleshooting docs - Add HIBP warning to Settings page password change - Remove separate Account nav link (already in Settings) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8137d5b20c
commit
affd2fc116
3 changed files with 255 additions and 1 deletions
179
ADMIN.md
Normal file
179
ADMIN.md
Normal file
|
|
@ -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 <command>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
|
@ -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 <id> 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue