diff --git a/core/src/main/java/se/su/dsv/scipro/CoreConfig.java b/core/src/main/java/se/su/dsv/scipro/CoreConfig.java index cfed2504db..4eb02ea93c 100644 --- a/core/src/main/java/se/su/dsv/scipro/CoreConfig.java +++ b/core/src/main/java/se/su/dsv/scipro/CoreConfig.java @@ -143,6 +143,7 @@ import se.su.dsv.scipro.project.ProjectPeopleStatisticsServiceImpl; import se.su.dsv.scipro.project.ProjectRepo; import se.su.dsv.scipro.project.ProjectService; import se.su.dsv.scipro.project.ProjectServiceImpl; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectServiceImpl; import se.su.dsv.scipro.projectpartner.ProjectPartnerServiceImpl; import se.su.dsv.scipro.reflection.ReflectionService; import se.su.dsv.scipro.reflection.ReflectionServiceImpl; @@ -801,6 +802,23 @@ public class CoreConfig { return new ProjectServiceImpl(projectRepo, clock, eventBus, em); } + @Bean + public SplitOrRestartProjectServiceImpl SplitOrRestartProjectService( + ProjectService projectService, + FinalSeminarService finalSeminarService, + RoughDraftApprovalService roughDraftApprovalService, + Clock clock, + EventBus eventBus + ) { + return new SplitOrRestartProjectServiceImpl( + projectService, + finalSeminarService, + roughDraftApprovalService, + clock, + eventBus + ); + } + @Bean public ProjectTypeServiceImpl projectTypeService(Provider<EntityManager> em) { return new ProjectTypeServiceImpl(em); diff --git a/core/src/main/java/se/su/dsv/scipro/milestones/service/MilestoneActivator.java b/core/src/main/java/se/su/dsv/scipro/milestones/service/MilestoneActivator.java index 145ee9d096..7a518492fc 100644 --- a/core/src/main/java/se/su/dsv/scipro/milestones/service/MilestoneActivator.java +++ b/core/src/main/java/se/su/dsv/scipro/milestones/service/MilestoneActivator.java @@ -21,6 +21,7 @@ import se.su.dsv.scipro.peer.SecondPeerReviewCompletedEvent; import se.su.dsv.scipro.project.Project; import se.su.dsv.scipro.report.SupervisorGradingReportSubmittedEvent; import se.su.dsv.scipro.reviewing.FinalSeminarApprovalApprovedEvent; +import se.su.dsv.scipro.reviewing.RoughDraftApprovalApprovedClonedEvent; import se.su.dsv.scipro.reviewing.RoughDraftApprovalApprovedEvent; import se.su.dsv.scipro.reviewing.RoughDraftApprovalRequestedEvent; import se.su.dsv.scipro.system.User; @@ -136,6 +137,11 @@ public class MilestoneActivator { activateProjectMilestone(Set.of(event.getName()), event.getProject()); } + @Subscribe + public void reviewerApprovalApprovedClone(RoughDraftApprovalApprovedClonedEvent event) { + activateProjectMilestone(Set.of(event.getName()), event.getProject()); + } + @Subscribe public void finalSeminarThesisDeleted(FinalSeminarThesisDeletedEvent event) { deactivateProjectMilestone(Set.of("FinalSeminarThesisUploaded"), event.getFinalSeminar().getProject()); diff --git a/core/src/main/java/se/su/dsv/scipro/project/Project.java b/core/src/main/java/se/su/dsv/scipro/project/Project.java index 82f8400f9c..21fceed91d 100755 --- a/core/src/main/java/se/su/dsv/scipro/project/Project.java +++ b/core/src/main/java/se/su/dsv/scipro/project/Project.java @@ -23,6 +23,7 @@ import jakarta.persistence.MapKeyJoinColumn; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; @@ -113,6 +114,18 @@ public class Project extends DomainObject { @Column(name = "daisy_identifier", unique = true) private Integer identifier; + @Basic + @Column(name = "parent_project_id") + private Long parentProjectId; + + @Basic + @Column(name = "root_project_id") + private Long rootProjectId; + + @Basic + @Column(name = "clone_timestamp") + private Instant cloneTimestamp; + // ---------------------------------------------------------------------------------- // Embedded JPA-mapping // ---------------------------------------------------------------------------------- @@ -365,6 +378,30 @@ public class Project extends DomainObject { this.userNotes = userNotes; } + public Long getParentProjectId() { + return parentProjectId; + } + + public void setParentProjectId(Long parentProjectId) { + this.parentProjectId = parentProjectId; + } + + public Long getRootProjectId() { + return rootProjectId; + } + + public void setRootProjectId(Long rootProjectId) { + this.rootProjectId = rootProjectId; + } + + public Instant getCloneTimestamp() { + return cloneTimestamp; + } + + public void setCloneTimestamp(Instant cloneTimestamp) { + this.cloneTimestamp = cloneTimestamp; + } + // ---------------------------------------------------------------------------------- // Methods Common To All Objects // ---------------------------------------------------------------------------------- diff --git a/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectService.java b/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectService.java new file mode 100644 index 0000000000..43b69e7780 --- /dev/null +++ b/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectService.java @@ -0,0 +1,28 @@ +package se.su.dsv.scipro.project.split; + +import java.util.List; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.reviewing.RoughDraftApproval; + +public interface SplitOrRestartProjectService { + enum SplittableStatus { + NOT_EXIST, + NOT_ACTIVE, + NOT_TWO_PARTICIPANTS, + PHASE_TWO_STARTED, + FINAL_SEMINAR_PHASE_STARTED, + OK, + } + + record SplittableStatusRecord( + SplittableStatus splittableStatus, + Project project, + RoughDraftApproval roughDraftApproval + ) {} + + SplittableStatusRecord getSplittableStatus(long projectId); + + void splitProject(long projectId); + + List<Project> getChildProjects(long parentProjectId); +} diff --git a/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceImpl.java b/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceImpl.java new file mode 100644 index 0000000000..a720f76827 --- /dev/null +++ b/core/src/main/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceImpl.java @@ -0,0 +1,133 @@ +package se.su.dsv.scipro.project.split; + +import com.google.common.eventbus.EventBus; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.time.Clock; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import se.su.dsv.scipro.finalseminar.FinalSeminarService; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.ProjectService; +import se.su.dsv.scipro.project.ProjectStatus; +import se.su.dsv.scipro.project.QProject; +import se.su.dsv.scipro.reviewing.RoughDraftApproval; +import se.su.dsv.scipro.reviewing.RoughDraftApprovalApprovedClonedEvent; +import se.su.dsv.scipro.reviewing.RoughDraftApprovalService; +import se.su.dsv.scipro.system.User; + +public class SplitOrRestartProjectServiceImpl implements SplitOrRestartProjectService { + + private final ProjectService projectService; + private final FinalSeminarService finalSeminarService; + private final RoughDraftApprovalService roughDraftApprovalService; + private final Clock clock; + private final EventBus eventBus; + + @Inject + public SplitOrRestartProjectServiceImpl( + ProjectService projectService, + FinalSeminarService finalSeminarService, + RoughDraftApprovalService roughDraftApprovalService, + Clock clock, + EventBus eventBus + ) { + this.projectService = projectService; + this.finalSeminarService = finalSeminarService; + this.roughDraftApprovalService = roughDraftApprovalService; + this.clock = clock; + this.eventBus = eventBus; + } + + @Override + @Transactional + public SplittableStatusRecord getSplittableStatus(long projectId) { + Project project = projectService.findOne(projectId); + if (project == null) return new SplittableStatusRecord(SplittableStatus.NOT_EXIST, null, null); + + if (project.getProjectStatus() != ProjectStatus.ACTIVE) { + return new SplittableStatusRecord(SplittableStatus.NOT_ACTIVE, project, null); + } + + if (project.getProjectParticipants().size() != 2) { + return new SplittableStatusRecord(SplittableStatus.NOT_TWO_PARTICIPANTS, project, null); + } + + if (finalSeminarService.findByProject(project) != null) { + return new SplittableStatusRecord(SplittableStatus.FINAL_SEMINAR_PHASE_STARTED, project, null); + } + + Optional<RoughDraftApproval> o = roughDraftApprovalService.findBy(project); + if (o.isPresent() && !o.get().isDecided()) { + return new SplittableStatusRecord(SplittableStatus.PHASE_TWO_STARTED, project, null); + } + + if (o.isPresent() && o.get().isApproved()) { + return new SplittableStatusRecord(SplittableStatus.OK, project, o.get()); + } else { + return new SplittableStatusRecord(SplittableStatus.OK, project, null); + } + } + + @Override + @Transactional + public void splitProject(long projectId) { + SplittableStatusRecord result = getSplittableStatus(projectId); + if (result.splittableStatus() != SplittableStatus.OK) { + throw new IllegalStateException( + "Project must to be verified to be able to split before this method can be called." + ); + } + + Project project = result.project(); + + for (User author : project.getProjectParticipants()) { + Project childProject = new Project(); + + childProject.setTitle(project.getTitle()); + childProject.setProjectType(project.getProjectType()); + childProject.setProjectStatus(ProjectStatus.ACTIVE); + childProject.setResearchArea(project.getResearchArea()); + + childProject.setStartDate(project.getStartDate()); + childProject.setExpectedEndDate(project.getExpectedEndDate()); + + childProject.setHeadSupervisor(project.getHeadSupervisor()); + childProject.setProjectParticipants(List.of(author)); + childProject.setCoSupervisors(project.getCoSupervisors()); + + childProject.setReviewers(project.getReviewers()); + + childProject.setParentProjectId(project.getId()); + childProject.setRootProjectId( + project.getRootProjectId() != null ? project.getRootProjectId() : project.getId() + ); + childProject.setCloneTimestamp(Instant.now()); + + childProject = projectService.save(childProject); + + // Add cloned RoughDraftApproval if it's 'APPROVED' + RoughDraftApproval rda = result.roughDraftApproval(); + if (rda != null && rda.isApproved()) { + RoughDraftApproval clonedRda = roughDraftApprovalService.saveCloned( + rda.cloneToProject(childProject, clock.instant()) + ); + + // Send event to eventBus to synchronize eventual Phase Two Approval with MileStone + eventBus.post(new RoughDraftApprovalApprovedClonedEvent(clonedRda)); + } + } + + // Parent project will set as inactive + project.setProjectStatus(ProjectStatus.INACTIVE); + projectService.save(project); + } + + @Override + @Transactional + public List<Project> getChildProjects(long parentProjectId) { + return projectService.findAll(QProject.project.rootProjectId.eq(parentProjectId)); + } +} diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/Decision.java b/core/src/main/java/se/su/dsv/scipro/reviewing/Decision.java index 67d701cdc9..b3b4ebc635 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/Decision.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/Decision.java @@ -196,6 +196,25 @@ public class Decision { decideWithDecisionDate(status, reason, attachment, Instant.now()); } + Decision cloneToReviewerApproval(RoughDraftApproval roughDraftApproval) { + Decision clonedDecision = new Decision(); + clonedDecision.reviewerApproval = roughDraftApproval; + + clonedDecision.status = this.status; + clonedDecision.reason = this.reason; + clonedDecision.comment = this.comment; + clonedDecision.requested = this.requested; + clonedDecision.decisionDate = this.decisionDate; + clonedDecision.deadline = this.deadline; + clonedDecision.reviewerAssignedAt = this.reviewerAssignedAt; + + clonedDecision.assignedReviewer = this.assignedReviewer; + clonedDecision.attachment = this.attachment; + clonedDecision.thesis = this.thesis; + + return clonedDecision; + } + private void decideWithDecisionDate( final Status status, final String reason, diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/DecisionRepositoryImpl.java b/core/src/main/java/se/su/dsv/scipro/reviewing/DecisionRepositoryImpl.java index 5843cf3335..97d55c2b84 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/DecisionRepositoryImpl.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/DecisionRepositoryImpl.java @@ -25,6 +25,7 @@ public class DecisionRepositoryImpl extends AbstractRepository implements Decisi .eq(reviewer) .and(QDecision.decision.reviewerAssignedAt.goe(fromDate)) .and(QDecision.decision.reviewerAssignedAt.loe(toDate)) + .and(QDecision.decision.reviewerApproval.isCloned.isFalse()) ) .distinct() .fetchCount(); diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/ReviewerApproval.java b/core/src/main/java/se/su/dsv/scipro/reviewing/ReviewerApproval.java index 9e91d2fca1..56e1decee9 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/ReviewerApproval.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/ReviewerApproval.java @@ -1,6 +1,8 @@ package se.su.dsv.scipro.reviewing; +import jakarta.persistence.Basic; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -11,6 +13,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; +import java.time.Instant; import java.util.Collections; import java.util.Date; import java.util.LinkedList; @@ -32,6 +35,14 @@ public abstract class ReviewerApproval extends DomainObject { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Basic + @Column(name = "is_cloned") + protected Boolean isCloned = false; + + @Basic + @Column(name = "clone_timestamp") + protected Instant cloneTimestamp; + // ---------------------------------------------------------------------------------- // JPA-mappings of foreign keys in this table (reviewer_approval) referencing other // tables. @@ -59,6 +70,22 @@ public abstract class ReviewerApproval extends DomainObject { return this.project; } + public Boolean getCloned() { + return isCloned; + } + + public void setCloned(Boolean cloned) { + isCloned = cloned; + } + + public Instant getCloneTimestamp() { + return cloneTimestamp; + } + + public void setCloneTimestamp(Instant cloneDate) { + this.cloneTimestamp = cloneDate; + } + // ---------------------------------------------------------------------------------- // Other methods // ---------------------------------------------------------------------------------- diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApproval.java b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApproval.java index bd8854b67b..d31d847cc5 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApproval.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApproval.java @@ -1,7 +1,8 @@ package se.su.dsv.scipro.reviewing; import jakarta.persistence.Entity; -import java.util.*; +import java.time.Instant; +import java.util.Date; import se.su.dsv.scipro.file.FileReference; import se.su.dsv.scipro.project.Project; @@ -24,4 +25,15 @@ public class RoughDraftApproval extends ReviewerApproval { public Step getStep() { return Step.ROUGH_DRAFT_APPROVAL; } + + public RoughDraftApproval cloneToProject(final Project newProject, Instant instant) { + RoughDraftApproval rda = new RoughDraftApproval(); + rda.project = newProject; + this.decisions.forEach(decision -> rda.decisions.add(decision.cloneToReviewerApproval(rda))); + + rda.isCloned = true; + rda.cloneTimestamp = instant; + + return rda; + } } diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalApprovedClonedEvent.java b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalApprovedClonedEvent.java new file mode 100644 index 0000000000..d403a44d07 --- /dev/null +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalApprovedClonedEvent.java @@ -0,0 +1,14 @@ +package se.su.dsv.scipro.reviewing; + +import se.su.dsv.scipro.project.Project; + +public record RoughDraftApprovalApprovedClonedEvent(ReviewerApproval process) { + public Project getProject() { + return process.getProject(); + } + + public String getName() { + ReviewerApproval.Step step = process.getStep(); + return step.getDeclaringClass().getSimpleName() + "." + step.name(); + } +} diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalService.java b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalService.java index a4e41dfa40..308ff71d00 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalService.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalService.java @@ -1,3 +1,5 @@ package se.su.dsv.scipro.reviewing; -public interface RoughDraftApprovalService extends ReviewerApprovalService<RoughDraftApproval> {} +public interface RoughDraftApprovalService extends ReviewerApprovalService<RoughDraftApproval> { + RoughDraftApproval saveCloned(RoughDraftApproval approval); +} diff --git a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalServiceImpl.java b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalServiceImpl.java index 7c0d9ae222..5c3ebc31f7 100644 --- a/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalServiceImpl.java +++ b/core/src/main/java/se/su/dsv/scipro/reviewing/RoughDraftApprovalServiceImpl.java @@ -72,4 +72,10 @@ public class RoughDraftApprovalServiceImpl public Optional<RoughDraftApproval> findBy(Project project) { return Optional.ofNullable(findOne(QRoughDraftApproval.roughDraftApproval.project.eq(project))); } + + @Override + @Transactional + public RoughDraftApproval saveCloned(RoughDraftApproval approval) { + return save(approval); + } } diff --git a/core/src/main/resources/db/migration/V8__project_parent_phase2_review.sql b/core/src/main/resources/db/migration/V8__project_parent_phase2_review.sql new file mode 100644 index 0000000000..dda4ed8e2c --- /dev/null +++ b/core/src/main/resources/db/migration/V8__project_parent_phase2_review.sql @@ -0,0 +1,28 @@ + +alter table `project` + add column `parent_project_id` bigint(20) null default null; + +alter table `project` + add column `root_project_id` bigint(20) null default null; + +alter table `project` + add column `clone_timestamp` datetime null default null; + +alter table `project` + add constraint fk_project_parent_project_id_project_id + foreign key (parent_project_id) references project(id) + on delete cascade on update cascade; + +alter table `project` + add constraint fk_project_root_project_id_project_id + foreign key (root_project_id) references project(id) + on delete cascade on update cascade; + + +alter table `reviewer_approval` + add column `is_cloned` boolean not null default false; + +update `reviewer_approval` set is_cloned = false; + +alter table `reviewer_approval` + add column `clone_timestamp` datetime null default null; diff --git a/core/src/test/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceIntegrationTest.java b/core/src/test/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceIntegrationTest.java new file mode 100644 index 0000000000..f8551c5596 --- /dev/null +++ b/core/src/test/java/se/su/dsv/scipro/project/split/SplitOrRestartProjectServiceIntegrationTest.java @@ -0,0 +1,298 @@ +package se.su.dsv.scipro.project.split; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static se.su.dsv.scipro.project.split.SplitOrRestartProjectService.SplittableStatus; +import static se.su.dsv.scipro.project.split.SplitOrRestartProjectService.SplittableStatusRecord; + +import jakarta.inject.Inject; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.Year; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import se.su.dsv.scipro.file.FileUpload; +import se.su.dsv.scipro.finalseminar.FinalSeminar; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.ProjectStatus; +import se.su.dsv.scipro.reviewing.FinalSeminarApprovalService; +import se.su.dsv.scipro.reviewing.ReviewerAssignmentService; +import se.su.dsv.scipro.reviewing.ReviewerCapacityService; +import se.su.dsv.scipro.reviewing.RoughDraftApproval; +import se.su.dsv.scipro.reviewing.RoughDraftApprovalService; +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 se.su.dsv.scipro.test.MutableFixedClock; + +public class SplitOrRestartProjectServiceIntegrationTest extends IntegrationTest { + + private static int TARGET = 3; + private static int REMAINING_TARGET = 2; + + private static int FIXED_YEAR = 2025; + + @Inject + private SplitOrRestartProjectService splitOrRestartProjectService; + + @Inject + private RoughDraftApprovalService roughDraftApprovalService; + + @Inject + private FinalSeminarApprovalService finalSeminarApprovalService; + + @Inject + MutableFixedClock clock; + + @Inject + private ReviewerAssignmentService reviewerAssignmentService; + + @Inject + private ReviewerCapacityService reviewerCapacityService; + + private ResearchArea researchArea; + private Project parentProject; + private User supervisor; + private User reviewer; + private User author1; + private User author2; + + @BeforeEach + public void setUp() { + ProjectType bachelor = save(new ProjectType(DegreeType.BACHELOR, "Bachelor", "Bachelor")); + + researchArea = new ResearchArea(); + researchArea.setTitle("Computer Science"); + researchArea = save(researchArea); + + parentProject = createProject(bachelor, ProjectStatus.ACTIVE); + } + + @Test + public void project_must_exist() { + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(0); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.NOT_EXIST, status); + } + + @Test + public void project_must_be_active() { + parentProject.setProjectStatus(ProjectStatus.INACTIVE); + parentProject = save(parentProject); + + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.NOT_ACTIVE, status); + } + + @Test + public void project_must_have_two_participants() { + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.NOT_TWO_PARTICIPANTS, status); + } + + @Test + public void project_phase_two_started() { + setUpBeforePhaseTwo(); + + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.PHASE_TWO_STARTED, status); + } + + @Test + public void split_on_failed_phase_two() { + setUpBeforePhaseTwo(); + + Optional<RoughDraftApproval> optional = this.roughDraftApprovalService.findBy(parentProject); + optional.ifPresent(rda -> rda.reject("Fail", Optional.empty())); + + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.OK, status); + + splitOrRestartProjectService.splitProject(parentProject.getId()); + + ReviewerCapacityService.RemainingTargets remainingTargets = reviewerCapacityService.getRemainingTargets( + reviewer, + Year.of(FIXED_YEAR) + ); + + assertEquals(REMAINING_TARGET, remainingTargets.spring()); + assertEquals(TARGET, remainingTargets.autumn()); + + List<Project> childProjects = splitOrRestartProjectService.getChildProjects(parentProject.getId()); + + assertEquals(ProjectStatus.INACTIVE, parentProject.getProjectStatus()); + assertEquals(2, childProjects.size()); + + childProjects.forEach(project -> { + assertEquals(1, project.getProjectParticipants().size()); + assertEquals(supervisor, project.getHeadSupervisor()); + assertEquals(ProjectStatus.ACTIVE, project.getProjectStatus()); + assertTrue(roughDraftApprovalService.findBy(project).isEmpty()); + }); + } + + @Test + public void split_on_approved_phase_two() { + setUpBeforePhaseTwo(); + + Optional<RoughDraftApproval> optional = this.roughDraftApprovalService.findBy(parentProject); + optional.ifPresent(rda -> rda.approve("Approve", Optional.empty())); + + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.OK, status); + + splitOrRestartProjectService.splitProject(parentProject.getId()); + + ReviewerCapacityService.RemainingTargets remainingTargets = reviewerCapacityService.getRemainingTargets( + reviewer, + Year.of(FIXED_YEAR) + ); + + assertEquals(REMAINING_TARGET, remainingTargets.spring()); + assertEquals(TARGET, remainingTargets.autumn()); + + List<Project> childProjects = splitOrRestartProjectService.getChildProjects(parentProject.getId()); + + assertEquals(ProjectStatus.INACTIVE, parentProject.getProjectStatus()); + assertEquals(2, childProjects.size()); + + childProjects.forEach(project -> { + assertEquals(1, project.getProjectParticipants().size()); + assertEquals(supervisor, project.getHeadSupervisor()); + assertEquals(ProjectStatus.ACTIVE, project.getProjectStatus()); + + Optional<RoughDraftApproval> optionalRda = roughDraftApprovalService.findBy(project); + assertTrue(optionalRda.isPresent()); + assertTrue(optional.get().isApproved()); + }); + } + + @Test + public void split_on_final_seminar_phase_started() { + setUpBeforePhaseTwo(); + + Optional<RoughDraftApproval> optional = this.roughDraftApprovalService.findBy(parentProject); + optional.ifPresent(rda -> rda.approve("Approve", Optional.empty())); + + FinalSeminar finalSeminar = new FinalSeminar(); + finalSeminar.setProject(parentProject); + + LocalDate plus30Days = LocalDate.now(clock).plusDays(30); + finalSeminar.setStartDate(Date.from(plus30Days.atStartOfDay(ZoneId.systemDefault()).toInstant())); + finalSeminar.setRoom("room"); + finalSeminar.setPresentationLanguage(Language.ENGLISH); + parentProject.setLanguage(Language.ENGLISH); + + save(finalSeminar); + + SplittableStatusRecord record = splitOrRestartProjectService.getSplittableStatus(parentProject.getId()); + SplittableStatus status = record.splittableStatus(); + + assertEquals(SplittableStatus.FINAL_SEMINAR_PHASE_STARTED, status); + } + + private Project createProject(ProjectType projectType, ProjectStatus active) { + supervisor = createEmployee("Eric", "Employee", "eric@example.com", false); + + Project project = new Project(); + project.setTitle("Some title"); + project.setProjectType(projectType); + project.setProjectStatus(active); + project.setHeadSupervisor(supervisor); + project.setStartDate(LocalDate.now().withMonth(1).withDayOfMonth(1)); + + author1 = createUser("Adam", "Student", "adam.student@example.com"); + project.addProjectParticipant(author1); + + project = save(project); + return project; + } + + private User createEmployee(String firstName, String lastName, String email, boolean isReviewer) { + User employee = createUser(firstName, lastName, email); + + Set<ResearchArea> researchAreas = new HashSet<>(); + researchAreas.add(researchArea); + employee.setResearchAreas(researchAreas); + + if (isReviewer) { + employee.addRole(Roles.REVIEWER); + } + + save(employee); + return employee; + } + + private User createUser(String firstName, String lastName, String email) { + User user = User.builder().firstName(firstName).lastName(lastName).emailAddress(email).build(); + return save(user); + } + + private void setUpBeforePhaseTwo() { + author2 = createUser("Bertil", "Student", "bertil.student@example.com"); + parentProject.addProjectParticipant(author2); + save(parentProject); + + clock.setDate(LocalDate.now().withYear(FIXED_YEAR).withMonth(3).withDayOfMonth(1)); + + roughDraftApprovalService.requestApproval(parentProject, dummyFile(), "comment"); + + reviewer = createEmployee("Lisa", "Employee", "lisa.employee@example.com", true); + reviewerCapacityService.assignTarget( + reviewer, + new ReviewerCapacityService.Target(Year.of(FIXED_YEAR), TARGET, TARGET, "") + ); + reviewerAssignmentService.assignReviewer(parentProject, reviewer); + } + + private FileUpload dummyFile() { + return new FileUpload() { + @Override + public String getFileName() { + return "dummy.tmp"; + } + + @Override + public String getContentType() { + return "text/plain"; + } + + @Override + public User getUploader() { + return null; + } + + @Override + public long getSize() { + return 0; + } + + @Override + public <T> T handleData(Function<InputStream, T> handler) { + return handler.apply(InputStream.nullInputStream()); + } + }; + } +} diff --git a/test-data/src/main/java/se/su/dsv/scipro/testdata/DataInitializer.java b/test-data/src/main/java/se/su/dsv/scipro/testdata/DataInitializer.java index f18dc3a133..e61f794393 100644 --- a/test-data/src/main/java/se/su/dsv/scipro/testdata/DataInitializer.java +++ b/test-data/src/main/java/se/su/dsv/scipro/testdata/DataInitializer.java @@ -7,8 +7,16 @@ import jakarta.transaction.Transactional; import java.time.LocalDate; import java.time.LocalTime; import java.time.Month; +import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; import se.su.dsv.scipro.checklist.ChecklistCategory; import se.su.dsv.scipro.data.dataobjects.Member; import se.su.dsv.scipro.file.FileReference; @@ -24,6 +32,7 @@ import se.su.dsv.scipro.match.TholanderBox; import se.su.dsv.scipro.milestones.dataobjects.MilestoneActivityTemplate; import se.su.dsv.scipro.milestones.dataobjects.MilestonePhaseTemplate; import se.su.dsv.scipro.milestones.service.MilestoneActivityTemplateService; +import se.su.dsv.scipro.milestones.service.MilestonePhaseTemplateService; import se.su.dsv.scipro.notifications.dataobject.CustomEvent; import se.su.dsv.scipro.notifications.dataobject.GroupEvent; import se.su.dsv.scipro.notifications.dataobject.IdeaEvent; @@ -44,7 +53,20 @@ import se.su.dsv.scipro.report.GradingReportTemplate; import se.su.dsv.scipro.reviewing.ReviewerAssignmentService; import se.su.dsv.scipro.reviewing.RoughDraftApprovalService; import se.su.dsv.scipro.security.auth.roles.Roles; -import se.su.dsv.scipro.system.*; +import se.su.dsv.scipro.system.Event; +import se.su.dsv.scipro.system.EventService; +import se.su.dsv.scipro.system.Language; +import se.su.dsv.scipro.system.Lifecycle; +import se.su.dsv.scipro.system.Password; +import se.su.dsv.scipro.system.PasswordHandler; +import se.su.dsv.scipro.system.PasswordService; +import se.su.dsv.scipro.system.Program; +import se.su.dsv.scipro.system.ProjectType; +import se.su.dsv.scipro.system.ResearchArea; +import se.su.dsv.scipro.system.Unit; +import se.su.dsv.scipro.system.User; +import se.su.dsv.scipro.system.UserService; +import se.su.dsv.scipro.system.Username; import se.su.dsv.scipro.util.Pair; public class DataInitializer implements Lifecycle, BaseData, Factory { @@ -69,6 +91,12 @@ public class DataInitializer implements Lifecycle, BaseData, Factory { @Inject private MilestoneActivityTemplateService milestoneActivityTemplateService; + @Inject + private MilestonePhaseTemplateService milestonePhaseTemplateService; + + @Inject + private EventService eventService; + @Inject private FileService fileService; @@ -2012,61 +2040,66 @@ public class DataInitializer implements Lifecycle, BaseData, Factory { MilestoneActivityTemplate.Type.STUDENT, "First meeting held", "First meeting with supervisor.", - milestonePhaseTemplate1, - null - ); - createMileStone( - MilestoneActivityTemplate.Type.PROJECT, - "Project plan approved", - "Project plan approved by supervisor.", - milestonePhaseTemplate1, - null + milestonePhaseTemplate1 ); createMileStone( MilestoneActivityTemplate.Type.PROJECT, - "Rough draft sent to reviewer for approval", - "Rough draft approved by reviewer.", - milestonePhaseTemplate2, - null + "Project plan approved", + "Project plan approved by supervisor.", + milestonePhaseTemplate1 ); + + List<Event> events = eventService.findAll(); + createMileStone( MilestoneActivityTemplate.Type.PROJECT, - "Rough draft approved by reviewer", + "Rough draft sent to reviewer for approval (Auto)", + "Rough draft sent to the reviewer for the first time.", + milestonePhaseTemplate2, + events.stream().filter(event -> event.getName().equals("RoughDraftApprovalRequested")).findFirst().get() + ); + + createMileStone( + MilestoneActivityTemplate.Type.PROJECT, + "Rough draft approved by reviewer (Auto)", "Rough draft approved.", milestonePhaseTemplate2, - null + events.stream().filter(event -> event.getName().equals("Step.ROUGH_DRAFT_APPROVAL")).findFirst().get() ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, - "Peer review 1", + "Peer review 1 (Auto)", "This is a recommendation of when to perform peer review 1.", milestonePhaseTemplate2, - MilestoneActivityTemplate.PEER_REVIEW_ONE + MilestoneActivityTemplate.PEER_REVIEW_ONE, + events.stream().filter(event -> event.getName().equals("FirstPeerReviewCompleted")).findFirst().get() ); createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Result and discussion completed and approved", "Result and discussion.", - milestonePhaseTemplate3, - null + milestonePhaseTemplate3 ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, - "Peer review 2", + "Peer review 2 (Auto)", "This is a recommendation of when to perform peer review 2.", milestonePhaseTemplate3, - MilestoneActivityTemplate.PEER_REVIEW_TWO + MilestoneActivityTemplate.PEER_REVIEW_TWO, + events.stream().filter(event -> event.getName().equals("SecondPeerReviewCompleted")).findFirst().get() ); createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Thesis approved for final seminar presentation", "Thesis approved for final seminar.", - milestonePhaseTemplate4, - null + milestonePhaseTemplate4 ); + createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Final seminar created", @@ -2074,6 +2107,7 @@ public class DataInitializer implements Lifecycle, BaseData, Factory { milestonePhaseTemplate4, MilestoneActivityTemplate.CREATE_SEMINAR ); + createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Final seminar thesis uploaded", @@ -2081,77 +2115,111 @@ public class DataInitializer implements Lifecycle, BaseData, Factory { milestonePhaseTemplate4, MilestoneActivityTemplate.THESIS_UPLOADED ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, "Perform an oral and written opposition", "Opposition.", - milestonePhaseTemplate4, - null + milestonePhaseTemplate4 ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, "Active participation in a final seminar", "Active participation.", - milestonePhaseTemplate4, - null + milestonePhaseTemplate4 ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, "Defend the thesis in a final seminar", "Defence of final thesis.", - milestonePhaseTemplate4, - null + milestonePhaseTemplate4 ); createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Revised final thesis of the submitted thesis", "Revised final thesis.", - milestonePhaseTemplate5, - null + milestonePhaseTemplate5 ); + createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Originality report approved", "Originality report.", - milestonePhaseTemplate5, - null + milestonePhaseTemplate5 ); + createMileStone( MilestoneActivityTemplate.Type.PROJECT, "Supervisor and reviewer final grading report submitted", "Final grading report.", - milestonePhaseTemplate5, - null + milestonePhaseTemplate5 ); + createMileStone( MilestoneActivityTemplate.Type.STUDENT, "Grading completed", "Grading completed by examiner.", - milestonePhaseTemplate5, - null + milestonePhaseTemplate5 ); } + private void createMileStone( + MilestoneActivityTemplate.Type type, + String title, + String description, + MilestonePhaseTemplate milestonePhaseTemplate + ) { + createMileStone(type, title, description, milestonePhaseTemplate, null, null); + } + + private void createMileStone( + MilestoneActivityTemplate.Type type, + String title, + String description, + MilestonePhaseTemplate milestonePhaseTemplate, + Event event + ) { + createMileStone(type, title, description, milestonePhaseTemplate, null, event); + } + private void createMileStone( MilestoneActivityTemplate.Type type, String title, String description, MilestonePhaseTemplate milestonePhaseTemplate, String code + ) { + createMileStone(type, title, description, milestonePhaseTemplate, code, null); + } + + private void createMileStone( + MilestoneActivityTemplate.Type type, + String title, + String description, + MilestonePhaseTemplate milestonePhaseTemplate, + String code, + Event event ) { MilestoneActivityTemplate milestoneActivityTemplate = new MilestoneActivityTemplate(type, title, description); + LocalDate ld = LocalDate.now().minusYears(1); + milestoneActivityTemplate.setDateCreated(Date.from(ld.atStartOfDay(ZoneId.systemDefault()).toInstant())); milestoneActivityTemplate.addProjectType(bachelorClass); milestoneActivityTemplate.addProjectType(masterClass); milestoneActivityTemplate.addProjectType(magisterClass); milestoneActivityTemplate.setMilestonePhaseTemplate(milestonePhaseTemplate); milestoneActivityTemplate.setCode(code); + milestoneActivityTemplate.setActivatedBy(event); milestoneActivityTemplateService.save(milestoneActivityTemplate, milestonePhaseTemplate); } private MilestonePhaseTemplate createMileStonePhase(String title, String description) { - MilestonePhaseTemplate milestonePhaseTemplate1 = new MilestonePhaseTemplate(title, description); - return save(milestonePhaseTemplate1); + MilestonePhaseTemplate milestonePhaseTemplate = new MilestonePhaseTemplate(title, description); + LocalDate ld = LocalDate.now().minusYears(1); + milestonePhaseTemplate.setDateCreated(Date.from(ld.atStartOfDay(ZoneId.systemDefault()).toInstant())); + return milestonePhaseTemplateService.save(milestonePhaseTemplate); } private <T> T save(T entity) { diff --git a/test-data/src/main/java/se/su/dsv/scipro/testdata/populators/SplitProjectPopulator.java b/test-data/src/main/java/se/su/dsv/scipro/testdata/populators/SplitProjectPopulator.java new file mode 100644 index 0000000000..33f0fd7e2a --- /dev/null +++ b/test-data/src/main/java/se/su/dsv/scipro/testdata/populators/SplitProjectPopulator.java @@ -0,0 +1,159 @@ +package se.su.dsv.scipro.testdata.populators; + +import jakarta.inject.Inject; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.Year; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.springframework.stereotype.Service; +import se.su.dsv.scipro.file.FileUpload; +import se.su.dsv.scipro.peer.PeerPortal; +import se.su.dsv.scipro.peer.PeerRequest; +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.ReviewerCapacityService; +import se.su.dsv.scipro.reviewing.ReviewerDecisionService; +import se.su.dsv.scipro.reviewing.RoughDraftApproval; +import se.su.dsv.scipro.reviewing.RoughDraftApprovalService; +import se.su.dsv.scipro.system.Language; +import se.su.dsv.scipro.system.User; +import se.su.dsv.scipro.testdata.BaseData; +import se.su.dsv.scipro.testdata.Factory; +import se.su.dsv.scipro.testdata.TestDataPopulator; + +@Service +public class SplitProjectPopulator implements TestDataPopulator { + + private final ProjectService projectService; + private final ReviewerCapacityService reviewerCapacityService; + private final RoughDraftApprovalService roughDraftApprovalService; + private final ReviewerAssignmentService reviewerAssignmentService; + private final ReviewerDecisionService reviewerDecisionService; + private final PeerPortal peerPortal; + + private User otherSupervisorAndReviewer; + + @Inject + public SplitProjectPopulator( + ProjectService projectService, + ReviewerCapacityService reviewerCapacityService, + RoughDraftApprovalService roughDraftApprovalService, + ReviewerAssignmentService reviewerAssignmentService, + ReviewerDecisionService reviewerDecisionService, + PeerPortal peerPortal + ) { + this.projectService = projectService; + this.reviewerCapacityService = reviewerCapacityService; + this.roughDraftApprovalService = roughDraftApprovalService; + this.reviewerAssignmentService = reviewerAssignmentService; + this.reviewerDecisionService = reviewerDecisionService; + this.peerPortal = peerPortal; + } + + @Override + public void populate(BaseData baseData, Factory factory) { + setUpOtherProjects(baseData, factory); + + User supervisor = factory.createSupervisor("Emil"); + + User author1 = factory.createAuthor("Scott"); + User author2 = factory.createAuthor("Scarlett"); + + reviewerCapacityService.assignTarget( + otherSupervisorAndReviewer, + new ReviewerCapacityService.Target(Year.now(), 3, 3, "") + ); + + Project project = Project.builder() + .title("Operating System Boot Time Security") + .projectType(baseData.bachelor()) + .startDate(LocalDate.now()) + .headSupervisor(supervisor) + .reviewers(Set.of(otherSupervisorAndReviewer)) + .projectParticipants(Set.of(author1, author2)) + .build(); + + projectService.save(project); + + setUpPeerRequest(project, author2, "Please checkout the OS Boot Time Security. Draft 1"); + setUpPeerRequest(project, author2, "Please checkout the OS Boot Time Security. Draft 2"); + + roughDraftApprovalService.requestApproval(project, dummyFile(), "Request review."); + + reviewerAssignmentService.assignReviewer(project, otherSupervisorAndReviewer); + + Optional<RoughDraftApproval> optional = roughDraftApprovalService.findBy(project); + optional.ifPresent(rda -> reviewerDecisionService.approve(rda, "Approved! Good Work!", Optional.empty())); + } + + private void setUpOtherProjects(BaseData baseData, Factory factory) { + otherSupervisorAndReviewer = factory.createReviewer("Elias"); + + User author1 = factory.createAuthor("Sebastian"); + + Project otherProject1 = Project.builder() + .title("The CISC Architecture") + .projectType(baseData.bachelor()) + .startDate(LocalDate.now()) + .headSupervisor(otherSupervisorAndReviewer) + .projectParticipants(Set.of(author1)) + .build(); + + projectService.save(otherProject1); + + setUpPeerRequest(otherProject1, author1, "Please checkout the CISC Architecture."); + + User author2 = factory.createAuthor("Sven"); + Project otherProject2 = Project.builder() + .title("The RISC Architecture") + .projectType(baseData.bachelor()) + .startDate(LocalDate.now()) + .headSupervisor(otherSupervisorAndReviewer) + .projectParticipants(Set.of(author2)) + .build(); + projectService.save(otherProject2); + + setUpPeerRequest(otherProject2, author2, "Please checkout the RISC Architecture."); + } + + private void setUpPeerRequest(Project project, User requester, String comment) { + PeerRequest peerRequest = new PeerRequest(); + peerRequest.setProject(project); + peerRequest.setRequester(requester); + peerRequest.setComment(comment); + peerRequest.setLanguage(Language.ENGLISH); + peerPortal.storePeerRequest(dummyFile(), peerRequest); + } + + private FileUpload dummyFile() { + return new FileUpload() { + @Override + public String getFileName() { + return "dummy.tmp"; + } + + @Override + public String getContentType() { + return "text/plain"; + } + + @Override + public User getUploader() { + return null; + } + + @Override + public long getSize() { + return 0; + } + + @Override + public <T> T handleData(Function<InputStream, T> handler) { + return handler.apply(InputStream.nullInputStream()); + } + }; + } +} diff --git a/view/src/main/java/se/su/dsv/scipro/SciProApplication.java b/view/src/main/java/se/su/dsv/scipro/SciProApplication.java index 9a33ff0ad1..7e375fcf01 100755 --- a/view/src/main/java/se/su/dsv/scipro/SciProApplication.java +++ b/view/src/main/java/se/su/dsv/scipro/SciProApplication.java @@ -294,6 +294,8 @@ public class SciProApplication extends LifecycleManagedWebApplication { mountPage("admin/maintenance", SystemMaintenancePage.class); mountPage("admin/project", ProjectManagementPage.class); mountPage("admin/project/create", AdminCreateProjectPage.class); + mountPage("admin/project/split", AdminSplitProjectPage.class); + mountPage("admin/project/viewparentproject", AdminViewParentProjectPage.class); mountPage("admin/project/survey", AdminSurveyPage.class); mountPage("admin/project/reviewer", AdminAssignReviewerPage.class); mountPage("admin/project/reviewer/capacity", AdminReviewerCapacityManagementPage.class); diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.html b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.html index 4f18134263..ef96d30cfa 100644 --- a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.html +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.html @@ -49,6 +49,9 @@ </form> </div> </div> + + <wicket:container wicket:id="splitPanel"/> + </wicket:extend> </body> </html> diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.java b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.java index d34c954c7f..ae6ac05b4a 100644 --- a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.java +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPage.java @@ -3,14 +3,28 @@ package se.su.dsv.scipro.admin.pages; import com.google.common.eventbus.EventBus; import jakarta.inject.Inject; import java.time.LocalDate; -import java.util.*; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.TreeSet; import org.apache.wicket.RestartResponseException; -import org.apache.wicket.markup.html.form.*; +import org.apache.wicket.markup.html.form.DropDownChoice; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.FormComponent; +import org.apache.wicket.markup.html.form.LambdaChoiceRenderer; +import org.apache.wicket.markup.html.form.RequiredTextField; +import org.apache.wicket.markup.html.form.TextField; import org.apache.wicket.markup.html.panel.FeedbackPanel; import org.apache.wicket.model.IModel; import org.apache.wicket.model.LambdaModel; import org.apache.wicket.request.mapper.parameter.PageParameters; -import se.su.dsv.scipro.components.*; +import se.su.dsv.scipro.components.AutoCompleteRoleProvider; +import se.su.dsv.scipro.components.BootstrapDatePicker; +import se.su.dsv.scipro.components.DatesValidator; +import se.su.dsv.scipro.components.DefaultSelect2MultiChoice; +import se.su.dsv.scipro.components.ResearchAreaChoiceRenderer; +import se.su.dsv.scipro.components.ResearchAreasModel; +import se.su.dsv.scipro.components.SupervisorAutoComplete; import se.su.dsv.scipro.components.menuhighlighting.MenuHighlightAdminProjectManagement; import se.su.dsv.scipro.data.DetachableServiceModel; import se.su.dsv.scipro.data.DetachableServiceModelCollection; @@ -26,7 +40,13 @@ import se.su.dsv.scipro.project.ProjectService; import se.su.dsv.scipro.project.ReviewerAssignedEvent; import se.su.dsv.scipro.security.auth.Authorization; import se.su.dsv.scipro.security.auth.roles.Roles; -import se.su.dsv.scipro.system.*; +import se.su.dsv.scipro.system.ProjectType; +import se.su.dsv.scipro.system.ProjectTypeService; +import se.su.dsv.scipro.system.ResearchArea; +import se.su.dsv.scipro.system.ResearchAreaService; +import se.su.dsv.scipro.system.User; +import se.su.dsv.scipro.system.UserSearchService; +import se.su.dsv.scipro.system.UserService; import se.su.dsv.scipro.util.PageParameterKeys; @Authorization(authorizedRoles = { Roles.SYSADMIN }) @@ -74,7 +94,10 @@ public class AdminEditProjectPage extends AbstractAdminProjectPage implements Me if (project == null) { throw new RestartResponseException(AdminCreateProjectPage.class); } - add(new ProjectForm(FORM, new DetachableServiceModel<>(projectService, project))); + DetachableServiceModel<Project> dsModel = new DetachableServiceModel<>(projectService, project); + + add(new ProjectForm(FORM, dsModel)); + add(new AdminSplitProjectPanel("splitPanel", dsModel)); } private class ProjectForm extends Form<Project> { diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.html b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.html new file mode 100644 index 0000000000..f05d53317e --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org" lang="en"> +<body> +<wicket:extend> + <div class="row"> + <div class="col-lg-5"> + <h4 wicket:id="projectTitle"></h4> + + <form class="form-horizontal" wicket:id="splitProjectForm"> + <p>This project will be split between following authors:</p> + + <div class="mb-3"> + <ul wicket:id="authorList"> + <li wicket:id="author"></li> + </ul> + </div> + + <button class="btn btn-success" type="submit">Split Project</button> + + <a wicket:id="cancelLink">Cancel</a> + </form> + </div> + </div> + +</wicket:extend> +</body> +</html> \ No newline at end of file diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.java b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.java new file mode 100644 index 0000000000..8a02d457aa --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPage.java @@ -0,0 +1,74 @@ +package se.su.dsv.scipro.admin.pages; + +import jakarta.inject.Inject; +import java.util.ArrayList; +import org.apache.wicket.RestartResponseException; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.model.IModel; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import se.su.dsv.scipro.components.menuhighlighting.MenuHighlightAdminProjectManagement; +import se.su.dsv.scipro.data.DetachableServiceModel; +import se.su.dsv.scipro.profile.UserLabel; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.ProjectService; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectService; +import se.su.dsv.scipro.security.auth.Authorization; +import se.su.dsv.scipro.security.auth.roles.Roles; +import se.su.dsv.scipro.system.User; +import se.su.dsv.scipro.util.PageParameterKeys; + +@Authorization(authorizedRoles = { Roles.SYSADMIN }) +public class AdminSplitProjectPage extends AbstractAdminProjectPage implements MenuHighlightAdminProjectManagement { + + @Inject + private ProjectService projectService; + + @Inject + private SplitOrRestartProjectService splitOrRestartProjectService; + + public AdminSplitProjectPage(PageParameters pp) { + final long id = pp.get(PageParameterKeys.MAP.get(Project.class)).toLong(0); + final Project project = projectService.findOne(id); + if (project == null) { + throw new RestartResponseException(AdminCreateProjectPage.class); + } + DetachableServiceModel<Project> dsModel = new DetachableServiceModel<>(projectService, project); + + add(new Label("projectTitle", dsModel.map(Project::getTitle))); + + add(new SplitProjectForm("splitProjectForm", dsModel)); + } + + private class SplitProjectForm extends Form<Project> { + + public SplitProjectForm(String id, final IModel<Project> model) { + super(id, model); + add( + new ListView<>("authorList", model.map(Project::getProjectParticipants).map(ArrayList::new)) { + @Override + protected void populateItem(ListItem<User> item) { + item.add(new UserLabel("author", item.getModel())); + } + } + ); + + add(new BookmarkablePageLink<Void>("cancelLink", ProjectManagementPage.class)); + } + + @Override + protected void onSubmit() { + Long projectId = getModel().getObject().getId(); + + splitOrRestartProjectService.splitProject(projectId); + + final PageParameters pp = new PageParameters(); + pp.set(PageParameterKeys.MAP.get(Project.class), projectId); + + setResponsePage(AdminViewParentProjectPage.class, pp); + } + } +} diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.html b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.html new file mode 100644 index 0000000000..0aedf51684 --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.html @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org"> +<body> +<wicket:panel> + <div class="row mt-5"> + <div class="col-md-12 col-lg-8 col-xl-5"> + <p> + <a class="btn" wicket:id="splitProjectLink"><wicket:message key="splitButton" /></a> + </p> + <p wicket:id="splitInfo"></p> + </div> + </div> +</wicket:panel> +</body> +</html> \ No newline at end of file diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.java b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.java new file mode 100644 index 0000000000..83a50b64c2 --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.java @@ -0,0 +1,70 @@ +package se.su.dsv.scipro.admin.pages; + +import static se.su.dsv.scipro.project.split.SplitOrRestartProjectService.SplittableStatus; + +import jakarta.inject.Inject; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.link.AbstractLink; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectService; +import se.su.dsv.scipro.util.PageParameterKeys; + +public class AdminSplitProjectPanel extends Panel { + + @Inject + private SplitOrRestartProjectService splitOrRestartProjectService; + + public AdminSplitProjectPanel(String id, final IModel<Project> projectModel) { + super(id, projectModel); + Project project = projectModel.getObject(); + + LoadableDetachableModel<SplittableStatus> ldModel = LoadableDetachableModel.of(() -> { + SplitOrRestartProjectService.SplittableStatusRecord status = + splitOrRestartProjectService.getSplittableStatus(project.getId()); + return status.splittableStatus(); + }); + + final PageParameters pp = new PageParameters(); + pp.set(PageParameterKeys.MAP.get(Project.class), project.getId()); + + AbstractLink splitProjectLink = new BookmarkablePageLink<Void>( + "splitProjectLink", + AdminSplitProjectPage.class, + pp + ) { + @Override + protected void onConfigure() { + super.onConfigure(); + + if (ldModel.getObject() == SplittableStatus.OK) { + setEnabled(true); + this.add(new AttributeAppender("class", Model.of(" btn-success"))); + } else { + setEnabled(false); + this.add(new AttributeAppender("class", Model.of(" btn-secondary disabled"))); + } + } + }; + add(splitProjectLink); + + add(new Label("splitInfo", ldModel.map(this::getStatusMessage))); + } + + private String getStatusMessage(SplittableStatus status) { + return switch (status) { + case OK -> ""; + case NOT_EXIST -> getString("status_not_exist"); + case NOT_ACTIVE -> getString("status_not_active"); + case NOT_TWO_PARTICIPANTS -> getString("status_not_two_participants"); + case PHASE_TWO_STARTED -> getString("status_phase_two_started"); + case FINAL_SEMINAR_PHASE_STARTED -> getString("status_final_seminar_phase_started"); + }; + } +} diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.utf8.properties b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.utf8.properties new file mode 100644 index 0000000000..b3ec52b2aa --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminSplitProjectPanel.utf8.properties @@ -0,0 +1,6 @@ +splitButton = Split Project +status_not_exit = Project does not exist. +status_not_active = Only active project can be split. +status_not_two_participants = To be able to split a project, it needs to have 2 participants. +status_phase_two_started = Phase 2 (Review) is already started, can't split right now. +status_final_seminar_phase_started = Final seminar phase has been started, too late to split. diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.html b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.html new file mode 100644 index 0000000000..c7f6ecaa59 --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org" lang="en"> +<body> +<wicket:extend> + <div class="row"> + <div class="col-lg-5"> + <h4 wicket:id="projectTitle"></h4> + + <p>The project has following children projects:</p> + + <div class="mb-3"> + <ul wicket:id="projectList"> + <li><a wicket:id="editLink"></a></li> + </ul> + </div> + + <div class="mt-5"> + <a class="btn btn-success" wicket:id="link">Projects</a> + </div> + </div> + </div> + +</wicket:extend> +</body> +</html> \ No newline at end of file diff --git a/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.java b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.java new file mode 100644 index 0000000000..3b1dacca7e --- /dev/null +++ b/view/src/main/java/se/su/dsv/scipro/admin/pages/AdminViewParentProjectPage.java @@ -0,0 +1,67 @@ +package se.su.dsv.scipro.admin.pages; + +import jakarta.inject.Inject; +import java.util.List; +import org.apache.wicket.RestartResponseException; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import se.su.dsv.scipro.components.menuhighlighting.MenuHighlightAdminProjectManagement; +import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.ProjectService; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectService; +import se.su.dsv.scipro.security.auth.Authorization; +import se.su.dsv.scipro.security.auth.roles.Roles; +import se.su.dsv.scipro.util.PageParameterKeys; + +@Authorization(authorizedRoles = { Roles.SYSADMIN }) +public class AdminViewParentProjectPage + extends AbstractAdminProjectPage + implements MenuHighlightAdminProjectManagement { + + @Inject + private ProjectService projectService; + + @Inject + private SplitOrRestartProjectService splitOrRestartProjectService; + + public AdminViewParentProjectPage(PageParameters pp) { + final long id = pp.get(PageParameterKeys.MAP.get(Project.class)).toLong(0); + final Project project = projectService.findOne(id); + if (project == null) { + throw new RestartResponseException(AdminCreateProjectPage.class); + } + + add(new Label("projectTitle", Model.of(project.getTitle()))); + + LoadableDetachableModel<List<Project>> ldModel = LoadableDetachableModel.of(() -> + splitOrRestartProjectService.getChildProjects(id) + ); + + add( + new ListView<>("projectList", ldModel) { + @Override + protected void populateItem(ListItem<Project> item) { + Project project = item.getModelObject(); + + final PageParameters pp = new PageParameters(); + pp.set(PageParameterKeys.MAP.get(Project.class), project.getId()); + + BookmarkablePageLink<Void> link = new BookmarkablePageLink<Void>( + "editLink", + AdminEditProjectPage.class, + pp + ); + link.setBody(item.getModel().map(p -> p.getTitle() + " - " + p.getAuthorNames())); + item.add(link); + } + } + ); + + add(new BookmarkablePageLink<Void>("link", ProjectManagementPage.class)); + } +} diff --git a/view/src/main/java/se/su/dsv/scipro/reviewer/ApprovalReviewerPanel.java b/view/src/main/java/se/su/dsv/scipro/reviewer/ApprovalReviewerPanel.java index 37e981c569..05ae98907e 100644 --- a/view/src/main/java/se/su/dsv/scipro/reviewer/ApprovalReviewerPanel.java +++ b/view/src/main/java/se/su/dsv/scipro/reviewer/ApprovalReviewerPanel.java @@ -83,10 +83,11 @@ public abstract class ApprovalReviewerPanel extends Panel { LinkWrapper.apply(componentId, id -> { AbstractLink link = newDecisionLink(id, rowModel.map(Decision::getReviewerApproval)); link.setBody( - rowModel - .map(Decision::getReviewerApproval) - .map(ReviewerApproval::getProject) - .map(Project::getTitle) + rowModel.map(dec -> { + ReviewerApproval ra = dec.getReviewerApproval(); + Project p = ra.getProject(); + return ra.getCloned() ? p.getTitle() + " (Cloned)" : p.getTitle(); + }) ); return link; }) diff --git a/view/src/test/java/se/su/dsv/scipro/SciProTest.java b/view/src/test/java/se/su/dsv/scipro/SciProTest.java index c746ac4b1a..e040af2a0d 100755 --- a/view/src/test/java/se/su/dsv/scipro/SciProTest.java +++ b/view/src/test/java/se/su/dsv/scipro/SciProTest.java @@ -96,6 +96,7 @@ 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; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectService; import se.su.dsv.scipro.projectpartner.ProjectPartnerService; import se.su.dsv.scipro.reflection.ReflectionService; import se.su.dsv.scipro.report.GradeCalculatorService; @@ -251,6 +252,9 @@ public abstract class SciProTest { @Mock protected ProjectService projectService; + @Mock + protected SplitOrRestartProjectService splitOrRestartProjectService; + @Mock protected ResearchAreaService researchAreaService; diff --git a/view/src/test/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPageTest.java b/view/src/test/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPageTest.java index fca1614838..f9fb3c2bc8 100644 --- a/view/src/test/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPageTest.java +++ b/view/src/test/java/se/su/dsv/scipro/admin/pages/AdminEditProjectPageTest.java @@ -20,11 +20,13 @@ import se.su.dsv.scipro.mail.MailEvent; import se.su.dsv.scipro.notifications.dataobject.NotificationSource; import se.su.dsv.scipro.notifications.dataobject.ProjectEvent; import se.su.dsv.scipro.project.Project; +import se.su.dsv.scipro.project.split.SplitOrRestartProjectService; import se.su.dsv.scipro.security.auth.roles.Roles; import se.su.dsv.scipro.system.DegreeType; import se.su.dsv.scipro.system.ProjectType; import se.su.dsv.scipro.system.User; import se.su.dsv.scipro.util.PageParameterKeys; +import se.su.dsv.scipro.util.Pair; public class AdminEditProjectPageTest extends SciProTest { @@ -269,6 +271,16 @@ public class AdminEditProjectPageTest extends SciProTest { private void startPage(Project project) { if (project.getId() != null) when(projectService.findOne(project.getId())).thenReturn(project); + lenient() + .when(splitOrRestartProjectService.getSplittableStatus(project.getId() != null ? project.getId() : 0L)) + .thenReturn( + new SplitOrRestartProjectService.SplittableStatusRecord( + SplitOrRestartProjectService.SplittableStatus.OK, + project, + null + ) + ); + PageParameters pp = new PageParameters(); pp.set(PageParameterKeys.MAP.get(Project.class), project.getId()); tester.startPage(AdminEditProjectPage.class, pp);