Jobs (cross-browser/cross-session):
- POST /quizzes/ stores job_id in Redis under user key (extraction:user_jobs:{uid})
- GET /quizzes/jobs returns all recent jobs for current user from any browser/session
- Navbar JobsBadge polls /quizzes/jobs API every 4s (not localStorage)
- Shows all recent jobs with status badges; links to quiz when complete
- Badge visible even after extraction completes so you can always get back
Mobile navbar fix:
- .navbar .container height was overriding dropdown to 52px (clipping all links)
- Fixed by using .navbar-inner class for the header row only
CLI extract command:
python manage.py list-sections [doc_id] — list docs + sections with IDs
python manage.py extract <section_id> — inline blocking extraction
python manage.py extract <section_id> --bg — background via Celery
python manage.py jobs — show all extraction jobs in Redis
python manage.py jobs --user <email> — filter by user
Quiz delete + question bank:
- When a quiz is deleted, questions that belong ONLY to that quiz are deleted
- Questions shared with other quizzes (via junction) are kept in the bank
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
244 lines
9.5 KiB
Python
244 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
||
"""PedQuiz management CLI.
|
||
|
||
Usage (inside container):
|
||
python manage.py reset-password <email> <new-password>
|
||
python manage.py list-users
|
||
python manage.py reembed # Regenerate all question embeddings
|
||
python manage.py extract <doc_id> <section_id> # Run extraction from CLI
|
||
python manage.py list-sections [doc_id] # List documents/sections
|
||
python manage.py jobs # Show recent extraction jobs
|
||
"""
|
||
import argparse
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
|
||
def reset_password(email: str, new_password: str):
|
||
from app.database import SessionLocal
|
||
from app.models.user import User
|
||
from app.utils.auth import get_password_hash
|
||
if len(new_password) < 8:
|
||
print("Error: Password must be at least 8 characters"); sys.exit(1)
|
||
db = SessionLocal()
|
||
try:
|
||
user = db.query(User).filter(User.email == email).first()
|
||
if not user:
|
||
print(f"Error: No user found with email '{email}'"); sys.exit(1)
|
||
user.hashed_password = get_password_hash(new_password)
|
||
db.commit()
|
||
print(f"Password reset for {user.email} (name={user.name!r}, role={user.role})")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def list_users():
|
||
from app.database import SessionLocal
|
||
from app.models.user import User
|
||
from app.models.email_verification import EmailVerification
|
||
db = SessionLocal()
|
||
try:
|
||
users = db.query(User).order_by(User.created_at).all()
|
||
print(f"{'ID':>4} {'Email':40} {'Role':12} {'Verified':8} Name")
|
||
print("-" * 90)
|
||
for u in users:
|
||
v = db.query(EmailVerification).filter(EmailVerification.user_id == u.id).first()
|
||
verified = "yes" if (v and v.verified_at) else "no"
|
||
print(f"{u.id:>4} {u.email:40} {u.role:12} {verified:8} {u.name}")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def reembed():
|
||
from app.database import SessionLocal
|
||
from app.models.question import Question
|
||
from app.services import embedding_service
|
||
db = SessionLocal()
|
||
try:
|
||
questions = db.query(Question).all()
|
||
print(f"Re-embedding {len(questions)} questions...")
|
||
ok = fail = 0
|
||
for q in questions:
|
||
q.embedding = None
|
||
if embedding_service.embed_question(q):
|
||
ok += 1
|
||
else:
|
||
fail += 1
|
||
db.commit()
|
||
print(f"Done: {ok} succeeded, {fail} failed")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def list_sections(doc_id=None):
|
||
from app.database import SessionLocal
|
||
from app.models.pdf_document import PDFDocument
|
||
from app.models.section import Section
|
||
db = SessionLocal()
|
||
try:
|
||
docs_q = db.query(PDFDocument)
|
||
if doc_id:
|
||
docs_q = docs_q.filter(PDFDocument.id == int(doc_id))
|
||
docs = docs_q.order_by(PDFDocument.id).all()
|
||
for doc in docs:
|
||
print(f"Doc {doc.id}: {doc.original_filename} ({doc.status}, {doc.total_pages or '?'} pages)")
|
||
sections = db.query(Section).filter(Section.document_id == doc.id).all()
|
||
if sections:
|
||
for s in sections:
|
||
print(f" Section {s.id}: {s.name!r} pages {s.start_page}–{s.end_page}")
|
||
else:
|
||
print(" (no sections defined yet)")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def extract(section_id: int, title: str, mode: str, email: str, background: bool):
|
||
"""Run quiz extraction — either in background (Celery) or inline (blocking)."""
|
||
from app.database import SessionLocal
|
||
from app.models.user import User
|
||
from app.models.section import Section
|
||
import uuid
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
section = db.query(Section).filter(Section.id == section_id).first()
|
||
if not section:
|
||
print(f"Error: Section {section_id} not found"); sys.exit(1)
|
||
|
||
user = db.query(User).filter(User.email == email).first()
|
||
if not user:
|
||
print(f"Error: User '{email}' not found"); sys.exit(1)
|
||
|
||
if not title:
|
||
title = f"Quiz: {section.name}"
|
||
|
||
print(f"Extracting from section {section_id}: {section.name!r} (pages {section.start_page}–{section.end_page})")
|
||
print(f"Title: {title!r} Mode: {mode} User: {email}")
|
||
|
||
if background:
|
||
job_id = str(uuid.uuid4())
|
||
try:
|
||
from app.tasks.quiz_tasks import extract_quiz
|
||
import redis as redis_lib
|
||
from app.config import settings
|
||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
|
||
r.set(f"extraction:job_title:{job_id}", title, ex=3600)
|
||
r.lpush(f"extraction:user_jobs:{user.id}", job_id)
|
||
r.expire(f"extraction:user_jobs:{user.id}", 86400)
|
||
extract_quiz.delay(job_id=job_id, user_id=user.id, section_id=section_id,
|
||
title=title, mode=mode, time_limit_minutes=None,
|
||
model_id=None, question_category_id=None)
|
||
print(f"\nJob started in background: {job_id}")
|
||
print(f"Monitor: python manage.py jobs")
|
||
except Exception as e:
|
||
print(f"Error dispatching to Celery: {e}")
|
||
print("Falling back to inline extraction...")
|
||
_extract_inline(db, user, section_id, title, mode)
|
||
else:
|
||
_extract_inline(db, user, section_id, title, mode)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _extract_inline(db, user, section_id, title, mode):
|
||
"""Run extraction synchronously with live output."""
|
||
from app.services.quiz_service import create_quiz_from_section
|
||
print("\nRunning extraction (this may take several minutes for large sections)...")
|
||
try:
|
||
quiz = create_quiz_from_section(
|
||
db=db, user_id=user.id, section_id=section_id,
|
||
title=title, mode=mode,
|
||
)
|
||
print(f"\nDone! Quiz created: id={quiz.id}, title={quiz.title!r}, questions={quiz.questions_count}")
|
||
except Exception as e:
|
||
print(f"\nExtraction failed: {e}")
|
||
sys.exit(1)
|
||
|
||
|
||
def show_jobs(user_email=None):
|
||
import redis, json
|
||
from app.config import settings
|
||
from app.database import SessionLocal
|
||
from app.models.user import User
|
||
|
||
r = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||
db = SessionLocal()
|
||
try:
|
||
if user_email:
|
||
user = db.query(User).filter(User.email == user_email).first()
|
||
if not user:
|
||
print(f"User not found: {user_email}"); return
|
||
users = [user]
|
||
else:
|
||
users = db.query(User).all()
|
||
|
||
for user in users:
|
||
job_ids = r.lrange(f"extraction:user_jobs:{user.id}", 0, 9)
|
||
if not job_ids:
|
||
continue
|
||
print(f"\n{user.email}:")
|
||
for jid in job_ids:
|
||
status = r.get(f"extraction:status:{jid}") or "expired"
|
||
title = r.get(f"extraction:job_title:{jid}") or "?"
|
||
steps = r.lrange(f"extraction:steps:{jid}", 0, -1)
|
||
quiz_id = r.get(f"extraction:quiz_id:{jid}")
|
||
last = json.loads(steps[-1]).get("message", "") if steps else ""
|
||
print(f" [{status:10}] {title[:40]:40} steps={len(steps):3}"
|
||
+ (f" quiz_id={quiz_id}" if quiz_id else "")
|
||
+ (f"\n {last[:80]}" if last else ""))
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser(description="PedQuiz management CLI")
|
||
sub = parser.add_subparsers(dest="command")
|
||
|
||
rp = sub.add_parser("reset-password", help="Reset a user password")
|
||
rp.add_argument("email"); rp.add_argument("password")
|
||
|
||
sub.add_parser("list-users", help="List all users")
|
||
sub.add_parser("reembed", help="Regenerate all question embeddings")
|
||
|
||
ls = sub.add_parser("list-sections", help="List documents and sections")
|
||
ls.add_argument("doc_id", nargs="?", help="Optional: filter by document ID")
|
||
|
||
ex = sub.add_parser("extract", help="Run quiz extraction from a section")
|
||
ex.add_argument("section_id", type=int, help="Section ID to extract from")
|
||
ex.add_argument("--title", default="", help="Quiz title (default: auto)")
|
||
ex.add_argument("--mode", default="timed", choices=["timed", "learning"])
|
||
ex.add_argument("--user", default="", help="User email to assign quiz to (default: first admin)")
|
||
ex.add_argument("--bg", action="store_true", help="Run in background via Celery")
|
||
|
||
jb = sub.add_parser("jobs", help="Show recent extraction jobs")
|
||
jb.add_argument("--user", default=None, help="Filter by user email")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "reset-password":
|
||
reset_password(args.email, args.password)
|
||
elif args.command == "list-users":
|
||
list_users()
|
||
elif args.command == "reembed":
|
||
reembed()
|
||
elif args.command == "list-sections":
|
||
list_sections(args.doc_id)
|
||
elif args.command == "extract":
|
||
email = args.user
|
||
if not email:
|
||
from app.database import SessionLocal
|
||
from app.models.user import User
|
||
db = SessionLocal()
|
||
admin = db.query(User).filter(User.role == "admin").first()
|
||
email = admin.email if admin else ""
|
||
db.close()
|
||
if not email:
|
||
print("Error: No admin user found. Use --user <email>"); sys.exit(1)
|
||
extract(args.section_id, args.title, args.mode, email, args.bg)
|
||
elif args.command == "jobs":
|
||
show_jobs(args.user)
|
||
else:
|
||
parser.print_help()
|