Backend logging: - Centralized JSON logging config with LOG_LEVEL env var - Request logging middleware: user, method, path, status, duration, request_id - Fixed all 9 silent except:pass blocks to log warnings with tracebacks - Celery workers use same structured JSON format Infrastructure: - Loki 3.3.2 for log storage (30-day retention) - Promtail 3.3.2 for Docker container log shipping - Grafana 10.3.1 with auto-provisioned Loki datasource - Grafana on port 3002 (admin/pedshub_grafana) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
836 lines
32 KiB
Python
836 lines
32 KiB
Python
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
|
|
import csv
|
|
import io
|
|
import logging
|
|
import os
|
|
import re
|
|
import uuid
|
|
|
|
logger = logging.getLogger(__name__)
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import cast, String, or_, text as sa_text, func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategory
|
|
from app.models.quiz import Quiz
|
|
from app.models.user import User
|
|
from app.models.favorite import Favorite
|
|
from app.utils.auth import get_current_user, require_moderator
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.delete("/{question_id}", status_code=204)
|
|
def delete_question(
|
|
question_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Delete a question. Moderators can delete any; regular users can delete their own."""
|
|
question = db.query(Question).filter(Question.id == question_id).first()
|
|
if not question:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
is_mod = current_user.role in ("admin", "moderator")
|
|
if not is_mod:
|
|
# Regular users can only delete questions they created (no source quiz)
|
|
if question.source_quiz_id is not None:
|
|
raise HTTPException(status_code=403, detail="Only moderators can delete extracted questions")
|
|
db.delete(question)
|
|
db.commit()
|
|
|
|
|
|
class QuestionEdit(BaseModel):
|
|
question_text: str | None = None
|
|
question_type: str | None = None
|
|
options: list[str] | None = None
|
|
correct_answer: str | None = None
|
|
explanation: str | None = None
|
|
question_category_id: int | None = None
|
|
image_path: str | None = None
|
|
|
|
|
|
@router.patch("/{question_id}")
|
|
def edit_question(
|
|
question_id: int,
|
|
data: QuestionEdit,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Edit a question. Owner or moderator can edit."""
|
|
question = db.query(Question).filter(Question.id == question_id).first()
|
|
if not question:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
is_mod = current_user.role in ("admin", "moderator")
|
|
if question.user_id != current_user.id and not is_mod:
|
|
raise HTTPException(status_code=403, detail="Not authorized to edit this question")
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(question, field, value)
|
|
db.commit()
|
|
db.refresh(question)
|
|
return {
|
|
"id": question.id,
|
|
"question_text": question.question_text,
|
|
"question_type": question.question_type,
|
|
"options": question.options,
|
|
"correct_answer": question.correct_answer,
|
|
"explanation": question.explanation,
|
|
"question_category_id": question.question_category_id,
|
|
"image_path": question.image_path,
|
|
}
|
|
|
|
|
|
@router.patch("/{question_id}/share")
|
|
def toggle_question_share(
|
|
question_id: int,
|
|
shared: int = Query(..., description="1 = shared, 0 = private"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Toggle question sharing. Owner or admin can change."""
|
|
question = db.query(Question).filter(Question.id == question_id).first()
|
|
if not question:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
is_admin = current_user.role in ("admin", "moderator")
|
|
if question.user_id != current_user.id and not is_admin:
|
|
raise HTTPException(status_code=403, detail="Not your question")
|
|
question.is_shared = 1 if shared else 0
|
|
db.commit()
|
|
return {"id": question_id, "is_shared": question.is_shared}
|
|
|
|
|
|
@router.patch("/{question_id}/category")
|
|
def set_question_category(
|
|
question_id: int,
|
|
category_id: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
"""Assign or remove a question category."""
|
|
question = db.query(Question).filter(Question.id == question_id).first()
|
|
if not question:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
if category_id is not None:
|
|
cat = db.query(QuestionCategory).filter(QuestionCategory.id == category_id).first()
|
|
if not cat:
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
question.question_category_id = category_id
|
|
db.commit()
|
|
return {"question_id": question_id, "question_category_id": category_id}
|
|
|
|
|
|
@router.get("/bank/ids")
|
|
def get_bank_ids(
|
|
q: str | None = Query(None),
|
|
quiz_id: int | None = Query(None),
|
|
category_id: int | None = Query(None),
|
|
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
|
uncategorized: bool = Query(False),
|
|
favorites_only: bool = Query(False),
|
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return just IDs for all matching questions (for server-side select-all)."""
|
|
query = db.query(Question.id)
|
|
if quiz_id:
|
|
query = query.filter(Question.source_quiz_id == quiz_id)
|
|
if category_ids:
|
|
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
|
|
if cat_id_list:
|
|
query = query.filter(Question.question_category_id.in_(cat_id_list))
|
|
elif category_id is not None:
|
|
query = query.filter(Question.question_category_id == category_id)
|
|
if uncategorized:
|
|
query = query.filter(Question.question_category_id.is_(None))
|
|
if favorites_only:
|
|
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
|
fav_ids = [f[0] for f in favorite_ids]
|
|
if not fav_ids:
|
|
return []
|
|
query = query.filter(Question.id.in_(fav_ids))
|
|
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}%"),
|
|
)
|
|
)
|
|
# Tag filter: questions must have ALL specified tags
|
|
if tag_ids:
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT question_id FROM question_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY question_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
if matching_ids:
|
|
query = query.filter(Question.id.in_(matching_ids))
|
|
else:
|
|
return []
|
|
return [row[0] for row in query.all()]
|
|
|
|
|
|
@router.get("/bank")
|
|
def get_question_bank(
|
|
q: str | None = Query(None),
|
|
quiz_id: int | None = Query(None),
|
|
category_id: int | None = Query(None),
|
|
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
|
uncategorized: bool = Query(False),
|
|
favorites_only: bool = Query(False),
|
|
my_questions: bool = Query(False, description="Show only questions created by current user"),
|
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
|
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
|
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."""
|
|
if my_questions:
|
|
query = db.query(Question).filter(Question.user_id == current_user.id)
|
|
else:
|
|
query = db.query(Question).filter(
|
|
or_(
|
|
Question.is_shared == 1,
|
|
Question.is_shared.is_(None), # legacy questions without is_shared
|
|
Question.user_id == current_user.id, # always show own questions
|
|
)
|
|
)
|
|
|
|
if quiz_id:
|
|
query = query.filter(Question.source_quiz_id == quiz_id)
|
|
|
|
if category_ids:
|
|
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
|
|
if cat_id_list:
|
|
query = query.filter(Question.question_category_id.in_(cat_id_list))
|
|
elif category_id is not None:
|
|
query = query.filter(Question.question_category_id == category_id)
|
|
|
|
if uncategorized:
|
|
query = query.filter(Question.question_category_id.is_(None))
|
|
|
|
# Tag filter: questions must have ALL specified tags
|
|
if tag_ids:
|
|
import logging as _log
|
|
_log.getLogger(__name__).info(f"Tag filter: tag_ids={tag_ids!r}")
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT question_id FROM question_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY question_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
_log.getLogger(__name__).info(f"Tag filter matched {len(matching_ids)} questions for tags {tag_id_list}")
|
|
if matching_ids:
|
|
query = query.filter(Question.id.in_(matching_ids))
|
|
else:
|
|
return {"total": 0, "questions": []}
|
|
|
|
if favorites_only:
|
|
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
|
fav_ids = [f[0] for f in favorite_ids]
|
|
if not fav_ids:
|
|
return {"total": 0, "questions": []}
|
|
query = query.filter(Question.id.in_(fav_ids))
|
|
|
|
# ── Semantic search (pgvector) ─────────────────────────────────
|
|
semantic_ids_ordered: list[int] = []
|
|
if q and q.strip() and search_mode in ("semantic", "hybrid"):
|
|
from app.services.embedding_service import generate_embedding
|
|
emb = generate_embedding(q.strip())
|
|
if emb:
|
|
# Validate all values are finite floats before interpolating into SQL
|
|
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
|
rows = db.execute(sa_text("""
|
|
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
|
FROM questions
|
|
WHERE embedding IS NOT NULL
|
|
ORDER BY embedding <=> CAST(:vec AS vector)
|
|
LIMIT :lim
|
|
"""), {"vec": emb_literal, "lim": limit * 2}).fetchall()
|
|
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.55]
|
|
|
|
# ── Keyword filter ─────────────────────────────────────────────
|
|
if q and q.strip() and search_mode in ("keyword", "hybrid"):
|
|
phrase = q.strip()
|
|
query = query.filter(
|
|
or_(
|
|
Question.question_text.ilike(f"%{phrase}%"),
|
|
cast(Question.options, String).ilike(f"%{phrase}%"),
|
|
)
|
|
)
|
|
|
|
# Apply semantic ID filter if semantic-only mode
|
|
if q and q.strip() and search_mode == "semantic" and semantic_ids_ordered:
|
|
query = query.filter(Question.id.in_(semantic_ids_ordered))
|
|
|
|
total = query.count()
|
|
questions = query.order_by(Question.source_quiz_id, Question.id).offset(offset).limit(limit).all()
|
|
|
|
# If hybrid: merge semantic first then keyword remainder
|
|
if semantic_ids_ordered and search_mode == "hybrid":
|
|
sem_set = set(semantic_ids_ordered)
|
|
sem_qs = [qu for qu in questions if qu.id in sem_set]
|
|
kw_qs = [qu for qu in questions if qu.id not in sem_set]
|
|
# Sort semantic by original similarity order
|
|
sem_order = {qid: i for i, qid in enumerate(semantic_ids_ordered)}
|
|
sem_qs.sort(key=lambda qu: sem_order.get(qu.id, 999))
|
|
questions = sem_qs + kw_qs
|
|
|
|
quiz_cache: dict[int, str] = {}
|
|
cat_cache: dict[int, str] = {}
|
|
result = []
|
|
for qu in questions:
|
|
src_id = qu.source_quiz_id
|
|
if src_id not in quiz_cache:
|
|
quiz = db.query(Quiz).filter(Quiz.id == src_id).first() if src_id else None
|
|
quiz_cache[src_id] = quiz.title if quiz else (f"Quiz {src_id}" if src_id else "Unknown")
|
|
cat_name = None
|
|
if qu.question_category_id:
|
|
if qu.question_category_id not in cat_cache:
|
|
cat = db.query(QuestionCategory).filter(QuestionCategory.id == qu.question_category_id).first()
|
|
cat_cache[qu.question_category_id] = cat.name if cat else None
|
|
cat_name = cat_cache.get(qu.question_category_id)
|
|
result.append({
|
|
"id": qu.id,
|
|
"quiz_id": qu.source_quiz_id,
|
|
"quiz_title": quiz_cache[src_id],
|
|
"question_category_id": qu.question_category_id,
|
|
"question_category_name": cat_name,
|
|
"question_text": qu.question_text,
|
|
"question_type": qu.question_type,
|
|
"options": qu.options,
|
|
"correct_answer": qu.correct_answer,
|
|
"explanation": qu.explanation,
|
|
"image_path": qu.image_path,
|
|
"user_id": qu.user_id,
|
|
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
|
})
|
|
|
|
return {"total": total, "questions": result}
|
|
|
|
|
|
def _strip_html(text: str) -> str:
|
|
"""Remove HTML tags and decode entities, returning plain text."""
|
|
if not text:
|
|
return text
|
|
# Remove tags
|
|
clean = re.sub(r"<[^>]+>", " ", text)
|
|
# Decode common entities
|
|
clean = clean.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", '"')
|
|
# Collapse whitespace
|
|
clean = re.sub(r"\s+", " ", clean).strip()
|
|
return clean
|
|
|
|
|
|
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "uploads", "questions")
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
|
|
class ManualQuestionCreate(BaseModel):
|
|
question_text: str
|
|
question_type: str = "mcq"
|
|
options: list[str] | None = None
|
|
correct_answer: str
|
|
explanation: str | None = None
|
|
question_category_id: int | None = None
|
|
image_path: str | None = None
|
|
|
|
|
|
@router.post("/create")
|
|
def create_question_manually(
|
|
data: ManualQuestionCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Create a single question manually (not from PDF extraction)."""
|
|
q_text = data.question_text.strip()
|
|
if not q_text:
|
|
raise HTTPException(status_code=400, detail="Question text is required")
|
|
if not data.correct_answer.strip():
|
|
raise HTTPException(status_code=400, detail="Correct answer is required")
|
|
if data.question_type == "mcq" and (not data.options or len(data.options) < 2):
|
|
raise HTTPException(status_code=400, detail="MCQ questions need at least 2 options")
|
|
if data.question_type == "mcq" and data.correct_answer not in data.options:
|
|
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
|
|
|
|
question = Question(
|
|
question_text=q_text,
|
|
question_type=data.question_type,
|
|
options=data.options,
|
|
correct_answer=data.correct_answer.strip(),
|
|
explanation=data.explanation,
|
|
question_category_id=data.question_category_id,
|
|
image_path=data.image_path,
|
|
user_id=current_user.id,
|
|
is_shared=1,
|
|
)
|
|
db.add(question)
|
|
db.commit()
|
|
db.refresh(question)
|
|
|
|
# Generate embedding in background
|
|
try:
|
|
from app.services import embedding_service
|
|
embedding_service.embed_question(question)
|
|
db.commit()
|
|
except Exception:
|
|
logger.warning("Failed to generate embedding for question %d", question.id, exc_info=True)
|
|
|
|
return {
|
|
"id": question.id,
|
|
"question_text": question.question_text,
|
|
"question_type": question.question_type,
|
|
"options": question.options,
|
|
"correct_answer": question.correct_answer,
|
|
"image_path": question.image_path,
|
|
}
|
|
|
|
|
|
@router.post("/upload-image")
|
|
def upload_question_image(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload an image for use in questions. Returns the relative path."""
|
|
if not file.content_type or not file.content_type.startswith("image/"):
|
|
raise HTTPException(status_code=400, detail="File must be an image")
|
|
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "png"
|
|
if ext not in ("png", "jpg", "jpeg", "gif", "webp", "svg"):
|
|
raise HTTPException(status_code=400, detail="Unsupported image format")
|
|
filename = f"{uuid.uuid4().hex[:12]}.{ext}"
|
|
filepath = os.path.join(UPLOAD_DIR, filename)
|
|
with open(filepath, "wb") as f:
|
|
f.write(file.file.read())
|
|
rel_path = f"questions/{filename}"
|
|
return {"image_path": rel_path, "url": f"/uploads/{rel_path}"}
|
|
|
|
|
|
@router.get("/images")
|
|
def list_question_images(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List all unique question images for the image bank browser."""
|
|
images = (
|
|
db.query(Question.image_path)
|
|
.filter(Question.image_path.isnot(None), Question.image_path != "")
|
|
.distinct()
|
|
.limit(200)
|
|
.all()
|
|
)
|
|
return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images]
|
|
|
|
|
|
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(get_current_user),
|
|
):
|
|
"""Create a new quiz referencing existing bank questions (no copying — edits propagate)."""
|
|
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")
|
|
|
|
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")
|
|
# Preserve caller's requested order
|
|
id_order = {qid: i for i, qid in enumerate(data.question_ids)}
|
|
source_questions.sort(key=lambda q: id_order.get(q.id, len(data.question_ids)))
|
|
|
|
# Find a section_id from the source questions' origin quiz
|
|
first_src_quiz_id = source_questions[0].source_quiz_id
|
|
first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None
|
|
section_id = first_quiz.section_id if first_quiz else None
|
|
|
|
is_mod = current_user.role in ("admin", "moderator")
|
|
|
|
new_quiz = Quiz(
|
|
section_id=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,
|
|
is_published=1 if is_mod else 0,
|
|
is_shared=1 if is_mod else 0,
|
|
)
|
|
db.add(new_quiz)
|
|
db.flush()
|
|
|
|
# Reference existing questions via junction (no copies)
|
|
from app.models.quiz_question_link import QuizQuestionLink
|
|
for pos, sq in enumerate(source_questions):
|
|
db.add(QuizQuestionLink(quiz_id=new_quiz.id, question_id=sq.id, position=pos))
|
|
|
|
db.commit()
|
|
db.refresh(new_quiz)
|
|
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}
|
|
|
|
|
|
class BulkCategoryRequest(BaseModel):
|
|
question_ids: list[int]
|
|
category_id: int | None = None
|
|
|
|
|
|
@router.post("/bulk-category")
|
|
def bulk_set_question_category(
|
|
data: BulkCategoryRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
"""Assign or remove a category from multiple questions."""
|
|
if data.category_id is not None:
|
|
cat = db.query(QuestionCategory).filter(QuestionCategory.id == data.category_id).first()
|
|
if not cat:
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
updated = db.query(Question).filter(Question.id.in_(data.question_ids)).update(
|
|
{"question_category_id": data.category_id}, synchronize_session=False
|
|
)
|
|
db.commit()
|
|
return {"updated": updated, "question_category_id": data.category_id}
|
|
|
|
|
|
@router.get("/import/sample")
|
|
def download_sample_csv():
|
|
"""Download a sample CSV template for question import."""
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow(["question_text", "option_a", "option_b", "option_c", "option_d", "option_e", "correct_answer", "explanation", "category"])
|
|
writer.writerow([
|
|
"What is the most common cause of neonatal jaundice?",
|
|
"Physiological jaundice", "ABO incompatibility", "G6PD deficiency", "Breast milk jaundice", "",
|
|
"A",
|
|
"Physiological jaundice occurs in up to 60% of term neonates due to immature hepatic conjugation.",
|
|
"Neonatology",
|
|
])
|
|
writer.writerow([
|
|
"Which vaccine is given at birth?",
|
|
"BCG", "Hepatitis B", "Both BCG and Hepatitis B", "OPV", "",
|
|
"C",
|
|
"Both BCG and Hepatitis B are given at birth per the WHO immunization schedule.",
|
|
"Immunology",
|
|
])
|
|
buf.seek(0)
|
|
return StreamingResponse(
|
|
iter([buf.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=question_import_sample.csv"},
|
|
)
|
|
|
|
|
|
@router.post("/import/upload")
|
|
def upload_questions_csv(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Import questions from CSV or XLSX file.
|
|
|
|
Expected columns: question_text, option_a, option_b, option_c, option_d, option_e,
|
|
correct_answer (A/B/C/D/E or full text), explanation, category
|
|
"""
|
|
if not file.filename:
|
|
raise HTTPException(status_code=400, detail="No file provided")
|
|
|
|
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
|
raw = file.file.read()
|
|
|
|
rows = []
|
|
if ext in ("xlsx", "xls"):
|
|
try:
|
|
import openpyxl
|
|
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True)
|
|
ws = wb.active
|
|
header = None
|
|
for row in ws.iter_rows(values_only=True):
|
|
if header is None:
|
|
header = [str(c or "").strip().lower().replace(" ", "_") for c in row]
|
|
continue
|
|
rows.append(dict(zip(header, [str(c) if c is not None else "" for c in row])))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {e}")
|
|
elif ext == "csv":
|
|
try:
|
|
text = raw.decode("utf-8-sig")
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
for row in reader:
|
|
rows.append({k.strip().lower().replace(" ", "_"): (v or "").strip() for k, v in row.items()})
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {e}")
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Unsupported file type. Use .csv or .xlsx")
|
|
|
|
if not rows:
|
|
raise HTTPException(status_code=400, detail="File is empty or has no data rows")
|
|
|
|
# Cache category lookups
|
|
cat_cache = {}
|
|
for cat in db.query(QuestionCategory).all():
|
|
cat_cache[cat.name.lower()] = cat.id
|
|
|
|
LETTER_MAP = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4}
|
|
created = []
|
|
errors = []
|
|
|
|
for i, row in enumerate(rows, start=2): # row 2 = first data row
|
|
q_text = row.get("question_text", "").strip()
|
|
if not q_text:
|
|
errors.append(f"Row {i}: missing question_text")
|
|
continue
|
|
|
|
options = []
|
|
for key in ("option_a", "option_b", "option_c", "option_d", "option_e"):
|
|
val = row.get(key, "").strip()
|
|
if val:
|
|
options.append(val)
|
|
|
|
correct_raw = row.get("correct_answer", "").strip()
|
|
correct_answer = ""
|
|
if correct_raw.lower() in LETTER_MAP and LETTER_MAP[correct_raw.lower()] < len(options):
|
|
correct_answer = options[LETTER_MAP[correct_raw.lower()]]
|
|
elif correct_raw in options:
|
|
correct_answer = correct_raw
|
|
else:
|
|
errors.append(f"Row {i}: invalid correct_answer '{correct_raw}'")
|
|
continue
|
|
|
|
explanation = row.get("explanation", "").strip() or None
|
|
cat_name = row.get("category", "").strip()
|
|
cat_id = None
|
|
if cat_name:
|
|
cat_lower = cat_name.lower()
|
|
if cat_lower not in cat_cache:
|
|
new_cat = QuestionCategory(name=cat_name, user_id=current_user.id)
|
|
db.add(new_cat)
|
|
db.flush()
|
|
cat_cache[cat_lower] = new_cat.id
|
|
cat_id = cat_cache[cat_lower]
|
|
|
|
question = Question(
|
|
question_text=q_text,
|
|
question_type="mcq" if len(options) >= 2 else "short_answer",
|
|
options=options if len(options) >= 2 else None,
|
|
correct_answer=correct_answer,
|
|
explanation=explanation,
|
|
question_category_id=cat_id,
|
|
)
|
|
db.add(question)
|
|
created.append(question)
|
|
|
|
db.commit()
|
|
|
|
# Embed in background
|
|
for q in created:
|
|
db.refresh(q)
|
|
try:
|
|
from app.services import embedding_service
|
|
embedding_service.embed_question(q)
|
|
except Exception:
|
|
logger.warning("Failed to generate embedding for imported question %d", q.id, exc_info=True)
|
|
if created:
|
|
db.commit()
|
|
|
|
return {
|
|
"imported": len(created),
|
|
"errors": errors[:20], # cap error list
|
|
"total_rows": len(rows),
|
|
}
|
|
|
|
|
|
# ── QTI Export / Import ──────────────────────────────────────────────
|
|
|
|
def _escape_xml(text: str) -> str:
|
|
"""Escape text for XML content."""
|
|
if not text:
|
|
return ""
|
|
return (text.replace("&", "&").replace("<", "<")
|
|
.replace(">", ">").replace('"', """).replace("'", "'"))
|
|
|
|
|
|
@router.get("/export/qti")
|
|
def export_qti(
|
|
question_ids: str = Query(None, description="Comma-separated question IDs (omit for all shared)"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Export questions as QTI 2.1 XML."""
|
|
if question_ids:
|
|
ids = [int(x.strip()) for x in question_ids.split(",") if x.strip().isdigit()]
|
|
questions = db.query(Question).filter(Question.id.in_(ids)).all()
|
|
else:
|
|
questions = db.query(Question).filter(
|
|
or_(Question.is_shared == 1, Question.is_shared.is_(None), Question.user_id == current_user.id)
|
|
).limit(500).all()
|
|
|
|
items_xml = []
|
|
for q in questions:
|
|
options = q.options or []
|
|
correct = q.correct_answer or ""
|
|
correct_id = None
|
|
|
|
choices_xml = []
|
|
for i, opt in enumerate(options):
|
|
choice_id = f"choice_{i}"
|
|
if opt.strip().lower() == correct.strip().lower():
|
|
correct_id = choice_id
|
|
choices_xml.append(
|
|
f' <simpleChoice identifier="{choice_id}">{_escape_xml(opt)}</simpleChoice>'
|
|
)
|
|
|
|
if not correct_id:
|
|
correct_id = "choice_0"
|
|
|
|
explanation_xml = ""
|
|
if q.explanation:
|
|
explanation_xml = f"""
|
|
<modalFeedback outcomeIdentifier="FEEDBACK" showHide="show">
|
|
{_escape_xml(q.explanation)}
|
|
</modalFeedback>"""
|
|
|
|
item = f""" <assessmentItem identifier="q_{q.id}" title="{_escape_xml(q.question_text[:80])}" adaptive="false" timeDependent="false">
|
|
<responseDeclaration identifier="RESPONSE" cardinality="single" baseType="identifier">
|
|
<correctResponse>
|
|
<value>{correct_id}</value>
|
|
</correctResponse>
|
|
</responseDeclaration>
|
|
<outcomeDeclaration identifier="SCORE" cardinality="single" baseType="float">
|
|
<defaultValue><value>0</value></defaultValue>
|
|
</outcomeDeclaration>
|
|
<itemBody>
|
|
<choiceInteraction responseIdentifier="RESPONSE" shuffle="false" maxChoices="1">
|
|
<prompt>{_escape_xml(q.question_text)}</prompt>
|
|
{chr(10).join(choices_xml)}
|
|
</choiceInteraction>
|
|
</itemBody>
|
|
<responseProcessing template="http://www.imsglobal.org/question/qti_v2p1/rptemplates/match_correct"/>{explanation_xml}
|
|
</assessmentItem>"""
|
|
items_xml.append(item)
|
|
|
|
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<assessmentTest xmlns="http://www.imsglobal.org/xsd/imsqti_v2p1"
|
|
identifier="pedshub_export" title="PedsHub Question Export">
|
|
{chr(10).join(items_xml)}
|
|
</assessmentTest>"""
|
|
|
|
return StreamingResponse(
|
|
io.BytesIO(xml.encode("utf-8")),
|
|
media_type="application/xml",
|
|
headers={"Content-Disposition": "attachment; filename=questions_qti.xml"},
|
|
)
|
|
|
|
|
|
@router.post("/import/qti")
|
|
def import_qti(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Import questions from a QTI 2.1 XML file."""
|
|
import xml.etree.ElementTree as ET
|
|
|
|
if not file.filename or not file.filename.lower().endswith(".xml"):
|
|
raise HTTPException(status_code=400, detail="Please upload a QTI XML file")
|
|
|
|
contents = file.file.read()
|
|
try:
|
|
root = ET.fromstring(contents)
|
|
except ET.ParseError:
|
|
raise HTTPException(status_code=400, detail="Invalid XML file")
|
|
|
|
# Handle namespaces
|
|
ns_match = re.match(r"\{(.+?)\}", root.tag)
|
|
ns = ns_match.group(1) if ns_match else ""
|
|
prefix = f"{{{ns}}}" if ns else ""
|
|
|
|
created = []
|
|
errors = []
|
|
|
|
# Find all assessmentItem elements (could be nested or flat)
|
|
items = root.findall(f".//{prefix}assessmentItem")
|
|
if not items:
|
|
items = root.findall(f".//assessmentItem") # try without namespace
|
|
|
|
for idx, item in enumerate(items):
|
|
try:
|
|
# Extract prompt/question text
|
|
prompt_el = item.find(f".//{prefix}prompt")
|
|
if prompt_el is None:
|
|
prompt_el = item.find(f".//prompt")
|
|
question_text = (prompt_el.text or "").strip() if prompt_el is not None else item.get("title", "")
|
|
|
|
if not question_text:
|
|
errors.append(f"Item {idx + 1}: No question text found")
|
|
continue
|
|
|
|
# Extract choices
|
|
options = []
|
|
choice_map = {} # identifier -> text
|
|
for choice in item.findall(f".//{prefix}simpleChoice") or item.findall(f".//simpleChoice"):
|
|
text = (choice.text or "").strip()
|
|
identifier = choice.get("identifier", "")
|
|
options.append(text)
|
|
choice_map[identifier] = text
|
|
|
|
# Extract correct answer
|
|
correct_answer = ""
|
|
correct_el = item.find(f".//{prefix}correctResponse/{prefix}value")
|
|
if correct_el is None:
|
|
correct_el = item.find(f".//correctResponse/value")
|
|
if correct_el is not None and correct_el.text:
|
|
correct_id = correct_el.text.strip()
|
|
correct_answer = choice_map.get(correct_id, "")
|
|
|
|
# Extract explanation from modalFeedback
|
|
explanation = ""
|
|
feedback_el = item.find(f".//{prefix}modalFeedback")
|
|
if feedback_el is None:
|
|
feedback_el = item.find(f".//modalFeedback")
|
|
if feedback_el is not None:
|
|
explanation = (feedback_el.text or "").strip()
|
|
|
|
q = Question(
|
|
question_text=question_text,
|
|
question_type="multiple_choice",
|
|
options=options if options else None,
|
|
correct_answer=correct_answer,
|
|
explanation=explanation or None,
|
|
user_id=current_user.id,
|
|
is_shared=0,
|
|
)
|
|
db.add(q)
|
|
created.append(q)
|
|
except Exception as e:
|
|
errors.append(f"Item {idx + 1}: {str(e)[:80]}")
|
|
|
|
if created:
|
|
db.commit()
|
|
|
|
return {
|
|
"imported": len(created),
|
|
"errors": errors[:20],
|
|
"total_items": len(items),
|
|
}
|