AssessmentController.java
package edu.ucsb.cs.scaffold.controller;
import com.fasterxml.jackson.annotation.JsonProperty;
import edu.ucsb.cs.scaffold.entity.Course;
import edu.ucsb.cs.scaffold.entity.PlAssessment;
import edu.ucsb.cs.scaffold.entity.PlAssessmentQuestion;
import edu.ucsb.cs.scaffold.entity.PlColor;
import edu.ucsb.cs.scaffold.entity.PlQuestion;
import edu.ucsb.cs.scaffold.errors.EntityNotFoundException;
import edu.ucsb.cs.scaffold.repository.CourseRepository;
import edu.ucsb.cs.scaffold.repository.PlAssessmentQuestionRepository;
import edu.ucsb.cs.scaffold.repository.PlAssessmentRepository;
import edu.ucsb.cs.scaffold.repository.PlColorRepository;
import edu.ucsb.cs.scaffold.repository.PlQuestionRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
// Serves ConceptGraphPage only (via useBackend, not legacyClient.ts). Assessments/questions come
// from the PrairieLearn-synced pl_assessment / pl_assessment_question / pl_question tables,
// scoped to the course's associated PlRepo + PlInstance, instead of the old hand-seeded
// assessments/questions tables (which now serve only LegacyHomePage via
// LegacyAssessmentController at /api/legacy/assessments).
@Tag(name = "Assessments")
@RestController
@RequiredArgsConstructor
public class AssessmentController extends ApiController {
private final CourseRepository courseRepository;
private final PlAssessmentRepository plAssessmentRepository;
private final PlAssessmentQuestionRepository plAssessmentQuestionRepository;
private final PlQuestionRepository plQuestionRepository;
private final PlColorRepository plColorRepository;
@Operation(
summary =
"List the unlocked assessments available for a course's associated PrairieLearn"
+ " repo/instance")
@GetMapping("/api/assessments")
public List<AssessmentDTO> getAssessments(
@Parameter(description = "Id of the course") @RequestParam Long courseId) {
Course course = courseRepository.findById(courseId).orElse(null);
if (course == null || course.getPlRepoId() == null || course.getPlInstanceId() == null) {
// No PrairieLearn repo/instance associated with this course yet (or the course doesn't
// exist): the menu shows no assessments rather than erroring.
return List.of();
}
Map<String, String> colorHexByName = loadColorHexByName();
return plAssessmentRepository
.findByPlRepoIdAndPlInstanceId(course.getPlRepoId(), course.getPlInstanceId())
.stream()
.filter(a -> !a.isLocked())
.sorted(
Comparator.comparing(
PlAssessment::getPlAssessmentOrder,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(PlAssessment::getName))
.map(a -> toAssessmentDTO(a, colorHexByName))
.toList();
}
@Operation(
summary =
"List all assessments (locked and unlocked) for a course, for instructor management")
@PreAuthorize("@CourseSecurity.hasInstructorPermissions(#root, #courseId)")
@GetMapping("/api/assessments/all")
public List<AssessmentManagementDTO> getAllAssessments(
@Parameter(description = "Id of the course") @RequestParam Long courseId) {
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
if (course.getPlRepoId() == null || course.getPlInstanceId() == null) {
// No PrairieLearn repo/instance associated with this course yet: a normal,
// not-yet-configured state, not an error. The modal shows no assessments to manage.
return List.of();
}
Map<String, String> colorHexByName = loadColorHexByName();
return plAssessmentRepository
.findByPlRepoIdAndPlInstanceId(course.getPlRepoId(), course.getPlInstanceId())
.stream()
.sorted(
Comparator.comparing(
PlAssessment::getPlAssessmentOrder,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(PlAssessment::getName))
.map(a -> toAssessmentManagementDTO(a, colorHexByName))
.toList();
}
@Operation(summary = "Lock or unlock an assessment so it is hidden from or shown to students")
@PreAuthorize("@CourseSecurity.hasInstructorPermissions(#root, #courseId)")
@PutMapping("/api/assessments/lock")
public AssessmentManagementDTO setLocked(
@Parameter(description = "Id of the course") @RequestParam Long courseId,
@Parameter(description = "Id of the PlAssessment") @RequestParam Long assessmentId,
@Parameter(description = "true to lock (hide), false to unlock (show)") @RequestParam
boolean locked) {
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
PlAssessment assessment =
plAssessmentRepository
.findById(assessmentId)
.orElseThrow(() -> new EntityNotFoundException(PlAssessment.class, assessmentId));
if (!Objects.equals(assessment.getPlRepoId(), course.getPlRepoId())
|| !Objects.equals(assessment.getPlInstanceId(), course.getPlInstanceId())) {
throw new IllegalArgumentException(
"assessmentId %d belongs to a different course".formatted(assessmentId));
}
assessment.setLocked(locked);
plAssessmentRepository.save(assessment);
return toAssessmentManagementDTO(assessment, loadColorHexByName());
}
@Operation(
summary = "List questions for an assessment, in the order they appear in the assessment")
@GetMapping("/api/assessments/{assessmentId}/questions")
public List<QuestionDTO> getQuestions(
@Parameter(description = "Id of the PlAssessment") @PathVariable Long assessmentId) {
List<PlAssessmentQuestion> joinRows =
plAssessmentQuestionRepository.findByPlAssessmentIdOrderByOrdinalAsc(assessmentId);
if (joinRows.isEmpty()) {
return List.of();
}
Map<Long, PlQuestion> questionsById =
plQuestionRepository
.findAllById(joinRows.stream().map(PlAssessmentQuestion::getPlQuestionId).toList())
.stream()
.collect(Collectors.toMap(PlQuestion::getId, q -> q));
return joinRows.stream()
.map(join -> questionsById.get(join.getPlQuestionId()))
.filter(Objects::nonNull)
.map(q -> toQuestionDTO(q, assessmentId))
.toList();
}
private static AssessmentDTO toAssessmentDTO(PlAssessment a, Map<String, String> colorHexByName) {
return new AssessmentDTO(
String.valueOf(a.getId()),
a.getPlAssessmentId() != null ? String.valueOf(a.getPlAssessmentId()) : null,
assessmentLabel(a),
a.getPlAssessmentSetAbbreviation(),
a.getPlAssessmentNumber(),
resolveColorHex(a.getPlAssessmentSetColor(), colorHexByName));
}
private static AssessmentManagementDTO toAssessmentManagementDTO(
PlAssessment a, Map<String, String> colorHexByName) {
return new AssessmentManagementDTO(
String.valueOf(a.getId()),
assessmentLabel(a),
a.isLocked(),
a.getPlAssessmentSetAbbreviation(),
a.getPlAssessmentNumber(),
resolveColorHex(a.getPlAssessmentSetColor(), colorHexByName));
}
private static String assessmentLabel(PlAssessment a) {
return a.getPlAssessmentTitle() != null ? a.getPlAssessmentTitle() : a.getName();
}
// Loads the pl_color name -> hex code map (populated by ReadPLColorsJob, issue #96) so that
// pl_assessment_set_color (a PrairieLearn color name like "gray1") can be translated into a hex
// code the frontend can use directly as a CSS background color.
private Map<String, String> loadColorHexByName() {
return plColorRepository.findAll().stream()
.collect(Collectors.toMap(PlColor::getColorName, PlColor::getHexCode));
}
// Translates a PrairieLearn color name (e.g. "gray1") into its hex code via the pl_color table.
// Returns null if the color name is null/blank or not found (e.g. before ReadPLColorsJob has
// been run), so the frontend can fall back to a default badge color.
private static String resolveColorHex(String colorName, Map<String, String> colorHexByName) {
if (colorName == null || colorName.isBlank()) {
return null;
}
return colorHexByName.get(colorName);
}
private static QuestionDTO toQuestionDTO(PlQuestion q, Long assessmentId) {
return new QuestionDTO(
String.valueOf(q.getId()),
String.valueOf(assessmentId),
q.getUuid().toString(),
q.getTitle());
}
public record AssessmentDTO(
String id,
@JsonProperty("pl_assessment_id") String plAssessmentId,
String name,
@JsonProperty("pl_assessment_set_abbreviation") String plAssessmentSetAbbreviation,
@JsonProperty("pl_assessment_number") String plAssessmentNumber,
@JsonProperty("pl_assessment_set_color") String plAssessmentSetColor) {}
public record AssessmentManagementDTO(
String id,
String name,
boolean locked,
@JsonProperty("pl_assessment_set_abbreviation") String plAssessmentSetAbbreviation,
@JsonProperty("pl_assessment_number") String plAssessmentNumber,
@JsonProperty("pl_assessment_set_color") String plAssessmentSetColor) {}
public record QuestionDTO(
String id,
@JsonProperty("assessment_id") String assessmentId,
@JsonProperty("pl_question_uuid") String plQuestionUuid,
String title) {}
}