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:
parent
a5188c4edd
commit
871206d891
5 changed files with 238 additions and 37 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue