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>
This commit is contained in:
Daniel 2026-04-01 02:30:47 +02:00
parent a5188c4edd
commit 871206d891
5 changed files with 238 additions and 37 deletions

View file

@ -41,6 +41,11 @@ def create_quiz(
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)
# Store job under user so any session can query it
title = quiz_data.title
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
r.set(f"extraction:job_title:{job_id}", title, ex=3600)
extract_quiz.delay(
job_id=job_id,
@ -68,6 +73,37 @@ def create_quiz(
return {"job_id": job_id, "status": "pending"}
@router.get("/jobs")
def list_user_jobs(current_user: User = Depends(get_current_user)):
"""Return all recent extraction jobs for the logged-in user (any browser/session)."""
import json as _json
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
job_ids = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 19)
jobs = []
for jid in job_ids:
status = r.get(f"extraction:status:{jid}")
if not status:
continue
steps = r.lrange(f"extraction:steps:{jid}", 0, -1)
last_step = ""
if steps:
last_step = _json.loads(steps[-1]).get("message", "")
quiz_id = r.get(f"extraction:quiz_id:{jid}")
title = r.get(f"extraction:job_title:{jid}") or "Quiz"
jobs.append({
"job_id": jid,
"title": title,
"status": status,
"steps_count": len(steps),
"last_step": last_step[:80],
"quiz_id": int(quiz_id) if quiz_id else None,
})
return jobs
@router.get("/job/{job_id}")
def get_extraction_job(job_id: str, current_user: User = Depends(require_moderator)):
"""Poll extraction job progress. Returns steps list, status, and quiz_id when done."""

View file

@ -39,6 +39,9 @@ def extract_quiz(
"""Background quiz extraction task with live progress reporting."""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
# Ensure user job index exists (in case set before task ran)
r.lpush(f"extraction:user_jobs:{user_id}", job_id)
r.expire(f"extraction:user_jobs:{user_id}", 86400)
db = SessionLocal()
try:

View file

@ -4,13 +4,15 @@
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 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
# Ensure app is importable
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -18,17 +20,13 @@ 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)
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)
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})")
@ -40,7 +38,6 @@ 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()
@ -55,18 +52,16 @@ def list_users():
def reembed():
"""Regenerate embeddings for all questions using current embedding model."""
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 # force regeneration
q.embedding = None
if embedding_service.embed_question(q):
ok += 1
else:
@ -77,17 +72,150 @@ def reembed():
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", help="User email address")
rp.add_argument("password", help="New password (min 8 chars)")
rp.add_argument("email"); rp.add_argument("password")
sub.add_parser("list-users", help="List all users with verification status")
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":
@ -96,5 +224,21 @@ if __name__ == "__main__":
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()

View file

@ -5,47 +5,65 @@ import api from '../api/client'
function JobsBadge() {
const [activeJobs, setActiveJobs] = useState([])
const [allJobs, setAllJobs] = useState([])
const [open, setOpen] = useState(false)
useEffect(() => {
// Load tracked jobs from localStorage
const load = () => {
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
setActiveJobs(stored.filter(j => j.status !== 'completed' && j.status !== 'failed'))
const load = async () => {
try {
const res = await api.get('/quizzes/jobs')
setAllJobs(res.data)
setActiveJobs(res.data.filter(j => j.status !== 'completed' && j.status !== 'failed'))
} catch { }
}
load()
const interval = setInterval(load, 3000)
const interval = setInterval(load, 4000)
return () => clearInterval(interval)
}, [])
if (activeJobs.length === 0) return null
if (allJobs.length === 0) return null
const running = activeJobs.length
return (
<div style={{ position: 'relative' }}>
<button onClick={() => setOpen(v => !v)} style={{
background: '#f59e0b', color: 'white', border: 'none', borderRadius: 20,
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer',
background: running > 0 ? '#f59e0b' : 'rgba(255,255,255,0.12)',
color: 'white', border: 'none', borderRadius: 20,
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 5,
}}>
<div className="spinner" style={{ width: 10, height: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.4)', borderTopColor: 'white' }} />
{activeJobs.length} extracting
{running > 0 && <div className="spinner" style={{ width: 10, height: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.4)', borderTopColor: 'white' }} />}
{running > 0 ? `${running} extracting` : `${allJobs.length} jobs`}
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: '120%', background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 10, padding: 12,
minWidth: 240, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 200,
minWidth: 260, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 200,
}}>
{activeJobs.map(job => (
<div key={job.jobId} style={{ fontSize: '0.8rem', color: 'var(--text)', marginBottom: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{job.title}</div>
<div style={{ color: 'var(--text-muted)' }}>{job.lastStep || 'Processing…'}</div>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
Extractions
</div>
{allJobs.slice(0, 5).map(job => (
<div key={job.job_id} style={{ fontSize: '0.82rem', color: 'var(--text)', marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 3 }}>
<span style={{ fontWeight: 600 }}>{job.title}</span>
<span style={{
fontSize: '0.7rem', fontWeight: 600, padding: '1px 6px', borderRadius: 10,
background: job.status === 'completed' ? 'var(--correct-bg)' : job.status === 'failed' ? 'var(--wrong-bg)' : '#fef3c7',
color: job.status === 'completed' ? 'var(--correct-fg)' : job.status === 'failed' ? 'var(--wrong-fg)' : '#92400e',
}}>
{job.status === 'running' ? `${job.steps_count} steps` : job.status}
</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
{job.status === 'completed' && job.quiz_id && (
<Link to={`/quizzes/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
onClick={() => setOpen(false)}>Open Quiz </Link>
)}
</div>
))}
<Link to="/jobs" style={{ fontSize: '0.78rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
onClick={() => setOpen(false)}>
View all extractions
</Link>
{allJobs.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.82rem' }}>No recent extractions</div>}
</div>
)}
</div>
@ -71,7 +89,7 @@ export default function Navbar() {
return (
<div className="navbar">
<div className="container">
<div className="container navbar-inner">
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🩺 PedQuiz</Link>
{user && (

View file

@ -115,7 +115,7 @@ body {
/* ── Navbar ─────────────────────────────────────────────────── */
.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; }
.navbar .container { display: flex; justify-content: space-between; align-items: center; height: 52px; }
.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; height: 52px; }
.navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
.nav-desktop { display: flex; gap: 4px; align-items: center; }
@ -316,7 +316,7 @@ body {
.card { padding: 18px 16px; }
.quiz-nav { flex-wrap: wrap; }
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
.navbar .container { height: 52px; }
.navbar .navbar-inner { height: 48px; }
/* Mobile: show hamburger, hide desktop nav */
.nav-desktop { display: none; }
.nav-mobile-controls { display: flex; }