diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py index d6653cd..70c2ba1 100644 --- a/backend/app/routers/question_categories.py +++ b/backend/app/routers/question_categories.py @@ -80,16 +80,24 @@ def update_question_category( @router.delete("/{cat_id}", status_code=204) def delete_question_category( cat_id: int, + move_to: int | None = None, # optional: move questions to this category instead of uncategorizing db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): cat = db.query(QuestionCategory).filter(QuestionCategory.id == cat_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") - # Unassign questions - db.query(Question).filter(Question.question_category_id == cat_id).update( - {"question_category_id": None} - ) + if move_to is not None: + target = db.query(QuestionCategory).filter(QuestionCategory.id == move_to).first() + if not target: + raise HTTPException(status_code=404, detail="Target category not found") + db.query(Question).filter(Question.question_category_id == cat_id).update( + {"question_category_id": move_to} + ) + else: + db.query(Question).filter(Question.question_category_id == cat_id).update( + {"question_category_id": None} + ) db.delete(cat) db.commit() diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index e729a19..964b977 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -34,6 +34,34 @@ def set_question_category( 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), + uncategorized: bool = Query(False), + 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.quiz_id == quiz_id) + if 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 q and q.strip(): + phrase = q.strip() + query = query.filter( + or_( + Question.question_text.ilike(f"%{phrase}%"), + cast(Question.options, String).ilike(f"%{phrase}%"), + ) + ) + return [row[0] for row in query.all()] + + @router.get("/bank") def get_question_bank( q: str | None = Query(None), diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index e43255d..f33c25f 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -235,19 +235,21 @@ export default function QuestionBankPage() { const [newCatName, setNewCatName] = useState('') const [showCatForm, setShowCatForm] = useState(false) const [assignCatId, setAssignCatId] = useState('') + const [bulkError, setBulkError] = useState('') + const [pageSize, setPageSize] = useState(50) const debounceRef = useRef(null) const { user } = useAuth() const isModerator = user?.role === 'admin' || user?.role === 'moderator' - const LIMIT = 50 + const LIMIT = pageSize useEffect(() => { api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {}) }, []) - const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode) => { + const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode, size = pageSize) => { setLoading(true) try { - const params = { limit: LIMIT, offset: off, search_mode: mode } + const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode } if (query.trim()) params.q = query.trim() if (catId) params.category_id = parseInt(catId) if (uncatOnly) params.uncategorized = true @@ -260,29 +262,48 @@ export default function QuestionBankPage() { useEffect(() => { clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode), 300) + debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode, pageSize), 300) return () => clearTimeout(debounceRef.current) - }, [searchQuery, filterCatId, showUncategorized, searchMode]) + }, [searchQuery, filterCatId, showUncategorized, searchMode, pageSize]) const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) - const selectAll = () => setSelectedIds(new Set(questions.map(q => q.id))) + + const selectAll = async () => { + // Fetch ALL matching IDs from server (not just loaded page) + try { + const params = {} + if (searchQuery.trim()) params.q = searchQuery.trim() + if (filterCatId) params.category_id = parseInt(filterCatId) + if (showUncategorized) params.uncategorized = true + const res = await api.get('/questions/bank/ids', { params }) + setSelectedIds(new Set(res.data)) + } catch { + // Fallback to loaded questions + setSelectedIds(new Set(questions.map(q => q.id))) + } + } + const clearSelection = () => setSelectedIds(new Set()) const bulkAssignCategory = async () => { - if (!assignCatId && assignCatId !== '0') return - const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId) + if (!assignCatId) return + setBulkError('') + const catIdVal = assignCatId === 'remove' ? null : parseInt(assignCatId) try { await api.post('/questions/bulk-category', { question_ids: [...selectedIds], category_id: catIdVal, }) + const catName = catIdVal ? categories.find(c => c.id === catIdVal)?.name ?? null : null setQuestions(prev => prev.map(q => selectedIds.has(q.id) - ? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null } + ? { ...q, question_category_id: catIdVal, question_category_name: catName } : q)) clearSelection() setAssignCatId('') api.get('/question-categories/').then(res => setCategories(res.data)) - } catch { } + } catch (err) { + setBulkError(err.response?.data?.detail || 'Failed to assign category') + } } const addCategory = async () => { @@ -295,9 +316,31 @@ export default function QuestionBankPage() { } const deleteCategory = async (catId) => { - if (!confirm('Delete this question category? Questions will become uncategorized.')) return + const cat = categories.find(c => c.id === catId) + const otherCats = categories.filter(c => c.id !== catId) + const count = cat?.question_count || 0 + + if (count === 0) { + if (!confirm(`Delete category "${cat?.name}"?`)) return + try { + await api.delete(`/question-categories/${catId}`) + setCategories(prev => prev.filter(c => c.id !== catId)) + if (parseInt(filterCatId) === catId) setFilterCatId('') + } catch { } + return + } + + // Has questions — ask where to move them + const options = ['Leave uncategorized', ...otherCats.map(c => c.name)] + const choice = window.prompt( + `"${cat?.name}" has ${count} question${count !== 1 ? 's' : ''}.\n\nWhere should they go?\n${options.map((o, i) => `${i}: ${o}`).join('\n')}\n\nEnter number (or Cancel to abort):` + ) + if (choice === null) return + const idx = parseInt(choice) + if (isNaN(idx) || idx < 0 || idx >= options.length) return + const moveTo = idx === 0 ? undefined : otherCats[idx - 1]?.id try { - await api.delete(`/question-categories/${catId}`) + await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} }) setCategories(prev => prev.filter(c => c.id !== catId)) if (parseInt(filterCatId) === catId) setFilterCatId('') loadQuestions(searchQuery, 0, '', showUncategorized) @@ -340,60 +383,82 @@ export default function QuestionBankPage() { )} - {/* Category chips */} + {/* Category filter chips */} {categories.length > 0 && ( -
+
+ Filter: + onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All ({total}) {categories.map(cat => ( -
+
- {isModerator && }
))}
)} - {/* Search + bulk actions */} + {/* Search row */}
🔍 setSearchQuery(e.target.value)} style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
-
- {[['keyword','Keyword'],['semantic','Semantic'],['hybrid','Hybrid']].map(([val, label]) => ( - - ))} + {/* Search mode — clearly labelled separately from category chips */} +
+ Search: + +
+ {/* Page size */} +
+ Show: +
- {isModerator && ( - <> - - {selectedIds.size > 0 && } - {selectedIds.size > 0 && ( -
- - -
- )} - - )}
+ {/* Bulk actions row — only shown for moderators, clearly separated */} + {isModerator && ( +
+ + {selectedIds.size > 0 && ( + <> + {selectedIds.size} selected + + + + + + )} + {bulkError && {bulkError}} +
+ )} + {/* Question list */} {loading && questions.length === 0 &&
}