diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ac522566f30ca64014c06b24d13ec23ced19df..7fb9866774c4ad54277420027c9bc9b267227693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.2.1] ### Added +- Requests in slotted labs can now be distributed to assistants in advanced. [@hpage](https://gitlab.ewi.tudelft.nl/hpage) ### Changed +- Teachers can only see assistant feedback for courses that they manage. [@hpage](https://gitlab.ewi.tudelft.nl/hpage) +- Slotted requests no longer show up 15 minutes in advance on the requests page. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) ### Fixed +- Teachers can no longer see feedback of other teachers. [@hpage](https://gitlab.ewi.tudelft.nl/hpage) +- Screens should no longer sleep when presenting (not supported on Firefox). [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) +- Lab filter for shared labs now shows the correct course codes in front of assignments. [@hpage](https://gitlab.ewi.tudelft.nl/hpage) +- Chips are no longer cut off on smaller devices. [@hpage](https://gitlab.ewi.tudelft.nl/hpage) +- Presentation text was invisible on dark mode. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) +- Forwarded requests did not always show up or showed up for the wrong people in the requests table. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) +- All requests would be added to the request table regardless of language preference. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) +- Slotted lab page did not display requests. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) ## [2.2.0] @@ -41,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Revoked requests could be finished (approved, rejected, etc.) by teachers. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) - Room maps did not work. [@rwbackx](https://gitlab.ewi.tudelft.nl/rwbackx) + - Archive button appears again. [@mmadara](https://gitlab.ewi.tudelft.nl/mmadara) ### Deprecated ### Removed diff --git a/build.gradle.kts b/build.gradle.kts index 39121623db56c46ebe974df974a7b013cf82a726..8f4a7ac52d3c191bfbfacb7c123ab6529dcb80bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.springframework.boot.gradle.tasks.run.BootRun import java.nio.file.Files group = "nl.tudelft.ewi.queue" -version = "2.2.0" +version = "2.2.1" val javaVersion = JavaVersion.VERSION_17 @@ -395,6 +395,8 @@ dependencies { implementation("org.webjars:momentjs:2.24.0") implementation("org.webjars:fullcalendar:5.9.0") implementation("org.webjars:codemirror:5.62.2") + implementation("org.webjars.npm:simplemde:1.11.2") + // Library for converting markdown to html implementation("org.commonmark:commonmark:0.18.1") diff --git a/src/main/java/nl/tudelft/queue/controller/HomeController.java b/src/main/java/nl/tudelft/queue/controller/HomeController.java index fb319f7ee74d58d2afc192ac2aac265e8b686004..fcc0416b048e9a7c1edf51439a822e6d86e28536 100644 --- a/src/main/java/nl/tudelft/queue/controller/HomeController.java +++ b/src/main/java/nl/tudelft/queue/controller/HomeController.java @@ -30,8 +30,10 @@ import nl.tudelft.labracore.api.PersonControllerApi; import nl.tudelft.labracore.api.SessionControllerApi; import nl.tudelft.labracore.api.dto.*; import nl.tudelft.labracore.lib.security.user.AuthenticatedPerson; +import nl.tudelft.labracore.lib.security.user.DefaultRole; import nl.tudelft.labracore.lib.security.user.Person; import nl.tudelft.librador.dto.view.View; +import nl.tudelft.queue.PageUtil; import nl.tudelft.queue.cache.EditionCacheManager; import nl.tudelft.queue.cache.EditionCollectionCacheManager; import nl.tudelft.queue.cache.PersonCacheManager; @@ -50,6 +52,7 @@ import nl.tudelft.queue.service.PermissionService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -252,13 +255,14 @@ public class HomeController { @PreAuthorize("@permissionService.canViewOwnFeedback()") public String ownFeedback(@AuthenticatedPerson Person person, Model model, Pageable pageable) { - fillInFeedbackModel(person.getId(), person, model, pageable); + fillInFeedbackModel(person.getId(), person, model, pageable, false); return "home/feedback"; } /** - * Maps the feedback url to a page. The Feedback page displays feedback for a user with the given ID. + * Maps the feedback url to a page. The Feedback page displays feedback for a user with the given ID. This + * endpoint is also used by TAM. * * @param person The person that is currently authenticated. * @param id The id of the person for which feedback will be shown. @@ -270,7 +274,17 @@ public class HomeController { @PreAuthorize("@permissionService.canViewFeedback(#id)") public String feedback(@AuthenticatedPerson Person person, @PathVariable("id") Long id, Model model, Pageable pageable) { - fillInFeedbackModel(id, person, model, pageable); + fillInFeedbackModel(id, person, model, pageable, false); + + return "home/feedback"; + } + + @GetMapping("/feedback/{id}/manager") + @PreAuthorize("@permissionService.canViewFeedback(#id)") + public String feedbackManager(@AuthenticatedPerson Person person, @PathVariable("id") Long id, + Model model, Pageable pageable) { + + fillInFeedbackModel(id, person, model, pageable, true); return "home/feedback"; } @@ -278,17 +292,32 @@ public class HomeController { /** * Fills in the model for a page where feedback is shown to the user. * - * @param assistantId The id of the user to find assistant for (this could be the current user). - * @param person The person that is currently authenticated. - * @param model The model that is to be filled. - * @param pageable The pageable containing information on how much feedback needs to be shown. + * + * @param assistantId The id of the user to find assistant for (this could be the current + * user). + * @param person The person that is currently authenticated. + * @param model The model that is to be filled. + * @param pageable The pageable containing information on how much feedback needs to be + * shown. + * @param restrictToCourseManager Used to restrict feedback to the courses a manager teaches. + * @throws AccessDeniedException In the case that a teacher purposefully adds a teacher as a TA to view + * their feedback. */ - private void fillInFeedbackModel(Long assistantId, Person person, Model model, Pageable pageable) { + private void fillInFeedbackModel(Long assistantId, Person person, Model model, Pageable pageable, + Boolean restrictToCourseManager) { var assistant = pCache.getRequired(assistantId); + if (assistant.getDefaultRole() == PersonSummaryDTO.DefaultRoleEnum.TEACHER + && person.getDefaultRole() != DefaultRole.ADMIN) { + throw new AccessDeniedException( + "Teachers are not permitted to view the feedback of other teachers."); + } + Page<Feedback> feedback = assistantId.equals(person.getId()) ? fr.findByAssistantAnonymised(assistantId, pageable) - : fr.findByAssistant(assistantId, pageable); + : restrictToCourseManager + ? fs.filterFeedbackForManagerCourses(fr.findByAssistant(assistantId), pageable) + : PageUtil.toPage(pageable, fr.findByAssistant(assistantId)); model.addAttribute("assistant", assistant); model.addAttribute("feedback", diff --git a/src/main/java/nl/tudelft/queue/controller/LabController.java b/src/main/java/nl/tudelft/queue/controller/LabController.java index 6d06f33f6e6d67b0d5c5fb0d9e45b1193d88ac23..fbd719e277bab525fa37607998a0e8d6e2fe4b1d 100644 --- a/src/main/java/nl/tudelft/queue/controller/LabController.java +++ b/src/main/java/nl/tudelft/queue/controller/LabController.java @@ -54,6 +54,7 @@ import nl.tudelft.queue.dto.patch.labs.CapacitySessionPatchDTO; import nl.tudelft.queue.dto.patch.labs.ExamLabPatchDTO; import nl.tudelft.queue.dto.patch.labs.RegularLabPatchDTO; import nl.tudelft.queue.dto.patch.labs.SlottedLabPatchDTO; +import nl.tudelft.queue.dto.util.DistributeRequestsDTO; import nl.tudelft.queue.dto.util.RequestTableFilterDTO; import nl.tudelft.queue.dto.view.RequestViewDTO; import nl.tudelft.queue.model.QueueSession; @@ -144,6 +145,9 @@ public class LabController { @Autowired private RoleDTOService roleService; + @Autowired + private RequestService requestService; + @Autowired private StudentGroupControllerApi sgApi; @@ -251,6 +255,26 @@ public class LabController { return "redirect:/lab/" + lab.getId(); } + /** + * + * @param lab The lab that we will distribute requests for + * @param person The distributor of the requests + * @param dto The DTO containing the assignments and assistants to distribute + * @return A redirect to the lab overview page. + */ + @Transactional + @PostMapping("/lab/{lab}/distribute") + @PreAuthorize("@permissionService.canManageSession(#lab)") + public String distributeRequests(@PathEntity Lab lab, @AuthenticatedPerson Person person, + DistributeRequestsDTO dto) { + + dto.getEditionSelections() + .forEach(edition -> requestService.distributeRequests(edition.getSelectedAssignments(), + edition.getSelectedAssistants(), person, lab)); + + return "redirect:/lab/" + lab.getId(); + } + /** * Gets the student enqueue view. This page displays the form that needs to be filled out by the student * to successfully enrol into the given session. @@ -765,6 +789,9 @@ public class LabController { return isStaffInAll || roleService.rolesForPersonInEdition(edition, person).stream() .noneMatch(roleService::isStaff); }).toList(); + Map<Long, CourseSummaryDTO> editionsToCourses = session.getEditions().stream() + .collect(Collectors.toMap(EditionSummaryDTO::getId, + edition -> eCache.getRequired(edition.getId()).getCourse())); Map<Long, String> assignments = allowedAssignments.stream() .collect(Collectors.toMap(AssignmentDetailsDTO::getId, a -> { var edition = mCache.getRequired(a.getModule().getId()).getEdition(); @@ -773,6 +800,7 @@ public class LabController { : eCache.getRequired(edition.getId()).getCourse().getName() + " - " + a.getName(); })); + Map<Long, Long> notEnqueueAble = allowedAssignments.stream().filter(a -> { var edition = mCache.getRequired(a.getModule().getId()).getEdition(); return roleService.rolesForPersonInEdition(edition, person).isEmpty(); @@ -781,6 +809,11 @@ public class LabController { return edition.getId(); })); + if (qSession instanceof AbstractSlottedLab<?>) { + model.addAttribute("distributeRequestsDto", + new DistributeRequestsDTO(editionsToCourses.size())); + } + Set<Long> alreadyInGroup = sgCache.getByPerson(person.getId()).stream() .map(g -> g.getModule().getId()).collect(Collectors.toSet()); Set<Long> hasEmptyGroups = qSession.getModules().stream() @@ -793,6 +826,7 @@ public class LabController { allowedAssignments.stream().filter(a -> hasEmptyGroups.contains(a.getModule().getId())) .map(AssignmentDetailsDTO::getId).collect(Collectors.toSet())); model.addAttribute("assignments", assignments); + model.addAttribute("editionsToCourses", editionsToCourses); model.addAttribute("notEnqueueAble", notEnqueueAble); model.addAttribute("types", lab.getAllowedRequests().stream() .collect(Collectors.groupingBy(AllowedRequest::getAssignment, diff --git a/src/main/java/nl/tudelft/queue/controller/RequestController.java b/src/main/java/nl/tudelft/queue/controller/RequestController.java index 39b90182c257f5c0cb4e85df47b74742939fc688..834d47e4f5a53f4e3e577b63f74c3ab0ae6731fd 100644 --- a/src/main/java/nl/tudelft/queue/controller/RequestController.java +++ b/src/main/java/nl/tudelft/queue/controller/RequestController.java @@ -20,6 +20,7 @@ package nl.tudelft.queue.controller; import static nl.tudelft.labracore.api.dto.RolePersonDetailsDTO.TypeEnum.*; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -43,6 +44,7 @@ import nl.tudelft.queue.model.LabRequest; import nl.tudelft.queue.model.QueueSession; import nl.tudelft.queue.model.Request; import nl.tudelft.queue.model.SelectionRequest; +import nl.tudelft.queue.model.enums.RequestStatus; import nl.tudelft.queue.model.labs.Lab; import nl.tudelft.queue.repository.LabRequestRepository; import nl.tudelft.queue.repository.ProfileRepository; @@ -148,7 +150,13 @@ public class RequestController { model.addAttribute("showName", false); model.addAttribute("showOnlyRelevant", true); setRequestTableAttributes(model, pageable, assistant, - "/requests", r -> r.getEventInfo().getStatus().isPending(), false, true); + "/requests", + r -> r.getEventInfo().getStatus() == RequestStatus.PENDING || + (r.getEventInfo().getStatus() == RequestStatus.FORWARDED && + !Objects.equals(r.getEventInfo().getForwardedBy(), assistant.getId()) && + (r.getEventInfo().getForwardedTo() == null || + r.getEventInfo().getForwardedTo().equals(assistant.getId()))), + false, true); return "request/list"; } @@ -186,7 +194,7 @@ public class RequestController { .collect(Collectors.toList()); List<LabRequest> filteredRequests = rs - .filterRequestsSharedEditionCheck(lrr.findAllByFilter(labs, filter, language)) + .filterRequestsSharedEditionCheck(lrr.findAllByFilterUpcoming(labs, filter, language)) .stream().filter(requestFilter) .toList(); diff --git a/src/main/java/nl/tudelft/queue/dto/util/DistributeRequestsDTO.java b/src/main/java/nl/tudelft/queue/dto/util/DistributeRequestsDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..faacb859ed0e653effd8c73608fb59060e6511ae --- /dev/null +++ b/src/main/java/nl/tudelft/queue/dto/util/DistributeRequestsDTO.java @@ -0,0 +1,58 @@ +/* + * Queue - A Queueing system that can be used to handle labs in higher education + * Copyright (C) 2016-2021 Delft University of Technology + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package nl.tudelft.queue.dto.util; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class DistributeRequestsDTO { + + @Builder.Default + private List<EditionRequestDistributionDTO> editionSelections = new ArrayList<>(); + + public DistributeRequestsDTO(int numEditions) { + this.editionSelections = new ArrayList<>(); + for (int i = 0; i < numEditions; i++) { + this.editionSelections.add(new EditionRequestDistributionDTO()); + } + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @EqualsAndHashCode(callSuper = false) + public static class EditionRequestDistributionDTO { + + private List<Long> selectedAssignments = new ArrayList<>(); + + private List<Long> selectedAssistants = new ArrayList<>(); + + } + +} diff --git a/src/main/java/nl/tudelft/queue/realtime/messages/RequestCreatedMessage.java b/src/main/java/nl/tudelft/queue/realtime/messages/RequestCreatedMessage.java index db7b74d5a5d3ec86aab1a31e623d6b4d7d60404e..64cdaacb976329638e469af885357ce85df5b45d 100644 --- a/src/main/java/nl/tudelft/queue/realtime/messages/RequestCreatedMessage.java +++ b/src/main/java/nl/tudelft/queue/realtime/messages/RequestCreatedMessage.java @@ -51,6 +51,8 @@ public class RequestCreatedMessage extends View<LabRequest> implements Message { private Long buildingId; private String buildingName; + private String language; + private OnlineMode onlineMode; private String onlineModeDisplayName; diff --git a/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToAnyMessage.java b/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToAnyMessage.java index 76bae7e626f74e3ae1c8cba35397edc861942e71..ba3d728913f6067ffb64f1b69d567be591919113 100644 --- a/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToAnyMessage.java +++ b/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToAnyMessage.java @@ -23,6 +23,7 @@ import lombok.*; import nl.tudelft.queue.dto.view.requests.LabRequestViewDTO; import nl.tudelft.queue.model.enums.OnlineMode; import nl.tudelft.queue.model.enums.RequestStatus; +import nl.tudelft.queue.model.enums.RequestType; import nl.tudelft.queue.model.events.RequestForwardedToAnyEvent; @Data @@ -53,6 +54,7 @@ public class RequestForwardedToAnyMessage extends RequestStatusUpdateMessage<Req private String moduleName; private Long labId; + private RequestType requestType; private String requestTypeDisplayName; @Override @@ -94,6 +96,7 @@ public class RequestForwardedToAnyMessage extends RequestStatusUpdateMessage<Req labId = view.getQSession().getId(); + requestType = view.getRequestType(); requestTypeDisplayName = view.getRequestType().displayName(); } } diff --git a/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToPersonMessage.java b/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToPersonMessage.java index 7e9ea201f4548c92246bf0e917c93c5a892ad7bf..7948f282c558aeedf61b9643419a94c9a8b81e13 100644 --- a/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToPersonMessage.java +++ b/src/main/java/nl/tudelft/queue/realtime/messages/RequestForwardedToPersonMessage.java @@ -21,6 +21,7 @@ import lombok.*; import nl.tudelft.queue.dto.view.requests.LabRequestViewDTO; import nl.tudelft.queue.model.enums.OnlineMode; import nl.tudelft.queue.model.enums.RequestStatus; +import nl.tudelft.queue.model.enums.RequestType; import nl.tudelft.queue.model.events.RequestForwardedToPersonEvent; @Data @@ -38,15 +39,20 @@ public class RequestForwardedToPersonMessage private String organizationName; + private Long roomId; private String roomName; + private Long buildingId; private String buildingName; private OnlineMode onlineMode; private String onlineModeDisplayName; + private Long assignmentId; private String assignmentName; + private Long moduleId; private String moduleName; + private RequestType requestType; private String requestTypeDisplayName; @Override @@ -72,16 +78,21 @@ public class RequestForwardedToPersonMessage requestedBy = view.requesterEntityName(); if (view.getRoom() != null) { + roomId = view.getRoom().getId(); roomName = view.getRoom().getName(); + buildingId = view.getRoom().getBuilding().getId(); buildingName = view.getRoom().getBuilding().getName(); } else if (view.getOnlineMode() != null) { onlineMode = view.getOnlineMode(); onlineModeDisplayName = view.getOnlineMode().getDisplayName(); } + assignmentId = view.getAssignment().getId(); assignmentName = view.getAssignment().getName(); + moduleId = view.getAssignment().getModule().getId(); moduleName = view.getAssignment().getModule().getName(); + requestType = view.getRequestType(); requestTypeDisplayName = view.getRequestType().displayName(); } } diff --git a/src/main/java/nl/tudelft/queue/repository/FeedbackRepository.java b/src/main/java/nl/tudelft/queue/repository/FeedbackRepository.java index 9fb6e629554baa23120710c0a791c163f38677ed..39ba61b6af96914bade7353cc54450477b5866ef 100644 --- a/src/main/java/nl/tudelft/queue/repository/FeedbackRepository.java +++ b/src/main/java/nl/tudelft/queue/repository/FeedbackRepository.java @@ -71,12 +71,11 @@ public interface FeedbackRepository /** * @param assistantId The assistant to find Feedback for. - * @param pageable The pageable object to page feedback with. * @return The page of feedback for the given Assistant. */ - default Page<Feedback> findByAssistant(Long assistantId, Pageable pageable) { + default List<Feedback> findByAssistant(Long assistantId) { return findAll(qf.id.assistantId.eq(assistantId) - .and(qf.feedback.isNotNull()).and(qf.feedback.isNotEmpty()), pageable); + .and(qf.feedback.isNotNull()).and(qf.feedback.isNotEmpty())); } /** diff --git a/src/main/java/nl/tudelft/queue/repository/LabRequestRepository.java b/src/main/java/nl/tudelft/queue/repository/LabRequestRepository.java index 556fdd0ff147ded47452390c61a0e822ce1385ba..075e27ebdc33a38e4da0cffc2280e4f306b911c7 100644 --- a/src/main/java/nl/tudelft/queue/repository/LabRequestRepository.java +++ b/src/main/java/nl/tudelft/queue/repository/LabRequestRepository.java @@ -355,24 +355,43 @@ public interface LabRequestRepository /** * Finds all requests that pass the given filter. This filter is a DTO transferred from the page * requesting to see these requests. The filter contains all labs, rooms, assignments, etc. that need to - * be displayed. If a field in the filter is left as an empty set, the filter ignores it. + * be displayed. If a field in the filter is left as an empty set, the filter ignores it. Additionally, + * returns only past and upcoming requests. * * @param labs The labs that the should also be kept in the filter. * @param filter The filter to apply to the boolean expression. * @param language The assistant's language choice * @return The filtered list of requests. */ - default List<LabRequest> findAllByFilter(List<Lab> labs, RequestTableFilterDTO filter, + default List<LabRequest> findAllByFilterUpcoming(List<Lab> labs, RequestTableFilterDTO filter, Language language) { return findAll(qlr.in(select(qlr).from(qlr) .leftJoin(qlr.timeSlot, QTimeSlot.timeSlot).on(qlr.timeSlot.id.eq(QTimeSlot.timeSlot.id)) .where(createFilterBooleanExpression(filter, qlr.session.in(labs).and( qlr.session.type.eq(QueueSessionType.REGULAR) .or(qlr.session.type.in(QueueSessionType.SLOTTED, QueueSessionType.EXAM) - .and(qlr.timeSlot.slot.opensAt.before(now().plusMinutes(10)))))) + .and(qlr.timeSlot.slot.opensAt.before(now()))))) .and(matchesLanguagePreference(language))))); } + /** + * Finds all requests that pass the given filter. This filter is a DTO transferred from the page + * requesting to see these requests. The filter contains all labs, rooms, assignments, etc. that need to + * be displayed. If a field in the filter is left as an empty set, the filter ignores it. + * + * @param labs The labs that the should also be kept in the filter. + * @param filter The filter to apply to the boolean expression. + * @param language The assistant's language choice + * @return The filtered list of requests. + */ + default List<LabRequest> findAllByFilter(List<Lab> labs, RequestTableFilterDTO filter, + Language language) { + return findAll(qlr.in(select(qlr).from(qlr) + .leftJoin(qlr.timeSlot, QTimeSlot.timeSlot).on(qlr.timeSlot.id.eq(QTimeSlot.timeSlot.id)) + .where(createFilterBooleanExpression(filter, qlr.session.in(labs)) + .and(matchesLanguagePreference(language))))); + } + /** * Creates the boolean expression that will filter requests through a filter DTO. This filter is a DTO * transferred from the page requesting to see these requests. The filter contains all labs, rooms, diff --git a/src/main/java/nl/tudelft/queue/service/FeedbackService.java b/src/main/java/nl/tudelft/queue/service/FeedbackService.java index 8dc69ea92dac183abdd159866fd29b64e447c7dd..3c325d8262af4cabb74dcddeb2797b0f0528aa42 100644 --- a/src/main/java/nl/tudelft/queue/service/FeedbackService.java +++ b/src/main/java/nl/tudelft/queue/service/FeedbackService.java @@ -21,12 +21,17 @@ import static java.time.LocalDateTime.now; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import javax.transaction.Transactional; import javax.validation.ValidationException; import nl.tudelft.labracore.api.dto.PersonSummaryDTO; +import nl.tudelft.labracore.api.dto.SessionDetailsDTO; +import nl.tudelft.queue.PageUtil; import nl.tudelft.queue.cache.PersonCacheManager; +import nl.tudelft.queue.cache.SessionCacheManager; import nl.tudelft.queue.dto.patch.FeedbackPatchDTO; import nl.tudelft.queue.model.Feedback; import nl.tudelft.queue.model.LabRequest; @@ -34,6 +39,9 @@ import nl.tudelft.queue.repository.FeedbackRepository; import nl.tudelft.queue.repository.LabRequestRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -48,6 +56,16 @@ public class FeedbackService { @Autowired private LabRequestRepository rr; + @Autowired + private PermissionService ps; + + @Autowired + @Lazy + private LabService ls; + + @Autowired + private SessionCacheManager sessionCacheManager; + /** * Finds all assistant that are involved in the given request. This list of people is generated for * students to be able to give feedback on the one TA they want to give feedback on. This list is ordered @@ -109,4 +127,32 @@ public class FeedbackService { }); } + /** + * Filters feedback for a manager, such that they can only see feedback for TAs in courses that they + * managed. + * + * @param feedback The list of feedback to be filtered. + * @param pageable The pageable used for paging. + * @return A Page of feedback that the manager can see. + */ + public Page<Feedback> filterFeedbackForManagerCourses(List<Feedback> feedback, Pageable pageable) { + List<Long> sessionIds = feedback.stream() + .map(fb -> fb.getRequest().getSession().getSession()) + .distinct() + .toList(); + + Map<Long, Boolean> canManage = sessionCacheManager + .getAndHandleAll(sessionIds, ls.deleteSessionsByIds()).stream() + .collect(Collectors.toMap( + SessionDetailsDTO::getId, + session -> ps + .canManageInAnyEdition(new ArrayList<>(session.getEditions())))); + + List<Feedback> filteredFeedback = feedback.stream() + .filter(fb -> canManage.getOrDefault(fb.getRequest().getSession().getSession(), false)) + .toList(); + + return PageUtil.toPage(pageable, filteredFeedback); + + } } diff --git a/src/main/java/nl/tudelft/queue/service/LabService.java b/src/main/java/nl/tudelft/queue/service/LabService.java index 3c65d220698a23cb0b33a39b9471f2d0f5abab2f..d825b05f211f8062b9936f2822c0884c2e2a95ac 100644 --- a/src/main/java/nl/tudelft/queue/service/LabService.java +++ b/src/main/java/nl/tudelft/queue/service/LabService.java @@ -18,6 +18,7 @@ package nl.tudelft.queue.service; import static java.time.LocalDateTime.now; +import static nl.tudelft.labracore.api.dto.RolePersonDetailsDTO.TypeEnum.*; import static nl.tudelft.queue.misc.QueueSessionStatus.*; import java.io.IOException; diff --git a/src/main/java/nl/tudelft/queue/service/ModuleDTOService.java b/src/main/java/nl/tudelft/queue/service/ModuleDTOService.java new file mode 100644 index 0000000000000000000000000000000000000000..fd3c80e3b8c2d22ff863b50cc781ad46cea4199a --- /dev/null +++ b/src/main/java/nl/tudelft/queue/service/ModuleDTOService.java @@ -0,0 +1,43 @@ +/* + * Queue - A Queueing system that can be used to handle labs in higher education + * Copyright (C) 2016-2021 Delft University of Technology + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package nl.tudelft.queue.service; + +import java.util.List; + +import nl.tudelft.labracore.api.dto.AssignmentSummaryDTO; +import nl.tudelft.labracore.api.dto.ModuleDetailsDTO; + +import org.springframework.stereotype.Service; + +@Service +public class ModuleDTOService { + + /** + * Gets assignments with respect to a certain edition within several modules. + * + * @param dto The list of modules to consider. + * @param editionId The edition to consider. + * @return The assignments that belong to that edition within the modules. + */ + public List<AssignmentSummaryDTO> getAssignmentsInEdition(List<ModuleDetailsDTO> dto, Long editionId) { + return dto.stream().filter(module -> module.getEdition().getId().equals(editionId)) + .flatMap(module -> module.getAssignments().stream()) + .collect(java.util.stream.Collectors.toList()); + } + +} diff --git a/src/main/java/nl/tudelft/queue/service/PermissionService.java b/src/main/java/nl/tudelft/queue/service/PermissionService.java index 7fb6c4d32890ea5fa4349dd977af09e9100ed50f..0f8c0bc86b29db55c026e10acd3f02c19385074a 100644 --- a/src/main/java/nl/tudelft/queue/service/PermissionService.java +++ b/src/main/java/nl/tudelft/queue/service/PermissionService.java @@ -759,7 +759,7 @@ public class PermissionService { * course and the request is not yet assigned to anybody. */ public boolean canPickRequest(Long requestId) { - return withRequest(requestId, + return isAdmin() || withRequest(requestId, request -> withEdition(request.toViewDTO().getEdition().getId(), edition -> withRole(edition.getId(), (person, role) -> MANAGER_ROLES.contains(role))) diff --git a/src/main/java/nl/tudelft/queue/service/RequestService.java b/src/main/java/nl/tudelft/queue/service/RequestService.java index 013a04f2ce2615a3110a8ba49306e96611e89452..cb81608c0b707952a94c7944d4fec921252075d1 100644 --- a/src/main/java/nl/tudelft/queue/service/RequestService.java +++ b/src/main/java/nl/tudelft/queue/service/RequestService.java @@ -39,6 +39,7 @@ import nl.tudelft.queue.model.LabRequest; import nl.tudelft.queue.model.Request; import nl.tudelft.queue.model.SelectionRequest; import nl.tudelft.queue.model.enums.OnlineMode; +import nl.tudelft.queue.model.enums.RequestStatus; import nl.tudelft.queue.model.enums.SelectionProcedure; import nl.tudelft.queue.model.events.*; import nl.tudelft.queue.model.labs.AbstractSlottedLab; @@ -143,11 +144,6 @@ public class RequestService { if (!currentlyEnqueuing.add(personId)) return; if (request instanceof LabRequest labRequest) { - /* - * TODO in the future, delegate to another service layer component, - * and let it generate a link for the respective online mode. - * This is for the far future, when more online modes need to be supported. - */ if (labRequest.getTimeSlot() != null && !labRequest.getTimeSlot().canTakeSlot()) { throw new AccessDeniedException("Time slot is not available"); @@ -589,4 +585,34 @@ public class RequestService { .flatMap(id -> sgApi.getStudentGroupsById(List.of(id)).collectList().map(l -> l.get(0))) .block(); } + + /** + * Distributes requests by abusing the forwarding mechanism. Assigns requests in a round-robin fashion. + * + * @param selectedAssignments Assignments that should be distributed + * @param selectedAssistants Assistants that should receieve the distributed requests + * @param distributor Person who is responsible for distributing the requests + * @param lab The lab session that the requests belong to. + */ + @Transactional + public void distributeRequests(List<Long> selectedAssignments, List<Long> selectedAssistants, + Person distributor, Lab lab) { + var assistants = pCache + .getAndIgnoreMissing(selectedAssistants); + + if (assistants.isEmpty()) + return; + + var requests = lab.getRequests().stream() + .filter(rq -> selectedAssignments.contains(rq.getAssignment()) + && rq.getEventInfo().getStatus() == RequestStatus.PENDING) + .sorted(Comparator.comparing(Request::getCreatedAt)) + .toList(); + + // round robin request assignment + for (int i = 0; i < requests.size(); i++) { + forwardRequestToPerson(requests.get(i), distributor, assistants.get(i % assistants.size()), + "Distributed by " + distributor.getDisplayName()); + } + } } diff --git a/src/main/java/nl/tudelft/queue/service/RequestTableService.java b/src/main/java/nl/tudelft/queue/service/RequestTableService.java index 58ebdf0be90f91c9e848caa741e39e8cad52cc5d..7a3476c4ebec1d15a90989a986a56d11025e5fc4 100644 --- a/src/main/java/nl/tudelft/queue/service/RequestTableService.java +++ b/src/main/java/nl/tudelft/queue/service/RequestTableService.java @@ -22,7 +22,6 @@ import static nl.tudelft.labracore.api.dto.RolePersonDetailsDTO.TypeEnum.*; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; -import java.util.stream.IntStream; import java.util.stream.Stream; import javax.servlet.http.HttpSession; @@ -34,6 +33,7 @@ import nl.tudelft.labracore.api.SessionControllerApi; import nl.tudelft.labracore.api.dto.*; import nl.tudelft.labracore.lib.security.user.Person; import nl.tudelft.librador.dto.view.View; +import nl.tudelft.queue.PageUtil; import nl.tudelft.queue.cache.*; import nl.tudelft.queue.dto.util.RequestTableFilterDTO; import nl.tudelft.queue.dto.view.QueueSessionSummaryDTO; @@ -97,6 +97,9 @@ public class RequestTableService { @Autowired private SessionCacheManager sCache; + @Autowired + private ModuleCacheManager mCache; + @Autowired private StudentGroupCacheManager sgCache; @@ -273,10 +276,7 @@ public class RequestTableService { .filter(r -> r.getEventInfo().getStatus() != RequestStatus.FORWARDED)) .toList(); } - final int start = (int) pageable.getOffset(); - final int end = (int) (Math.min((start + pageable.getPageSize()), requestList.size())); - return new PageImpl<>(convertRequestsToView(sortedRequestList.subList(start, end)), pageable, - requestList.size()); + return PageUtil.toPage(pageable, convertRequestsToView(sortedRequestList)); } /** @@ -375,18 +375,11 @@ public class RequestTableService { if (assignments.isEmpty()) return Collections.emptyMap(); - List<Long> assignmentIds = assignments.stream().map(AssignmentDetailsDTO::getId).toList(); - - var assignmentModules = Objects - .requireNonNull(aApi.getAssignmentsWithModules(assignmentIds).collectList().block()); - - List<String> courseCodes = eCache - .getAndIgnoreMissing(assignmentModules.stream() - .map(assignment -> assignment.getModule().getEdition().getId())) - .stream().map(edition -> edition.getCourse().getCode()).toList(); - - return IntStream.range(0, assignments.size()).boxed() - .collect(Collectors.toMap(assignmentIds::get, courseCodes::get)); + return assignments.stream().collect(Collectors.toMap( + AssignmentDetailsDTO::getId, + assignment -> eCache + .getRequired(mCache.getRequired(assignment.getModule().getId()).getEdition().getId()) + .getCourse().getCode())); } diff --git a/src/main/java/nl/tudelft/queue/service/RoleDTOService.java b/src/main/java/nl/tudelft/queue/service/RoleDTOService.java index 5778a49563a7e1f775761f9a1431a2a4d71ab7e0..2824d217919b4f6182aaf6ea7130673e856959fb 100644 --- a/src/main/java/nl/tudelft/queue/service/RoleDTOService.java +++ b/src/main/java/nl/tudelft/queue/service/RoleDTOService.java @@ -27,7 +27,9 @@ import java.util.stream.Collectors; import nl.tudelft.labracore.api.RoleControllerApi; import nl.tudelft.labracore.api.dto.*; import nl.tudelft.labracore.lib.security.user.Person; +import nl.tudelft.queue.cache.EditionCacheManager; import nl.tudelft.queue.cache.EditionRolesCacheManager; +import nl.tudelft.queue.cache.SessionCacheManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -40,6 +42,12 @@ public class RoleDTOService { @Autowired private RoleControllerApi rApi; + @Autowired + private EditionCacheManager eCache; + + @Autowired + private SessionCacheManager sessionCacheManager; + public List<String> names(List<PersonSummaryDTO> people) { return people.stream().map(PersonSummaryDTO::getDisplayName).collect(Collectors.toList()); } @@ -77,6 +85,10 @@ public class RoleDTOService { return roles(erCache.getRequired(eDto.getId()).getRoles(), types); } + public List<PersonSummaryDTO> roles(EditionSummaryDTO eDto, Set<RolePersonDetailsDTO.TypeEnum> types) { + return roles(eCache.getRequired(eDto.getId()), types); + } + /** * Gets the display name of the role type. * @@ -131,6 +143,10 @@ public class RoleDTOService { return roles(eDto, Set.of(TA)); } + public List<PersonSummaryDTO> staff(EditionSummaryDTO eDto) { + return roles(eDto, Set.of(TA, HEAD_TA, TEACHER, TEACHER_RO)); + } + public List<PersonSummaryDTO> headTAs(EditionDetailsDTO eDto) { return roles(eDto, Set.of(HEAD_TA)); } diff --git a/src/main/resources/scss/presentation.scss b/src/main/resources/scss/presentation.scss index 263b08d2bc7f277e7937d4ddd58c825499280530..7b3fd72f97e4c95fb1b016e43602dda44235f281 100644 --- a/src/main/resources/scss/presentation.scss +++ b/src/main/resources/scss/presentation.scss @@ -8,6 +8,7 @@ } background-color: white; + color: black; display: flex; flex-direction: column; height: 100cqh; diff --git a/src/main/resources/static/js/request_table.js b/src/main/resources/static/js/request_table.js index 53844fe13cd17d93f184906705d675f66aa50d29..ec92da53f233f41f7f4a9d40d799caecfdd23f6d 100644 --- a/src/main/resources/static/js/request_table.js +++ b/src/main/resources/static/js/request_table.js @@ -77,6 +77,24 @@ const inFilter = (() => { }; }).apply(); +/** + * Checks if the event matches the user's language preference. + * + * @param event The event to check + */ +function matchesLanguage(event) { + switch (event.language) { + case "ANY": + return true; + case "DUTCH_ONLY": + return userLanguage === "ANY" || userLanguage === "DUTCH_ONLY"; + case "ENGLISH_ONLY": + return userLanguage === "ANY" || userLanguage === "ENGLISH_ONLY"; + default: + return false; + } +} + /** * Handles the creation of a websocket connection by subscribing to the relevant topics. * @param client The STOMP Client object to connect with. @@ -88,7 +106,7 @@ function handleSocketCreation(client) { if (page && page > 0) return; const event = JSON.parse(msg.body); - if (event.type === "request-created" && inFilter(event)) { + if (event.type === "request-created" && inFilter(event) && matchesLanguage(event)) { appendToRequestTable(event); increaseGetNextCounter(event["labId"]); } else if (event.type !== "request-created") { diff --git a/src/main/resources/templates/admin/view/running.html b/src/main/resources/templates/admin/view/running.html index 142bfc9a529e3745833b9e986fae18737ecc5e8c..9c157335de342e16028c97900abf0a32f07f0e60 100644 --- a/src/main/resources/templates/admin/view/running.html +++ b/src/main/resources/templates/admin/view/running.html @@ -36,22 +36,26 @@ <div th:if="${#lists.isEmpty(runningLabs)}">No sessions running</div> <ul class="list divided" role="list"> - <li th:each="lab : ${runningLabs}" class="pbl-3"> + <li th:each="lab : ${runningLabs}" class="pbl-3 flex align-start align-center"> <a class="link" th:href="@{/lab/{id}(id=${lab.id})}" th:text="|${lab.name} ${#temporals.format(lab.slot.opensAt, 'dd MMMM yyyy HH:mm')} - ${#temporals.format(lab.slot.closesAt, 'dd MMMM yyyy HH:mm')} ${lab.slotOccupationString}|"></a> - <span class="chip" th:if="${lab.slot.open()}">Active</span> - <span class="chip" th:if="${lab.slot.closed()}">Completed</span> + <div class="flex wrap gap-2 p-1"> + <span class="chip" th:if="${lab.slot.open()}">Active</span> + <span class="chip" th:if="${lab.slot.closed()}">Completed</span> - <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).EXAM}">Exam</span> - <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).SLOTTED}">Slotted</span> - <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).CAPACITY}">Limited Capacity</span> + <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).EXAM}">Exam</span> + <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).SLOTTED}">Slotted</span> + <span class="chip" th:if="${lab.type == T(nl.tudelft.queue.model.enums.QueueSessionType).CAPACITY}"> + Limited Capacity + </span> - <span class="chip" data-type="warning" th:if="${!lab.slot.closed() && lab.status.isOpenToEnqueueing()}"> - Open for enqueueing - </span> + <span class="chip" data-type="warning" th:if="${!lab.slot.closed() && lab.status.isOpenToEnqueueing()}"> + Open for enqueueing + </span> + </div> </li> </ul> </div> diff --git a/src/main/resources/templates/edition/view/archive.html b/src/main/resources/templates/edition/view/archive.html index 98548c6d0dbb93460dfb496fb4aba80d81dde7ff..d614d8de3e4f972c3bc93f44bb9b331d8b3c8eb2 100644 --- a/src/main/resources/templates/edition/view/archive.html +++ b/src/main/resources/templates/edition/view/archive.html @@ -10,9 +10,9 @@ <body> <section layout:fragment="subcontent" th:with="currentDateTime=${T(java.time.LocalDateTime).now()}"> <div class="page-sub-header"> - <h3>Archive</h3> + <h3 class="font-500 mb-5">Archive edition</h3> </div> - <p th:if="${currentDateTime < edition.endDate}"> + <p th:if="${currentDateTime < edition.endDate}" class="mb-5"> The edition has <strong>not yet finished</strong> . It will finish on @@ -23,20 +23,18 @@ <strong th:text="|${edition.course.name} (${edition.name})|"></strong> ? </p> - <p th:if="${currentDateTime >= edition.endDate}"> + <p th:if="${currentDateTime >= edition.endDate}" class="mb-5"> The edition has finished. Are you sure you want to archive <strong th:text="|${edition.course.name} (${edition.name})|"></strong> ? </p> - <form action="#" th:action="@{/edition/{id}/archive(id=${edition.id})}" class="form-horizontal" method="post"> - <div class="text-center"> - <button type="submit" class="btn btn-danger">Archive</button> - <small> - or - <a th:href="@{/edition/{id}(id=${edition.id})}">go back</a> - </small> - </div> + <form th:action="@{/edition/{id}/archive(id=${edition.id})}" method="post"> + <button type="submit" class="button" data-type="error">Archive</button> + <small> + or + <a class="link" th:href="@{/edition/{id}(id=${edition.id})}">go back</a> + </small> </form> </section> </body> diff --git a/src/main/resources/templates/edition/view/info.html b/src/main/resources/templates/edition/view/info.html index 2c9773cf489b8e73082ef94a86286176e66f540f..2a6e8a82e35bbb3bba96b401e17982b1c2392252 100644 --- a/src/main/resources/templates/edition/view/info.html +++ b/src/main/resources/templates/edition/view/info.html @@ -88,6 +88,7 @@ <h3 class="font-500 mb-3">Options</h3> <div class="flex wrap gap-3"> + <a th:href="@{/edition/{id}/archive(id=${edition.id})}" class="button" data-style="outlined" data-type="error">Archive edition</a> <form class="d-inline" th:action="@{/edition/{editionId}/visibility(editionId=${edition.id})}" method="post"> <button class="button" data-style="outlined"> <span th:if="${edition.hidden}">Unhide in Queue</span> diff --git a/src/main/resources/templates/edition/view/labs.html b/src/main/resources/templates/edition/view/labs.html index a97c352404a530784a44e5d003a235736862bda5..309f8475fe96d7719bfd850b616aa687482676f0 100644 --- a/src/main/resources/templates/edition/view/labs.html +++ b/src/main/resources/templates/edition/view/labs.html @@ -82,14 +82,14 @@ <ul class="surface divided list" role="list"> <th:block th:each="lab : ${labs}"> <li class="flex space-between pbl-3"> - <div class="flex gap-3"> + <div class="flex gap-3 align-center"> <a href="#" th:href="@{/lab/{id}(id=${lab.id})}" class="link" th:text="|${lab.name} ${#temporals.format(lab.slot.opensAt, 'dd MMMM yyyy HH:mm')} - ${#temporals.format(lab.slot.closesAt, 'dd MMMM yyyy HH:mm')} ${lab.slotOccupationString}|"></a> - <div> + <div class="flex wrap gap-2 p-1 align-center"> <span class="chip" th:if="${lab.slot.open()}">Active</span> <span class="chip" th:if="${lab.slot.closed()}">Completed</span> diff --git a/src/main/resources/templates/edition/view/participants.html b/src/main/resources/templates/edition/view/participants.html index 8d15040c082c07d8973987ed27b5cbd13ed2261e..7835f7e0cdef6e3cc8eb2575dc03f1ddf39f6b8b 100644 --- a/src/main/resources/templates/edition/view/participants.html +++ b/src/main/resources/templates/edition/view/participants.html @@ -73,7 +73,7 @@ <ul class="surface__content divided list" role="list" th:unless="${#lists.isEmpty(headTAs)}"> <li class="pbl-3 flex space-between" th:each="person : ${headTAs}"> <th:block th:if="${@permissionService.canViewFeedback(person.id)}"> - <a class="link" th:href="@{/feedback/{id}(id=${person.id})}" th:text="${person.displayName}"></a> + <a class="link" th:href="@{/feedback/{id}/manager(id=${person.id})}" th:text="${person.displayName}"></a> </th:block> <span th:unless="${@permissionService.canViewFeedback(person.id)}" th:text="${person.displayName}"></span> <div class="flex gap-3"> @@ -95,7 +95,7 @@ <ul class="surface__content divided list" role="list" th:unless="${#lists.isEmpty(assistants)}"> <li class="pbl-3 flex space-between" th:each="person : ${assistants}"> <th:block th:if="${@permissionService.canViewFeedback(person.id)}"> - <a class="link" th:href="@{/feedback/{id}(id=${person.id})}" th:text="${person.displayName}"></a> + <a class="link" th:href="@{/feedback/{id}/manager(id=${person.id})}" th:text="${person.displayName}"></a> </th:block> <span th:unless="${@permissionService.canViewFeedback(person.id)}" th:text="${person.displayName}"></span> <div class="flex gap-3"> diff --git a/src/main/resources/templates/home/dashboard.html b/src/main/resources/templates/home/dashboard.html index 514f57a554c13abeda51d0662b1e2391c8094c1b..14d9170b754051e28ee723e7c483cab3d741c358 100644 --- a/src/main/resources/templates/home/dashboard.html +++ b/src/main/resources/templates/home/dashboard.html @@ -175,7 +175,7 @@ class="link" th:href="@{/lab/{id}(id=${lab.id})}" th:text="|${lab.name} ${#temporals.format(lab.slot.opensAt, 'dd MMMM yyyy HH:mm')} - ${#temporals.format(lab.slot.closesAt, 'dd MMMM yyyy HH:mm')} ${lab.slotOccupationString}|"></a> - <div> + <div class="flex wrap gap-2 p-1"> <span th:if="${lab.isShared}" class="chip">Shared lab</span> <span class="chip" th:if="${lab.slot.open()}">Active</span> <span class="chip" th:if="${lab.slot.closed()}">Completed</span> diff --git a/src/main/resources/templates/home/language.html b/src/main/resources/templates/home/language.html index 2849ad84c997ca568ad323d64e7b2367193625ca..9670d5e3e4394a2002bca943e021f61cd0092e1e 100644 --- a/src/main/resources/templates/home/language.html +++ b/src/main/resources/templates/home/language.html @@ -20,6 +20,11 @@ </div> </form> + <script th:inline="javascript"> + //<![CDATA[ + const userLanguage = /*[[${#profile.language.name()}]]*/ "ENGLISH_ONLY"; + //]]> + </script> <script> document.addEventListener("DOMContentLoaded", function () { const form = document.getElementById("profile-update-form"); diff --git a/src/main/resources/templates/lab/create.html b/src/main/resources/templates/lab/create.html index 454d970d0cec50d456e1ecf5acf265b9ce880f08..ab985c3ae7db5a45243475fdf70bd8c088e04e9e 100644 --- a/src/main/resources/templates/lab/create.html +++ b/src/main/resources/templates/lab/create.html @@ -24,8 +24,8 @@ <script src="/webjars/momentjs/min/moment.min.js"></script> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css" /> - <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script> + <link rel="stylesheet" href="/webjars/simplemde/dist/simplemde.min.css" /> + <script src="/webjars/simplemde/dist/simplemde.min.js"></script> </head> <!--@thymesVar id="ec" type="nl.tudelft.labracore.api.dto.EditionCollectionDetailsDTO"--> diff --git a/src/main/resources/templates/lab/edit.html b/src/main/resources/templates/lab/edit.html index 15d4ef7b73c4f2d40a71ed46c585684be76a6c9f..953dce4dc37e7a5a26057b0cc9747e1a0d70d4e0 100644 --- a/src/main/resources/templates/lab/edit.html +++ b/src/main/resources/templates/lab/edit.html @@ -22,8 +22,8 @@ <head> <script src="/webjars/momentjs/min/moment.min.js"></script> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css" /> - <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script> + <link rel="stylesheet" href="/webjars/simplemde/dist/simplemde.min.css" /> + <script src="/webjars/simplemde/dist/simplemde.min.js"></script> </head> <!--@thymesVar id="ec" type="nl.tudelft.labracore.api.dto.EditionCollectionDetailsDTO"--> diff --git a/src/main/resources/templates/lab/presentation/edit.html b/src/main/resources/templates/lab/presentation/edit.html index cb70b3040ef1969908b6dc0ba482bc4f579f7145..249eea3d9757dcda9088224c8bf27f9970c402bb 100644 --- a/src/main/resources/templates/lab/presentation/edit.html +++ b/src/main/resources/templates/lab/presentation/edit.html @@ -22,8 +22,8 @@ <head> <link rel="stylesheet" href="/css/presentation.css" /> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css" /> - <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script> + <link rel="stylesheet" href="/webjars/simplemde/dist/simplemde.min.css" /> + <script src="/webjars/simplemde/dist/simplemde.min.js"></script> </head> <body> diff --git a/src/main/resources/templates/lab/presentation/view.html b/src/main/resources/templates/lab/presentation/view.html index 73ca4b2f5d941c5fa498633f67dff0fe7e004828..ce82d25041a5ba090d925fda20952b704feb0b28 100644 --- a/src/main/resources/templates/lab/presentation/view.html +++ b/src/main/resources/templates/lab/presentation/view.html @@ -22,6 +22,11 @@ <head> <link rel="stylesheet" href="/css/presentation.css" /> <script src="/webjars/momentjs/min/moment.min.js"></script> + <script> + document.addEventListener("DOMContentLoaded", async function () { + await navigator.wakeLock.request("screen"); + }); + </script> </head> <body> diff --git a/src/main/resources/templates/lab/view/components/slots-info.html b/src/main/resources/templates/lab/view/components/slots-info.html index bcdda9b8e98a5f8f0f0e938c10631cfb03d50b45..89dadfbd130fb055984a8635ff31cabf642e631d 100644 --- a/src/main/resources/templates/lab/view/components/slots-info.html +++ b/src/main/resources/templates/lab/view/components/slots-info.html @@ -22,6 +22,8 @@ <!--@thymesVar id="qSession" type="nl.tudelft.queue.dto.view.labs.AbstractSlottedLabViewDTO"--> + <!--@thymesVar id="distributeRequestsDto" type="nl.tudelft.queue.dto.util.DistributeRequestsDTO"--> + <body> <div th:fragment="slots-info"> <div th:if="${@permissionService.canManageSession(qSession.data)}" class="tabs mb-5" role="tablist"> @@ -33,6 +35,10 @@ <i class="fa fa-chair"></i> Edit time slot Capacity </button> + <button type="button" role="tab" aria-controls="ts-distribution-tab" aria-selected="false"> + <i class="fa fa-users-cog"></i> + Distribute Requests + </button> </div> <div id="basic-tab"> @@ -168,6 +174,81 @@ </form> </div> </th:block> + + <th:block th:if="${@permissionService.canManageSession(qSession.data)}"> + <div id="ts-distribution-tab" hidden> + <div class="flex vertical gap-5"> + <h2 class="font-600 fw-400">Distribute Requests</h2> + <div class="banner" data-type="info"> + <span class="fa-solid fa-info-circle"></span> + <p> + Distribute pending requests relating to specific assignments in a round-robin fashion among selected assistants. The + distribution will be done in the order of the assistants selected. + </p> + </div> + + <form + th:object="${distributeRequestsDto}" + th:action="@{/lab/{id}/distribute(id=${qSession.id})}" + th:method="POST" + class="flex vertical gap-5"> + <div id="distribute-edition-cards" class="flex vertical space-between"> + <th:block th:each="edition, editionItemStat : ${qSession.session.editions}"> + <div + class="surface p-0" + th:id="'distribute-card-' + ${edition.id}" + th:if="${@permissionService.canManageEdition(edition.id)}"> + <h3 class="surface__header" th:text="${editionsToCourses.get(edition.id).name}"></h3> + + <div class="surface__content"> + <div class="grid col-2 align-center" style="--col-1: minmax(0, 10rem)"> + <label th:for="'assignment-distribute-select-' + ${edition.id}">Select Assignments</label> + <div> + <select + data-select + multiple + class="textfield" + data-style="variant" + th:id="'assignment-distribute-select-' + ${edition.id}" + data-title="Select assignment(s) to distribute" + th:field="*{editionSelections[__${editionItemStat.index}__].selectedAssignments}"> + <th:block + th:each="assignment : ${@moduleDTOService.getAssignmentsInEdition(modules, edition.id)}"> + <option th:value="${assignment.id}" th:text="|${assignment.name}|"></option> + </th:block> + </select> + </div> + + <label th:for="'assistant-distribute-select-' + ${edition.id}">Select Assistants</label> + <div> + <select + data-select + multiple + class="textfield" + data-style="variant" + th:id="'assistant-distribute-select-' + ${edition.id}" + data-title="Select assistant(s) to distribute among" + th:field="*{editionSelections[__${editionItemStat.index}__].selectedAssistants}"> + <th:block th:each="person : ${@roleDTOService.staff(edition)}"> + <option th:value="${person.id}" th:text="${person.displayName}"></option> + </th:block> + </select> + </div> + </div> + </div> + </div> + </th:block> + </div> + + <div> + <button type="submit" data-type="primary" data-style="filled" class="button">Distribute Requests</button> + </div> + </form> + </div> + </div> + </th:block> + + <script type="text/javascript" src="/js/form_submission_enhancer.js"></script> </div> </body> </html> diff --git a/src/main/resources/templates/request/list/filters.html b/src/main/resources/templates/request/list/filters.html index 828ed2a43c96f57bdddcfd10b7e57a4b4dde53a8..fea3caee1797687fb9b0afe24294a66284dc403d 100644 --- a/src/main/resources/templates/request/list/filters.html +++ b/src/main/resources/templates/request/list/filters.html @@ -61,7 +61,7 @@ <option th:value="${assignment.id}" th:selected="${filter.assignments.contains(assignment.id)}" - th:text="|${assignmentsWithCourseCodes.getOrDefault(assignment.id,'CSEXXXX')} - ${assignment.name}|"></option> + th:text="|${assignmentsWithCourseCodes.getOrDefault(assignment.id,'Unknown Course')} - ${assignment.name}|"></option> </th:block> </select> diff --git a/src/test/java/nl/tudelft/queue/controller/HomeControllerTest.java b/src/test/java/nl/tudelft/queue/controller/HomeControllerTest.java index f5c9643d8e462f93adcc6fc9d309293c906499f3..0b27b4569df4e68325213ed1a135bd270a1e57e8 100644 --- a/src/test/java/nl/tudelft/queue/controller/HomeControllerTest.java +++ b/src/test/java/nl/tudelft/queue/controller/HomeControllerTest.java @@ -17,6 +17,7 @@ */ package nl.tudelft.queue.controller; +import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; @@ -48,8 +49,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.web.servlet.MockMvc; @@ -83,6 +84,10 @@ class HomeControllerTest { private PersonSummaryDTO student50; private PersonSummaryDTO student100; + private PersonSummaryDTO teacher1; + + private PersonSummaryDTO teacher2; + private final ModelMapper mapper = new ModelMapper(); @BeforeEach @@ -94,6 +99,10 @@ class HomeControllerTest { student50 = db.getStudents()[50]; student100 = db.getStudents()[100]; + teacher1 = db.getTeachers()[1]; + + teacher2 = db.getTeachers()[2]; + // This mock has been untrustworthy in the past, so we should reset it every test. reset(feedbackRepository); } @@ -189,7 +198,7 @@ class HomeControllerTest { .andExpect(status().isForbidden()); verify(permissionService).canViewFeedback(student100.getId()); - verify(feedbackRepository, never()).findByAssistant(anyLong(), any(Pageable.class)); + verify(feedbackRepository, never()).findByAssistant(anyLong()); } @Test @@ -201,10 +210,52 @@ class HomeControllerTest { .andExpect(model().attributeExists("feedback")) .andExpect(view().name("home/feedback")); - verify(feedbackRepository, atLeastOnce()).findByAssistant(eq(student100.getId()), any()); + verify(feedbackRepository, atLeastOnce()).findByAssistant(eq(student100.getId())); + verify(permissionService).canViewFeedback(student100.getId()); + } + + @Test + @WithUserDetails("teacher1") + void managerViewWorks() throws Exception { + + mvc.perform(get("/feedback/{id}/manager", student100.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("assistant", matchPersonId(student100.getId()))) + .andExpect(model().attributeExists("feedback")) + .andExpect(view().name("home/feedback")) + .andExpect(model().attribute("feedback", instanceOf(Page.class))) + .andExpect(model().attribute("feedback", hasProperty("totalElements", equalTo(0L)))); + + verify(feedbackRepository, atLeastOnce()).findByAssistant(eq(student100.getId())); verify(permissionService).canViewFeedback(student100.getId()); } + @Test + @WithUserDetails("admin") + void adminCanViewTeacherFeedbackSuccessfully() throws Exception { + mvc.perform(get("/feedback/{id}/manager", teacher1.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("assistant", matchPersonId(teacher1.getId()))) + .andExpect(model().attributeExists("feedback")) + .andExpect(view().name("home/feedback")); + + mvc.perform(get("/feedback/{id}/manager", teacher2.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("assistant", matchPersonId(teacher2.getId()))) + .andExpect(model().attributeExists("feedback")) + .andExpect(view().name("home/feedback")); + + } + + @Test + @WithUserDetails("teacher1") + void teachersCannotViewFeedbackOfOtherTeachers() throws Exception { + mvc.perform(get("/feedback/{id}/manager", teacher2.getId())) + .andExpect(status().isForbidden()) + .andExpect(view().name("error/403")); + + } + @Test void specificFeedbackNeedsAuthentication() throws Exception { mvc.perform(get("/feedback/1")) diff --git a/src/test/java/nl/tudelft/queue/controller/LabControllerTest.java b/src/test/java/nl/tudelft/queue/controller/LabControllerTest.java index 7dac8658472846052ecf43095616fcd81cf520de..c743cc76e9351dd5be02292435973d7f030ae510 100644 --- a/src/test/java/nl/tudelft/queue/controller/LabControllerTest.java +++ b/src/test/java/nl/tudelft/queue/controller/LabControllerTest.java @@ -42,6 +42,7 @@ import nl.tudelft.queue.dto.create.requests.SelectionRequestCreateDTO; import nl.tudelft.queue.dto.patch.labs.ExamLabPatchDTO; import nl.tudelft.queue.dto.patch.labs.RegularLabPatchDTO; import nl.tudelft.queue.dto.patch.labs.SlottedLabPatchDTO; +import nl.tudelft.queue.dto.util.DistributeRequestsDTO; import nl.tudelft.queue.model.LabRequest; import nl.tudelft.queue.model.QueueSession; import nl.tudelft.queue.model.SelectionRequest; @@ -69,6 +70,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -78,6 +80,8 @@ import reactor.core.publisher.Mono; import test.TestDatabaseLoader; import test.test.TestQueueApplication; +import com.fasterxml.jackson.databind.ObjectMapper; + @Transactional @AutoConfigureMockMvc @SpringBootTest(classes = TestQueueApplication.class) @@ -712,6 +716,39 @@ class LabControllerTest { } + @Test + @WithUserDetails("admin") + void distributingRequestsWithoutFaultWorks() throws Exception { + DistributeRequestsDTO dto = new DistributeRequestsDTO(2); + dto.getEditionSelections().get(0).getSelectedAssignments().addAll(List.of(1L, 2L)); + dto.getEditionSelections().get(0).getSelectedAssistants().addAll(List.of(1L, 2L, 10L)); + mvc.perform(post("/lab/" + slottedLab1.getId() + "/distribute") + .with(csrf()) + .content(new ObjectMapper().writeValueAsString(dto)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/lab/" + slottedLab1.getId())); + + } + + @Test + @WithUserDetails("admin") + void distributingRequestsWithoutDtoDoesNotCrash() throws Exception { + mvc.perform(post("/lab/" + slottedLab1.getId() + "/distribute") + .with(csrf())) + .andExpect(status().is3xxRedirection()); + + verify(rs, never()).distributeRequests(any(), any(), any(), any()); + } + + @Test + @WithUserDetails("student50") + void sendingDistributionRequestWorksWhenAuthorised() throws Exception { + mvc.perform(post("/lab/" + regLab1.getId() + "/distribute") + .with(csrf())) + .andExpect(view().name("error/403")); + } + @Test @WithUserDetails("student150") void redirectToEnrollPageWhenNotEnrolled() throws Exception { @@ -769,6 +806,7 @@ class LabControllerTest { get("/lab/1/enqueue"), post("/lab/1/enqueue"), post("/lab/1/revoke"), + post("/lab/1/distribute"), get("/edition/1/lab/create"), get("/shared-edition/1/lab/create"), diff --git a/src/test/java/nl/tudelft/queue/service/RequestServiceTest.java b/src/test/java/nl/tudelft/queue/service/RequestServiceTest.java index fca416d1bd649af5131d7a00e2ee50ad39725a96..badbb7a3731e019b2d793aa9d68b623f9c7bc969 100644 --- a/src/test/java/nl/tudelft/queue/service/RequestServiceTest.java +++ b/src/test/java/nl/tudelft/queue/service/RequestServiceTest.java @@ -20,6 +20,7 @@ package nl.tudelft.queue.service; import static nl.tudelft.queue.model.enums.RequestStatus.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.AdditionalMatchers.or; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -33,7 +34,8 @@ import nl.tudelft.labracore.api.AssignmentControllerApi; import nl.tudelft.labracore.api.RoleControllerApi; import nl.tudelft.labracore.api.StudentGroupControllerApi; import nl.tudelft.labracore.api.dto.*; -import nl.tudelft.queue.cache.SessionCacheManager; +import nl.tudelft.labracore.lib.security.user.Person; +import nl.tudelft.queue.cache.PersonCacheManager; import nl.tudelft.queue.dto.create.requests.SelectionRequestCreateDTO; import nl.tudelft.queue.model.LabRequest; import nl.tudelft.queue.model.QSelectionRequest; @@ -44,6 +46,7 @@ import nl.tudelft.queue.model.enums.RequestStatus; import nl.tudelft.queue.model.enums.SelectionProcedure; import nl.tudelft.queue.model.labs.CapacitySession; import nl.tudelft.queue.model.labs.RegularLab; +import nl.tudelft.queue.model.labs.SlottedLab; import nl.tudelft.queue.repository.CapacitySessionRepository; import nl.tudelft.queue.repository.SelectionRequestRepository; @@ -64,7 +67,6 @@ import test.test.TestQueueApplication; @Transactional @SpringBootTest(classes = TestQueueApplication.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) public class RequestServiceTest { private final RoomDetailsDTO room1 = new RoomDetailsDTO() .id(8932L) @@ -98,6 +100,8 @@ public class RequestServiceTest { private RegularLab oopNowRegularLab1; + private SlottedLab oopNowSlottedLab1; + private RoleDetailsDTO[] oopNowTAs; private LabRequest[] rlOopNowSharedLabRequests; @@ -113,12 +117,9 @@ public class RequestServiceTest { @Autowired private SelectionRequestRepository srr; - @Autowired + @SpyBean private RequestService rs; - @Autowired - private SessionCacheManager sCache; - @Autowired private SessionApiMocker sApiMocker; @@ -146,6 +147,9 @@ public class RequestServiceTest { @Autowired private StudentGroupControllerApi sgApi; + @SpyBean + private PersonCacheManager pCache; + @Autowired private RoleControllerApi rlApi; @@ -163,6 +167,8 @@ public class RequestServiceTest { oopNowRegularLab1 = db.getOopNowRegularLab1(); + oopNowSlottedLab1 = db.getOopNowSlottedLab1(); + oopNowTAs = db.getOopNowTAs(); rlOopNowSharedLabRequests = db.getRlOopNowSharedLabRequests(); @@ -348,6 +354,67 @@ public class RequestServiceTest { } + @Test + void distributingEmptyRequestsNothingHappens() { + rs.distributeRequests(List.of(), List.of(), + Person.builder().id(1L).displayName("Test Person").build(), oopNowSlottedLab1); + + rs.distributeRequests(List.of(), List.of(1L, 2L), + Person.builder().id(1L).displayName("Test Person").build(), oopNowSlottedLab1); + + verify(rs, never()).forwardRequestToPerson(any(), any(), any(), any()); + + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.BEFORE_METHOD) + void onlyPendingRequestsWithCorrectAssignmentsGetDistributed() { + pApiMocker.save(new PersonSummaryDTO().id(1L)); + pApiMocker.save(new PersonSummaryDTO().id(2L)); + + doNothing().when(rs).forwardRequestToPerson(any(), any(), any(), any()); + + oopNowSlottedLab1.getRequests().addAll(List.of(LabRequest.builder().assignment(1L).build(), + LabRequest.builder().assignment(1L).build(), LabRequest.builder().assignment(2L).build(), + LabRequest.builder().assignment(3L).build(), LabRequest.builder().assignment(3L).build())); + oopNowSlottedLab1.getRequests().get(1).getEventInfo().setStatus(APPROVED); + oopNowSlottedLab1.getRequests().get(2).getEventInfo().setStatus(PROCESSING); + + rs.distributeRequests(List.of(1L), List.of(1L, 2L), Person.builder().build(), oopNowSlottedLab1); + + verify(rs, times(1)).forwardRequestToPerson(eq(oopNowSlottedLab1.getRequests().get(0)), any(), any(), + any()); + + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.BEFORE_METHOD) + void distributingRequestsWithMissingPeopleBehavesAsExpected() { + pApiMocker.save(new PersonSummaryDTO().id(1L)); + + doNothing().when(rs).forwardRequestToPerson(any(), any(), any(), any()); + + oopNowSlottedLab1.getRequests().addAll(List.of(LabRequest.builder().assignment(9801L).build(), + LabRequest.builder().assignment(9801L).build(), + LabRequest.builder().assignment(9802L).build(), + LabRequest.builder().assignment(9803L).build(), + LabRequest.builder().assignment(9803L).build())); + oopNowSlottedLab1.getRequests().get(1).getEventInfo().setStatus(APPROVED); + oopNowSlottedLab1.getRequests().get(2).getEventInfo().setStatus(PENDING); + + rs.distributeRequests(List.of(9801L), List.of(2L), Person.builder().build(), oopNowSlottedLab1); + + verify(rs, never()).forwardRequestToPerson( + or(eq(oopNowSlottedLab1.getRequests().get(0)), eq(oopNowSlottedLab1.getRequests().get(1))), + any(), any(), any()); + + rs.distributeRequests(List.of(9801L, 9802L), List.of(1L, 2L), Person.builder().build(), + oopNowSlottedLab1); + + verify(rs, times(2)).forwardRequestToPerson(any(), any(), any(), any()); + + } + @Test @WithUserDetails("student200") void oopTaGetsOopRequestsOnlyInSharedSession() {