Basic GUI for assigning reviewers

This commit is contained in:
Andreas Svanberg 2023-12-05 16:02:55 +01:00
parent 33e0e033b5
commit 1a9a1a13d2
12 changed files with 404 additions and 92 deletions

@ -2,6 +2,8 @@ package se.su.dsv.scipro.reviewing;
import se.su.dsv.scipro.project.Project;
import java.time.LocalDate;
public interface ReviewerAssignmentService {
ReviewerCandidates getCandidatesToReview(Project project);
ReviewerCandidates getCandidatesToReview(Project project, LocalDate date);
}

@ -4,7 +4,19 @@ import se.su.dsv.scipro.system.User;
import java.util.List;
public record ReviewerCandidates(List<Candidate> goodCandidates) {
/**
* Candidates that can review a project.
* @param good reviewers have not met their quota, supervises the language, and matches the projects research area
* @param mismatched reviewers have not met their quota, but does not match the projects research area or language
* @param busy reviewers that have met their quota
* @param unavailable reviewers that are not available
*/
public record ReviewerCandidates(
List<Candidate> good,
List<Candidate> mismatched,
List<Candidate> busy,
List<Candidate> unavailable)
{
public record Candidate(User reviewer, int target, int assigned) {
}
}

@ -2,17 +2,21 @@ package se.su.dsv.scipro.reviewing;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.system.UserService;
import javax.inject.Inject;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
class ReviewerCapacityServiceImpl implements ReviewerCapacityService, ReviewerAssignmentService {
private final ReviewerTargetRepository reviewerTargetRepository;
private final UserService userService;
@Inject
ReviewerCapacityServiceImpl(ReviewerTargetRepository reviewerTargetRepository) {
ReviewerCapacityServiceImpl(ReviewerTargetRepository reviewerTargetRepository, UserService userService) {
this.reviewerTargetRepository = reviewerTargetRepository;
this.userService = userService;
}
@Override
@ -35,8 +39,38 @@ class ReviewerCapacityServiceImpl implements ReviewerCapacityService, ReviewerAs
}
@Override
public ReviewerCandidates getCandidatesToReview(Project project) {
return new ReviewerCandidates(
List.of(new ReviewerCandidates.Candidate(project.getHeadSupervisor(), 5, 2)));
public ReviewerCandidates getCandidatesToReview(Project project, LocalDate date) {
List<User> reviewers = userService.findActiveReviewers();
List<ReviewerCandidates.Candidate> good = new ArrayList<>();
List<ReviewerCandidates.Candidate> mismatched = new ArrayList<>();
List<ReviewerCandidates.Candidate> busy = new ArrayList<>();
List<ReviewerCandidates.Candidate> unavailable = new ArrayList<>();
for (User reviewer : reviewers) {
int target = getTarget(reviewer, date);
int assigned = 1; //TODO
ReviewerCandidates.Candidate candidate = new ReviewerCandidates.Candidate(reviewer, target, assigned);
if (target > 0) {
if (assigned < target) {
boolean canSuperviseProjectsLanguage = reviewer.getLanguages().contains(project.getLanguage());
boolean matchingResearchArea = reviewer.getResearchAreas().contains(project.getResearchArea());
if (canSuperviseProjectsLanguage && matchingResearchArea) {
good.add(candidate);
}
else {
mismatched.add(candidate);
}
}
else {
busy.add(candidate);
}
}
else {
unavailable.add(candidate);
}
}
return new ReviewerCandidates(good, mismatched, busy, unavailable);
}
}

@ -9,4 +9,6 @@ public interface ReviewerTargetRepository {
void save(ReviewerTarget reviewerTarget);
List<ReviewerTarget> getReviewerTargets(User reviewer, LocalDate date);
List<ReviewerTarget> getAllReviewerTargets(LocalDate date);
}

@ -33,4 +33,12 @@ public class ReviewerTargetRepositoryImpl extends AbstractRepository implements
.and(QReviewerTarget.reviewerTarget.toDate.goe(date)))
.fetch();
}
@Override
public List<ReviewerTarget> getAllReviewerTargets(LocalDate date) {
return from(QReviewerTarget.reviewerTarget)
.where(QReviewerTarget.reviewerTarget.fromDate.loe(date)
.and(QReviewerTarget.reviewerTarget.toDate.goe(date)))
.fetch();
}
}

