diff --git a/.gitignore b/.gitignore index b63da45..4a5f328 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .gradle build/ +.idea !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..fffee96 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,20 @@ plugins { id("java") + id("io.freefair.lombok") version "9.1.0" } -group = "org.lab" -version = "1.0-SNAPSHOT" +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +group = "org.itmo" +version = "1.0" repositories { mavenCentral() + gradlePluginPortal() } dependencies { @@ -15,6 +23,11 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +tasks.compileJava { + options.release = 25 + options.encoding = "UTF-8" +} + tasks.test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/org/itmo/CurrentUserHolder.java b/src/main/java/org/itmo/CurrentUserHolder.java new file mode 100644 index 0000000..36770af --- /dev/null +++ b/src/main/java/org/itmo/CurrentUserHolder.java @@ -0,0 +1,17 @@ +package org.itmo; + +import org.itmo.entity.UserEntity; + +import lombok.Setter; + +public class CurrentUserHolder { + @Setter + private static UserEntity user; + + public static UserEntity getUser() { + if (user == null) { + throw new IllegalStateException("No current user"); + } + return user; + } +} diff --git a/src/main/java/org/itmo/Main.java b/src/main/java/org/itmo/Main.java new file mode 100644 index 0000000..6ed03b0 --- /dev/null +++ b/src/main/java/org/itmo/Main.java @@ -0,0 +1,6 @@ +package org.itmo; + +public class Main { + static void main(String[] args) { + } +} diff --git a/src/main/java/org/itmo/dto/BugDto.java b/src/main/java/org/itmo/dto/BugDto.java new file mode 100644 index 0000000..c9feed3 --- /dev/null +++ b/src/main/java/org/itmo/dto/BugDto.java @@ -0,0 +1,6 @@ +package org.itmo.dto; + +import org.itmo.type.BugStatusEnum; + +public record BugDto(long id, String title, BugStatusEnum status) { +} diff --git a/src/main/java/org/itmo/dto/ProjectBugsDto.java b/src/main/java/org/itmo/dto/ProjectBugsDto.java new file mode 100644 index 0000000..99b2ac3 --- /dev/null +++ b/src/main/java/org/itmo/dto/ProjectBugsDto.java @@ -0,0 +1,6 @@ +package org.itmo.dto; + +import java.util.Set; + +public record ProjectBugsDto(ProjectDto projectDto, Set bugs) { +} diff --git a/src/main/java/org/itmo/dto/ProjectDto.java b/src/main/java/org/itmo/dto/ProjectDto.java new file mode 100644 index 0000000..bab5eab --- /dev/null +++ b/src/main/java/org/itmo/dto/ProjectDto.java @@ -0,0 +1,4 @@ +package org.itmo.dto; + +public record ProjectDto(long id, String name) { +} diff --git a/src/main/java/org/itmo/dto/ProjectTicketsDto.java b/src/main/java/org/itmo/dto/ProjectTicketsDto.java new file mode 100644 index 0000000..074b195 --- /dev/null +++ b/src/main/java/org/itmo/dto/ProjectTicketsDto.java @@ -0,0 +1,8 @@ +package org.itmo.dto; + +import java.util.Set; + +import org.itmo.entity.TicketEntity; + +public record ProjectTicketsDto(ProjectDto projectDto, Set tickets) { +} diff --git a/src/main/java/org/itmo/entity/AbstractEntity.java b/src/main/java/org/itmo/entity/AbstractEntity.java new file mode 100644 index 0000000..61a9e5a --- /dev/null +++ b/src/main/java/org/itmo/entity/AbstractEntity.java @@ -0,0 +1,18 @@ +package org.itmo.entity; + +import java.util.concurrent.atomic.AtomicLong; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +public sealed abstract class AbstractEntity permits BugEntity, MilestoneEntity, ProjectEntity, TicketEntity, UserEntity { + private static final AtomicLong idSequence = new AtomicLong(0); + + @Getter + private final Long id; + + public AbstractEntity() { + this.id = idSequence.incrementAndGet(); + } +} diff --git a/src/main/java/org/itmo/entity/BugEntity.java b/src/main/java/org/itmo/entity/BugEntity.java new file mode 100644 index 0000000..868a4ca --- /dev/null +++ b/src/main/java/org/itmo/entity/BugEntity.java @@ -0,0 +1,24 @@ +package org.itmo.entity; + +import org.itmo.dto.BugDto; +import org.itmo.type.BugStatusEnum; + +import lombok.Getter; +import lombok.Setter; + +public final class BugEntity extends AbstractEntity { + private final String title; + @Getter + @Setter + private BugStatusEnum status; + + public BugEntity(String title) { + super(); + this.title = title; + this.status = BugStatusEnum.NEW; + } + + public BugDto toDto() { + return new BugDto(getId(), title, status); + } +} diff --git a/src/main/java/org/itmo/entity/MilestoneEntity.java b/src/main/java/org/itmo/entity/MilestoneEntity.java new file mode 100644 index 0000000..f802b27 --- /dev/null +++ b/src/main/java/org/itmo/entity/MilestoneEntity.java @@ -0,0 +1,74 @@ +package org.itmo.entity; + +import java.time.LocalDate; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.itmo.type.MilestoneStatusEnum; +import org.itmo.type.TicketStatusEnum; + +import lombok.Getter; +import lombok.Setter; + +public final class MilestoneEntity extends AbstractEntity { + @Getter + private final String name; + @Getter + private final LocalDate startDate; + @Getter + private final LocalDate endDate; + + @Getter + @Setter + private MilestoneStatusEnum status; + + private final Map tickets = new HashMap<>(); + private final Map> assigneesTickets = new HashMap<>(); + + public MilestoneEntity(String name, LocalDate startDate, LocalDate endDate) { + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Start date must not be after end date"); + } + this.name = name; + this.startDate = startDate; + this.endDate = endDate; + this.status = MilestoneStatusEnum.OPEN; + } + + public boolean hasActiveTickets() { + return tickets.values().stream().anyMatch(t -> t.getStatus() != TicketStatusEnum.COMPLETED); + } + + public Set getAssignedTickets(UserEntity user) { + var tickets = assigneesTickets.get(user); + if (tickets == null) { + return new HashSet<>(); + } + return tickets; + } + + public TicketEntity createTicket(String title) { + TicketEntity ticket = new TicketEntity(title); + tickets.put(ticket.getId(), ticket); + return ticket; + } + + public void assignTicket(UserEntity assignee, TicketEntity ticket) { + if (tickets.get(ticket.getId()) == null) { + throw new IllegalArgumentException("Unknown ticket"); + } + assigneesTickets.compute(assignee, (_, v) -> { + // ^^^ feature! + if (v == null) { + Set set = new HashSet<>(); + set.add(ticket); + return set; + } + v.add(ticket); + return v; + }); + } +} diff --git a/src/main/java/org/itmo/entity/ProjectEntity.java b/src/main/java/org/itmo/entity/ProjectEntity.java new file mode 100644 index 0000000..13c2ef4 --- /dev/null +++ b/src/main/java/org/itmo/entity/ProjectEntity.java @@ -0,0 +1,204 @@ +package org.itmo.entity; + +import java.time.LocalDate; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.stream.Collectors; + +import org.itmo.CurrentUserHolder; +import org.itmo.dto.BugDto; +import org.itmo.dto.ProjectDto; +import org.itmo.type.BugStatusEnum; +import org.itmo.type.MilestoneStatusEnum; +import org.itmo.type.RoleEnum; + +import lombok.Getter; + +public final class ProjectEntity extends AbstractEntity { + @Getter + private final String name; + @Getter + private final UserEntity manager; + + @Getter + private UserEntity teamLead; + + private final Set developers = new HashSet<>(); + private final Set testers = new HashSet<>(); + + private final Queue milestones = new LinkedList<>(); + private final List closedMilestones = new LinkedList<>(); + + private final Map bugs = new HashMap<>(); + + public ProjectEntity(String name, UserEntity manager) { + super(); + this.name = name; + this.manager = manager; + } + + public ProjectEntity(String name, UserEntity manager, UserEntity teamLead) { + super(); + this.name = name; + this.manager = manager; + this.teamLead = teamLead; + } + + public void addDeveloper(UserEntity user) { + checkAnyAccess(RoleEnum.MANAGER); + if (developers.contains(user)) { + throw new IllegalStateException("User %s is already a developer in project %s".formatted(user.getUsername(), this.name)); + } + developers.add(user); + } + + public void addTester(UserEntity user) { + checkAnyAccess(RoleEnum.MANAGER); + if (testers.contains(user)) { + throw new IllegalStateException("User %s is already a tester in project %s".formatted(user.getUsername(), this.name)); + } + testers.add(user); + } + + public void assignTeamLead(UserEntity user) { + checkAnyAccess(RoleEnum.MANAGER); + this.teamLead = user; + } + + public void createMilestone(String name, LocalDate startDate, LocalDate endDate) { + checkAnyAccess(RoleEnum.MANAGER); + var milestone = new MilestoneEntity(name,startDate, endDate); + milestones.add(milestone); + } + + public void closeCurrentMilestone() { + checkAnyAccess(RoleEnum.MANAGER); + var milestone = getCurrentMilestone(); + if (milestone.hasActiveTickets()) { + throw new IllegalStateException("All tickets must be completed before milestone can be closed"); + } + milestone.setStatus(MilestoneStatusEnum.CLOSED); + milestones.poll(); + closedMilestones.add(milestone); + } + + public void activateNextMilestone() { + checkAnyAccess(RoleEnum.MANAGER); + if (milestones.isEmpty()) { + throw new IllegalStateException("There is no milestone to activate"); + } + var milestone = milestones.peek(); + milestone.setStatus(MilestoneStatusEnum.ACTIVE); + } + + public Set getAssignedTickets() { + UserEntity user = CurrentUserHolder.getUser(); + return getCurrentMilestone().getAssignedTickets(user); + } + + public TicketEntity createTicket(String title) { + checkAnyAccess(RoleEnum.MANAGER, RoleEnum.TEAM_LEAD); + return getCurrentMilestone().createTicket(title); + } + + public void assignTicket(UserEntity assignee, TicketEntity ticket) { + checkAnyAccess(RoleEnum.MANAGER, RoleEnum.TEAM_LEAD); + if (!developers.contains(assignee)) { + throw new IllegalStateException("Assignee is not developer"); + } + getCurrentMilestone().assignTicket(assignee, ticket); + } + + public BugDto createBug(String title) { + checkAnyAccess(RoleEnum.DEVELOPER, RoleEnum.TESTER); + var bug = new BugEntity(title); + bugs.put(bug.getId(), bug); + return bug.toDto(); + } + + public Set getBugsToFix() { + if (!getUserRoles(CurrentUserHolder.getUser()).contains(RoleEnum.DEVELOPER)) { + return Set.of(); + } + return bugs.values().stream() + .filter(b -> b.getStatus() == BugStatusEnum.NEW) + .map(BugEntity::toDto) + .collect(Collectors.toSet()); + } + + public void fixBug(long id) { + checkAnyAccess(RoleEnum.DEVELOPER); + var bug = bugs.get(id); + if (bug == null) { + throw new IllegalArgumentException("Unknown bug"); + } + bug.setStatus(BugStatusEnum.FIXED); + } + + public Set getBugsToTest() { + if (!getUserRoles(CurrentUserHolder.getUser()).contains(RoleEnum.TESTER)) { + return Set.of(); + } + return bugs.values().stream() + .filter(b -> b.getStatus() == BugStatusEnum.FIXED) + .map(BugEntity::toDto) + .collect(Collectors.toSet()); + } + + public void testBug(long id) { + checkAnyAccess(RoleEnum.TESTER); + var bug = bugs.get(id); + if (bug == null) { + throw new IllegalArgumentException("Unknown bug"); + } + bug.setStatus(BugStatusEnum.TESTED); + } + + public Set getUserRoles(UserEntity user) { + Set userRoles = new HashSet<>(); + if (manager.equals(user)) { + userRoles.add(RoleEnum.MANAGER); + } + if (teamLead != null && teamLead.equals(user)) { + userRoles.add(RoleEnum.TEAM_LEAD); + } + if (developers.contains(user)) { + userRoles.add(RoleEnum.DEVELOPER); + } + if (testers.contains(user)) { + userRoles.add(RoleEnum.TESTER); + } + return userRoles; + } + + public ProjectDto toDto() { + return new ProjectDto(getId(), name); + } + + private MilestoneEntity getCurrentMilestone() { + checkActiveMilestone(); + return milestones.peek(); + } + + private void checkActiveMilestone() { + if (milestones.isEmpty() || milestones.peek().getStatus() != MilestoneStatusEnum.ACTIVE) { + throw new IllegalStateException("There is no active milestone"); + } + } + + private void checkAnyAccess(RoleEnum... roles) { + var userRoles = getUserRoles(CurrentUserHolder.getUser()); + for (var role : roles) { + if (userRoles.contains(role)) { + return; + } + } + throw new SecurityException("Operation not allowed"); + } +} diff --git a/src/main/java/org/itmo/entity/TicketEntity.java b/src/main/java/org/itmo/entity/TicketEntity.java new file mode 100644 index 0000000..9351c06 --- /dev/null +++ b/src/main/java/org/itmo/entity/TicketEntity.java @@ -0,0 +1,29 @@ +package org.itmo.entity; + +import org.itmo.type.TicketStatusEnum; + +import lombok.Getter; + +@Getter +public final class TicketEntity extends AbstractEntity { + private final String title; + private TicketStatusEnum status; + + public TicketEntity(String title) { + super(); + this.title = title; + this.status = TicketStatusEnum.NEW; + } + + public void accept() { + this.status = TicketStatusEnum.ACCEPTED; + } + + public void startProgress() { + this.status = TicketStatusEnum.IN_PROGRESS; + } + + public void complete() { + this.status = TicketStatusEnum.COMPLETED; + } +} diff --git a/src/main/java/org/itmo/entity/UserEntity.java b/src/main/java/org/itmo/entity/UserEntity.java new file mode 100644 index 0000000..6331f5d --- /dev/null +++ b/src/main/java/org/itmo/entity/UserEntity.java @@ -0,0 +1,13 @@ +package org.itmo.entity; + +import lombok.Getter; + +@Getter +public final class UserEntity extends AbstractEntity { + private final String username; + + public UserEntity(String username) { + super(); + this.username = username; + } +} diff --git a/src/main/java/org/itmo/service/ProjectService.java b/src/main/java/org/itmo/service/ProjectService.java new file mode 100644 index 0000000..0e93181 --- /dev/null +++ b/src/main/java/org/itmo/service/ProjectService.java @@ -0,0 +1,45 @@ +package org.itmo.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.itmo.CurrentUserHolder; +import org.itmo.dto.ProjectBugsDto; +import org.itmo.dto.ProjectTicketsDto; +import org.itmo.entity.ProjectEntity; + +public class ProjectService { + private final Map projects = new HashMap<>(); + + public ProjectEntity createProject(String name) { + return new ProjectEntity(name, CurrentUserHolder.getUser()); + } + + public Set getUserProjects() { + var user = CurrentUserHolder.getUser(); + return projects.values().stream() + .filter(p -> !p.getUserRoles(user).isEmpty()) + .collect(Collectors.toSet()); + } + + public Set getTickets() { + return projects.values().stream() + .map(p -> { + var tickets = p.getAssignedTickets(); + return new ProjectTicketsDto(p.toDto(), tickets); + }) + .collect(Collectors.toSet()); + } + + public Set getBugs() { + return projects.values().stream() + .map(p -> { + var bugs = p.getBugsToFix(); + return new ProjectBugsDto(p.toDto(), bugs); + }) + .filter(dto -> !dto.bugs().isEmpty()) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/org/itmo/type/BugStatusEnum.java b/src/main/java/org/itmo/type/BugStatusEnum.java new file mode 100644 index 0000000..7fcbc47 --- /dev/null +++ b/src/main/java/org/itmo/type/BugStatusEnum.java @@ -0,0 +1,8 @@ +package org.itmo.type; + +public enum BugStatusEnum { + NEW, + FIXED, + TESTED, + CLOSED +} diff --git a/src/main/java/org/itmo/type/MilestoneStatusEnum.java b/src/main/java/org/itmo/type/MilestoneStatusEnum.java new file mode 100644 index 0000000..1756270 --- /dev/null +++ b/src/main/java/org/itmo/type/MilestoneStatusEnum.java @@ -0,0 +1,7 @@ +package org.itmo.type; + +public enum MilestoneStatusEnum { + OPEN, + ACTIVE, + CLOSED +} diff --git a/src/main/java/org/itmo/type/RoleEnum.java b/src/main/java/org/itmo/type/RoleEnum.java new file mode 100644 index 0000000..c5f2ce9 --- /dev/null +++ b/src/main/java/org/itmo/type/RoleEnum.java @@ -0,0 +1,8 @@ +package org.itmo.type; + +public enum RoleEnum { + MANAGER, + TEAM_LEAD, + DEVELOPER, + TESTER +} diff --git a/src/main/java/org/itmo/type/TicketStatusEnum.java b/src/main/java/org/itmo/type/TicketStatusEnum.java new file mode 100644 index 0000000..c8d6638 --- /dev/null +++ b/src/main/java/org/itmo/type/TicketStatusEnum.java @@ -0,0 +1,8 @@ +package org.itmo.type; + +public enum TicketStatusEnum { + NEW, + ACCEPTED, + IN_PROGRESS, + COMPLETED +} diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java deleted file mode 100644 index 22028ef..0000000 --- a/src/main/java/org/lab/Main.java +++ /dev/null @@ -1,4 +0,0 @@ -void main() { - IO.println("Hello and welcome!"); -} - diff --git a/src/test/java/org/itmo/BaseTest.java b/src/test/java/org/itmo/BaseTest.java new file mode 100644 index 0000000..3ba0f0a --- /dev/null +++ b/src/test/java/org/itmo/BaseTest.java @@ -0,0 +1,78 @@ +package org.itmo; + +import java.time.LocalDate; + +import org.itmo.entity.UserEntity; +import org.itmo.service.ProjectService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BaseTest { + + @Test + void baseTest() { + var projectService = new ProjectService(); + + var manager = new UserEntity("manager"); + var teamLead = new UserEntity("teamLead"); + var developer = new UserEntity("developer"); + var tester = new UserEntity("tester"); + + assertThrows(IllegalStateException.class, () -> projectService.createProject("project")); + + CurrentUserHolder.setUser(manager); + var project = projectService.createProject("project"); + project.assignTeamLead(teamLead); + project.addDeveloper(developer); + project.addTester(tester); + + assertThrows(IllegalStateException.class, () -> project.createTicket("ticket 1")); + project.createMilestone("milestone", LocalDate.now(), LocalDate.now().plusDays(14)); + + assertThrows(IllegalStateException.class, () -> project.createTicket("ticket 1")); + project.activateNextMilestone(); + + CurrentUserHolder.setUser(teamLead); + var ticket = project.createTicket("ticket 1"); + project.assignTicket(developer, ticket); + + CurrentUserHolder.setUser(developer); + var tickets = project.getAssignedTickets(); + assertTrue(tickets.size() == 1 && tickets.stream().findFirst().get().equals(ticket)); + + ticket.accept(); + ticket.startProgress(); + project.createBug("bug"); + + var bugs = project.getBugsToFix(); + assertEquals(1, bugs.size()); + var bug = bugs.stream().findFirst().get(); + + CurrentUserHolder.setUser(tester); + assertThrows(SecurityException.class, () -> project.fixBug(bug.id())); + CurrentUserHolder.setUser(developer); + project.fixBug(bug.id()); + + CurrentUserHolder.setUser(tester); + var bugsToTest = project.getBugsToTest(); + assertEquals(1, bugsToTest.size()); + var bugToTest = bugsToTest.stream().findFirst().get(); + + CurrentUserHolder.setUser(developer); + assertThrows(SecurityException.class, () -> project.testBug(bugToTest.id())); + CurrentUserHolder.setUser(tester); + project.testBug(bugToTest.id()); + + CurrentUserHolder.setUser(manager); + assertThrows(IllegalStateException.class, project::closeCurrentMilestone); + + CurrentUserHolder.setUser(developer); + ticket.complete(); + + CurrentUserHolder.setUser(manager); + project.closeCurrentMilestone(); + } +}