CopyConceptGraphJob.java
package edu.ucsb.cs.scaffold.jobs;
import edu.ucsb.cs.scaffold.entity.Course;
import edu.ucsb.cs.scaffold.entity.User;
import edu.ucsb.cs.scaffold.repository.AdminRepository;
import edu.ucsb.cs.scaffold.repository.CourseRepository;
import edu.ucsb.cs.scaffold.repository.UserRepository;
import edu.ucsb.cs.scaffold.services.ConceptYamlService;
import edu.ucsb.cs156.jobs.services.JobContext;
import edu.ucsb.cs156.jobs.services.JobContextConsumer;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.Builder;
/**
* Copies one course's entire concept-graph content (concepts, subconcepts, prerequisite edges, and
* practice problems) into another course, replacing ALL of the destination course's content and
* clearing every user's saved per-course scaffold state for that course.
*
* <p>Implemented as an in-memory YAML export of the "from" course immediately followed by a YAML
* import into the "to" course (see docs/yaml-format.md), so this job cannot drift from the behavior
* of the download/upload endpoints in {@link
* edu.ucsb.cs.scaffold.controller.ConceptsYamlController}: the same code performs both, and any
* future bug fix in {@link ConceptYamlService} applies to this job automatically.
*
* <p>Before copying anything, the job verifies that both courses exist and that the launching user
* has admin or instructor/staff level access to both; otherwise it logs an error and terminates
* without touching either course.
*/
@Builder
public class CopyConceptGraphJob implements JobContextConsumer {
private long userId;
private Long fromCourseId;
private Long toCourseId;
private UserRepository userRepository;
private CourseRepository courseRepository;
private AdminRepository adminRepository;
private ConceptYamlService conceptYamlService;
@Override
public String getScopeType() {
return "course";
}
@Override
public Long getScopeId() {
return toCourseId;
}
@Override
public void accept(JobContext ctx) throws Exception {
ctx.log(
"Copying concept graph from course %d to course %d".formatted(fromCourseId, toCourseId));
Optional<Course> fromCourse = courseRepository.findById(fromCourseId);
Optional<Course> toCourse = courseRepository.findById(toCourseId);
List<String> missingCourses = new ArrayList<>();
if (fromCourse.isEmpty()) {
missingCourses.add("from course id %d".formatted(fromCourseId));
}
if (toCourse.isEmpty()) {
missingCourses.add("to course id %d".formatted(toCourseId));
}
if (!missingCourses.isEmpty()) {
throw new Exception(
"Cannot copy concept graph: %s not found"
.formatted(String.join(" and ", missingCourses)));
}
User user =
userRepository
.findById(userId)
.orElseThrow(
() ->
new Exception(
"Cannot copy concept graph: user %d not found".formatted(userId)));
if (!hasManagePermissions(user, fromCourse.get())) {
throw new Exception(
("Cannot copy concept graph: user %s does not have admin or instructor/staff access"
+ " to the from course (id %d)")
.formatted(user.getEmail(), fromCourseId));
}
if (!hasManagePermissions(user, toCourse.get())) {
throw new Exception(
("Cannot copy concept graph: user %s does not have admin or instructor/staff access"
+ " to the to course (id %d)")
.formatted(user.getEmail(), toCourseId));
}
String yaml = conceptYamlService.createYAML(fromCourseId);
Map<String, Object> report =
conceptYamlService.replaceFromYAML(
toCourseId, new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)));
if (!Boolean.TRUE.equals(report.get("success"))) {
throw new Exception("Failed to copy concept graph: %s".formatted(report.get("errors")));
}
ctx.log(
("Copied concept graph: %s concepts, %s subconcepts, %s edges, %s practice problems"
+ " created; %s user states cleared")
.formatted(
report.get("conceptsCreated"),
report.get("subconceptsCreated"),
report.get("edgesCreated"),
report.get("practiceProblemsCreated"),
report.get("userStatesCleared")));
}
private boolean hasManagePermissions(User user, Course course) {
String email = user.getEmail();
if (adminRepository.existsByEmail(email)) {
return true;
}
if (course.getCourseStaff() != null
&& course.getCourseStaff().stream().anyMatch(staff -> email.equals(staff.getEmail()))) {
return true;
}
return email.equals(course.getInstructorEmail());
}
}