Allow supervisors to write a note associated with their projects ()

There is a need among supervisors to maintain some work notes for each project. Where they are in the process, what students need to do, and other such things.

This PR gives the supervisors the ability to write a note for each project on their overview page.

Card 3399

Reviewed-on: 
Reviewed-by: niat8586 <nico@dsv.su.se>
This commit is contained in:
Andreas Svanberg 2024-07-10 13:43:27 +02:00
parent 4dc0ba32d0
commit 949de4a260
19 changed files with 318 additions and 1 deletions

@ -48,6 +48,7 @@ import se.su.dsv.scipro.notifications.settings.service.ReceiverConfigurationServ
import se.su.dsv.scipro.notifications.settings.service.ReceiverConfigurationServiceImpl;
import se.su.dsv.scipro.peer.*;
import se.su.dsv.scipro.plagiarism.*;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsServiceImpl;
import se.su.dsv.scipro.project.ProjectService;
@ -139,6 +140,7 @@ public class CoreModule extends AbstractModule {
bind(FirstMeetingService.class).to(FirstMeetingServiceImpl.class);
bind(FinalSeminarCreationSubscribers.class).asEagerSingleton();
bind(ProjectPartnerRepository.class).to(ProjectPartnerRepositoryImpl.class);
bind(ProjectNoteService.class).to(ProjectServiceImpl.class);
install(new PlagiarismModule());
install(new NotificationModule());

@ -85,6 +85,13 @@ public class Project extends DomainObject {
@Enumerated(EnumType.STRING)
private Language language;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "project_user_note", joinColumns = @JoinColumn(name = "project_id"))
@Column(name = "note")
@SuppressWarnings("JpaDataSourceORMInspection") // false warning from IntelliJ for the @MapKeyJoinColumn
@MapKeyJoinColumn(name = "user_id")
private Map<User, String> userNotes = new HashMap<>();
@PrePersist
@PreUpdate
void cleanTitle() {
@ -94,6 +101,14 @@ public class Project extends DomainObject {
title = title.trim();
}
public Map<User, String> getUserNotes() {
return userNotes;
}
public void setUserNotes(Map<User, String> userNotes) {
this.userNotes = userNotes;
}
public boolean isFinalSeminarRuleExempted() {
return finalSeminarRuleExempted;
}

@ -0,0 +1,9 @@
package se.su.dsv.scipro.project;
import se.su.dsv.scipro.system.User;
public interface ProjectNoteService {
String getUserNote(Project project, User user);
void setUserNote(Project project, User user, String note);
}

@ -11,4 +11,8 @@ import java.util.List;
@Transactional
public interface ProjectRepo extends JpaRepository<Project, Long>, QueryDslPredicateExecutor<Project> {
List<User> findMultipleAuthors(Collection<Project> projects);
String getUserNoteForProject(Project project, User user);
void setUserNoteForProject(Project project, User user, String note);
}

@ -1,5 +1,6 @@
package se.su.dsv.scipro.project;
import com.google.inject.persist.Transactional;
import se.su.dsv.scipro.system.GenericRepo;
import se.su.dsv.scipro.system.User;
@ -30,4 +31,16 @@ public class ProjectRepoImpl extends GenericRepo<Project, Long> implements Proje
query.setParameter("projects", projects);
return query.getResultList();
}
@Override
public String getUserNoteForProject(Project project, User user) {
return project.getUserNotes().get(user);
}
@Override
@Transactional
public void setUserNoteForProject(Project project, User user, String note) {
project.getUserNotes().put(user, note);
save(project);
}
}

