Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 139 additions & 5 deletions api/exam.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"`
}
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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 topic names from topic IDs
examCategories := body.Categories
if len(body.TopicIDs) > 0 {
examCategories = topicNamesFromIDs(h.db, body.TopicIDs)
}

exam := &models.Exam{
Name: body.Name,
Categories: body.Categories,
Categories: examCategories,
DifficultyLevels: body.DifficultyLevels,
QuestionIDs: questionIDs,
QuestionCount: len(questionIDs),
Expand Down Expand Up @@ -118,6 +131,29 @@ func (h *Handler) GenerateExamHandler(w http.ResponseWriter, r *http.Request) {
})
}

// 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 _, 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
}

// ListExamsHandler handles GET /instructor/exams
func (h *Handler) ListExamsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand Down Expand Up @@ -470,3 +506,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})
}
168 changes: 168 additions & 0 deletions api/exam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading