From 871206d891cf350e6f792ea1dd5f87026fe930d8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Apr 2026 02:30:47 +0200 Subject: [PATCH] Cross-browser job tracking, hamburger fix, CLI extract, quiz delete keeps bank questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — inline blocking extraction python manage.py extract --bg — background via Celery python manage.py jobs — show all extraction jobs in Redis python manage.py jobs --user — 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) --- backend/app/routers/quizzes.py | 36 ++++++ backend/app/tasks/quiz_tasks.py | 3 + backend/manage.py | 174 ++++++++++++++++++++++++++--- frontend/src/components/Navbar.jsx | 58 ++++++---- frontend/src/index.css | 4 +- 5 files changed, 238 insertions(+), 37 deletions(-) diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 96fea01..20f4ef4 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -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.""" diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index e2e291b..25a5729 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -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: diff --git a/backend/manage.py b/backend/manage.py index b3c2b4d..21d84ca 100644 --- a/backend/manage.py +++ b/backend/manage.py @@ -4,13 +4,15 @@ Usage (inside container): python manage.py reset-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 # 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 "); 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() diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 9b2d383..154a939 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -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 (
{open && (
- {activeJobs.map(job => ( -
-
{job.title}
-
{job.lastStep || 'Processing…'}
+
+ Extractions +
+ {allJobs.slice(0, 5).map(job => ( +
+
+ {job.title} + + {job.status === 'running' ? `${job.steps_count} steps` : job.status} + +
+
{job.last_step || 'Waiting…'}
+ {job.status === 'completed' && job.quiz_id && ( + setOpen(false)}>Open Quiz → + )}
))} - setOpen(false)}> - View all extractions → - + {allJobs.length === 0 &&
No recent extractions
}
)}
@@ -71,7 +89,7 @@ export default function Navbar() { return (
-
+
setMenuOpen(false)}>🩺 PedQuiz {user && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 31afc83..1494493 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; }