@ -22,7 +22,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.*;
public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> implements ProjectService {
public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> implements ProjectService, ProjectNoteService {
public static final int MIN_TITLE_LENGTH = 3;
private final ProjectRepo projectRepo;
@ -175,6 +175,16 @@ public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> imple
return completed;
}
@Override
public String getUserNote(Project project, User user) {
return projectRepo.getUserNoteForProject(project, user);
}
@Override
public void setUserNote(Project project, User user, String note) {
projectRepo.setUserNoteForProject(project, user, note);
}
@Override
public List<Project> findAll(Filter filter, Pageable pageable) {
return findAll(toPredicate(filter), pageable);

@ -0,0 +1,5 @@
package se.su.dsv.scipro.settings.dataobjects;
public enum SupervisorProjectNoteDisplay {
COMPACT, FULL
}

@ -58,6 +58,11 @@ public class UserProfile extends DomainObject {
@Enumerated(EnumType.STRING)
private Roles selectedRole;
@Basic
@Enumerated(EnumType.STRING)
@Column(name = "supervisor_project_note_display")
private SupervisorProjectNoteDisplay supervisorProjectNoteDisplay = SupervisorProjectNoteDisplay.COMPACT;
@Override
public Long getId() {
return this.id;
@ -147,6 +152,14 @@ public class UserProfile extends DomainObject {
this.selectedRole = selectedRole;
}
public SupervisorProjectNoteDisplay getSupervisorProjectNoteDisplay() {
return supervisorProjectNoteDisplay;
}
public void setSupervisorProjectNoteDisplay(SupervisorProjectNoteDisplay supervisorProjectNoteDisplay) {
this.supervisorProjectNoteDisplay = supervisorProjectNoteDisplay;
}
@Override
public String toString() {
return "UserProfile(id=" + this.getId() + ", user=" + this.getUser() + ", skypeId=" + this.getSkypeId() + ", phoneNumber=" + this.getPhoneNumber() + ", otherInfo=" + this.getOtherInfo() + ", mailCompilation=" + this.isMailCompilation() + ", defaultProjectStatusFilter=" + this.getDefaultProjectStatusFilter() + ", defaultProjectTeamMemberRolesFilter=" + this.getDefaultProjectTeamMemberRolesFilter() + ", defaultSupervisorFilter=" + this.isDefaultSupervisorFilter() + ", defaultProjectTypeFilter=" + this.getDefaultProjectTypeFilter() + ", selectedRole=" + this.getSelectedRole() + ")";

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `project_user_note` (
`project_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`note` text NULL,
PRIMARY KEY (`project_id`, `user_id`),
CONSTRAINT `FK_project_user_note_project` FOREIGN KEY (`project_id`) REFERENCES `project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_project_user_note_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
);
ALTER TABLE `user_profile`
ADD COLUMN `supervisor_project_note_display` VARCHAR(15) NOT NULL DEFAULT 'COMPACT';

@ -0,0 +1,37 @@
package se.su.dsv.scipro.components;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.model.IModel;
import org.apache.wicket.util.convert.IConverter;
import java.util.Locale;
public class MaxLengthLabel extends Label {
private final IModel<Integer> maxLengthModel;
public MaxLengthLabel(String id, IModel<String> dataModel, IModel<Integer> maxLength) {
super(id, dataModel);
this.maxLengthModel = maxLength;
}
@Override
protected IConverter<?> createConverter(Class<?> type) {
return new MaxLengthConverter();
}
private class MaxLengthConverter implements IConverter<String> {
@Override
public String convertToObject(String s, Locale locale) {
return s;
}
@Override
public String convertToString(String o, Locale locale) {
Integer maxLength = maxLengthModel.getObject();
if (o.length() > maxLength) {
return o.substring(0, maxLength) + "...";
}
return o;
}
}
}

@ -1,6 +1,7 @@
package se.su.dsv.scipro.components;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.AttributeAppender;
import org.apache.wicket.markup.ComponentTag;
@ -9,6 +10,7 @@ import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import org.danekja.java.util.function.serializable.SerializableConsumer;
import java.util.function.Function;
@ -83,4 +85,13 @@ public class ModalWindowPlus extends Panel {
component.setOutputMarkupPlaceholderTag(true);
replace(component);
}
public void onClose(SerializableConsumer<AjaxRequestTarget> onClose) {
add(new AjaxEventBehavior("hidden.bs.modal") {
@Override
protected void onEvent(AjaxRequestTarget target) {
onClose.accept(target);
}
});
}
}

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<div wicket:id="edit_note_modal"></div>
<span wicket:id="shortened_note"></span>
<wicket:container wicket:id="full_note"></wicket:container>
<a wicket:id="view_note"><wicket:message key="note.view.edit"/></a>
</wicket:panel>
</body>
</html>

@ -0,0 +1,2 @@
note.modal.title=View/edit note for ${title}
note.view.edit=View/edit note

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<form wicket:id="form">
<div wicket:id="feedback"></div>
<div class="mb-3">
<label wicket:for="note" class="sr-only">Note</label>
<textarea wicket:id="note" rows="20" class="form-control"></textarea>
</div>
<button wicket:id="save" type="submit" class="btn btn-primary">Save</button>
</form>
</wicket:panel>
</body>
</html>

@ -0,0 +1,129 @@
package se.su.dsv.scipro.supervisor.panels;
import jakarta.inject.Inject;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.ajax.markup.html.form.AjaxSubmitLink;
import org.apache.wicket.extensions.markup.html.repeater.data.grid.ICellPopulator;
import org.apache.wicket.extensions.markup.html.repeater.data.table.export.AbstractExportableColumn;
import org.apache.wicket.feedback.FencedFeedbackPanel;
import org.apache.wicket.injection.Injector;
import org.apache.wicket.markup.html.basic.MultiLineLabel;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.TextArea;
import org.apache.wicket.markup.html.panel.GenericPanel;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.model.StringResourceModel;
import se.su.dsv.scipro.components.LargeModalWindow;
import se.su.dsv.scipro.components.MaxLengthLabel;
import se.su.dsv.scipro.components.ModalWindowPlus;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.settings.dataobjects.SupervisorProjectNoteDisplay;
import se.su.dsv.scipro.system.User;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class ProjectNoteColumn extends AbstractExportableColumn<Project, String> {
@Inject
private ProjectNoteService projectNoteService;
private final IModel<User> user;
private final IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel;
public ProjectNoteColumn(IModel<String> displayModel, IModel<User> user,
IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel) {
super(displayModel);
Injector.get().inject(this);
this.supervisorProjectNoteDisplayModel = supervisorProjectNoteDisplayModel;
this.user = user;
}
@Override
public IModel<String> getDataModel(IModel<Project> rowModel) {
return LoadableDetachableModel.of(() -> projectNoteService.getUserNote(
rowModel.getObject(),
user.getObject()));
}
@Override
public void populateItem(
Item<ICellPopulator<Project>> cellItem,
String componentId,
IModel<Project> rowModel)
{
cellItem.add(new ViewAndEditNoteCellPanel(componentId, rowModel));
}
private class ViewAndEditNoteCellPanel extends GenericPanel<Project> {
public ViewAndEditNoteCellPanel(String id, IModel<Project> model) {
super(id, model);
ModalWindowPlus modal = new LargeModalWindow("edit_note_modal");
modal.setTitle(new StringResourceModel("note.modal.title", this, model));
modal.setContent(componentId -> new ViewAndEditNoteForm(componentId, model));
add(modal);
setOutputMarkupId(true);
modal.onClose(target -> target.add(this));
add(new MaxLengthLabel("shortened_note", getDataModel(model), Model.of(100)) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisibilityAllowed(supervisorProjectNoteDisplayModel.getObject() == SupervisorProjectNoteDisplay.COMPACT);
}
});
add(new MultiLineLabel("full_note", getDataModel(model)) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisibilityAllowed(supervisorProjectNoteDisplayModel.getObject() == SupervisorProjectNoteDisplay.FULL);
}
});
AjaxLink<Object> noteLink = new AjaxLink<>("view_note") {
@Override
public void onClick(AjaxRequestTarget target) {
modal.show(target);
}
};
add(noteLink);
}
}
private class ViewAndEditNoteForm extends GenericPanel<Project> {
public ViewAndEditNoteForm(String id, IModel<Project> project) {
super(id, project);
IModel<String> note = getDataModel(project);
Form<Project> form = new Form<>("form", project) {
@Override
protected void onSubmit() {
projectNoteService.setUserNote(
project.getObject(),
user.getObject(),
note.getObject()
);
success("Note saved at " + LocalTime.now().truncatedTo(ChronoUnit.SECONDS));
}
};
add(form);
form.add(new FencedFeedbackPanel("feedback", form));
form.add(new TextArea<>("note", note));
form.add(new AjaxSubmitLink("save") {
@Override
protected void onAfterSubmit(AjaxRequestTarget target) {
target.add(form);
}
});
}
}
}

