From 9af63e67b5b4b9d24663d4cc54a22f2f077ba137 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 31 Mar 2026 20:08:05 +0200 Subject: [PATCH] Major: categories, question bank, security fixes, mobile layout, UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Nginx: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, Referrer-Policy, Permissions-Policy headers - Redis-backed login rate limiting (survives container restarts) - Admin litellm/models endpoint: api_key moved from GET query param to POST body - Nextcloud credentials moved from localStorage to sessionStorage (cleared on tab close) UX / Layout: - Login: unverified users see inline "Resend verification email" option - QuizPage mobile: TTS Listen button on its own row below question text - QuizPage mobile: Voice selector on its own row in header card (not squashed with timer) - QuizEditPage: scroll position preserved after saving a question edit Quiz Categories: - New QuizCategory model + quiz_categories table - category_id column added to quizzes table - GET/POST/DELETE /api/categories endpoints - Quizzes grouped by category in QuizzesPage; moderators can assign via 🏷 menu - Uncategorized section shown when categories exist Question Bank: - GET /api/questions/bank β€” search all questions across quizzes - POST /api/questions/from-bank β€” create new quiz from selected questions (copies, originals untouched) - QuestionBankPage: search, checkbox select, study modal, create quiz form - "Question Bank" link added to Navbar Search: - "View all N questions β†’" button expands to full question list - Each question has a Study button opening in-place modal with study mode - Summary view shows 2 questions per quiz with Study button Extraction prompt: - Stronger emphasis on correct_answer field with step-by-step letter β†’ full text example - Explicit instruction never to store just the letter Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/main.py | 27 +- backend/app/models/quiz.py | 3 + backend/app/models/quiz_category.py | 15 + backend/app/routers/admin.py | 15 +- backend/app/routers/auth.py | 26 +- backend/app/routers/categories.py | 89 +++++ backend/app/routers/questions.py | 128 +++++++ backend/app/schemas/quiz.py | 1 + backend/app/services/ai_service.py | 20 +- frontend/nginx.conf | 8 + frontend/src/App.jsx | 2 + frontend/src/components/Navbar.jsx | 1 + frontend/src/pages/LoginPage.jsx | 42 ++- frontend/src/pages/QuestionBankPage.jsx | 251 ++++++++++++++ frontend/src/pages/QuizEditPage.jsx | 3 + frontend/src/pages/QuizPage.jsx | 57 ++-- frontend/src/pages/QuizzesPage.jsx | 428 ++++++++++++++++-------- frontend/src/pages/SettingsPage.jsx | 18 +- frontend/src/pages/UploadPage.jsx | 8 +- 19 files changed, 926 insertions(+), 216 deletions(-) create mode 100644 backend/app/models/quiz_category.py create mode 100644 backend/app/routers/categories.py create mode 100644 backend/app/routers/questions.py create mode 100644 frontend/src/pages/QuestionBankPage.jsx diff --git a/backend/app/main.py b/backend/app/main.py index 77582c8..87e383c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings from app.database import engine, Base, SessionLocal -from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud +from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -119,19 +119,30 @@ def seed_default_models(): def setup_pgvector(): - """Enable pgvector extension and add embedding column if missing.""" + """Enable pgvector, add new columns/tables, run schema migrations.""" from sqlalchemy import text + # Import new model so create_all picks it up + from app.models import quiz_category # noqa with engine.connect() as conn: conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) - conn.execute(text(""" - ALTER TABLE questions - ADD COLUMN IF NOT EXISTS embedding vector(1024) - """)) - # HNSW index for fast cosine similarity β€” created only if not exists + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)")) conn.execute(text(""" CREATE INDEX IF NOT EXISTS questions_embedding_hnsw ON questions USING hnsw (embedding vector_cosine_ops) """)) + # Quiz categories + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS quiz_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + user_id INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW() + ) + """)) + conn.execute(text(""" + ALTER TABLE quizzes + ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES quiz_categories(id) ON DELETE SET NULL + """)) conn.commit() @@ -210,6 +221,8 @@ app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(tts.router, prefix="/api/tts", tags=["tts"]) app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]) +app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) +app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) @app.get("/api/health") diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index 9b752e9..8c1f706 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -4,6 +4,7 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text from sqlalchemy.orm import relationship from app.database import Base +from app.models.quiz_category import QuizCategory # noqa β€” ensures mapper resolves "QuizCategory" class Quiz(Base): @@ -12,6 +13,7 @@ class Quiz(Base): id = Column(Integer, primary_key=True, index=True) section_id = Column(Integer, ForeignKey("sections.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + category_id = Column(Integer, ForeignKey("quiz_categories.id", ondelete="SET NULL"), nullable=True) title = Column(String, nullable=False) questions_count = Column(Integer, default=0) time_limit_minutes = Column(Integer, nullable=True) # null = no limit @@ -21,6 +23,7 @@ class Quiz(Base): section = relationship("Section", back_populates="quizzes") user = relationship("User", back_populates="quizzes") + category = relationship("QuizCategory", back_populates="quizzes", foreign_keys=[category_id]) questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan") attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan") reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id") diff --git a/backend/app/models/quiz_category.py b/backend/app/models/quiz_category.py new file mode 100644 index 0000000..857ae1d --- /dev/null +++ b/backend/app/models/quiz_category.py @@ -0,0 +1,15 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from app.database import Base + + +class QuizCategory(Base): + __tablename__ = "quiz_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + quizzes = relationship("Quiz", back_populates="category", foreign_keys="Quiz.category_id") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 83b7094..11db76f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy.orm import Session import httpx @@ -97,15 +98,19 @@ def list_available_models( return result -@router.get("/litellm/models") +class LiteLLMModelQuery(BaseModel): + api_key: str | None = None + api_base: str | None = None + + +@router.post("/litellm/models") def search_litellm_models( - api_key: str = Query(None), - api_base: str = Query(None), + body: LiteLLMModelQuery = LiteLLMModelQuery(), admin: User = Depends(require_admin), ): """Query available models from LiteLLM proxy or OpenAI-compatible API.""" - base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/") - key = api_key or settings.LITELLM_API_KEY + base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/") + key = body.api_key or settings.LITELLM_API_KEY if base: try: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b3f56cc..b8cb2cf 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,4 @@ import secrets -from collections import defaultdict from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request @@ -20,17 +19,22 @@ from app.utils.auth import ( router = APIRouter() -# Simple in-memory login rate limiter: max 10 attempts per IP per 15 min -_login_attempts: dict = defaultdict(list) - def _check_login_rate_limit(client_ip: str): - now = datetime.utcnow() - window = now - timedelta(minutes=15) - attempts = [t for t in _login_attempts[client_ip] if t > window] - _login_attempts[client_ip] = attempts - if len(attempts) >= 10: - raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.") - _login_attempts[client_ip].append(now) + """Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis.""" + try: + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) + key = f"login_attempts:{client_ip}" + count = r.incr(key) + if count == 1: + r.expire(key, 15 * 60) + if count > 10: + raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.") + except HTTPException: + raise + except Exception: + pass # Redis unavailable β€” degrade gracefully, don't block login # Rate limit: max 3 reset requests per email per hour diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..96d19e4 --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.quiz import Quiz +from app.models.quiz_category import QuizCategory +from app.models.user import User +from app.utils.auth import get_current_user, require_moderator + +router = APIRouter() + + +class CategoryCreate(BaseModel): + name: str + + +class CategoryResponse(BaseModel): + id: int + name: str + quiz_count: int = 0 + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[CategoryResponse]) +def list_categories( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + cats = db.query(QuizCategory).order_by(QuizCategory.name).all() + return [ + CategoryResponse(id=c.id, name=c.name, + quiz_count=db.query(Quiz).filter(Quiz.category_id == c.id).count()) + for c in cats + ] + + +@router.post("/", response_model=CategoryResponse) +def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + if not data.name.strip(): + raise HTTPException(status_code=400, detail="Category name cannot be empty") + existing = db.query(QuizCategory).filter(QuizCategory.name == data.name.strip()).first() + if existing: + raise HTTPException(status_code=400, detail="Category already exists") + cat = QuizCategory(name=data.name.strip(), user_id=current_user.id) + db.add(cat) + db.commit() + db.refresh(cat) + return CategoryResponse(id=cat.id, name=cat.name, quiz_count=0) + + +@router.delete("/{category_id}", status_code=204) +def delete_category( + category_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + cat = db.query(QuizCategory).filter(QuizCategory.id == category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + # Unassign quizzes + db.query(Quiz).filter(Quiz.category_id == category_id).update({"category_id": None}) + db.delete(cat) + db.commit() + + +@router.patch("/quizzes/{quiz_id}") +def set_quiz_category( + quiz_id: int, + category_id: int | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Assign or remove a category from a quiz. Pass category_id=null to uncategorize.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found") + if category_id is not None: + cat = db.query(QuizCategory).filter(QuizCategory.id == category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + quiz.category_id = category_id + db.commit() + return {"quiz_id": quiz_id, "category_id": category_id} diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py new file mode 100644 index 0000000..3abbecd --- /dev/null +++ b/backend/app/routers/questions.py @@ -0,0 +1,128 @@ +"""Question bank β€” view, search, and create quizzes from individual questions.""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import cast, String, or_ +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.question import Question +from app.models.quiz import Quiz +from app.models.user import User +from app.utils.auth import get_current_user, require_moderator + +router = APIRouter() + + +@router.get("/bank") +def get_question_bank( + q: str | None = Query(None), + quiz_id: int | None = Query(None), + limit: int = Query(50, le=200), + offset: int = Query(0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all questions across all quizzes. Supports keyword filter and quiz filter.""" + query = db.query(Question) + + if quiz_id: + query = query.filter(Question.quiz_id == quiz_id) + + if q and q.strip(): + phrase = q.strip() + query = query.filter( + or_( + Question.question_text.ilike(f"%{phrase}%"), + cast(Question.options, String).ilike(f"%{phrase}%"), + ) + ) + + total = query.count() + questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all() + + # Attach quiz title + quiz_cache: dict[int, str] = {} + result = [] + for qu in questions: + if qu.quiz_id not in quiz_cache: + quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first() + quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}" + result.append({ + "id": qu.id, + "quiz_id": qu.quiz_id, + "quiz_title": quiz_cache[qu.quiz_id], + "question_text": qu.question_text, + "question_type": qu.question_type, + "options": qu.options, + "correct_answer": qu.correct_answer, + "explanation": qu.explanation, + }) + + return {"total": total, "questions": result} + + +class CreateFromBankRequest(BaseModel): + title: str + question_ids: list[int] + mode: str = "timed" + time_limit_minutes: int | None = None + + +@router.post("/from-bank") +def create_quiz_from_bank( + data: CreateFromBankRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Create a new quiz by copying selected questions from the question bank.""" + if not data.title.strip(): + raise HTTPException(status_code=400, detail="Title is required") + if not data.question_ids: + raise HTTPException(status_code=400, detail="Select at least one question") + if data.mode not in ("timed", "learning"): + raise HTTPException(status_code=400, detail="Mode must be timed or learning") + + # Fetch source questions + source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all() + if not source_questions: + raise HTTPException(status_code=404, detail="No valid questions found") + + # Use the section_id from the first source question's quiz + first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first() + if not first_quiz: + raise HTTPException(status_code=400, detail="Source quiz not found") + + import json + new_quiz = Quiz( + section_id=first_quiz.section_id, + user_id=current_user.id, + title=data.title.strip(), + questions_count=len(source_questions), + mode=data.mode, + time_limit_minutes=data.time_limit_minutes, + ) + db.add(new_quiz) + db.flush() + + # Copy questions (independent copies β€” original questions untouched) + from app.services import embedding_service + for sq in source_questions: + new_q = Question( + quiz_id=new_quiz.id, + question_text=sq.question_text, + question_type=sq.question_type, + options=sq.options, + correct_answer=sq.correct_answer, + explanation=sq.explanation, + page_reference=sq.page_reference, + ) + db.add(new_q) + db.flush() + try: + embedding_service.embed_question(new_q) + except Exception: + pass + + db.commit() + db.refresh(new_quiz) + return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count} diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index e295a95..2313037 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -37,6 +37,7 @@ class QuizResponse(BaseModel): mode: str time_limit_minutes: int | None skipped_questions: str | None = None + category_id: int | None = None created_at: datetime class Config: diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index e5fa4e0..3734305 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -24,26 +24,28 @@ These PDFs follow a strict format: 5. A "Critique:" section with detailed reasoning 6. A "Content Specifications:" section listing the learning objectives -Your task: extract every question from the content below and return ONLY a JSON object in this exact format: +Your task: extract every question and return ONLY a JSON object in this exact format: {{"questions": [ {{ "question_text": "", "question_type": "mcq", "options": ["