diff --git a/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java b/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java new file mode 100644 index 000000000..863671815 --- /dev/null +++ b/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java @@ -0,0 +1,15 @@ +package org.wise.portal.dao.teacherpresentation; + +import org.wise.portal.dao.SimpleDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +public interface TeacherPresentationConfigDao + extends SimpleDao { + + /** + * @return the config for the given run, period, node and component, or null if none exists + */ + TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, String componentId); +} diff --git a/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java b/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java new file mode 100644 index 000000000..234126d62 --- /dev/null +++ b/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java @@ -0,0 +1,37 @@ +package org.wise.portal.dao.teacherpresentation.impl; + +import java.util.List; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import org.springframework.stereotype.Repository; +import org.wise.portal.dao.impl.AbstractHibernateDao; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@Repository("teacherPresentationConfigDao") +public class HibernateTeacherPresentationConfigDao + extends AbstractHibernateDao + implements TeacherPresentationConfigDao { + + @Override + protected Class getDataObjectClass() { + return TeacherPresentationConfig.class; + } + + @Override + public TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, + String componentId) { + CriteriaBuilder cb = getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(TeacherPresentationConfig.class); + Root root = cq.from(TeacherPresentationConfig.class); + cq.select(root).where(cb.equal(root.get("run"), run), cb.equal(root.get("period"), period), + cb.equal(root.get("nodeId"), nodeId), cb.equal(root.get("componentId"), componentId)); + List results = entityManager.createQuery(cq).getResultList(); + return results.isEmpty() ? null : results.get(0); + } +} diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java new file mode 100644 index 000000000..8b47b9d00 --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java @@ -0,0 +1,131 @@ +package org.wise.portal.presentation.web.controllers.teacher; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.user.User; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.service.run.RunService; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.portal.service.user.UserService; +import org.wise.portal.service.workgroup.WorkgroupService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +/** + * REST API for TeacherPresentation: the teacher's selected student work, student name display + * setting, prompt and reflection answers for a component in a period. + */ +@RestController +@RequestMapping("/api/teacher/presentation-config") +@PreAuthorize("hasRole('ROLE_TEACHER')") +public class TeacherPresentationConfigAPIController { + + @Autowired + private TeacherPresentationConfigService teacherPresentationConfigService; + + @Autowired + private RunService runService; + + @Autowired + private UserService userService; + + @Autowired + private WorkgroupService workgroupService; + + public record Item(Integer studentWorkId) { + } + + public record ConfigRequest(Long runId, Long periodId, String nodeId, String componentId, + String componentType, List items, String studentNamesDisplay, String prompt) { + } + + public record ConfigResponse(Long id, Long runId, Long periodId, String nodeId, + String componentId, String componentType, List items, String studentNamesDisplay, + String prompt, Long updatedByWorkgroupId, Long updatedAt) { + } + + @GetMapping + ConfigResponse getConfig(Authentication auth, @RequestParam Long runId, + @RequestParam Long periodId, @RequestParam String nodeId, + @RequestParam String componentId) throws ObjectNotFoundException { + Run run = runService.retrieveById(runId); + User user = userService.retrieveUserByUsername(auth.getName()); + if (!runService.isAllowedToViewStudentWork(run, user)) { + throw new AccessDeniedException("Not permitted"); + } + Group period = getPeriod(run, periodId); + TeacherPresentationConfig config = teacherPresentationConfigService.getConfig(run, period, + nodeId, componentId); + if (config == null) { + return new ConfigResponse(null, runId, periodId, nodeId, componentId, null, new ArrayList<>(), + TeacherPresentationConfig.NAMES_HIDE, null, null, null); + } + return toResponse(config); + } + + @PutMapping + ConfigResponse saveConfig(Authentication auth, @RequestBody ConfigRequest request) + throws ObjectNotFoundException { + Run run = runService.retrieveById(request.runId()); + User user = userService.retrieveUserByUsername(auth.getName()); + if (!runService.isAllowedToGradeStudentWork(run, user)) { + throw new AccessDeniedException("Not permitted"); + } + Group period = getPeriod(run, request.periodId()); + List studentWorkIds = new ArrayList<>(); + if (request.items() != null) { + for (Item item : request.items()) { + studentWorkIds.add(item == null ? null : item.studentWorkId()); + } + } + try { + TeacherPresentationConfig config = teacherPresentationConfigService.saveConfig(run, period, + request.nodeId(), request.componentId(), request.componentType(), studentWorkIds, + request.studentNamesDisplay(), request.prompt(), getTeacherWorkgroup(run, user)); + return toResponse(config); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + + private Group getPeriod(Run run, Long periodId) { + for (Group period : run.getPeriods()) { + if (period.getId().equals(periodId)) { + return period; + } + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Period does not belong to the run"); + } + + private Workgroup getTeacherWorkgroup(Run run, User user) { + List workgroups = workgroupService.getWorkgroupListByRunAndUser(run, user); + return workgroups.isEmpty() ? null : workgroups.get(0); + } + + private ConfigResponse toResponse(TeacherPresentationConfig config) { + List items = new ArrayList<>(); + for (Integer id : teacherPresentationConfigService.getStudentWorkIds(config)) { + items.add(new Item(id)); + } + Workgroup updatedBy = config.getUpdatedByWorkgroup(); + return new ConfigResponse(config.getId(), config.getRun().getId(), config.getPeriod().getId(), + config.getNodeId(), config.getComponentId(), config.getComponentType(), items, + config.getStudentNamesDisplay(), config.getPrompt(), + updatedBy == null ? null : updatedBy.getId(), config.getUpdatedAt().getTime()); + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java new file mode 100644 index 000000000..d77adb36b --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java @@ -0,0 +1,33 @@ +package org.wise.portal.service.teacherpresentation; + +import java.util.List; + +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +public interface TeacherPresentationConfigService { + + int MAX_ITEMS = 500; + int MAX_PROMPT_LENGTH = 1000; + + /** + * @return the config, or null if the teacher has not saved one yet + */ + TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, String componentId); + + List getStudentWorkIds(TeacherPresentationConfig config); + + /** + * Creates or updates the config for the run, period, node and component. + * + * @param prompt the teacher's prompt. null or blank resets to the component default. + * @param studentNamesDisplay one of show, hide or anonymize. null keeps the current value (or + * hide for a new config). + * @throws IllegalArgumentException if the input is invalid + */ + TeacherPresentationConfig saveConfig(Run run, Group period, String nodeId, String componentId, + String componentType, List studentWorkIds, String studentNamesDisplay, + String prompt, Workgroup teacherWorkgroup); +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java b/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java new file mode 100644 index 000000000..c1c976cc0 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java @@ -0,0 +1,172 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class TeacherPresentationConfigServiceImpl implements TeacherPresentationConfigService { + + private static final int MAX_ID_LENGTH = 30; + private static final Set NAMES_DISPLAY_OPTIONS = Set.of( + TeacherPresentationConfig.NAMES_SHOW, TeacherPresentationConfig.NAMES_HIDE, + TeacherPresentationConfig.NAMES_ANONYMIZE); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private TeacherPresentationConfigDao teacherPresentationConfigDao; + + @Autowired + private StudentWorkDao studentWorkDao; + + @Override + @Transactional(readOnly = true) + public TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, + String componentId) { + return teacherPresentationConfigDao.getConfig(run, period, nodeId, componentId); + } + + @Override + public List getStudentWorkIds(TeacherPresentationConfig config) { + try { + List> items = objectMapper.readValue(config.getItems(), + new TypeReference>>() { + }); + List ids = new ArrayList<>(); + for (Map item : items) { + ids.add(item.get("studentWorkId")); + } + return ids; + } catch (JsonProcessingException e) { + return new ArrayList<>(); + } + } + + @Override + @Transactional + public TeacherPresentationConfig saveConfig(Run run, Group period, String nodeId, + String componentId, String componentType, List studentWorkIds, + String studentNamesDisplay, String prompt, Workgroup teacherWorkgroup) { + validateComponent(nodeId, componentId, componentType); + if (studentNamesDisplay != null && !NAMES_DISPLAY_OPTIONS.contains(studentNamesDisplay)) { + throw new IllegalArgumentException("Invalid studentNamesDisplay: " + studentNamesDisplay); + } + String normalizedPrompt = normalizePrompt(prompt); + Set uniqueIds = validateStudentWorkIds(run, period, studentWorkIds); + TeacherPresentationConfig config = getOrCreateConfig(run, period, nodeId, componentId, + componentType); + config.setItems(serializeItems(uniqueIds)); + if (studentNamesDisplay != null) { + config.setStudentNamesDisplay(studentNamesDisplay); + } + config.setPrompt(normalizedPrompt); + config.setUpdatedByWorkgroup(teacherWorkgroup); + config.setUpdatedAt(new Timestamp(System.currentTimeMillis())); + teacherPresentationConfigDao.save(config); + return config; + } + + private TeacherPresentationConfig getOrCreateConfig(Run run, Group period, String nodeId, + String componentId, String componentType) { + TeacherPresentationConfig config = teacherPresentationConfigDao.getConfig(run, period, nodeId, + componentId); + if (config == null) { + Timestamp now = new Timestamp(System.currentTimeMillis()); + config = new TeacherPresentationConfig(); + config.setRun(run); + config.setPeriod(period); + config.setNodeId(nodeId); + config.setComponentId(componentId); + config.setComponentType(componentType); + config.setCreatedAt(now); + config.setUpdatedAt(now); + } + return config; + } + + private void validateComponent(String nodeId, String componentId, String componentType) { + if (nodeId == null || nodeId.isBlank() || nodeId.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid nodeId"); + } + if (componentId == null || componentId.isBlank() || componentId.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid componentId"); + } + if (componentType == null || componentType.isBlank() + || componentType.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid componentType"); + } + } + + private String normalizePrompt(String prompt) { + if (prompt == null || prompt.isBlank()) { + return null; + } + if (prompt.length() > MAX_PROMPT_LENGTH) { + throw new IllegalArgumentException("prompt is too long"); + } + return prompt; + } + + /** + * Each student work must belong to the run and period. It is not required to belong to the + * component, because discussion posts can come from connected components. + */ + private Set validateStudentWorkIds(Run run, Group period, List ids) { + Set uniqueIds = new LinkedHashSet<>(); + if (ids != null) { + uniqueIds.addAll(ids); + } + if (uniqueIds.size() > MAX_ITEMS) { + throw new IllegalArgumentException("Too many items. The maximum is " + MAX_ITEMS); + } + for (Integer id : uniqueIds) { + if (id == null) { + throw new IllegalArgumentException("Invalid studentWorkId"); + } + StudentWork work; + try { + work = studentWorkDao.getById(id); + } catch (ObjectNotFoundException e) { + throw new IllegalArgumentException("Unknown studentWorkId: " + id); + } + if (work == null || !work.getRun().getId().equals(run.getId()) + || !work.getPeriod().getId().equals(period.getId())) { + throw new IllegalArgumentException("Unknown studentWorkId: " + id); + } + } + return uniqueIds; + } + + private String serializeItems(Set studentWorkIds) { + List> items = new ArrayList<>(); + for (Integer id : studentWorkIds) { + items.add(Map.of("studentWorkId", id)); + } + try { + return objectMapper.writeValueAsString(items); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java b/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java new file mode 100644 index 000000000..34e07aa6e --- /dev/null +++ b/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java @@ -0,0 +1,89 @@ +package org.wise.vle.domain.teacherpresentation; + +import java.sql.Timestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.Setter; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.domain.workgroup.impl.WorkgroupImpl; + +/** + * A teacher's TeacherPresentation configuration for one component in one period of a run. It + * stores which student work items (e.g. discussion posts and comments) the teacher chose to + * present, how student names are displayed, and an optional override of the default prompt. + */ +@Entity(name = "teacherPresentationConfig") +@Table(name = "teacher_presentation_config", + uniqueConstraints = @UniqueConstraint(name = "teacherPresentationConfigUniqueKey", + columnNames = { "runId", "periodId", "nodeId", "componentId" }), + indexes = @Index(columnList = "runId", name = "teacherPresentationConfigRunIdIndex")) +@Getter +@Setter +public class TeacherPresentationConfig { + + public static final String NAMES_SHOW = "show"; + public static final String NAMES_HIDE = "hide"; + public static final String NAMES_ANONYMIZE = "anonymize"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(targetEntity = RunImpl.class, fetch = FetchType.LAZY) + @JoinColumn(name = "runId", nullable = false) + @JsonIgnore + private Run run; + + @ManyToOne(targetEntity = PersistentGroup.class, fetch = FetchType.LAZY) + @JoinColumn(name = "periodId", nullable = false) + @JsonIgnore + private Group period; + + @Column(name = "nodeId", length = 30, nullable = false) + private String nodeId; + + @Column(name = "componentId", length = 30, nullable = false) + private String componentId; + + @Column(name = "componentType", length = 30, nullable = false) + private String componentType; + + /** JSON array of the form [{"studentWorkId": 123}, ...] */ + @Column(name = "items", length = 65536, columnDefinition = "text", nullable = false) + private String items = "[]"; + + @Column(name = "studentNamesDisplay", length = 30, nullable = false) + private String studentNamesDisplay = NAMES_HIDE; + + /** The teacher's edited prompt. null means use the component's default prompt. */ + @Column(name = "prompt", length = 65536, columnDefinition = "text") + private String prompt; + + @ManyToOne(targetEntity = WorkgroupImpl.class, fetch = FetchType.LAZY) + @JoinColumn(name = "updatedByWorkgroupId") + @JsonIgnore + private Workgroup updatedByWorkgroup; + + @Column(name = "createdAt", nullable = false) + private Timestamp createdAt; + + @Column(name = "updatedAt", nullable = false) + private Timestamp updatedAt; +} diff --git a/src/main/resources/db/migration/V2__create_teacher_presentation_tables.sql b/src/main/resources/db/migration/V2__create_teacher_presentation_tables.sql new file mode 100644 index 000000000..27b743728 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_teacher_presentation_tables.sql @@ -0,0 +1,20 @@ +create table teacher_presentation_config ( + id bigint not null auto_increment, + runId bigint not null, + periodId bigint not null, + nodeId varchar(30) not null, + componentId varchar(30) not null, + componentType varchar(30) not null, + items text not null, + studentNamesDisplay varchar(30) not null default 'hide', + prompt text, + updatedByWorkgroupId bigint, + createdAt datetime(3) not null, + updatedAt datetime(3) not null, + primary key (id), + unique key teacherPresentationConfigUniqueKey (runId, periodId, nodeId, componentId), + index teacherPresentationConfigRunIdIndex (runId), + constraint teacherPresentationConfigRunIdFK foreign key (runId) references runs (id), + constraint teacherPresentationConfigPeriodIdFK foreign key (periodId) references `groups` (id), + constraint teacherPresentationConfigUpdatedByWorkgroupIdFK foreign key (updatedByWorkgroupId) references workgroups (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java b/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java new file mode 100644 index 000000000..98e24fcdd --- /dev/null +++ b/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java @@ -0,0 +1,131 @@ +package org.wise.portal.dao.teacherpresentation.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.Date; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.wise.portal.domain.authentication.Schoollevel; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.user.User; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.junit.AbstractTransactionalDbTests; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@SpringBootTest +public class HibernateTeacherPresentationConfigDaoTest extends AbstractTransactionalDbTests { + + private Run run; + private Run otherRun; + private Group period1; + private Group period2; + private Workgroup teacherWorkgroup; + + @Autowired + private HibernateTeacherPresentationConfigDao teacherPresentationConfigDao; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + Long projectId1 = getNextAvailableProjectId(); + Long projectId2 = getNextAvailableProjectId(); + Date startTime = Calendar.getInstance().getTime(); + + User teacher = createTeacherUser("Albus", "Dumbledore", "adumbledore", "Prof. Dumbledore", + "secret", "Hogwarts", "Scotland", "UK", "albus@hogwarts.edu", "Hogwarts School", + Schoollevel.COLLEGE, "google-teacher-1"); + + run = createProjectAndRun(projectId1, "Potions 101", teacher, startTime, "POT101"); + otherRun = createProjectAndRun(projectId2, "Defense Against the Dark Arts", teacher, startTime, + "DADA101"); + + period1 = createPeriod("Period 1"); + period2 = createPeriod("Period 2"); + + Set periods = new TreeSet<>(); + periods.add(period1); + periods.add(period2); + run.setPeriods(periods); + + teacherWorkgroup = addUserToRun(teacher, run, period1); + } + + private TeacherPresentationConfig createConfig(Run run, Group period, String nodeId, + String componentId, String componentType) { + TeacherPresentationConfig config = new TeacherPresentationConfig(); + config.setRun(run); + config.setPeriod(period); + config.setNodeId(nodeId); + config.setComponentId(componentId); + config.setComponentType(componentType); + config.setItems("[{\"studentWorkId\":101}]"); + config.setStudentNamesDisplay(TeacherPresentationConfig.NAMES_HIDE); + config.setPrompt("Sample discussion prompt"); + config.setUpdatedByWorkgroup(teacherWorkgroup); + Timestamp now = new Timestamp(System.currentTimeMillis()); + config.setCreatedAt(now); + config.setUpdatedAt(now); + teacherPresentationConfigDao.save(config); + return config; + } + + @Test + public void getConfig_WhenNoneExists_ReturnsNull() { + TeacherPresentationConfig config = teacherPresentationConfigDao.getConfig(run, period1, + "node1", "component1"); + assertNull(config); + } + + @Test + public void saveAndGetConfig_WhenExists_ReturnsMatchingConfig() { + TeacherPresentationConfig saved = createConfig(run, period1, "node1", "component1", + "Discussion"); + + TeacherPresentationConfig retrieved = teacherPresentationConfigDao.getConfig(run, period1, + "node1", "component1"); + assertNotNull(retrieved); + assertEquals(saved.getId(), retrieved.getId()); + assertEquals(run.getId(), retrieved.getRun().getId()); + assertEquals(period1.getId(), retrieved.getPeriod().getId()); + assertEquals("node1", retrieved.getNodeId()); + assertEquals("component1", retrieved.getComponentId()); + assertEquals("Discussion", retrieved.getComponentType()); + assertEquals("[{\"studentWorkId\":101}]", retrieved.getItems()); + assertEquals(TeacherPresentationConfig.NAMES_HIDE, retrieved.getStudentNamesDisplay()); + assertEquals("Sample discussion prompt", retrieved.getPrompt()); + assertEquals(teacherWorkgroup.getId(), retrieved.getUpdatedByWorkgroup().getId()); + } + + @Test + public void getConfig_WithDifferentParameters_ReturnsNull() { + createConfig(run, period1, "node1", "component1", "Discussion"); + + assertNull(teacherPresentationConfigDao.getConfig(otherRun, period1, "node1", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period2, "node1", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "otherNode", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "node1", "otherComponent")); + } + + @Test + public void delete_Config_RemovesConfig() { + TeacherPresentationConfig config = createConfig(run, period1, "node1", "component1", + "Discussion"); + flush(); + + teacherPresentationConfigDao.delete(config); + flush(); + + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "node1", "component1")); + } +} diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java new file mode 100644 index 000000000..90897b64d --- /dev/null +++ b/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java @@ -0,0 +1,234 @@ +package org.wise.portal.presentation.web.controllers.teacher; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isNull; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.easymock.TestSubject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.presentation.web.controllers.APIControllerTest; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.ConfigRequest; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.ConfigResponse; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.Item; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@ExtendWith(EasyMockExtension.class) +public class TeacherPresentationConfigAPIControllerTest extends APIControllerTest { + + @TestSubject + private TeacherPresentationConfigAPIController controller = new TeacherPresentationConfigAPIController(); + + @Mock + private TeacherPresentationConfigService teacherPresentationConfigService; + + private void expectTeacherWithPermissions(boolean canView, boolean canGrade) + throws ObjectNotFoundException { + expect(runService.retrieveById(runId1)).andReturn(run1).anyTimes(); + expect(userService.retrieveUserByUsername(teacherAuth.getName())).andReturn(teacher1) + .anyTimes(); + expect(runService.isAllowedToViewStudentWork(run1, teacher1)).andReturn(canView).anyTimes(); + expect(runService.isAllowedToGradeStudentWork(run1, teacher1)).andReturn(canGrade).anyTimes(); + expect(workgroupService.getWorkgroupListByRunAndUser(run1, teacher1)) + .andReturn(Arrays.asList(teacher1Run1Workgroup)).anyTimes(); + } + + private ConfigRequest request(Long periodId, List items, String namesDisplay, + String prompt) { + return new ConfigRequest(runId1, periodId, run1Node1Id, run1Component1Id, "Discussion", items, + namesDisplay, prompt); + } + + @Test + public void getConfig_NoConfigSaved_ReturnDefaults() throws Exception { + expectTeacherWithPermissions(true, true); + expect(teacherPresentationConfigService.getConfig(run1, run1Period1, run1Node1Id, + run1Component1Id)).andReturn(null); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.getConfig(teacherAuth, runId1, run1Period1Id, + run1Node1Id, run1Component1Id); + assertTrue(response.items().isEmpty()); + assertEquals("hide", response.studentNamesDisplay()); + assertNull(response.prompt()); + verify(teacherPresentationConfigService); + } + + @Test + public void getConfig_NoViewPermission_ThrowAccessDenied() throws Exception { + expectTeacherWithPermissions(false, false); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + assertThrows(AccessDeniedException.class, () -> controller.getConfig(teacherAuth, runId1, + run1Period1Id, run1Node1Id, run1Component1Id)); + } + + @Test + public void getConfig_PeriodNotInRun_ThrowBadRequest() throws Exception { + expectTeacherWithPermissions(true, true); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ResponseStatusException e = assertThrows(ResponseStatusException.class, + () -> controller.getConfig(teacherAuth, runId1, 99999L, run1Node1Id, run1Component1Id)); + assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); + } + + @Test + public void saveConfig_NoGradePermission_ThrowAccessDenied() throws Exception { + expectTeacherWithPermissions(true, false); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + assertThrows(AccessDeniedException.class, () -> controller.saveConfig(teacherAuth, + request(run1Period1Id, Arrays.asList(new Item(1)), "hide", null))); + } + + @Test + public void saveConfig_ServiceRejectsInput_ThrowBadRequest() throws Exception { + expectTeacherWithPermissions(true, true); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), anyObject(), eq("bogus"), + isNull(), eq(teacher1Run1Workgroup))).andThrow(new IllegalArgumentException("Invalid")); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> controller + .saveConfig(teacherAuth, request(run1Period1Id, Arrays.asList(new Item(1)), "bogus", null))); + assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); + } + + @Test + public void saveConfig_ValidRequest_SaveWithTeacherWorkgroupAndReturnConfig() throws Exception { + expectTeacherWithPermissions(true, true); + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(5L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setPrompt("My prompt"); + saved.setStudentNamesDisplay("anonymize"); + saved.setUpdatedByWorkgroup(teacher1Run1Workgroup); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(Arrays.asList(7, 8)), + eq("anonymize"), eq("My prompt"), eq(teacher1Run1Workgroup))).andReturn(saved); + expect(teacherPresentationConfigService.getStudentWorkIds(saved)) + .andReturn(Arrays.asList(7, 8)); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, Arrays.asList(new Item(7), new Item(8)), "anonymize", "My prompt")); + assertEquals(5L, response.id()); + assertEquals(2, response.items().size()); + assertEquals("My prompt", response.prompt()); + assertEquals(teacher1Run1Workgroup.getId(), response.updatedByWorkgroupId()); + verify(teacherPresentationConfigService); + } + + @Test + public void getConfig_ExistingConfig_ReturnPopulatedResponse() throws Exception { + expectTeacherWithPermissions(true, true); + + TeacherPresentationConfig config = new TeacherPresentationConfig(); + config.setId(10L); + config.setRun(run1); + config.setPeriod(run1Period1); + config.setNodeId(run1Node1Id); + config.setComponentId(run1Component1Id); + config.setComponentType("Discussion"); + config.setPrompt("Prompt override"); + config.setStudentNamesDisplay("show"); + config.setUpdatedAt(new Timestamp(1000)); + config.setUpdatedByWorkgroup(teacher1Run1Workgroup); + + expect(teacherPresentationConfigService.getConfig(run1, run1Period1, run1Node1Id, + run1Component1Id)).andReturn(config); + expect(teacherPresentationConfigService.getStudentWorkIds(config)) + .andReturn(Arrays.asList(101, 102)); + + replay(runService, userService, workgroupService, teacherPresentationConfigService); + + ConfigResponse response = controller.getConfig(teacherAuth, runId1, run1Period1Id, + run1Node1Id, run1Component1Id); + + assertEquals(10L, response.id()); + assertEquals(2, response.items().size()); + assertEquals(101, response.items().get(0).studentWorkId()); + assertEquals("Prompt override", response.prompt()); + assertEquals("show", response.studentNamesDisplay()); + verify(teacherPresentationConfigService); + } + + @Test + public void saveConfig_NullItems_SaveWithEmptyList() throws Exception { + expectTeacherWithPermissions(true, true); + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(6L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setStudentNamesDisplay("hide"); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(new ArrayList<>()), + eq("hide"), isNull(), eq(teacher1Run1Workgroup))).andReturn(saved); + expect(teacherPresentationConfigService.getStudentWorkIds(saved)) + .andReturn(new ArrayList<>()); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, null, "hide", null)); + assertEquals(6L, response.id()); + assertTrue(response.items().isEmpty()); + verify(teacherPresentationConfigService); + } + + @Test + public void saveConfig_TeacherWithNoWorkgroup_SaveWithNullWorkgroup() throws Exception { + expect(runService.retrieveById(runId1)).andReturn(run1).anyTimes(); + expect(userService.retrieveUserByUsername(teacherAuth.getName())).andReturn(teacher1) + .anyTimes(); + expect(runService.isAllowedToViewStudentWork(run1, teacher1)).andReturn(true).anyTimes(); + expect(runService.isAllowedToGradeStudentWork(run1, teacher1)).andReturn(true).anyTimes(); + expect(workgroupService.getWorkgroupListByRunAndUser(run1, teacher1)) + .andReturn(new ArrayList<>()).anyTimes(); + + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(7L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setStudentNamesDisplay("hide"); + saved.setUpdatedByWorkgroup(null); + + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), anyObject(), + eq("hide"), isNull(), isNull())).andReturn(saved); + expect(teacherPresentationConfigService.getStudentWorkIds(saved)) + .andReturn(new ArrayList<>()); + + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, new ArrayList<>(), "hide", null)); + assertEquals(7L, response.id()); + assertNull(response.updatedByWorkgroupId()); + verify(teacherPresentationConfigService); + } +} diff --git a/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java b/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java new file mode 100644 index 000000000..75ba3332b --- /dev/null +++ b/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java @@ -0,0 +1,138 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.replay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.easymock.TestSubject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +@ExtendWith(EasyMockExtension.class) +public class TeacherPresentationConfigServiceImplTest { + + @TestSubject + private TeacherPresentationConfigServiceImpl service = new TeacherPresentationConfigServiceImpl(); + + @Mock + private TeacherPresentationConfigDao teacherPresentationConfigDao; + + @Mock + private StudentWorkDao studentWorkDao; + + private RunImpl run = new RunImpl(); + private RunImpl otherRun = new RunImpl(); + private PersistentGroup period = new PersistentGroup(); + private PersistentGroup otherPeriod = new PersistentGroup(); + + @BeforeEach + public void setUp() { + run.setId(1L); + otherRun.setId(2L); + period.setId(10L); + otherPeriod.setId(11L); + } + + private StudentWork work(RunImpl workRun, PersistentGroup workPeriod) { + StudentWork work = new StudentWork(); + work.setRun(workRun); + work.setPeriod(workPeriod); + return work; + } + + private TeacherPresentationConfig save(List ids, String namesDisplay, String prompt) { + return service.saveConfig(run, period, "node1", "comp1", "Discussion", ids, namesDisplay, + prompt, null); + } + + @Test + public void saveConfig_InvalidNamesDisplay_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(new ArrayList<>(), "bogus", null)); + } + + @Test + public void saveConfig_MoreThan500Items_ThrowIllegalArgument() { + List ids = new ArrayList<>(); + for (int i = 0; i < 501; i++) { + ids.add(i); + } + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(ids, "hide", null)); + } + + @Test + public void saveConfig_PromptTooLong_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> save(new ArrayList<>(), "hide", "x".repeat(1001))); + } + + @Test + public void saveConfig_StudentWorkFromOtherPeriod_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getById(5)).andReturn(work(run, otherPeriod)); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(Arrays.asList(5), "hide", null)); + } + + @Test + public void saveConfig_StudentWorkFromOtherRun_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getById(5)).andReturn(work(otherRun, period)); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(Arrays.asList(5), "hide", null)); + } + + @Test + public void saveConfig_UnknownStudentWork_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getById(5)).andThrow(new ObjectNotFoundException(5L, StudentWork.class)); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(Arrays.asList(5), "hide", null)); + } + + @Test + public void saveConfig_NewConfig_CreateWithDefaultsAndDeduplicateItems() throws Exception { + expect(studentWorkDao.getById(5)).andReturn(work(run, period)); + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")).andReturn(null); + teacherPresentationConfigDao.save(anyObject(TeacherPresentationConfig.class)); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + TeacherPresentationConfig config = save(Arrays.asList(5, 5), null, " "); + assertEquals("hide", config.getStudentNamesDisplay()); + assertNull(config.getPrompt()); + assertEquals(Arrays.asList(5), service.getStudentWorkIds(config)); + assertEquals("Discussion", config.getComponentType()); + } + + @Test + public void saveConfig_ExistingConfig_UpdatePromptAndNames() throws Exception { + TeacherPresentationConfig existing = new TeacherPresentationConfig(); + existing.setStudentNamesDisplay("show"); + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")) + .andReturn(existing); + teacherPresentationConfigDao.save(existing); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + TeacherPresentationConfig config = save(new ArrayList<>(), "anonymize", "My prompt"); + assertEquals("anonymize", config.getStudentNamesDisplay()); + assertEquals("My prompt", config.getPrompt()); + assertEquals(0, service.getStudentWorkIds(config).size()); + } +}