From 4c2afce49906ff577198340b11480a36634335ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:20:26 +0000 Subject: [PATCH 1/4] Initial plan From f861e6861970cc8fdbabc6a814911b5fccad2e16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:29:20 +0000 Subject: [PATCH 2/4] Add exam topic selection, delete, and unassign features - Show all available topics (not just distinct categories) in exam generation form - Support generating exams from topic_ids in addition to categories - Add DELETE /instructor/exams endpoint to delete exams - Add DELETE /instructor/exams/unassign endpoint to unassign exams from students - Add GET /instructor/exams/topics endpoint returning all active topics - Update frontend to use topics, show delete/unassign buttons - Add tests for new handlers --- api/exam.go | 134 ++++++++++++++++++++++++++- api/exam_test.go | 168 ++++++++++++++++++++++++++++++++++ database/database.go | 87 ++++++++++++++++++ database/exam.go | 92 +++++++++++++++++++ router/router.go | 3 + static/js/admin/exams-page.js | 133 ++++++++++++++++++++------- 6 files changed, 581 insertions(+), 36 deletions(-) diff --git a/api/exam.go b/api/exam.go index e231545..3e13e59 100644 --- a/api/exam.go +++ b/api/exam.go @@ -26,7 +26,7 @@ func generateToken() (string, error) { // GenerateExamHandler handles POST /instructor/exams/generate // Creates a new exam by randomly selecting questions that match the given -// categories and difficulty levels, then saves the exam. +// categories/topics and difficulty levels, then saves the exam. func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -36,6 +36,7 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` Categories []string `json:"categories"` + TopicIDs []int `json:"topic_ids"` DifficultyLevels []int `json:"difficulty_levels"` QuestionCount int `json:"question_count"` } @@ -48,8 +49,8 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "name is required", http.StatusBadRequest) return } - if len(body.Categories) == 0 { - http.Error(w, "at least one category is required", http.StatusBadRequest) + if len(body.Categories) == 0 && len(body.TopicIDs) == 0 { + http.Error(w, "at least one category or topic is required", http.StatusBadRequest) return } if len(body.DifficultyLevels) == 0 { @@ -61,7 +62,13 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { count = 30 } - questions, err := h.db.GetQuestionsByCategories(body.Categories, body.DifficultyLevels, count) + var questions []*models.Question + var err error + if len(body.TopicIDs) > 0 { + questions, err = h.db.GetQuestionsByTopicIDs(body.TopicIDs, body.DifficultyLevels, count) + } else { + questions, err = h.db.GetQuestionsByCategories(body.Categories, body.DifficultyLevels, count) + } if err != nil { h.logger.WithContext(r.Context()).WithError(err).Error("Failed to fetch questions for exam generation") http.Error(w, "Failed to fetch questions: "+err.Error(), http.StatusInternalServerError) @@ -87,9 +94,15 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { assignerIDStr := r.Header.Get("X-User-ID") assignerID, _ := strconv.Atoi(assignerIDStr) + // Record categories: use provided categories, or derive from topic IDs + examCategories := body.Categories + if len(body.TopicIDs) > 0 { + examCategories = deriveTopicNames(questions) + } + exam := &models.Exam{ Name: body.Name, - Categories: body.Categories, + Categories: examCategories, DifficultyLevels: body.DifficultyLevels, QuestionIDs: questionIDs, QuestionCount: len(questionIDs), @@ -118,6 +131,19 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { }) } +// deriveTopicNames builds a deduplicated list of category labels from questions. +func deriveTopicNames(questions []*models.Question) []string { + seen := map[string]bool{} + var result []string + for _, q := range questions { + if len(q.Tags) > 0 && !seen[q.Tags[0]] { + seen[q.Tags[0]] = true + result = append(result, q.Tags[0]) + } + } + return result +} + // ListExamsHandler handles GET /instructor/exams func (h *Handler) ListExamsHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -470,3 +496,101 @@ func (h *Handler) GetAvailableExamCategoriesHandler(w http.ResponseWriter, r *ht w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(categories) } + +// GetAvailableExamTopicsHandler handles GET /instructor/exams/topics +// Returns all active topics (id, name, category) available for exam generation. +func (h *Handler) GetAvailableExamTopicsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + topics, err := h.db.ListAllTopics() + if err != nil { + h.logger.WithContext(r.Context()).WithError(err).Error("Failed to list topics") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + type topicOption struct { + ID int `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + } + result := make([]topicOption, 0, len(topics)) + for _, t := range topics { + result = append(result, topicOption{ + ID: t.ID, + Name: t.Name, + Category: t.Category, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// DeleteExamHandler handles DELETE /instructor/exams?exam_id={id} +// Deletes an exam and its assignments/attempts. +func (h *Handler) DeleteExamHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + examIDStr := r.URL.Query().Get("exam_id") + examID, err := strconv.Atoi(examIDStr) + if err != nil || examID <= 0 { + http.Error(w, "Invalid exam_id", http.StatusBadRequest) + return + } + + if err := h.db.DeleteExam(examID); err != nil { + if err == database.ErrNotFound { + http.Error(w, "Exam not found", http.StatusNotFound) + return + } + h.logger.WithContext(r.Context()).WithError(err).Error("Failed to delete exam") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + +// UnassignExamFromUserHandler handles DELETE /instructor/exams/unassign?exam_id={id}&user_id={id} +// Removes an exam assignment from a specific user. +func (h *Handler) UnassignExamFromUserHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + examIDStr := r.URL.Query().Get("exam_id") + examID, err := strconv.Atoi(examIDStr) + if err != nil || examID <= 0 { + http.Error(w, "Invalid exam_id", http.StatusBadRequest) + return + } + + userIDStr := r.URL.Query().Get("user_id") + userID, err := strconv.Atoi(userIDStr) + if err != nil || userID <= 0 { + http.Error(w, "Invalid user_id", http.StatusBadRequest) + return + } + + if err := h.db.UnassignExamFromUser(examID, userID); err != nil { + if err == database.ErrNotFound { + http.Error(w, "Assignment not found", http.StatusNotFound) + return + } + h.logger.WithContext(r.Context()).WithError(err).Error("Failed to unassign exam") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} diff --git a/api/exam_test.go b/api/exam_test.go index 7577de0..7aef0d4 100644 --- a/api/exam_test.go +++ b/api/exam_test.go @@ -448,3 +448,171 @@ func TestGetAvailableExamCategoriesHandler(t *testing.T) { t.Errorf("Expected 'java' in categories, got %v", categories) } } + +func TestGetAvailableExamTopicsHandler(t *testing.T) { + db, admin, _ := setupExamDB(t) + handler := NewHandler(db) + + req := httptest.NewRequest(http.MethodGet, "/instructor/exams/topics", nil) + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.GetAvailableExamTopicsHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var topics []map[string]interface{} + json.NewDecoder(w.Body).Decode(&topics) + if len(topics) == 0 { + t.Fatal("Expected at least one topic") + } + found := false + for _, topic := range topics { + if topic["name"] == "Java Basics" { + found = true + if topic["category"] != "java" { + t.Errorf("Expected category 'java', got %v", topic["category"]) + } + } + } + if !found { + t.Errorf("Expected 'Java Basics' in topics, got %v", topics) + } +} + +func TestGenerateExamHandler_WithTopicIDs(t *testing.T) { + db, admin, _ := setupExamDB(t) + handler := NewHandler(db) + + // Get the topic ID that was created in setupExamDB + topics, err := db.ListAllTopics() + if err != nil || len(topics) == 0 { + t.Fatal("No topics in DB") + } + topicID := topics[0].ID + + body, _ := json.Marshal(map[string]interface{}{ + "name": "Topic-based Exam", + "topic_ids": []int{topicID}, + "difficulty_levels": []int{1, 2, 3}, + "question_count": 3, + }) + + req := httptest.NewRequest(http.MethodPost, "/instructor/exams/generate", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.GenerateExamHandler(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("Expected status 201, got %d: %s", w.Code, w.Body.String()) + } + + var result map[string]interface{} + json.NewDecoder(w.Body).Decode(&result) + if result["success"] != true { + t.Errorf("Expected success=true, got %v", result["success"]) + } +} + +func TestDeleteExamHandler(t *testing.T) { + db, admin, _ := setupExamDB(t) + handler := NewHandler(db) + + exam := &models.Exam{ + Name: "To Delete", Categories: []string{"java"}, DifficultyLevels: []int{1}, + QuestionIDs: []int{1}, QuestionCount: 1, AccessToken: "deletetoken", + CreatedBy: admin.ID, CreatedAt: time.Now(), + } + db.CreateExam(exam) + + req := httptest.NewRequest(http.MethodDelete, + fmt.Sprintf("/instructor/exams?exam_id=%d", exam.ID), nil) + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.DeleteExamHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify deleted + if _, err := db.GetExam(exam.ID); err == nil { + t.Error("Expected exam to be deleted") + } +} + +func TestDeleteExamHandler_NotFound(t *testing.T) { + db, admin, _ := setupExamDB(t) + handler := NewHandler(db) + + req := httptest.NewRequest(http.MethodDelete, "/instructor/exams?exam_id=99999", nil) + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.DeleteExamHandler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestUnassignExamFromUserHandler(t *testing.T) { + db, admin, student := setupExamDB(t) + handler := NewHandler(db) + + exam := &models.Exam{ + Name: "Unassign Exam", Categories: []string{"java"}, DifficultyLevels: []int{1}, + QuestionIDs: []int{1}, QuestionCount: 1, AccessToken: "unassigntoken2", + CreatedBy: admin.ID, CreatedAt: time.Now(), + } + db.CreateExam(exam) + db.AssignExamToUser(exam.ID, student.ID, admin.ID) + + req := httptest.NewRequest(http.MethodDelete, + fmt.Sprintf("/instructor/exams/unassign?exam_id=%d&user_id=%d", exam.ID, student.ID), nil) + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.UnassignExamFromUserHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify unassigned + exams, _ := db.GetAssignedExamsForUser(student.ID) + for _, e := range exams { + if e.ExamID == exam.ID { + t.Error("Expected exam to be unassigned from student") + } + } +} + +func TestUnassignExamFromUserHandler_NotFound(t *testing.T) { + db, admin, student := setupExamDB(t) + handler := NewHandler(db) + + exam := &models.Exam{ + Name: "Not Assigned", Categories: []string{"java"}, DifficultyLevels: []int{1}, + QuestionIDs: []int{1}, QuestionCount: 1, AccessToken: "notassignedtoken", + CreatedBy: admin.ID, CreatedAt: time.Now(), + } + db.CreateExam(exam) + // Do NOT assign + + req := httptest.NewRequest(http.MethodDelete, + fmt.Sprintf("/instructor/exams/unassign?exam_id=%d&user_id=%d", exam.ID, student.ID), nil) + req.Header.Set("X-User-ID", fmt.Sprintf("%d", admin.ID)) + w := httptest.NewRecorder() + + handler.UnassignExamFromUserHandler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} diff --git a/database/database.go b/database/database.go index 825c476..e73c645 100644 --- a/database/database.go +++ b/database/database.go @@ -190,9 +190,12 @@ type ExamRepository interface { GetExam(id int) (*models.Exam, error) GetExamByToken(token string) (*models.Exam, error) ListExams() ([]*models.Exam, error) + DeleteExam(id int) error GetQuestionsByCategories(categories []string, difficultyLevels []int, limit int) ([]*models.Question, error) + GetQuestionsByTopicIDs(topicIDs []int, difficultyLevels []int, limit int) ([]*models.Question, error) AssignExamToUser(examID, userID, assignedBy int) error + UnassignExamFromUser(examID, userID int) error GetAssignedExamsForUser(userID int) ([]*models.ExamInfo, error) CreateExamAttempt(attempt *models.ExamAttempt) error @@ -2767,6 +2770,28 @@ func (db *InMemoryDB) ListExams() ([]*models.Exam, error) { return result, nil } +func (db *InMemoryDB) DeleteExam(id int) error { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.exams[id]; !ok { + return ErrNotFound + } + delete(db.exams, id) + // Remove assignments for this exam + for aid, a := range db.examAssignments { + if a.ExamID == id { + delete(db.examAssignments, aid) + } + } + // Remove attempts for this exam + for aid, a := range db.examAttempts { + if a.ExamID == id { + delete(db.examAttempts, aid) + } + } + return nil +} + func (db *InMemoryDB) GetQuestionsByCategories(categories []string, difficultyLevels []int, limit int) ([]*models.Question, error) { db.mu.RLock() defer db.mu.RUnlock() @@ -2817,6 +2842,56 @@ func (db *InMemoryDB) GetQuestionsByCategories(categories []string, difficultyLe return result, nil } +func (db *InMemoryDB) GetQuestionsByTopicIDs(topicIDs []int, difficultyLevels []int, limit int) ([]*models.Question, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + topicSet := map[int]bool{} + for _, id := range topicIDs { + topicSet[id] = true + } + + diffRanges := difficultyRanges(difficultyLevels) + + inDiff := func(d int) bool { + if len(diffRanges) == 0 { + return true + } + for _, r := range diffRanges { + if d >= r[0] && d <= r[1] { + return true + } + } + return false + } + + var result []*models.Question + for _, q := range db.questions { + topic, ok := db.topics[q.TopicID] + if !ok || !topic.IsActive { + continue + } + if len(topicSet) > 0 && !topicSet[q.TopicID] { + continue + } + if !inDiff(q.Difficulty) { + continue + } + cp := *q + result = append(result, &cp) + } + + // shuffle + for i := len(result) - 1; i > 0; i-- { + j := i + result[i], result[j] = result[j], result[i] + } + if limit > 0 && len(result) > limit { + result = result[:limit] + } + return result, nil +} + func (db *InMemoryDB) AssignExamToUser(examID, userID, assignedBy int) error { db.mu.Lock() defer db.mu.Unlock() @@ -2848,6 +2923,18 @@ func (db *InMemoryDB) AssignExamToUser(examID, userID, assignedBy int) error { return nil } +func (db *InMemoryDB) UnassignExamFromUser(examID, userID int) error { + db.mu.Lock() + defer db.mu.Unlock() + for id, a := range db.examAssignments { + if a.ExamID == examID && a.UserID == userID { + delete(db.examAssignments, id) + return nil + } + } + return ErrNotFound +} + func (db *InMemoryDB) GetAssignedExamsForUser(userID int) ([]*models.ExamInfo, error) { db.mu.RLock() defer db.mu.RUnlock() diff --git a/database/exam.go b/database/exam.go index 5ceecad..0cb7684 100644 --- a/database/exam.go +++ b/database/exam.go @@ -95,6 +95,21 @@ func (db *SQLiteDB) ListExams() ([]*models.Exam, error) { return exams, rows.Err() } +func (db *SQLiteDB) DeleteExam(id int) error { + result, err := db.db.Exec(`DELETE FROM exams WHERE id = ?`, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + // GetQuestionsByCategories returns up to `limit` questions from the given // categories and difficulty levels, chosen in random order from all matches. // DifficultyLevels mapping: 1 = difficulty 1-2, 2 = difficulty 3-4, 3 = difficulty 5-7. @@ -156,6 +171,65 @@ func (db *SQLiteDB) GetQuestionsByCategories(categories []string, difficultyLeve return all, nil } +// GetQuestionsByTopicIDs returns up to `limit` questions from the given topic IDs +// and difficulty levels, chosen in random order. +func (db *SQLiteDB) GetQuestionsByTopicIDs(topicIDs []int, difficultyLevels []int, limit int) ([]*models.Question, error) { + if len(topicIDs) == 0 { + return nil, nil + } + + diffRanges := difficultyRanges(difficultyLevels) + + placeholders := make([]string, len(topicIDs)) + args := []interface{}{} + for i, id := range topicIDs { + placeholders[i] = "?" + args = append(args, id) + } + + query := ` + SELECT DISTINCT q.id, q.topic_id, q.question_text, q.difficulty, q.tags, q.topic_question_id, q.created_at + FROM questions q + JOIN topics t ON q.topic_id = t.id + WHERE t.is_active = 1 + AND q.topic_id IN (` + joinStrings(placeholders, ",") + `)` + + if len(diffRanges) > 0 { + conds := make([]string, len(diffRanges)) + for i, r := range diffRanges { + conds[i] = "(q.difficulty >= ? AND q.difficulty <= ?)" + args = append(args, r[0], r[1]) + } + query += " AND (" + joinStrings(conds, " OR ") + ")" + } + + rows, err := db.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var all []*models.Question + for rows.Next() { + q := &models.Question{} + var tagsJSON string + if err := rows.Scan(&q.ID, &q.TopicID, &q.QuestionText, &q.Difficulty, &tagsJSON, &q.TopicQuestionID, &q.CreatedAt); err != nil { + return nil, err + } + json.Unmarshal([]byte(tagsJSON), &q.Tags) + all = append(all, q) + } + if err := rows.Err(); err != nil { + return nil, err + } + + rand.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] }) + if limit > 0 && len(all) > limit { + all = all[:limit] + } + return all, nil +} + func (db *SQLiteDB) AssignExamToUser(examID, userID, assignedBy int) error { user, err := db.GetUser(userID) if err != nil { @@ -174,6 +248,24 @@ func (db *SQLiteDB) AssignExamToUser(examID, userID, assignedBy int) error { return err } +func (db *SQLiteDB) UnassignExamFromUser(examID, userID int) error { + result, err := db.db.Exec( + `DELETE FROM exam_assignments WHERE exam_id = ? AND user_id = ?`, + examID, userID, + ) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + func (db *SQLiteDB) GetAssignedExamsForUser(userID int) ([]*models.ExamInfo, error) { query := ` SELECT ea.id, ea.exam_id, ea.user_id, ea.assigned_at, ea.assigned_by, diff --git a/router/router.go b/router/router.go index d4de551..cae33bb 100644 --- a/router/router.go +++ b/router/router.go @@ -226,9 +226,12 @@ func New(h *api.Handler) *chi.Mux { // Assigned Exam routes r.Post("/exams/generate", h.GenerateExamHandler) r.Get("/exams", h.ListExamsHandler) + r.Delete("/exams", h.DeleteExamHandler) r.Post("/exams/assign", h.AssignExamToStudentsHandler) + r.Delete("/exams/unassign", h.UnassignExamFromUserHandler) r.Get("/exams/status", h.GetExamStatusHandler) r.Get("/exams/categories", h.GetAvailableExamCategoriesHandler) + r.Get("/exams/topics", h.GetAvailableExamTopicsHandler) // Topic Management routes r.Route("/topics", func(r chi.Router) { diff --git a/static/js/admin/exams-page.js b/static/js/admin/exams-page.js index 0500c25..f7a23dd 100644 --- a/static/js/admin/exams-page.js +++ b/static/js/admin/exams-page.js @@ -10,39 +10,59 @@ function escapeHtml(text) { return div.innerHTML; } -async function loadExamCategories() { +async function loadExamTopics() { const container = document.getElementById('examCategoriesCheckboxes'); try { - const response = await fetch('/instructor/exams/categories', {headers: {'X-User-ID': userId}}); - const categories = await response.json(); - if (!categories || categories.length === 0) { - container.innerHTML = 'No categories available.'; + const response = await fetch('/instructor/exams/topics', {headers: {'X-User-ID': userId}}); + const topics = await response.json(); + if (!topics || topics.length === 0) { + container.innerHTML = 'No topics available.'; return; } container.innerHTML = ''; - categories.forEach((category) => { - const categoryValue = String(category); - const categoryId = `examCat_${categoryValue.replace(/[^a-zA-Z0-9_-]+/g, '_')}`; - const div = document.createElement('div'); - div.className = 'form-check form-check-inline'; - const input = document.createElement('input'); - input.className = 'form-check-input exam-category-check'; - input.type = 'checkbox'; - input.value = categoryValue; - input.id = categoryId; - - const label = document.createElement('label'); - label.className = 'form-check-label'; - label.htmlFor = categoryId; - label.textContent = categoryValue.toUpperCase(); + // Group by category for display + const byCategory = {}; + topics.forEach((topic) => { + const cat = topic.category || 'Other'; + if (!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push(topic); + }); - div.appendChild(input); - div.appendChild(label); - container.appendChild(div); + Object.keys(byCategory).sort().forEach((cat) => { + const groupDiv = document.createElement('div'); + groupDiv.className = 'mb-1'; + + const catLabel = document.createElement('small'); + catLabel.className = 'text-muted fw-bold d-block'; + catLabel.textContent = cat.toUpperCase(); + groupDiv.appendChild(catLabel); + + byCategory[cat].forEach((topic) => { + const topicId = `examTopic_${topic.id}`; + const div = document.createElement('div'); + div.className = 'form-check form-check-inline'; + + const input = document.createElement('input'); + input.className = 'form-check-input exam-topic-check'; + input.type = 'checkbox'; + input.value = String(topic.id); + input.id = topicId; + + const label = document.createElement('label'); + label.className = 'form-check-label'; + label.htmlFor = topicId; + label.textContent = topic.name; + + div.appendChild(input); + div.appendChild(label); + groupDiv.appendChild(div); + }); + + container.appendChild(groupDiv); }); } catch (e) { - container.innerHTML = 'Error loading categories.'; + container.innerHTML = 'Error loading topics.'; } } @@ -55,9 +75,9 @@ async function generateExam() { return; } - const checkedCats = Array.from(document.querySelectorAll('.exam-category-check:checked')).map((cb) => cb.value); - if (checkedCats.length === 0) { - alert('Please select at least one category.'); + const checkedTopics = Array.from(document.querySelectorAll('.exam-topic-check:checked')).map((cb) => parseInt(cb.value, 10)); + if (checkedTopics.length === 0) { + alert('Please select at least one topic.'); return; } @@ -76,7 +96,7 @@ async function generateExam() { headers: {'Content-Type': 'application/json', 'X-User-ID': userId}, body: JSON.stringify({ name, - categories: checkedCats, + topic_ids: checkedTopics, difficulty_levels: checkedDifficultyLevels, question_count: questionCount, }), @@ -239,6 +259,27 @@ async function assignExamToStudents() { } } +async function deleteExam(examId, examName) { + if (!confirm(`Delete exam "${examName}"? This will also remove all assignments and attempts.`)) { + return; + } + try { + const response = await fetch(`/instructor/exams?exam_id=${examId}`, { + method: 'DELETE', + headers: {'X-User-ID': userId}, + }); + if (response.ok) { + loadExamStatus(); + loadAssignedExamDropdown(); + } else { + const text = await response.text(); + alert('Failed to delete exam: ' + text); + } + } catch (e) { + alert('Network error: ' + e.message); + } +} + async function loadExamStatus() { const container = document.getElementById('examSummaryTable'); container.innerHTML = '

Loading...

'; @@ -271,7 +312,10 @@ async function loadExamStatus() { ${escapeHtml(difficultyLevelsDisplay)} ${exam.question_count} ${escapeHtml(examUrl)} - + + + + `; }); html += ''; @@ -281,7 +325,30 @@ async function loadExamStatus() { } } +async function unassignExamFromUser(examId, userId, username) { + if (!confirm(`Unassign exam from "${username}"?`)) { + return; + } + try { + const response = await fetch(`/instructor/exams/unassign?exam_id=${examId}&user_id=${userId}`, { + method: 'DELETE', + headers: {'X-User-ID': window._currentUserId}, + }); + if (response.ok) { + showExamDetail(examId, document.getElementById('examDetailTitle').textContent.replace('Scores: ', '')); + } else { + const text = await response.text(); + alert('Failed to unassign: ' + text); + } + } catch (e) { + alert('Network error: ' + e.message); + } +} + +let _currentDetailExamId = null; + async function showExamDetail(examId, examName) { + _currentDetailExamId = examId; const panel = document.getElementById('examDetailPanel'); const title = document.getElementById('examDetailTitle'); const content = document.getElementById('examDetailContent'); @@ -302,7 +369,7 @@ async function showExamDetail(examId, examName) { let html = `
- + `; status.student_results.forEach((result) => { const completed = result.completed_at @@ -320,6 +387,7 @@ async function showExamDetail(examId, examName) { + `; }); html += '
StudentAssignedCompletedScoreCorrect / TotalStudentAssignedCompletedScoreCorrect / TotalActions
${completed} ${scoreHtml} ${correct}
'; @@ -338,10 +406,11 @@ window.addEventListener('DOMContentLoaded', async () => { if (!currentUser) return; userId = String(currentUser.id); + window._currentUserId = userId; document.getElementById('username').textContent = currentUser.username; document.getElementById('assignExamSelect').addEventListener('change', updateExamUrlDisplay); - await loadExamCategories(); + await loadExamTopics(); await loadAssignedExamDropdown(); await loadExamAssignStudentsList(); }); @@ -352,5 +421,7 @@ window.copyExamUrl = copyExamUrl; window.selectAllExamStudents = selectAllExamStudents; window.deselectAllExamStudents = deselectAllExamStudents; window.assignExamToStudents = assignExamToStudents; +window.deleteExam = deleteExam; window.showExamDetail = showExamDetail; window.hideExamDetail = hideExamDetail; +window.unassignExamFromUser = unassignExamFromUser; From f28f0f4c1b068e87bbb27f18781c6edd79554d33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:53 +0000 Subject: [PATCH 3/4] Fix review issues: use topic names from DB, proper shuffle, clean up frontend JS - topicNamesFromIDs now resolves topic IDs via GetTopic for correct names - Replace no-op shuffle with rand.Shuffle in GetQuestionsByTopicIDs - Frontend: use module-level userId variable in unassign handler - Frontend: store _currentDetailExamName alongside _currentDetailExamId --- api/exam.go | 26 ++++++++++++++++++-------- database/database.go | 6 ++---- static/js/admin/exams-page.js | 11 ++++++----- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/api/exam.go b/api/exam.go index 3e13e59..432cceb 100644 --- a/api/exam.go +++ b/api/exam.go @@ -94,10 +94,10 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { assignerIDStr := r.Header.Get("X-User-ID") assignerID, _ := strconv.Atoi(assignerIDStr) - // Record categories: use provided categories, or derive from topic IDs + // Record categories: use provided categories, or derive topic names from topic IDs examCategories := body.Categories if len(body.TopicIDs) > 0 { - examCategories = deriveTopicNames(questions) + examCategories = topicNamesFromIDs(h.db, body.TopicIDs) } exam := &models.Exam{ @@ -131,14 +131,24 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) { }) } -// deriveTopicNames builds a deduplicated list of category labels from questions. -func deriveTopicNames(questions []*models.Question) []string { +// topicNamesFromIDs resolves topic IDs to their names for display in exam metadata. +func topicNamesFromIDs(db interface { + GetTopic(id int) (*models.Topic, error) +}, topicIDs []int) []string { seen := map[string]bool{} var result []string - for _, q := range questions { - if len(q.Tags) > 0 && !seen[q.Tags[0]] { - seen[q.Tags[0]] = true - result = append(result, q.Tags[0]) + for _, id := range topicIDs { + topic, err := db.GetTopic(id) + if err != nil { + continue + } + name := strings.TrimSpace(topic.Name) + if name == "" { + name = strings.TrimSpace(topic.Category) + } + if name != "" && !seen[name] { + seen[name] = true + result = append(result, name) } } return result diff --git a/database/database.go b/database/database.go index e73c645..3be2124 100644 --- a/database/database.go +++ b/database/database.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "math/rand" "sort" "strconv" "strings" @@ -2882,10 +2883,7 @@ func (db *InMemoryDB) GetQuestionsByTopicIDs(topicIDs []int, difficultyLevels [] } // shuffle - for i := len(result) - 1; i > 0; i-- { - j := i - result[i], result[j] = result[j], result[i] - } + rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) if limit > 0 && len(result) > limit { result = result[:limit] } diff --git a/static/js/admin/exams-page.js b/static/js/admin/exams-page.js index f7a23dd..abbc5b9 100644 --- a/static/js/admin/exams-page.js +++ b/static/js/admin/exams-page.js @@ -325,17 +325,17 @@ async function loadExamStatus() { } } -async function unassignExamFromUser(examId, userId, username) { +async function unassignExamFromUser(examId, studentUserId, username) { if (!confirm(`Unassign exam from "${username}"?`)) { return; } try { - const response = await fetch(`/instructor/exams/unassign?exam_id=${examId}&user_id=${userId}`, { + const response = await fetch(`/instructor/exams/unassign?exam_id=${examId}&user_id=${studentUserId}`, { method: 'DELETE', - headers: {'X-User-ID': window._currentUserId}, + headers: {'X-User-ID': userId}, }); if (response.ok) { - showExamDetail(examId, document.getElementById('examDetailTitle').textContent.replace('Scores: ', '')); + showExamDetail(examId, _currentDetailExamName); } else { const text = await response.text(); alert('Failed to unassign: ' + text); @@ -346,9 +346,11 @@ async function unassignExamFromUser(examId, userId, username) { } let _currentDetailExamId = null; +let _currentDetailExamName = null; async function showExamDetail(examId, examName) { _currentDetailExamId = examId; + _currentDetailExamName = examName; const panel = document.getElementById('examDetailPanel'); const title = document.getElementById('examDetailTitle'); const content = document.getElementById('examDetailContent'); @@ -406,7 +408,6 @@ window.addEventListener('DOMContentLoaded', async () => { if (!currentUser) return; userId = String(currentUser.id); - window._currentUserId = userId; document.getElementById('username').textContent = currentUser.username; document.getElementById('assignExamSelect').addEventListener('change', updateExamUrlDisplay); From 8f347bf7ed69de29867ef38cbcbec9ac009b282f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:35:16 +0000 Subject: [PATCH 4/4] Handle json.Unmarshal error in GetQuestionsByTopicIDs --- database/exam.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/database/exam.go b/database/exam.go index 0cb7684..6fbedb7 100644 --- a/database/exam.go +++ b/database/exam.go @@ -216,7 +216,11 @@ func (db *SQLiteDB) GetQuestionsByTopicIDs(topicIDs []int, difficultyLevels []in if err := rows.Scan(&q.ID, &q.TopicID, &q.QuestionText, &q.Difficulty, &tagsJSON, &q.TopicQuestionID, &q.CreatedAt); err != nil { return nil, err } - json.Unmarshal([]byte(tagsJSON), &q.Tags) + if tagsJSON != "" { + if err := json.Unmarshal([]byte(tagsJSON), &q.Tags); err != nil { + q.Tags = []string{} + } + } all = append(all, q) } if err := rows.Err(); err != nil {