@ -6,12 +6,15 @@ import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
public interface UserService extends GenericService<User, Long> ,FilteredService<User, Long, UserService.Filter> {
User findByUsername(String username);
User findByExternalIdentifier(Integer externalIdentifier);
List<User> findActiveReviewers();
class Filter implements Serializable {
private Collection<Roles> roles = EnumSet.allOf(Roles.class);
private String name;

@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable;
import javax.inject.Inject;
import javax.inject.Provider;
import jakarta.persistence.EntityManager;
import se.su.dsv.scipro.security.auth.roles.Roles;
import java.util.List;
@ -39,6 +40,11 @@ public class UserServiceImpl extends AbstractServiceImpl<User,Long> implements U
return findOne(QUser.user.identifier.eq(externalIdentifier));
}
@Override
public List<User> findActiveReviewers() {
return findAll(QUser.user.roles.any().eq(Roles.REVIEWER).and(QUser.user.activeAsSupervisor.isTrue()));
}
private static Predicate fromParams(Filter filter) {
BooleanBuilder booleanBuilder = new BooleanBuilder();
if (!filter.getRoles().isEmpty()) {

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS `reviewer_target` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`dateCreated` DATETIME NOT NULL,
`lastModified` DATETIME NOT NULL,
`version` INT NOT NULL,
`reviewer_id` BIGINT NOT NULL,
`target` INT NOT NULL,
`from_date` DATE NOT NULL,
`to_date` DATE NOT NULL,
PRIMARY KEY (`id`),
CONSTRAINT `FK_reviewer_target_user` FOREIGN KEY (`reviewer_id`) REFERENCES `user` (`id`)
);

@ -0,0 +1,158 @@
package se.su.dsv.scipro.reviewing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.security.auth.roles.Roles;
import se.su.dsv.scipro.system.DegreeType;
import se.su.dsv.scipro.system.Language;
import se.su.dsv.scipro.system.ProjectType;
import se.su.dsv.scipro.system.ResearchArea;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.test.IntegrationTest;
import javax.inject.Inject;
import java.time.LocalDate;
import java.time.Month;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class ReviewerCapacityServiceImplTest extends IntegrationTest {
private static DateRange VT24 = new DateRange(LocalDate.of(2024, Month.JANUARY, 1), LocalDate.of(2024, Month.JUNE, 30));
@Inject
ReviewerCapacityServiceImpl service;
private User reviewer;
private Project project;
private ResearchArea researchArea;
@BeforeEach
void setUp() {
User reviewer = User.builder()
.firstName("John")
.lastName("Doe")
.emailAddress("john@example.com")
.roles(EnumSet.of(Roles.REVIEWER))
.build();
reviewer.setActiveAsSupervisor(true);
this.reviewer = save(reviewer);
User supervisor = User.builder()
.firstName("Bob")
.lastName("Doe")
.emailAddress("bob@example.com")
.roles(EnumSet.of(Roles.SUPERVISOR))
.build();
supervisor.setActiveAsSupervisor(true);
save(supervisor);
ResearchArea researchArea = new ResearchArea();
researchArea.setTitle("AI");
this.researchArea = save(researchArea);
ProjectType bachelor = new ProjectType(DegreeType.BACHELOR, "Bachelor", "Bachelor");
save(bachelor);
Project project = Project.builder()
.title("A project")
.projectType(bachelor)
.startDate(LocalDate.of(2024, Month.JANUARY, 1))
.headSupervisor(supervisor)
.build();
project.setLanguage(Language.SWEDISH);
project.setResearchArea(researchArea);
this.project = save(project);
}
@Test
void saves_assigned_targets() {
// when
int target = 8;
service.assignTarget(reviewer, VT24, target);
// then
assertEquals(target, service.getTarget(reviewer, VT24.from()));
}
@Test
void given_multiple_overlapping_period_the_target_is_the_highest() {
// given
int target = 8;
int higherTarget = 10;
service.assignTarget(reviewer, VT24, target);
service.assignTarget(reviewer, VT24, higherTarget);
// when
int actual = service.getTarget(reviewer, VT24.from());
// then
assertEquals(higherTarget, actual);
}
@Test
void target_is_zero_if_nothing_is_assigned() {
// when
int actual = service.getTarget(reviewer, VT24.from());
// then
assertEquals(0, actual);
}
@Test
void reviewer_is_unavailable_without_target() {
// when
ReviewerCandidates candidates = service.getCandidatesToReview(project, VT24.from());
// then
assertTrue(candidates.unavailable().stream().anyMatch(c -> c.reviewer().equals(reviewer)));
}
@Test
void reviewer_with_target_but_wrong_language_is_mismatched() {
// given
reviewer.setLanguages(EnumSet.noneOf(Language.class));
reviewer.addResearchArea(researchArea);
service.assignTarget(reviewer, VT24, 3);
// when
ReviewerCandidates candidates = service.getCandidatesToReview(project, VT24.from());
// then
assertTrue(candidates.mismatched().stream().anyMatch(c -> c.reviewer().equals(reviewer)));
}
@Test
void reviewer_with_target_but_wrong_research_area_is_mismatched() {
// given
reviewer.getResearchAreas().clear();
reviewer.setLanguages(EnumSet.allOf(Language.class));
service.assignTarget(reviewer, VT24, 3);
// when
ReviewerCandidates candidates = service.getCandidatesToReview(project, VT24.from());
// then
assertTrue(candidates.mismatched().stream().anyMatch(c -> c.reviewer().equals(reviewer)));
}
@Test
void reviewer_with_target_matching_language_and_research_area_is_good() {
// given
reviewer.setLanguages(EnumSet.allOf(Language.class));
reviewer.addResearchArea(researchArea);
service.assignTarget(reviewer, VT24, 3);
// when
ReviewerCandidates candidates = service.getCandidatesToReview(project, VT24.from());
// then
assertTrue(candidates.good().stream().anyMatch(c -> c.reviewer().equals(reviewer)));
}
}

@ -1,69 +0,0 @@
package se.su.dsv.scipro.reviewing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.test.IntegrationTest;
import javax.inject.Inject;
import java.time.LocalDate;
import java.time.Month;
import static org.junit.jupiter.api.Assertions.*;
class ReviewerCapacityServiceTest extends IntegrationTest {
private static DateRange VT24 = new DateRange(LocalDate.of(2024, Month.JANUARY, 1), LocalDate.of(2024, Month.JUNE, 30));
@Inject
ReviewerCapacityService service;
private User reviewer;
@BeforeEach
void setUp() {
User reviewer = User.builder()
.firstName("John")
.lastName("Doe")
.emailAddress("john@example.com")
.build();
this.reviewer = save(reviewer);
}
@Test
void saves_assigned_targets() {
// when
int target = 8;
service.assignTarget(reviewer, VT24, target);
// then
assertEquals(target, service.getTarget(reviewer, VT24.from()));
}
@Test
void given_multiple_overlapping_period_the_target_is_the_highest() {
// given
int target = 8;
int higherTarget = 10;
service.assignTarget(reviewer, VT24, target);
service.assignTarget(reviewer, VT24, higherTarget);
// when
int actual = service.getTarget(reviewer, VT24.from());
// then
assertEquals(higherTarget, actual);
}
@Test
void target_is_zero_if_nothing_is_assigned() {
// when
int actual = service.getTarget(reviewer, VT24.from());
// then
assertEquals(0, actual);
}
}

@ -2,9 +2,10 @@
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:extend>
<h2>Reviewer assignment</h2>
<div class="row">
<div class="col-12 col-xl-4" wicket:id="project_details">
<dl>
<dl class="sticky-xl-top">
<dt>Project</dt>
<dd wicket:id="title"></dd>
@ -20,24 +21,121 @@
</div>
<div class="col-12 col-xl-4 col-md-6" wicket:id="reviewers">
<div class="card bg-success text-white bg-opacity-50" wicket:id="good_candidates">
<div class="row">
<div class="col-auto">
<img class="img-fluid rounded-start" wicket:id="image">
</div>
<div class="col my-auto">
<div class="card-body p-0">
<h4 class="card-title text-white" wicket:id="user"></h4>
<span wicket:id="assigned"></span> / <span wicket:id="target"></span>
<wicket:enclosure child="good_candidates">
<h3>Good candidates</h3>
<p>
These reviewers have not met their review quota, can supervise in the thesis language,
and their research area matches the project's.
</p>
<div class="card bg-success text-white bg-opacity-50 mb-3" wicket:id="good_candidates">
<div class="row">
<div class="col-auto">
<img class="img-fluid rounded-start" wicket:id="image">
</div>
</div>
<div class="col-auto my-auto">
<div class="card-body py-0">
<button class="btn btn-success">Assign</button>
<div class="col my-auto">
<div class="card-body p-0">
<h4 class="card-title text-white" wicket:id="user"></h4>
<span wicket:id="assigned"></span> / <span wicket:id="target"></span>
</div>
</div>
<div class="col-auto my-auto">
<div class="card-body py-0">
<button class="btn btn-success">Assign</button>
</div>
</div>
</div>
</div>
</div>
</wicket:enclosure>
<wicket:enclosure child="mismatched_candidates">
<h3>Mismatched candidates</h3>
<p>
These reviewers have not met their review quota, but their language or research areas does not match the project's.
</p>
<div class="card bg-warning opacity-50 mb-3" wicket:id="mismatched_candidates">
<div class="row">
<div class="col-auto">
<img class="img-fluid rounded-start" wicket:id="image">
</div>
<div class="col my-auto">
<div class="card-body p-0">
<h4 class="card-title" wicket:id="user"></h4>
<span wicket:id="assigned"></span> / <span wicket:id="target"></span>
<div>
Research areas:
<ul class="csv">
<li wicket:id="research_areas">
<wicket:container wicket:id="research_area"></wicket:container>
</li>
</ul>
</div>
<div>
Languages:
<ul class="csv">
<li wicket:id="languages">
<wicket:container wicket:id="language"></wicket:container>
</li>
</ul>
</div>
</div>
</div>
<div class="col-auto my-auto">
<div class="card-body py-0">
<button class="btn btn-success">Assign</button>
</div>
</div>
</div>
</div>
</wicket:enclosure>
<wicket:enclosure child="busy_candidates">
<h3>Busy candidates</h3>
<p>
These reviewers have already met their assigned review quota.
</p>
<div class="card bg-danger text-white opacity-50 mb-3" wicket:id="busy_candidates">
<div class="row">
<div class="col-auto">
<img class="img-fluid rounded-start" wicket:id="image">
</div>
<div class="col my-auto">
<div class="card-body p-0">
<h4 class="card-title text-white" wicket:id="user"></h4>
<span wicket:id="assigned"></span> / <span wicket:id="target"></span>
</div>
</div>
<div class="col-auto my-auto">
<div class="card-body py-0">
<button class="btn btn-success">Assign</button>
</div>
</div>
</div>
</div>
</wicket:enclosure>
<wicket:enclosure child="unavailable_candidates">
<h3>Unavailable candidates</h3>
<p>
These reviewers have not been assigned a review quota at all.
</p>
<div class="card bg-secondary text-white opacity-50 mb-3" wicket:id="unavailable_candidates">
<div class="row">
<div class="col-auto">
<img class="img-fluid rounded-start" wicket:id="image">
</div>
<div class="col my-auto">
<div class="card-body p-0">
<h4 class="card-title text-white" wicket:id="user"></h4>
</div>
</div>
<div class="col-auto my-auto">
<div class="card-body py-0">
<button class="btn btn-secondary">Assign</button>
</div>
</div>
</div>
</div>
</wicket:enclosure>
</div>
</div>
</wicket:extend>

@ -10,6 +10,7 @@ import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.util.string.StringValueConversionException;
import se.su.dsv.scipro.components.AutoHidingListView;
import se.su.dsv.scipro.data.DetachableServiceModel;
import se.su.dsv.scipro.profile.UserLabel;
import se.su.dsv.scipro.profile.UserLinkPanel;
@ -18,9 +19,14 @@ import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.reviewing.ReviewerAssignmentService;
import se.su.dsv.scipro.reviewing.ReviewerCandidates;
import se.su.dsv.scipro.system.Language;
import se.su.dsv.scipro.system.ResearchArea;
import se.su.dsv.scipro.system.User;
import javax.inject.Inject;
import java.time.Clock;
import java.time.LocalDate;
import java.util.ArrayList;
public class AdminAssignReviewerPage extends AbstractAdminProjectPage {
@Inject
@ -64,14 +70,16 @@ public class AdminAssignReviewerPage extends AbstractAdminProjectPage {
private static class AvailableReviewersPanel extends WebMarkupContainer {
@Inject
private ReviewerAssignmentService reviewerAssignmentService;
@Inject
private Clock clock;
public AvailableReviewersPanel(String id, IModel<Project> projectModel) {
super(id, projectModel);
IModel<ReviewerCandidates> reviewerCandidates = LoadableDetachableModel.of(() ->
reviewerAssignmentService.getCandidatesToReview(projectModel.getObject()));
reviewerAssignmentService.getCandidatesToReview(projectModel.getObject(), LocalDate.now(clock)));
add(new ListView<>("good_candidates", reviewerCandidates.map(ReviewerCandidates::goodCandidates)) {
add(new AutoHidingListView<>("good_candidates", reviewerCandidates.map(ReviewerCandidates::good)) {
@Override
protected void populateItem(ListItem<ReviewerCandidates.Candidate> item) {
item.add(new UserProfileImage("image", item.getModel().map(ReviewerCandidates.Candidate::reviewer), UserProfileImage.Size.MEDIUM));
@ -80,6 +88,44 @@ public class AdminAssignReviewerPage extends AbstractAdminProjectPage {
item.add(new Label("assigned", item.getModel().map(ReviewerCandidates.Candidate::assigned)));
}
});
add(new AutoHidingListView<>("mismatched_candidates", reviewerCandidates.map(ReviewerCandidates::mismatched)) {
@Override
protected void populateItem(ListItem<ReviewerCandidates.Candidate> item) {
IModel<User> reviewerModel = item.getModel().map(ReviewerCandidates.Candidate::reviewer);
item.add(new UserProfileImage("image", reviewerModel, UserProfileImage.Size.MEDIUM));
item.add(new UserLabel("user", reviewerModel));
item.add(new Label("target", item.getModel().map(ReviewerCandidates.Candidate::target)));
item.add(new Label("assigned", item.getModel().map(ReviewerCandidates.Candidate::assigned)));
item.add(new ListView<>("research_areas", reviewerModel.map(User::getResearchAreas).map(ArrayList::new)) {
@Override
protected void populateItem(ListItem<ResearchArea> item) {
item.add(new Label("research_area", item.getModel().map(ResearchArea::getTitle)));
}
});
item.add(new ListView<>("languages", reviewerModel.map(User::getLanguages).map(ArrayList::new)) {
@Override
protected void populateItem(ListItem<Language> item) {
item.add(new EnumLabel<>("language", item.getModel()));
}
});
}
});
add(new AutoHidingListView<>("busy_candidates", reviewerCandidates.map(ReviewerCandidates::busy)) {
@Override
protected void populateItem(ListItem<ReviewerCandidates.Candidate> item) {
item.add(new UserProfileImage("image", item.getModel().map(ReviewerCandidates.Candidate::reviewer), UserProfileImage.Size.MEDIUM));
item.add(new UserLabel("user", item.getModel().map(ReviewerCandidates.Candidate::reviewer)));
item.add(new Label("target", item.getModel().map(ReviewerCandidates.Candidate::target)));
item.add(new Label("assigned", item.getModel().map(ReviewerCandidates.Candidate::assigned)));
}
});
add(new AutoHidingListView<>("unavailable_candidates", reviewerCandidates.map(ReviewerCandidates::unavailable)) {
@Override
protected void populateItem(ListItem<ReviewerCandidates.Candidate> item) {
item.add(new UserProfileImage("image", item.getModel().map(ReviewerCandidates.Candidate::reviewer), UserProfileImage.Size.MEDIUM));
item.add(new UserLabel("user", item.getModel().map(ReviewerCandidates.Candidate::reviewer)));
}
});
}
}
}