pdf-quiz-generator/backend/manage.py
Daniel 871206d891 Cross-browser job tracking, hamburger fix, CLI extract, quiz delete keeps bank questions
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>
2026-04-01 02:30:47 +02:00

244 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()