diff --git a/server/build.gradle b/server/build.gradle index 634e67ba74..9b829a7f34 100755 --- a/server/build.gradle +++ b/server/build.gradle @@ -79,6 +79,7 @@ dependencies { yarnBuildElements(project(":web-ui")) + implementation("net.steppschuh.markdowngenerator:markdowngenerator:1.3.1.1") implementation("io.micronaut:micronaut-jackson-databind") implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut:micronaut-management") diff --git a/server/src/main/java/com/objectcomputing/checkins/services/reports/MarkdownGeneration.java b/server/src/main/java/com/objectcomputing/checkins/services/reports/MarkdownGeneration.java new file mode 100644 index 0000000000..a1eea89b78 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/reports/MarkdownGeneration.java @@ -0,0 +1,399 @@ +package com.objectcomputing.checkins.services.reports; + +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.permissions.Permission; +import com.objectcomputing.checkins.services.permissions.RequiredPermission; +import com.objectcomputing.checkins.services.kudos.KudosRepository; +import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipientRepository; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileRepository; +import com.objectcomputing.checkins.services.reviews.ReviewPeriodServices; +import com.objectcomputing.checkins.services.feedback_template.FeedbackTemplateServices; +import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestServices; +import com.objectcomputing.checkins.services.feedback_answer.FeedbackAnswerServices; +import com.objectcomputing.checkins.services.feedback_template.template_question.TemplateQuestionServices; +import com.objectcomputing.checkins.services.employee_hours.EmployeeHoursServices; +import com.objectcomputing.checkins.services.file.FileServices; + +import net.steppschuh.markdowngenerator.*; +import net.steppschuh.markdowngenerator.text.heading.Heading; +import net.steppschuh.markdowngenerator.text.emphasis.ItalicText; +import net.steppschuh.markdowngenerator.list.UnorderedList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.HashMap; +import java.util.Comparator; +import java.util.Collections; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.function.Predicate; +import java.io.IOException; + +class MarkdownGeneration { + class AnswerComparator implements java.util.Comparator { + @Override + public int compare(Feedback.Answer a, Feedback.Answer b) { + return a.getNumber() - b.getNumber(); + } + } + + class PositionComparator implements java.util.Comparator { + @Override + public int compare(PositionHistory.Position a, + PositionHistory.Position b) { + LocalDate left = a.date(); + LocalDate right = b.date(); + if (left.isBefore(right)) { + return -1; + } else if (left.isEqual(right)) { + return 0; + } else { + return 1; + } + } + } + + class CompensationComparator implements java.util.Comparator { + @Override + public int compare(CompensationHistory.Compensation a, + CompensationHistory.Compensation b) { + LocalDate left = a.startDate(); + LocalDate right = b.startDate(); + if (left.isBefore(right)) { + return -1; + } else if (left.isEqual(right)) { + return 0; + } else { + return 1; + } + } + } + + private static final Logger LOG = LoggerFactory.getLogger(MarkdownGeneration.class); + private static final String noneAvailable = "None available during the period covered by this review."; + private static final String directory = "merit-reports"; + + private final ReportDataServices reportDataServices; + private final KudosRepository kudosRepository; + private final KudosRecipientRepository kudosRecipientRepository; + private final MemberProfileServices memberProfileServices; + private final ReviewPeriodServices reviewPeriodServices; + private final FeedbackTemplateServices feedbackTemplateServices; + private final FeedbackRequestServices feedbackRequestServices; + private final FeedbackAnswerServices feedbackAnswerServices; + private final TemplateQuestionServices templateQuestionServices; + private final EmployeeHoursServices employeeHoursServices; + private final FileServices fileServices; + + public MarkdownGeneration(ReportDataServices reportDataServices, + KudosRepository kudosRepository, + KudosRecipientRepository kudosRecipientRepository, + MemberProfileServices memberProfileServices, + ReviewPeriodServices reviewPeriodServices, + FeedbackTemplateServices feedbackTemplateServices, + FeedbackRequestServices feedbackRequestServices, + FeedbackAnswerServices feedbackAnswerServices, + TemplateQuestionServices templateQuestionServices, + EmployeeHoursServices employeeHoursServices, + FileServices fileServices) { + this.reportDataServices = reportDataServices; + this.kudosRepository = kudosRepository; + this.kudosRecipientRepository = kudosRecipientRepository; + this.memberProfileServices = memberProfileServices; + this.reviewPeriodServices = reviewPeriodServices; + this.feedbackTemplateServices = feedbackTemplateServices; + this.feedbackRequestServices = feedbackRequestServices; + this.feedbackAnswerServices = feedbackAnswerServices; + this.templateQuestionServices = templateQuestionServices; + this.employeeHoursServices = employeeHoursServices; + this.fileServices = fileServices; + } + + void upload(List memberIds, UUID reviewPeriodId) { + for (UUID memberId : memberIds) { + ReportDataCollation data = new ReportDataCollation( + memberId, reviewPeriodId, + kudosRepository, + kudosRecipientRepository, + memberProfileServices, + reviewPeriodServices, + reportDataServices, + feedbackTemplateServices, + feedbackRequestServices, + feedbackAnswerServices, + templateQuestionServices, + employeeHoursServices); + generateAndStore(data); + } + } + + void generateAndStore(ReportDataCollation data) { + final String markdown = generate(data); + store(data, markdown); + } + + String generate(ReportDataCollation data) { + StringBuilder sb = new StringBuilder(); + title(data, sb); + currentInfo(data, sb); + kudos(data, sb); + reviewsImpl("Self-Review", data.getSelfReviews(), false, sb); + reviewsImpl("Reviews", data.getReviews(), true, sb); + feedback(data, sb); + titleHistory(data, sb); + employeeHours(data, sb); + compensation(data, sb); + compensationHistory(data, sb); + sb.append(new Heading("Reviewer Notes", 4)).append("\n"); + return sb.toString(); + } + + void store(ReportDataCollation data, String markdown) { + // Send this text over to be uploaded to the google drive. + fileServices.uploadDocument(directory, + data.getMemberProfile().getWorkEmail(), + markdown); + } + + private String formatDate(LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")); + } + + private void title(ReportDataCollation data, StringBuilder sb) { + MemberProfile profile = data.getMemberProfile(); + sb.append(new Heading(MemberProfileUtils.getFullName(profile), 1)) + .append("\n") + .append(profile.getTitle()).append("\n\n") + .append("Review Period: ") + .append(formatDate(data.getStartDate())) + .append(" - ") + .append(formatDate(data.getEndDate())).append("\n"); + } + + private void currentInfo(ReportDataCollation data, StringBuilder sb) { + MemberProfile profile = data.getMemberProfile(); + CurrentInformation.Information current = data.getCurrentInformation(); + String bio = current.biography(); + ZonedDateTime zdt = ZonedDateTime.of( + profile.getStartDate().atTime(0, 0), + ZoneId.systemDefault()); + long ms = System.currentTimeMillis() - zdt.toInstant().toEpochMilli(); + double years = TimeUnit.DAYS.convert(ms, TimeUnit.MILLISECONDS) / 365.25; + sb.append(new Heading("Current Information", 1)).append("\n") + .append(String.format("%.1f", years)).append(" years\n\n") + .append(new Heading("Biographical Notes", 2)).append("\n") + .append(bio.isEmpty() ? noneAvailable : bio).append("\n\n"); + } + + private void kudos(ReportDataCollation data, StringBuilder sb) { + List received = data.getKudos(); + sb.append(new Heading("Kudos", 1)).append("\n\n"); + if (received.isEmpty()) { + sb.append(noneAvailable).append("\n\n"); + } else { + for (ReportKudos kudo : received) { + sb.append(kudo.message()).append("\n\n") + .append("     ") + .append(new ItalicText("Submitted on " + + formatDate(kudo.dateCreated()) + + ", by " + kudo.sender())) + .append("\n\n\n"); + } + } + } + + private Map getUniqueMembers(List answers) { + Map members = new HashMap<>(); + List sorted = new ArrayList<>(answers); + Collections.sort(sorted, new AnswerComparator()); + for (Feedback.Answer answer : sorted) { + if (!members.containsKey(answer.getMemberName())) { + members.put(answer.getMemberName(), answer.getSubmitted()); + } + } + return members; + } + + private Map>> getUniqueQuestions(List answers) { + Map>> questions = new HashMap<>(); + for (Feedback.Answer answer : answers) { + String key = answer.getQuestion(); + if (!questions.containsKey(key)) { + questions.put(key, new ArrayList>()); + } + List list = new ArrayList(); + list.add(answer.getMemberName()); + list.add(answer.getAnswer()); + questions.get(key).add(list); + } + return questions; + } + + private void reviewsImpl(String title, List feedbackList, boolean listMembers, StringBuilder sb) { + sb.append(new Heading(title, 1)).append("\n"); + if (feedbackList.isEmpty()) { + sb.append(noneAvailable).append("\n\n"); + } else { + for (Feedback feedback : feedbackList) { + Map members = + getUniqueMembers(feedback.getAnswers()); + for(Map.Entry entry : members.entrySet()) { + if (listMembers) { + sb.append(entry.getKey()).append(": "); + } + sb.append("Submitted - ") + .append(formatDate(entry.getValue())).append("\n\n"); + } + sb.append("\n"); + + Map>> questions = + getUniqueQuestions(feedback.getAnswers()); + for (Map.Entry>> question : questions.entrySet()) { + sb.append(new Heading(question.getKey(), 4)).append("\n"); + for (List answer : question.getValue()) { + if (listMembers) { + sb.append(answer.get(0)).append(": "); + } + sb.append(answer.get(1)).append("\n\n"); + } + sb.append("\n"); + } + } + sb.append("\n"); + } + } + + private void feedback(ReportDataCollation data, StringBuilder sb) { + sb.append(new Heading("Feedback", 1)).append("\n"); + List feedbackList = data.getFeedback(); + if (feedbackList.isEmpty()) { + sb.append(noneAvailable).append("\n\n"); + } else { + for (Feedback feedback : feedbackList) { + sb.append(new Heading("Template: " + feedback.getName(), 2)) + .append("\n"); + + Map members = + getUniqueMembers(feedback.getAnswers()); + for (Map.Entry entry : members.entrySet()) { + sb.append(entry.getKey()).append(": "); + sb.append(formatDate(entry.getValue())).append("\n\n"); + } + sb.append("\n"); + + Map>> questions = + getUniqueQuestions(feedback.getAnswers()); + for (Map.Entry>> question : questions.entrySet()) { + sb.append(new Heading(question.getKey(), 4)).append("\n"); + for (List answer : question.getValue()) { + sb.append(answer.get(0)).append(": "); + sb.append(answer.get(1)).append("\n\n"); + } + sb.append("\n"); + } + } + sb.append("\n"); + } + } + + private void titleHistory(ReportDataCollation data, StringBuilder sb) { + List posHistory = + new ArrayList<>(data.getPositionHistory()); + Collections.sort(posHistory, new PositionComparator()); + sb.append(new Heading("Title History", 2)).append("\n"); + List positions = new ArrayList<>(); + for (PositionHistory.Position position : posHistory) { + positions.add(String.valueOf(position.date().getYear()) + " - " + + position.title()); + } + sb.append(new UnorderedList<>(positions)).append("\n\n"); + } + + private void compensation(ReportDataCollation data, StringBuilder sb) { + CurrentInformation.Information current = data.getCurrentInformation(); + sb.append(new Heading("Compensation and Commitments", 2)).append("\n") + .append("$").append(String.format("%.2f", current.salary())) + .append(" Base Salary\n\n") + .append("OCI Range for role: ").append(current.range()) + .append("\n\n"); + String commitments = current.commitments(); + if (commitments == null || commitments.isEmpty()) { + sb.append("No current bonus commitments\n"); + } else { + sb.append("Commitments: ").append(current.commitments()) + .append("\n"); + } + sb.append("\n"); + } + + private List prepareCompHistory(ReportDataCollation data, Predicate fn) { + List comp = + data.getCompensationHistory().stream() + .filter(fn).collect(Collectors.toList()); + Collections.sort(comp, new CompensationComparator()); + return comp.subList(0, Math.min(3, comp.size())); + } + + private void compensationHistory(ReportDataCollation data, StringBuilder sb) { + List compBase = + prepareCompHistory(data, comp -> comp.amount() != null && + !comp.amount().isEmpty()); + List compTotal = + prepareCompHistory(data, comp -> comp.totalComp() != null && + !comp.totalComp().isEmpty()); + sb.append(new Heading("Compensation History", 2)).append("\n") + .append(new Heading("Base Compensation (annual or hourly)", 3)) + .append("\n"); + List list = new ArrayList<>(); + final String compFormat = "%.2f"; + for (CompensationHistory.Compensation comp : compBase) { + String value = comp.amount(); + try { + double val = Double.parseDouble(value); + value = String.format(compFormat, val); + } catch (Exception e) { + } + list.add(formatDate(comp.startDate()) + " - $" + value); + } + sb.append(new UnorderedList<>(list)).append("\n\n") + .append(new Heading("Total Compensation", 3)) + .append("\n"); + list.clear(); + for (CompensationHistory.Compensation comp : compTotal) { + LocalDate startDate = comp.startDate(); + String date = startDate.getMonthValue() == 0 && + startDate.getDayOfMonth() == 1 ? + String.valueOf(startDate.getYear()) : + formatDate(startDate); + list.add(date + " - " + comp.totalComp()); + } + sb.append(new UnorderedList<>(list)).append("\n\n"); + } + + private void employeeHours(ReportDataCollation data, StringBuilder sb) { + sb.append(new Heading("Employee Hours", 2)).append("\n"); + List list = new ArrayList<>(); + ReportHours hours = data.getReportHours(); + final String hourFormat = "%.2f"; + list.add("Contribution Hours: " + + String.format(hourFormat, hours.contributionHours())); + list.add("PTO Hours: " + String.format(hourFormat, hours.ptoHours())); + list.add("Overtime Hours: " + + String.format(hourFormat, hours.overtimeHours())); + list.add("Billable Utilization: " + + String.format(hourFormat, hours.billableUtilization())); + sb.append(new UnorderedList<>(list)).append("\n\n"); + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataController.java b/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataController.java index 15647cede2..1a5a15626b 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataController.java @@ -12,8 +12,12 @@ import com.objectcomputing.checkins.services.feedback_answer.FeedbackAnswerServices; import com.objectcomputing.checkins.services.feedback_template.template_question.TemplateQuestionServices; import com.objectcomputing.checkins.services.employee_hours.EmployeeHoursServices; +import com.objectcomputing.checkins.services.file.FileServices; + import io.micronaut.http.MediaType; +import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Post; import io.micronaut.http.annotation.Get; import io.micronaut.http.multipart.CompletedFileUpload; @@ -27,6 +31,7 @@ import reactor.core.publisher.Flux; import reactor.core.scheduler.Schedulers; import jakarta.validation.constraints.NotNull; +import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +56,7 @@ public class ReportDataController { private final FeedbackAnswerServices feedbackAnswerServices; private final TemplateQuestionServices templateQuestionServices; private final EmployeeHoursServices employeeHoursServices; + private final FileServices fileServices; public ReportDataController(ReportDataServices reportDataServices, KudosRepository kudosRepository, @@ -61,7 +67,8 @@ public ReportDataController(ReportDataServices reportDataServices, FeedbackRequestServices feedbackRequestServices, FeedbackAnswerServices feedbackAnswerServices, TemplateQuestionServices templateQuestionServices, - EmployeeHoursServices employeeHoursServices) { + EmployeeHoursServices employeeHoursServices, + FileServices fileServices) { this.reportDataServices = reportDataServices; this.kudosRepository = kudosRepository; this.kudosRecipientRepository = kudosRecipientRepository; @@ -72,6 +79,7 @@ public ReportDataController(ReportDataServices reportDataServices, this.feedbackAnswerServices = feedbackAnswerServices; this.templateQuestionServices = templateQuestionServices; this.employeeHoursServices = employeeHoursServices; + this.fileServices = fileServices; } @Post(uri="/upload", consumes = MediaType.MULTIPART_FORM_DATA) @@ -115,35 +123,22 @@ private String uploadHelper(ReportDataServices.DataType dataType, } } - @Get + @Post(uri="/generate") @RequiredPermission(Permission.CAN_CREATE_MERIT_REPORT) - public List get(@NotNull List memberIds, - @NotNull UUID reviewPeriodId) { - List list = new ArrayList(); - for (UUID memberId : memberIds) { - ReportDataCollation data = new ReportDataCollation( - memberId, reviewPeriodId, - kudosRepository, - kudosRecipientRepository, - memberProfileServices, - reviewPeriodServices, - reportDataServices, - feedbackTemplateServices, - feedbackRequestServices, - feedbackAnswerServices, - templateQuestionServices, - employeeHoursServices); - list.add(new ReportDataDTO(memberId, reviewPeriodId, - data.getStartDate(), data.getEndDate(), - data.getMemberProfile(), data.getKudos(), - data.getCompensationHistory(), - data.getCurrentInformation(), - data.getPositionHistory(), - data.getSelfReviews(), - data.getReviews(), - data.getFeedback(), - data.getReportHours())); - } - return list; + public HttpStatus generate(@Body @Valid ReportDataDTO dto) { + MarkdownGeneration markdown = + new MarkdownGeneration(reportDataServices, + kudosRepository, + kudosRecipientRepository, + memberProfileServices, + reviewPeriodServices, + feedbackTemplateServices, + feedbackRequestServices, + feedbackAnswerServices, + templateQuestionServices, + employeeHoursServices, + fileServices); + markdown.upload(dto.getMemberIds(), dto.getReviewPeriodId()); + return HttpStatus.OK; } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataDTO.java index 2d290145eb..d9fa1566b2 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataDTO.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/reports/ReportDataDTO.java @@ -1,58 +1,20 @@ package com.objectcomputing.checkins.services.reports; -import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import io.micronaut.core.annotation.Introspected; import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; -import java.time.LocalDate; import java.util.List; import java.util.UUID; @Getter @Setter -@AllArgsConstructor @Introspected public class ReportDataDTO { - @NotNull - private UUID memberId; + private List memberIds; @NotNull private UUID reviewPeriodId; - - @NotNull - private LocalDate startDate; - - @NotNull - private LocalDate endDate; - - @NotNull - private MemberProfile memberProfile; - - @NotNull - private List kudos; - - @NotNull - private List compensationHistory; - - @NotNull - private CurrentInformation.Information currentInformation; - - @NotNull - private List positionHistory; - - @NotNull - private List selfReviews; - - @NotNull - private List reviews; - - @NotNull - private List feedback; - - @NotNull - private ReportHours hours; } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/reports/FileServicesImplReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/reports/FileServicesImplReplacement.java new file mode 100644 index 0000000000..68b6ec4ea1 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/reports/FileServicesImplReplacement.java @@ -0,0 +1,59 @@ +package com.objectcomputing.checkins.services.reports; + +/************************************************************************* + * + * This class is here due to the fact that the ReportDataController now + * references the FileServices. The real FileServicesImpl requires the + * GoogleApiAccess class that does not exist during testing. + * + * This replacement class does not require that and can help us test the + * output of the MarkdownGeneration class. + * + ************************************************************************/ + +import com.objectcomputing.checkins.services.file.FileInfoDTO; +import com.objectcomputing.checkins.services.file.FileServices; +import com.objectcomputing.checkins.services.file.FileServicesImpl; + +import io.micronaut.http.multipart.CompletedFileUpload; + +import java.io.File; +import java.util.Set; +import java.util.HashSet; +import java.util.UUID; + +import jakarta.inject.Singleton; +import io.micronaut.context.env.Environment; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; + +@Singleton +@Replaces(FileServicesImpl.class) +@Requires(env = Environment.TEST) +public class FileServicesImplReplacement implements FileServices { + public String documentName = ""; + public String documentText = ""; + + public Set findFiles(UUID checkInId) { + return new HashSet(); + } + + public File downloadFiles(String uploadDocId) { + return null; + } + + public FileInfoDTO uploadFile(UUID checkInID, CompletedFileUpload file) { + return new FileInfoDTO(); + } + + public FileInfoDTO uploadDocument(String directory, + String name, String text) { + documentName = name; + documentText = text; + return new FileInfoDTO(); + } + + public boolean deleteFile(String uploadDocId) { + return true; + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/reports/ReportDataControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/reports/ReportDataControllerTest.java index d88012c0b9..06c9cb7fa5 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/reports/ReportDataControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/reports/ReportDataControllerTest.java @@ -1,9 +1,5 @@ package com.objectcomputing.checkins.services.reports; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.JsonProcessingException; import com.objectcomputing.checkins.services.kudos.Kudos; import com.objectcomputing.checkins.services.TestContainersSuite; import com.objectcomputing.checkins.services.fixture.KudosFixture; @@ -22,6 +18,8 @@ import com.objectcomputing.checkins.services.feedback_template.template_question.TemplateQuestion; import com.objectcomputing.checkins.services.employee_hours.EmployeeHours; +import io.micronaut.core.util.StringUtils; +import io.micronaut.context.annotation.Property; import io.micronaut.http.HttpRequest; import io.micronaut.http.client.HttpClient; import io.micronaut.http.client.annotation.Client; @@ -33,7 +31,10 @@ import org.junit.jupiter.api.Test; import java.io.File; +import java.util.UUID; +import java.util.ArrayList; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import static com.objectcomputing.checkins.services.role.RoleType.Constants.ADMIN_ROLE; import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE; @@ -44,12 +45,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +@Property(name = "replace.fileservicesimpl", value = StringUtils.TRUE) class ReportDataControllerTest extends TestContainersSuite implements MemberProfileFixture, RoleFixture, KudosFixture, ReviewPeriodFixture, FeedbackTemplateFixture, FeedbackRequestFixture, TemplateQuestionFixture, FeedbackAnswerFixture, EmployeeHoursFixture { @Inject @Client("/services/report/data") HttpClient client; + @Inject + private FileServicesImplReplacement fileServices; + private EmployeeHours employeeHours; private FeedbackTemplate feedbackTemplate; private ReviewPeriod reviewPeriod; @@ -58,6 +63,7 @@ class ReportDataControllerTest extends TestContainersSuite implements MemberProf private TemplateQuestion questionTwo; private MemberProfile regular; private MemberProfile admin; + private Kudos kudos; private final String basePath = "src/test/java/com/objectcomputing/checkins/services/reports/"; @BeforeEach @@ -79,7 +85,7 @@ void createRolesAndPermissions() { saveSampleFeedbackAnswer(questionOne.getId(), feedbackRequest.getId()); saveSampleFeedbackAnswer(questionTwo.getId(), feedbackRequest.getId()); - Kudos kudos = createApprovedKudos(admin.getId()); + kudos = createApprovedKudos(admin.getId()); createKudosRecipient(kudos.getId(), regular.getId()); employeeHours = new EmployeeHours(regular.getEmployeeId(), @@ -106,47 +112,33 @@ void uploadReportDataWithoutPermission() { } @Test - void getReportData() throws JsonProcessingException { + void processReportData() { MemberProfile target = regular; HttpRequest request = postData(admin, ADMIN_ROLE); final String response = client.toBlocking().retrieve(request); assertNotNull(response); - request = HttpRequest.GET( - String.format("/?memberIds=%s&reviewPeriodId=%s", - target.getId(), - reviewPeriod.getId().toString())) + ReportDataDTO dto = new ReportDataDTO(); + ArrayList memberIds = new ArrayList<>(); + memberIds.add(target.getId()); + dto.setReviewPeriodId(reviewPeriod.getId()); + dto.setMemberIds(memberIds); + request = HttpRequest.POST("/generate", dto) .basicAuth(admin.getWorkEmail(), ADMIN_ROLE); - final String data = client.toBlocking().retrieve(request); - ObjectMapper objectMapper = new ObjectMapper(); - assertNotNull(data); - - // Perform minimal validation of returned data - JsonNode root = objectMapper.readTree(data); - assertTrue(root.isArray()); - assertFalse(root.isEmpty()); - - ArrayNode arrayNode = (ArrayNode)root; - JsonNode first = arrayNode.get(0); - assertNotNull(first.get("memberProfile")); - assertNotNull(first.get("kudos")); - assertNotNull(first.get("compensationHistory")); - assertNotNull(first.get("currentInformation")); - assertNotNull(first.get("positionHistory")); - assertNotNull(first.get("selfReviews")); - assertNotNull(first.get("reviews")); - assertNotNull(first.get("feedback")); - assertNotNull(first.get("hours")); - - validateReportData(first, target); + client.toBlocking().exchange(request); + + validateReportData(fileServices.documentName, + fileServices.documentText, target); } @Test - void getReportDataWithoutPermission() { - final HttpRequest request = HttpRequest.GET( - String.format("/?memberIds=%s&reviewPeriodId=%s", - regular.getId(), - reviewPeriod.getId())) + void processReportDataWithoutPermission() { + ReportDataDTO dto = new ReportDataDTO(); + ArrayList memberIds = new ArrayList<>(); + memberIds.add(regular.getId()); + dto.setReviewPeriodId(reviewPeriod.getId()); + dto.setMemberIds(memberIds); + final HttpRequest request = HttpRequest.POST("/generate", dto) .basicAuth(regular.getWorkEmail(), MEMBER_ROLE); HttpClientResponseException responseException = assertThrows(HttpClientResponseException.class, @@ -168,54 +160,43 @@ HttpRequest postData(MemberProfile user, String role) { .contentType(MULTIPART_FORM_DATA); } - void validateReportData(JsonNode node, MemberProfile user) { - final String memberId = user.getId().toString(); + private String formatDate(LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")); + } + + void validateReportData(String filename, String text, MemberProfile user) { + assertEquals(user.getWorkEmail(), filename); + + // Review Period + assertTrue(text.contains( + formatDate(reviewPeriod.getPeriodStartDate().toLocalDate()))); + assertTrue(text.contains( + formatDate(reviewPeriod.getPeriodEndDate().toLocalDate()))); // Member Info - assertEquals(memberId, node.get("memberId").asText()); - JsonNode profile = node.get("memberProfile"); - assertEquals(user.getFirstName(), profile.get("firstName").asText()); - assertEquals(user.getLastName(), profile.get("lastName").asText()); - assertEquals(user.getTitle(), profile.get("title").asText()); + assertTrue(text.contains(user.getFirstName())); + assertTrue(text.contains(user.getLastName())); + assertTrue(text.contains(user.getTitle())); // Kudos - ArrayNode kudos = (ArrayNode)node.get("kudos"); - assertEquals(1, kudos.size()); - assertEquals("Default Kudos", kudos.get(0).get("message").asText()); - - // Compensation History - ArrayNode comp = (ArrayNode)node.get("compensationHistory"); - assertEquals(10, comp.size()); - assertEquals(memberId, comp.get(0).get("memberId").asText()); - assertTrue(comp.get(0).get("amount").asDouble() > 0); - assertFalse(comp.get(9).get("totalComp").asText().isEmpty()); - - // Current Information - JsonNode curr = node.get("currentInformation"); - assertEquals(memberId, curr.get("memberId").asText()); - assertTrue(curr.get("salary").asDouble() > 0); - assertEquals("$90000 - $150000", curr.get("range").asText()); - assertEquals("$89000 - $155000", curr.get("nationalRange").asText()); - - // Position History - ArrayNode pos = (ArrayNode)node.get("positionHistory"); - assertEquals(3, pos.size()); - assertEquals(memberId, pos.get(2).get("memberId").asText()); - assertEquals("Software Engineer", pos.get(2).get("title").asText()); + assertTrue(text.contains(kudos.getMessage())); // Feedback - ArrayNode feedback = (ArrayNode)node.get("feedback"); - assertEquals(1, feedback.size()); - ArrayNode answers = (ArrayNode)feedback.get(0).get("answers"); - assertEquals(2, answers.size()); - assertEquals("TEXT", answers.get(0).get("type").asText()); - assertEquals(1, answers.get(0).get("number").asInt()); + assertTrue(text.contains(feedbackTemplate.getTitle())); + assertTrue(text.contains(questionOne.getQuestion())); + assertTrue(text.contains(questionTwo.getQuestion())); + assertTrue(text.contains( + formatDate(feedbackRequest.getSubmitDate()))); // Hours - JsonNode hours = node.get("hours"); - assertEquals(employeeHours.getContributionHours(), hours.get("contributionHours").asDouble(), 0.0); - assertEquals(employeeHours.getPtoHours(), hours.get("ptoHours").asDouble(), 0.0); - assertEquals(employeeHours.getOvertimeWorked(), hours.get("overtimeHours").asDouble(), 0.0); - assertEquals(employeeHours.getBillableUtilization(), hours.get("billableUtilization").asDouble(), 0.0); + final String format = "%.2f"; + assertTrue(text.contains( + String.format(format, employeeHours.getContributionHours()))); + assertTrue(text.contains( + String.format(format, employeeHours.getPtoHours()))); + assertTrue(text.contains( + String.format(format, employeeHours.getOvertimeWorked()))); + assertTrue(text.contains( + String.format(format, employeeHours.getBillableUtilization()))); } } diff --git a/web-ui/src/api/generic.js b/web-ui/src/api/generic.js index 85bc63bc2d..23998c6403 100644 --- a/web-ui/src/api/generic.js +++ b/web-ui/src/api/generic.js @@ -20,6 +20,19 @@ export const downloadData = (url, cookie, params) => { 'X-CSRF-Header': cookie, Accept: 'application/json', }, - url: url //fullURL + url: url + }); +}; + +export const initiate = (url, cookie, params) => { + return resolve({ + method: 'POST', + headers: { + 'X-CSRF-Header': cookie, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + url: url, + data: params, }); }; diff --git a/web-ui/src/pages/MeritReportPage.jsx b/web-ui/src/pages/MeritReportPage.jsx index dde1ff7dd3..38165504c2 100644 --- a/web-ui/src/pages/MeritReportPage.jsx +++ b/web-ui/src/pages/MeritReportPage.jsx @@ -2,7 +2,7 @@ import React, { useContext, useRef, useState, useEffect } from 'react'; import { Autocomplete, Button, TextField } from '@mui/material'; -import { uploadData, downloadData } from '../api/generic'; +import { uploadData, initiate } from '../api/generic'; import { getReviewPeriods } from '../api/reviewperiods'; import { UPDATE_TOAST } from '../context/actions'; import { AppContext } from '../context/AppContext'; @@ -19,8 +19,6 @@ import { useQueryParameters } from '../helpers/query-parameters'; import markdown from 'markdown-builder'; -const noneAvailable = "None available during the period covered by this review."; - const MeritReportPage = () => { const { state, dispatch } = useContext(AppContext); @@ -151,15 +149,14 @@ const MeritReportPage = () => { } }; - const download = async () => { - let data; + const createReportMarkdownDocuments = async () => { let error; // Get the list of selected member ids. - let selected = selectedMembers.reduce((result, item) => { - result.push(item.id); - return result; - }, []); + const selected = selectedMembers.reduce((result, item) => { + result.push(item.id); + return result; + }, []); // Check for required parameters before calling the server. if (selected.length == 0) { @@ -169,11 +166,10 @@ const MeritReportPage = () => { } if (!error) { - const res = await downloadData("/services/report/data", - csrf, {memberIds: selected, - reviewPeriodId: reviewPeriodId.id}); + const res = await initiate("/services/report/data/generate", + csrf, {memberIds: selected, + reviewPeriodId: reviewPeriodId.id}); error = res?.error?.message; - data = res?.payload?.data; } // Display the error, if there was one. @@ -185,9 +181,16 @@ const MeritReportPage = () => { toast: error } }); + } else { + dispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'success', + toast: selected.length == 1 ? 'The report has been generated' + : 'The reports have been generated' + } + }); } - - return data; }; const uploadDocument = async (directory, name, text) => { @@ -239,258 +242,6 @@ const MeritReportPage = () => { } }; - const markdownTitle = (data) => { - const memberProfile = data.memberProfile; - const startDate = dateFromArray(data.startDate); - const endDate = dateFromArray(data.endDate); - let text = markdown.headers.h1(memberProfile.firstName + " " + - memberProfile.lastName); - text += memberProfile.title + "\n\n"; - text += "Review Period: " + - formatDate(startDate) + " - " + formatDate(endDate) + "\n\n"; - return text; - }; - - const markdownCurrentInformation = (data) => { - const memberProfile = data.memberProfile; - const currentInfo = data.currentInformation; - const startDate = dateFromArray(memberProfile.startDate); - const years = (Date.now() - startDate) / (1000 * 60 * 60 * 24 * 365.25); - let text = markdown.headers.h1("Current Information"); - text += years.toFixed(1) + " years\n\n"; - text += markdown.headers.h2("Biographical Notes"); - text += (currentInfo.biography ? currentInfo.biography : - noneAvailable) + "\n\n"; - return text; - }; - - const markdownKudos = (data) => { - const kudosList = data.kudos; - let text = markdown.headers.h1("Kudos"); - if (kudosList.length > 0) { - for (let kudos of kudosList) { - const date = dateFromArray(kudos.dateCreated); - text += kudos.message + "\n\n"; - text += "     " + - markdown.emphasis.i("Submitted on " + formatDate(date) + - ", by " + kudos.sender) + - "\n\n\n"; - } - } else { - text += noneAvailable + "\n\n"; - } - return text; - }; - - const markdownReviewsImpl = (title, feedbackList, listMembers) => { - let text = markdown.headers.h1(title); - if (feedbackList.length > 0) { - for(let feedback of feedbackList) { - const members = getUniqueMembers(feedback.answers); - for(let member of Object.keys(members)) { - if (listMembers) { - text += member + ": "; - } - text += "Submitted - " + formatDate(members[member]) + "\n\n"; - } - text += "\n"; - - const questions = getUniqueQuestions(feedback.answers); - for(let question of Object.keys(questions)) { - text += markdown.headers.h4(question) + "\n"; - for(let answer of questions[question]) { - if (listMembers) { - text += answer[0] + ": "; - } - text += answer[1] + "\n\n"; - } - text += "\n"; - } - } - text += "\n"; - } else { - text += noneAvailable + "\n\n"; - } - return text; - } - - const markdownSelfReviews = (data) => { - return markdownReviewsImpl("Self-Review", data.selfReviews, false); - } - - const markdownReviews = (data) => { - return markdownReviewsImpl("Reviews", data.reviews, true); - }; - - const getUniqueMembers = (answers) => { - let members = {}; - for(let answer of answers) { - const key = answer.memberName; - if (!(key in members)) { - // Put in member name and date - members[key] = dateFromArray(answer.submitted); - } - } - return members; - }; - - const getAnswerText = (answer) => { - return answer.answer; - }; - - const getUniqueQuestions = (answers) => { - let questions = {}; - answers = answers.sort((a, b) => { - return a.number - b.number; - }); - - for(let answer of answers) { - const key = answer.question; - if (!(key in questions)) { - // Put in member name and answer - questions[key] = []; - } - const text = getAnswerText(answer); - questions[key].push([answer.memberName, text]); - } - return questions; - }; - - const markdownFeedback = (data) => { - let text = markdown.headers.h1("Feedback"); - const feedbackList = data.feedback; - if (feedbackList.length > 0) { - for(let feedback of feedbackList) { - text += markdown.headers.h2("Template: " + feedback.name); - const members = getUniqueMembers(feedback.answers); - for(let member of Object.keys(members)) { - text += member + ": " + formatDate(members[member]) + "\n\n"; - } - text += "\n"; - - const questions = getUniqueQuestions(feedback.answers); - for(let question of Object.keys(questions)) { - text += markdown.headers.h4(question) + "\n"; - for(let answer of questions[question]) { - text += answer[0] + ": " + answer[1] + "\n\n"; - } - text += "\n"; - } - } - text += "\n"; - } else { - text += noneAvailable + "\n\n"; - } - return text; - }; - - const markdownTitleHistory = (data) => { - // Get the position history sorted latest to earliest - const posHistory = data.positionHistory.sort((a, b) => { - for(let i = 0; i < a.date.length; i++) { - if (a.date[i] != b.date[i]) { - return b.date[i] - a.date[i]; - } - } - return 0; - }); - - let text = markdown.headers.h2("Title History"); - text += markdown.lists.ul(posHistory, - (position) => position.date[0] + " - " + - position.title); - return text; - }; - - const markdownCompensation = (data) => { - const currentInfo = data.currentInformation; - let text = markdown.headers.h2("Compensation and Commitments"); - text += "$" + currentInfo.salary.toFixed(2) + " Base Salary\n\n"; - text += "OCI Range for role: " + currentInfo.range + "\n\n"; - text += "National Range for role: " + currentInfo.nationalRange + "\n\n"; - if (currentInfo.commitments) { - text += "Commitments: " + currentInfo.commitments + "\n"; - } else { - text += "No current bonus commitments\n"; - } - text += "\n"; - return text; - }; - - const prepareCompensationHistory = (data, fn) => { - return data.compensationHistory.filter(fn).sort((a, b) => { - for(let i = 0; i < a.startDate.length; i++) { - if (a.startDate[i] != b.startDate[i]) { - return b.startDate[i] - a.startDate[i]; - } - } - return 0; - }).slice(0, 3); - }; - - const markdownCompensationHistory = (data) => { - // Sort them latest to oldest and truncate to the first 3. - const compBase = prepareCompensationHistory(data, (comp) => !!comp.amount); - const compTotal = prepareCompensationHistory(data, (comp) => !!comp.totalComp); - - let text = markdown.headers.h2("Compensation History"); - text += markdown.headers.h3("Base Compensation (annual or hourly)"); - text += markdown.lists.ul(compBase, - (comp) => formatDate(dateFromArray(comp.startDate)) + " - " + - "$" + parseFloat(comp.amount).toFixed(2)); - text += markdown.headers.h3("Total Compensation") - text += markdown.lists.ul(compTotal, - (comp) => { - var date = dateFromArray(comp.startDate); - date = date.getMonth() === 0 && date.getDate() === 1 ? date.getFullYear() : formatDate(date); - return date + " - " + comp.totalComp; - }); - return text; - }; - - const markdownEmployeeHours = (data) => { - let text = markdown.headers.h2("Employee Hours"); - let hours = { - 'Contribution Hours': data.hours.contributionHours, - 'PTO Hours': data.hours.ptoHours, - 'Overtime Hours': data.hours.overtimeHours, - 'Billable Utilization': data.hours.billableUtilization, - }; - text += markdown.lists.ul(Object.keys(hours), - (key) => key + ": " + hours[key]); - return text; - }; - - const markdownReviewerNotes = (data) => { - let text = markdown.headers.h4("Reviewer Notes"); - return text; - }; - - const createReportMarkdownDocuments = async () => { - const dataSet = await download(); - if (dataSet) { - for (let data of dataSet) { - // Generate markdown - let text = markdownTitle(data); - text += markdownCurrentInformation(data); - text += markdownKudos(data); - text += markdownSelfReviews(data); - text += markdownReviews(data); - text += markdownFeedback(data); - text += markdownTitleHistory(data); - text += markdownEmployeeHours(data); - text += markdownCompensation(data); - text += markdownCompensationHistory(data); - text += markdownReviewerNotes(data); - - // Store the markdown on the google drive. - const directory = "merit-reports"; - const fileName = data.memberProfile.workEmail; - uploadDocument(directory, fileName, text); - } - } - }; - const onReviewPeriodChange = (event, newValue) => { setReviewPeriodId(newValue); }; @@ -560,7 +311,7 @@ const MeritReportPage = () => { )} diff --git a/web-ui/src/pages/__snapshots__/MeritReportPage.test.jsx.snap b/web-ui/src/pages/__snapshots__/MeritReportPage.test.jsx.snap index 470a70b25a..b85accd776 100644 --- a/web-ui/src/pages/__snapshots__/MeritReportPage.test.jsx.snap +++ b/web-ui/src/pages/__snapshots__/MeritReportPage.test.jsx.snap @@ -173,7 +173,7 @@ exports[`renders correctly 1`] = ` for="reviewPeriodSelect" id="reviewPeriodSelect-label" > - ReviewPeriod + Review Period
- ReviewPeriod + Review Period