@ -31,6 +31,10 @@
</strong>
<div wicket:id="projectTypes"></div>
</div>
<fieldset class="col-6 col-md-3 col-lg-2">
<legend>Note</legend>
<div wicket:id="note_display"></div>
</fieldset>
</div>
</form>
</div>

@ -1,6 +1,7 @@
package se.su.dsv.scipro.supervisor.panels;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.form.AjaxFormChoiceComponentUpdatingBehavior;
import org.apache.wicket.ajax.markup.html.form.AjaxCheckBox;
import org.apache.wicket.extensions.markup.html.repeater.data.grid.ICellPopulator;
import org.apache.wicket.extensions.markup.html.repeater.data.sort.SortOrder;
@ -15,6 +16,7 @@ import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LambdaModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.model.Model;
import se.su.dsv.scipro.components.*;
import se.su.dsv.scipro.components.datatables.MultipleUsersColumn;
@ -30,6 +32,7 @@ import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.project.ProjectStatus;
import se.su.dsv.scipro.project.ProjectTeamMemberRoles;
import se.su.dsv.scipro.session.SciProSession;
import se.su.dsv.scipro.settings.dataobjects.SupervisorProjectNoteDisplay;
import se.su.dsv.scipro.settings.dataobjects.UserProfile;
import se.su.dsv.scipro.springdata.services.UserProfileService;
import se.su.dsv.scipro.system.ProjectType;
@ -62,6 +65,7 @@ public class SupervisorMyProjectsPanel extends Panel {
private ExportableDataPanel dataPanel;
private ProjectService.Filter filter = new ProjectService.Filter();
private IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel = new Model<>();
public SupervisorMyProjectsPanel(String id) {
super(id);
@ -90,6 +94,7 @@ public class SupervisorMyProjectsPanel extends Panel {
return new ListAdapterModel<>(rowModel.map(Project::getProjectParticipants));
}
});
columns.add(new ProjectNoteColumn(Model.of("Note"), LoadableDetachableModel.of(this::currentUser), supervisorProjectNoteDisplayModel));
columns.add(new UserColumn<>(Model.of("Head supervisor"), "headSupervisor.fullName", Project::getHeadSupervisor));
return columns;
}
@ -112,6 +117,7 @@ public class SupervisorMyProjectsPanel extends Panel {
filter.setRoles(userProfile.getDefaultProjectTeamMemberRolesFilter());
filter.setFilterSupervisor(userProfile.isDefaultSupervisorFilter());
filter.setProjectTypes(userProfile.getDefaultProjectTypeFilter());
supervisorProjectNoteDisplayModel.setObject(userProfile.getSupervisorProjectNoteDisplay());
}
private User currentUser() {
@ -155,6 +161,19 @@ public class SupervisorMyProjectsPanel extends Panel {
updateProfileWithCurrentFilter();
}
});
BootstrapRadioChoice<SupervisorProjectNoteDisplay> noteDisplay = new BootstrapRadioChoice<>(
"note_display",
supervisorProjectNoteDisplayModel,
List.of(SupervisorProjectNoteDisplay.values()),
new EnumChoiceRenderer<>(this));
noteDisplay.add(new AjaxFormChoiceComponentUpdatingBehavior() {
@Override
public void onUpdate(AjaxRequestTarget target) {
target.add(dataPanel);
updateProfileWithCurrentFilter();
}
});
add(noteDisplay);
}
private void updateProfileWithCurrentFilter() {
@ -163,6 +182,7 @@ public class SupervisorMyProjectsPanel extends Panel {
userProfile.setDefaultProjectTeamMemberRolesFilter(filter.getRoles());
userProfile.setDefaultSupervisorFilter(filter.isFilterSupervisor());
userProfile.setDefaultProjectTypeFilter(filter.getProjectTypes());
userProfile.setSupervisorProjectNoteDisplay(supervisorProjectNoteDisplayModel.getObject());
profileService.save(userProfile);
}
}

@ -9,3 +9,6 @@ ProjectTeamMemberRoles.CO_SUPERVISOR= Co-Supervisor
ProjectStatus.ACTIVE= Active
ProjectStatus.INACTIVE= Inactive
ProjectStatus.COMPLETED= Completed
SupervisorProjectNoteDisplay.COMPACT=Compact
SupervisorProjectNoteDisplay.FULL=Full

@ -87,6 +87,7 @@ import se.su.dsv.scipro.peer.PerformReviewService;
import se.su.dsv.scipro.plagiarism.PlagiarismControl;
import se.su.dsv.scipro.plagiarism.urkund.UrkundService;
import se.su.dsv.scipro.profiles.CurrentProfile;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsService;
import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.project.pages.ProjectStartPage;
@ -355,6 +356,8 @@ public abstract class SciProTest {
protected ExaminerTimelineService examinerTimelineService;
@Mock
protected NationalSubjectCategoryService nationalSubjectCategoryService;
@Mock
protected ProjectNoteService projectNoteService;
protected WicketTester tester;