"""Question bank — view, search, categorise, and create quizzes from individual questions.""" import csv import io import os import re import uuid 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: pass 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: pass 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' {_escape_xml(opt)}' ) if not correct_id: correct_id = "choice_0" explanation_xml = "" if q.explanation: explanation_xml = f""" {_escape_xml(q.explanation)} """ item = f""" {correct_id} 0 {_escape_xml(q.question_text)} {chr(10).join(choices_xml)} {explanation_xml} """ items_xml.append(item) xml = f""" {chr(10).join(items_xml)} """ 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), }