From 50d79bf77bb3371b4101d19e3fe0a0e7250402b8 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 9 Nov 2020 16:44:22 +0100 Subject: [PATCH] NCI-Agency/anet#3231: Add PendingAssessmentsNotificationWorker --- insertBaseData-mssql.sql | 22 +- mssql2pg.pl | 4 +- .../java/mil/dds/anet/AnetApplication.java | 8 + .../java/mil/dds/anet/beans/JobHistory.java | 4 + .../beans/search/PositionSearchQuery.java | 16 +- .../java/mil/dds/anet/database/TaskDao.java | 10 - .../PendingAssessmentsNotificationEmail.java | 102 ++++ .../anet/search/AbstractPositionSearcher.java | 9 + .../PendingAssessmentsNotificationWorker.java | 529 ++++++++++++++++++ .../pendingAssessmentsNotification.ftlh | 65 +++ ...dingAssessmentsNotificationWorkerTest.java | 223 ++++++++ 11 files changed, 978 insertions(+), 14 deletions(-) create mode 100644 src/main/java/mil/dds/anet/emails/PendingAssessmentsNotificationEmail.java create mode 100644 src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java create mode 100644 src/main/resources/emails/pendingAssessmentsNotification.ftlh create mode 100644 src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java diff --git a/insertBaseData-mssql.sql b/insertBaseData-mssql.sql index 557389cf43..2ded995d24 100644 --- a/insertBaseData-mssql.sql +++ b/insertBaseData-mssql.sql @@ -324,7 +324,7 @@ INSERT INTO tasks (uuid, shortName, longName, category, createdAt, updatedAt, cu VALUES (N'953e0b0b-25e6-44b6-bc77-ef98251d046a', '1.2.A', 'Milestone the First in EF 1.2', 'Milestone', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, N'fe6b6b2f-d2a1-4ce1-9aa7-05361812a4d0', '{ "assessments":[{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Test Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Test Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Test Question 3", "aggregation": { "widget": "numberAggregation" } } },"relatedObjectType":"report"}] }'), (N'9d3da7f4-8266-47af-b518-995f587250c9', '1.2.B', 'Milestone the Second in EF 1.2', 'Milestone', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, N'fe6b6b2f-d2a1-4ce1-9aa7-05361812a4d0', '{ "assessments":[{"questions":{ "frenchFlag": { "type": "special_field", "widget": "likertScale", "label": "French Flag assessment", "helpText": "Please tell us which is the best color in the French flag", "levels": [ { "color": "blue", "endValue": 3.3, "label": "blue" }, { "color": "white", "endValue": 6.6, "label": "white" }, { "color": "red", "endValue": 10, "label": "red" } ] }, "levels": { "type": "enumset", "label": "Achieved levels", "choices": { "lvl1": { "label": "Level 1" }, "lvl2": { "label": "Level 2" }, "lvl3": { "label": "Level 3" } } }, "description": { "type": "special_field", "label": "Detail levels", "widget": "richTextEditor" } },"relatedObjectType":"report"}] }'), - (N'6bbb1be9-4655-48d7-83f2-bc474781544a', '1.2.C', 'Milestone the Third in EF 1.2', 'Milestone', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, N'fe6b6b2f-d2a1-4ce1-9aa7-05361812a4d0', '{ "assessments":[{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Monthly assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Monthly assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Monthly assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"recurrence":"monthly"},{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Weekly assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Weekly assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Weekly assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"recurrence":"weekly"},{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Instant assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Instant assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Instant assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"relatedObjectType":"report"}] }'); + (N'6bbb1be9-4655-48d7-83f2-bc474781544a', '1.2.C', 'Milestone the Third in EF 1.2', 'Milestone', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, N'fe6b6b2f-d2a1-4ce1-9aa7-05361812a4d0', '{ "assessments":[{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Monthly assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Monthly assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Monthly assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"recurrence":"quarterly"},{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Weekly assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Weekly assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Weekly assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"recurrence":"daily"},{"questions":{ "question1": { "type": "special_field", "widget": "likertScale", "label": "Instant assessment Question 1", "helpText": "Please provide assessment for something important", "levels": [ { "color": "red", "endValue": 2, "label": "test" }, { "color": "#FFBF00", "endValue": 8, "label": "mid" }, { "color": "green", "endValue": 10, "label": "high" } ], "aggregation": { "widget": "likertScale" } }, "question2": { "type": "number", "label": "Instant assessment Question 2", "aggregation": { "widget": "numberAggregation" } }, "question3": { "type": "number", "label": "Instant assessment Question 3", "aggregation": { "widget": "numberAggregation" } } },"relatedObjectType":"report"}] }'); INSERT INTO tasks (uuid, shortName, longName, category, createdAt, updatedAt, customFieldRef1Uuid) VALUES @@ -952,6 +952,26 @@ INSERT INTO noteRelatedObjects (noteUuid, relatedObjectType, relatedObjectUuid) FROM tasks t WHERE t.shortName = '1.2.B'; +-- Add periodic assessment for a task +SET @authorUuid = (SELECT uuid FROM people WHERE name = 'ANDERSON, Andrew'); +SET @noteUuid = lower(newid()); +INSERT INTO notes (uuid, authorUuid, type, text, createdAt, updatedAt) + VALUES (@noteUuid, @authorUuid, 3, '{"status":"GREEN","issues":"
  1. one
  2. two
  3. three
","__recurrence":"monthly","__periodStart":"' + FORMAT(DATEADD(month, DATEDIFF(month, 0, CURRENT_TIMESTAMP), 0), 'yyyy-MM-dd') + '"}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO noteRelatedObjects (noteUuid, relatedObjectType, relatedObjectUuid) + SELECT @noteUuid, 'tasks', t.uuid + FROM tasks t + WHERE t.shortName = '1.1.A'; + +-- Add periodic assessment for a person +SET @authorUuid = (SELECT uuid FROM people WHERE name = 'JACKSON, Jack'); +SET @noteUuid = lower(newid()); +INSERT INTO notes (uuid, authorUuid, type, text, createdAt, updatedAt) + VALUES (@noteUuid, @authorUuid, 3, '{"test3":"3","test2":"3","test1":"3","__recurrence":"quarterly","__periodStart":"' + FORMAT(DATEADD(quarter, DATEDIFF(quarter, 0, CURRENT_TIMESTAMP), 0), 'yyyy-MM-dd') + '"}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO noteRelatedObjects (noteUuid, relatedObjectType, relatedObjectUuid) + SELECT @noteUuid, 'people', p.uuid + FROM people p + WHERE p.name = 'ROGWELL, Roger'; + -- LEAVE THIS AS LAST STATEMENT -- Truncate all the dates (on reports etc.) to dates that could have been generated by -- Java (millisecond precision) rather than by the database itself (microsecond precision) diff --git a/mssql2pg.pl b/mssql2pg.pl index 5a263d59a8..b6eef132ee 100755 --- a/mssql2pg.pl +++ b/mssql2pg.pl @@ -20,7 +20,7 @@ # Eliminate the weird "[key]" column naming s/\[key\]/key/g; # Quote mixed-case column and table names -s/(?> getTaskedOrganizationsForTask( FkDataLoaderKey.TASK_TASKED_ORGANIZATIONS, taskUuid); } - @InTransaction - public List getActiveTasks(boolean topLevel) { - final String sql = String.format( - "/* get%1$sTasks */ SELECT * FROM tasks WHERE status = :active" - + " AND \"customFieldRef1Uuid\" %2$s NULL", - topLevel ? "TopLevel" : "SubLevel", topLevel ? "IS" : "IS NOT"); - return getDbHandle().createQuery(sql).bind("active", DaoUtils.getEnumId(Task.Status.ACTIVE)) - .map(new TaskMapper()).list(); - } - @Override public AnetBeanList search(TaskSearchQuery query) { return AnetObjectEngine.getInstance().getSearcher().getTaskSearcher().runSearch(query); diff --git a/src/main/java/mil/dds/anet/emails/PendingAssessmentsNotificationEmail.java b/src/main/java/mil/dds/anet/emails/PendingAssessmentsNotificationEmail.java new file mode 100644 index 0000000000..968c0988c9 --- /dev/null +++ b/src/main/java/mil/dds/anet/emails/PendingAssessmentsNotificationEmail.java @@ -0,0 +1,102 @@ +package mil.dds.anet.emails; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.Person; +import mil.dds.anet.beans.Position; +import mil.dds.anet.beans.Task; +import mil.dds.anet.utils.IdDataLoaderKey; +import mil.dds.anet.views.UuidFetcher; + +public class PendingAssessmentsNotificationEmail implements AnetEmailAction { + + private String advisorUuid; + private List positionUuidsToAssess; + private List taskUuidsToAssess; + + @Override + public String getTemplateName() { + return "/emails/pendingAssessmentsNotification.ftlh"; + } + + @Override + public String getSubject(Map context) { + return "ANET assessments due"; + } + + @Override + public Map buildContext(Map context) { + @SuppressWarnings("unchecked") + final Map dbContext = (Map) context.get("context"); + + // Load positions and person & organization for each position + final UuidFetcher positionFetcher = new UuidFetcher(); + final List> positions = positionUuidsToAssess.stream() + .map(uuid -> positionFetcher.load(dbContext, IdDataLoaderKey.POSITIONS, uuid) + .thenCompose(pos -> pos.loadPerson(dbContext).thenCompose( + person -> pos.loadOrganization(dbContext).thenApply(organization -> pos)))) + .collect(Collectors.toList()); + + // Load tasks + final UuidFetcher taskFetcher = new UuidFetcher(); + final List> tasks = taskUuidsToAssess.stream() + .map(uuid -> taskFetcher.load(dbContext, IdDataLoaderKey.TASKS, uuid)) + .collect(Collectors.toList()); + + // Wait for our futures to complete + final List> allFutures = new ArrayList<>(); + allFutures.addAll(positions); + allFutures.addAll(tasks); + CompletableFuture.allOf(allFutures.toArray(new CompletableFuture[0])).join(); + + // Fill email context + context.put("advisor", AnetObjectEngine.getInstance().getPersonDao().getByUuid(advisorUuid)); + context.put("positions", positions.stream().map(p -> p.join()).collect(Collectors.toList())); + context.put("tasks", tasks.stream().map(p -> p.join()).collect(Collectors.toList())); + return context; + } + + public String getAdvisorUuid() { + return advisorUuid; + } + + public void setAdvisorUuid(String advisorUuid) { + this.advisorUuid = advisorUuid; + } + + public void setAdvisor(Person advisor) { + this.advisorUuid = advisor.getUuid(); + } + + public List getPositionUuidsToAssess() { + return positionUuidsToAssess; + } + + public void setPositionUuidsToAssess(List positionUuidsToAssess) { + this.positionUuidsToAssess = positionUuidsToAssess; + } + + public void setPositionsToAssess(Set positionsToAssess) { + this.positionUuidsToAssess = + positionsToAssess.stream().map(p -> p.getUuid()).collect(Collectors.toList()); + } + + public List getTaskUuidsToAssess() { + return taskUuidsToAssess; + } + + public void setTaskUuidsToAssess(List taskUuidsToAssess) { + this.taskUuidsToAssess = taskUuidsToAssess; + } + + public void setTasksToAssess(Set tasksToAssess) { + this.taskUuidsToAssess = + tasksToAssess.stream().map(t -> t.getUuid()).collect(Collectors.toList()); + } + +} diff --git a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java index 297eaf2cd9..46604fb8e1 100644 --- a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java @@ -75,6 +75,15 @@ protected void buildQuery(PositionSearchQuery query) { qb.addSqlArg("authorizationGroupUuid", query.getAuthorizationGroupUuid()); } + if (query.getHasCounterparts()) { + qb.addWhereClause("(" + + "positions.uuid IN (SELECT \"positionUuid_a\" FROM \"positionRelationships\"" + + " WHERE \"positionUuid_b\" IS NOT NULL AND deleted = :deleted)" + + " OR positions.uuid IN (" + "SELECT \"positionUuid_b\" FROM \"positionRelationships\"" + + " WHERE \"positionUuid_a\" IS NOT NULL AND deleted = :deleted))"); + qb.addSqlArg("deleted", false); + } + addOrderByClauses(qb, query); } diff --git a/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java b/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java new file mode 100644 index 0000000000..12e9ae37aa --- /dev/null +++ b/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java @@ -0,0 +1,529 @@ +package mil.dds.anet.threads; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.google.common.collect.ImmutableList; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjuster; +import java.time.temporal.TemporalAdjusters; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.AnetEmail; +import mil.dds.anet.beans.JobHistory; +import mil.dds.anet.beans.Note.NoteType; +import mil.dds.anet.beans.Person; +import mil.dds.anet.beans.Position; +import mil.dds.anet.beans.Position.PositionType; +import mil.dds.anet.beans.Task; +import mil.dds.anet.beans.search.PositionSearchQuery; +import mil.dds.anet.beans.search.TaskSearchQuery; +import mil.dds.anet.config.AnetConfiguration; +import mil.dds.anet.database.PositionDao; +import mil.dds.anet.database.TaskDao; +import mil.dds.anet.emails.PendingAssessmentsNotificationEmail; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.Utils; +import mil.dds.anet.views.AbstractAnetBean; + +public class PendingAssessmentsNotificationWorker extends AbstractWorker { + + // Recurrence types that we support + public enum Recurrence { + DAILY("daily"), WEEKLY("weekly"), BIWEEKLY("biweekly"), SEMIMONTHLY("semimonthly"), + MONTHLY("monthly"), QUARTERLY("quarterly"), SEMIANNUALLY("semiannually"), ANNUALLY("annually"); + + private final String recurrence; + + private Recurrence(final String recurrence) { + this.recurrence = recurrence; + } + + @Override + public String toString() { + return recurrence; + } + + public static Recurrence valueOfRecurrence(final String recurrence) { + for (final Recurrence v : values()) { + if (v.recurrence.equalsIgnoreCase(recurrence)) { + return v; + } + } + return null; + } + } + + // Dictionary lookup keys we use + private static final String PRINCIPAL_PERSON_ASSESSMENTS = "fields.principal.person.assessments"; + private static final String TASK_SUB_LEVEL_ASSESSMENTS = "fields.task.subLevel.assessments"; + private static final String TASK_TOP_LEVEL_ASSESSMENTS = "fields.task.topLevel.assessments"; + // JSON fields in task.customFields we use + private static final String TASK_ASSESSMENTS = "assessments"; + private static final String TASK_RECURRENCE = "recurrence"; + // JSON fields in note.text we use + private static final String NOTE_RECURRENCE = "__recurrence"; + private static final String NOTE_PERIOD_START = "__periodStart"; + + private final PositionDao positionDao; + private final TaskDao taskDao; + + public PendingAssessmentsNotificationWorker(AnetConfiguration config) { + super(config, + "Pending Assessments Notification Worker waking up to check for pending periodic assessments"); + this.positionDao = AnetObjectEngine.getInstance().getPositionDao(); + this.taskDao = AnetObjectEngine.getInstance().getTaskDao(); + } + + @Override + protected void runInternal(Instant now, JobHistory jobHistory, Map context) { + // Define which recurrences may need to be notified since last run + final Set recurrenceSet = getRecurrenceSet(now, JobHistory.getLastRun(jobHistory)); + if (recurrenceSet.isEmpty()) { + logger.debug("Nothing to do, now new recurrences since last run"); + return; + } + + // Look up periodic assessment definitions for people in the dictionary + final Set globalPositionAssessmentRecurrence = + getGlobalAssessmentRecurrence(recurrenceSet, PRINCIPAL_PERSON_ASSESSMENTS); + logger.trace("globalPositionAssessmentRecurrence={}", globalPositionAssessmentRecurrence); + + // Look up periodic assessment definitions for all tasks + final Map> taskAssessmentRecurrence = new HashMap<>(); + addTaskDefinitions(recurrenceSet, taskAssessmentRecurrence, true); + addTaskDefinitions(recurrenceSet, taskAssessmentRecurrence, false); + logger.trace("taskAssessmentRecurrence={}", taskAssessmentRecurrence); + + // Prepare maps of positions and tasks linked to active advisor positions + final Map objectsToAssessByPosition = new HashMap<>(); + final Map> allPositionsToAssess = preparePositionAssessmentMap( + context, globalPositionAssessmentRecurrence, objectsToAssessByPosition); + logger.trace("the following positions need to be checked for missing assessments: {}", + allPositionsToAssess); + final Map> allTasksToAssess = + prepareTaskAssessmentMap(context, taskAssessmentRecurrence, objectsToAssessByPosition); + logger.trace("the following tasks need to be checked for missing assessments: {}", + allTasksToAssess); + + // First load person for each position, and filter out the inactive ones + filterPositionsToAssessOnPerson(context, allPositionsToAssess); + // Process the existing assessments for positions to assess + processExistingAssessments(context, now, recurrenceSet, allPositionsToAssess); + // Process the existing assessments for tasks to assess + processExistingAssessments(context, now, recurrenceSet, allTasksToAssess); + // Now filter out the ones that don't need assessments + filterObjectsToAssess(objectsToAssessByPosition, allPositionsToAssess, allTasksToAssess); + // Load the people who should receive a notification email + loadPeopleToBeNotified(context, objectsToAssessByPosition, allPositionsToAssess, + allTasksToAssess); + } + + public static Instant getAssessmentDate(final Instant now, final Recurrence recurrence) { + final ZonedDateTime zonefulNow = now.atZone(DaoUtils.getDefaultZoneId()); + final ZonedDateTime bod = zonefulNow.truncatedTo(ChronoUnit.DAYS); + // Monday is the first day of the week + final TemporalAdjuster firstDayOfWeek = TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY); + final ZonedDateTime bow = zonefulNow.with(firstDayOfWeek).truncatedTo(ChronoUnit.DAYS); + // Bi-weekly reference date is first Monday of 2021 + final ZonedDateTime biWeeklyReferenceDate = LocalDate.of(2021, 1, 4).with(firstDayOfWeek) + .atStartOfDay(DaoUtils.getDefaultZoneId()).toInstant().atZone(DaoUtils.getDefaultZoneId()); + final ZonedDateTime bom = + zonefulNow.with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.DAYS); + final ZonedDateTime boy = + zonefulNow.with(TemporalAdjusters.firstDayOfYear()).truncatedTo(ChronoUnit.DAYS); + final int moyLessOne = zonefulNow.get(ChronoField.MONTH_OF_YEAR) - 1; + final ZonedDateTime result; + switch (recurrence) { + case DAILY: + result = bod.minus(1, ChronoUnit.DAYS); + break; + case WEEKLY: + result = bow.minus(1, ChronoUnit.WEEKS); + break; + case BIWEEKLY: + result = bow.minus(2 + Math.abs(ChronoUnit.WEEKS.between(biWeeklyReferenceDate, bow)) % 2, + ChronoUnit.WEEKS); + break; + case SEMIMONTHLY: // two per month: [1 - 14] and [15 - end-of-month] + final int daysInFirstPeriod = 14; + result = zonefulNow.get(ChronoField.DAY_OF_MONTH) > daysInFirstPeriod ? bom + : bom.minus(1, ChronoUnit.MONTHS).plus(daysInFirstPeriod, ChronoUnit.DAYS); + break; + case MONTHLY: + result = bom.minus(1, ChronoUnit.MONTHS); + break; + case QUARTERLY: + final long monthsInQuarter = 3; + final long q = moyLessOne / monthsInQuarter; + result = boy.plus((q - 1) * monthsInQuarter, ChronoUnit.MONTHS); + break; + case SEMIANNUALLY: // two per year: [Jan 1 - Jun 30] and [Jul 1 - Dec 31] + final long monthsInHalfYear = 6; + final long sa = moyLessOne / monthsInHalfYear; + result = boy.plus((sa - 1) * monthsInHalfYear, ChronoUnit.MONTHS); + break; + case ANNUALLY: + result = boy.minus(1, ChronoUnit.YEARS); + break; + default: + // Unknown recurrence + logger.error("Unknown recurrence encountered: {}", recurrence); + result = null; + break; + } + return result == null ? null : result.toInstant(); + } + + private Set getRecurrenceSet(final Instant now, final Instant lastRun) { + final Set recurrenceSet = + Stream.of(Recurrence.values()).collect(Collectors.toSet()); + for (final Iterator iter = recurrenceSet.iterator(); iter.hasNext();) { + final Recurrence recurrence = iter.next(); + final Instant nextAssessment = getAssessmentDate(now, recurrence); + // If the next assessment is before the last run, we have probably notified already, + // although if someone gets assigned a new counterpart or a new task, or the recurrence of + // assessment definitions is changed, this means they won't be notified until the *next* + // period. + if (nextAssessment == null || (lastRun != null && nextAssessment.isBefore(lastRun))) { + logger.debug("recurrence {} does not need checking since last run {}", recurrence, lastRun); + iter.remove(); + } + } + return recurrenceSet; + } + + private Set getGlobalAssessmentRecurrence(final Set recurrenceSet, + final String keyPath) { + final Set globalPersonAssessmentRecurrence = new HashSet<>(); + @SuppressWarnings("unchecked") + final List> personAssessmentDefinitions = + (List>) config.getDictionaryEntry(keyPath); + if (personAssessmentDefinitions != null) { + personAssessmentDefinitions.stream().forEach(pad -> { + final Recurrence recurrence = + Recurrence.valueOfRecurrence((String) pad.get(TASK_RECURRENCE)); + if (shouldAddRecurrence(recurrenceSet, recurrence)) { + globalPersonAssessmentRecurrence.add(recurrence); + } + }); + } + return globalPersonAssessmentRecurrence; + } + + private boolean shouldAddRecurrence(final Set recurrenceSet, + final Recurrence recurrence) { + return recurrenceSet.contains(recurrence); + } + + private void addTaskDefinitions(final Set recurrenceSet, + final Map> taskAssessmentRecurrence, boolean topLevel) { + // Look up periodic assessment definitions for all tasks in the dictionary + final Set globalTaskAssessmentRecurrence = getGlobalAssessmentRecurrence( + recurrenceSet, topLevel ? TASK_TOP_LEVEL_ASSESSMENTS : TASK_SUB_LEVEL_ASSESSMENTS); + + // Look up periodic assessment definitions for each tasks in customFields + final List tasks = getActiveTasks(topLevel); + tasks.stream().forEach(t -> { + if (!globalTaskAssessmentRecurrence.isEmpty()) { + // Add all global recurrence definitions for this task + taskAssessmentRecurrence.computeIfAbsent(t, + task -> new HashSet<>(globalTaskAssessmentRecurrence)); + } + try { + final JsonNode taskCustomFields = Utils.parseJsonSafe(t.getCustomFields()); + if (taskCustomFields != null) { + final JsonNode taskAssessmentsDefinition = taskCustomFields.get(TASK_ASSESSMENTS); + if (taskAssessmentsDefinition != null && taskAssessmentsDefinition.isArray()) { + final ArrayNode arrayNode = (ArrayNode) taskAssessmentsDefinition; + for (int i = 0; i < arrayNode.size(); i++) { + final JsonNode recurrenceDefinition = arrayNode.get(i).get(TASK_RECURRENCE); + if (recurrenceDefinition != null) { + final Recurrence recurrence = + Recurrence.valueOfRecurrence(recurrenceDefinition.asText()); + if (shouldAddRecurrence(recurrenceSet, recurrence)) { + // Add task-specific recurrence definition + taskAssessmentRecurrence.compute(t, (task, currentValue) -> { + if (currentValue == null) { + return new HashSet<>(Collections.singleton(recurrence)); + } else { + currentValue.add(recurrence); + return currentValue; + } + }); + } + } + } + } + } + } catch (JsonProcessingException ignored) { + // Invalid JSON, log and skip it + logger.error("Task {} has invalid JSON in customFields: {}", t, t.getCustomFields()); + } + }); + } + + private Map> preparePositionAssessmentMap( + final Map context, final Set globalPositionAssessmentRecurrence, + final Map objectsToAssessByPosition) { + final Map> allPositionsToAssess = new HashMap<>(); + final CompletableFuture[] allFutures = getActiveAdvisorPositions(true).stream() + .map(p -> getPositionsToAssess(context, p, globalPositionAssessmentRecurrence) + .thenApply(positionsToAssess -> { + if (!positionsToAssess.isEmpty()) { + positionsToAssess.stream().forEach(pta -> allPositionsToAssess.put(pta, + new HashSet<>(globalPositionAssessmentRecurrence))); + objectsToAssessByPosition.put(p, new ObjectsToAssess(positionsToAssess, null)); + } + return null; + })) + .toArray(CompletableFuture[]::new); + // Wait for our futures to complete before returning + CompletableFuture.allOf(allFutures).join(); + return allPositionsToAssess; + } + + private Map> prepareTaskAssessmentMap(final Map context, + final Map> taskAssessmentRecurrence, + final Map objectsToAssessByPosition) { + final List activeAdvisors = getActiveAdvisorPositions(false); + final Map> allTasksToAssess = new HashMap<>(); + final CompletableFuture[] allFutures = + taskAssessmentRecurrence.entrySet().stream().map(e -> { + final Task taskToAssess = e.getKey(); + final Set recurrenceSet = e.getValue(); + return taskToAssess.loadResponsiblePositions(context).thenApply(positions -> { + // Only active advisors can assess + final Set positionsToAssess = positions.stream() + .filter(pos -> activeAdvisors.contains(pos)).collect(Collectors.toSet()); + if (!positionsToAssess.isEmpty()) { + allTasksToAssess.put(taskToAssess, recurrenceSet); + positionsToAssess.stream().forEach(pta -> { + objectsToAssessByPosition.compute(pta, (pos, currentValue) -> { + if (currentValue == null) { + return new ObjectsToAssess(null, Collections.singleton(taskToAssess)); + } else { + currentValue.getTasksToAssess().add(taskToAssess); + return currentValue; + } + }); + }); + } + return null; + }); + }).toArray(CompletableFuture[]::new); + // Wait for our futures to complete before returning + CompletableFuture.allOf(allFutures).join(); + return allTasksToAssess; + } + + private List getActiveAdvisorPositions(boolean withCounterparts) { + // Get all active, filled advisor positions, possibly with counterparts + final PositionSearchQuery psq = new PositionSearchQuery(); + psq.setPageSize(0); + psq.setStatus(Position.Status.ACTIVE); + psq.setIsFilled(Boolean.TRUE); + if (withCounterparts) { + psq.setHasCounterparts(Boolean.TRUE); + } + psq.setType(ImmutableList.of(PositionType.ADMINISTRATOR, PositionType.SUPER_USER, + PositionType.ADVISOR)); + return positionDao.search(psq).getList(); + } + + private List getActiveTasks(boolean topLevel) { + // Get all active tasks with a non-empty customFields + final TaskSearchQuery tsq = new TaskSearchQuery(); + tsq.setPageSize(0); + tsq.setStatus(Position.Status.ACTIVE); + tsq.setHasCustomFieldRef1(!topLevel); + return taskDao.search(tsq).getList(); + } + + private CompletableFuture> getPositionsToAssess(final Map context, + final Position position, final Set globalPersonAssessmentRecurrence) { + if (position == null || globalPersonAssessmentRecurrence.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptySet()); + } else { + return position.loadAssociatedPositions(context).thenApply(ap -> ap.stream() + .filter(pp -> Position.Status.ACTIVE.equals(pp.getStatus())).collect(Collectors.toSet())); + } + } + + private void filterPositionsToAssessOnPerson(final Map context, + final Map> allPositionsToAssess) { + // Load person for each position + final CompletableFuture[] allFutures = allPositionsToAssess.keySet().stream() + .map(p -> p.loadPerson(context)).toArray(CompletableFuture[]::new); + // Wait for our futures to complete before returning + CompletableFuture.allOf(allFutures).join(); + // Remove inactive people + for (final Iterator aptai = allPositionsToAssess.keySet().iterator(); aptai + .hasNext();) { + final Position p = aptai.next(); + if (p.getPerson() == null || !Person.Status.ACTIVE.equals(p.getPerson().getStatus())) { + aptai.remove(); + } + } + } + + private void processExistingAssessments(final Map context, final Instant now, + final Set recurrenceSet, + final Map> objectsToAssess) { + final CompletableFuture[] allFutures = objectsToAssess.entrySet().stream().map(e -> { + final AbstractAnetBean entryKey = e.getKey(); + final Set periods = e.getValue(); + // For positions the current person holding it gets assessed + final AbstractAnetBean ota = + entryKey instanceof Position ? ((Position) entryKey).getPerson() : entryKey; + return ota.loadNotes(context).thenApply(notes -> { + final Map assessmentsByRecurrence = new HashMap<>(); + notes.stream().filter(note -> NoteType.ASSESSMENT.equals(note.getType())).forEach(note -> { + try { + final JsonNode noteJson = Utils.parseJsonSafe(note.getText()); + final JsonNode recurrence = noteJson.get(NOTE_RECURRENCE); + final JsonNode periodStart = noteJson.get(NOTE_PERIOD_START); + if (periodStart != null && recurrence != null && shouldAddRecurrence(recurrenceSet, + Recurrence.valueOfRecurrence(recurrence.asText()))) { + // __periodStart is stored in the database as a zone-agnostic date string yyyy-mm-dd + final LocalDate periodStartDate = + DateTimeFormatter.ISO_LOCAL_DATE.parse(periodStart.asText(), LocalDate::from); + final Instant periodStartInstant = + periodStartDate.atStartOfDay(DaoUtils.getDefaultZoneId()).toInstant(); + assessmentsByRecurrence.compute(Recurrence.valueOfRecurrence(recurrence.asText()), + (r, currentValue) -> currentValue == null ? periodStartInstant + : periodStartInstant.isAfter(currentValue) ? periodStartInstant + : currentValue); + } + } catch (JsonProcessingException ignored) { + // Invalid JSON, skip it + } + }); + assessmentsByRecurrence.entrySet().stream().forEach(entry -> { + final Recurrence recurrence = entry.getKey(); + final Instant lastAssessment = entry.getValue(); + final Instant nextAssessment = getAssessmentDate(now, recurrence); + if (nextAssessment == null || !lastAssessment.isBefore(nextAssessment)) { + // Assessment already done + logger.trace("{} assessment for {} already done on {}", recurrence, ota, + lastAssessment); + periods.remove(recurrence); + } + }); + return null; + }); + }).toArray(CompletableFuture[]::new); + // Wait for our futures to complete before returning + CompletableFuture.allOf(allFutures).join(); + } + + private void filterObjectsToAssess(final Map objectsToAssessByPosition, + final Map> allPositionsToAssess, + final Map> allTasksToAssess) { + for (final Iterator> otabpi = + objectsToAssessByPosition.entrySet().iterator(); otabpi.hasNext();) { + final Entry otabp = otabpi.next(); + final ObjectsToAssess ota = otabp.getValue(); + final Set positionsToAssess = ota.getPositionsToAssess(); + for (final Iterator ptai = positionsToAssess.iterator(); ptai.hasNext();) { + final Position pta = ptai.next(); + if (!allPositionsToAssess.containsKey(pta) || allPositionsToAssess.get(pta).isEmpty()) { + // Position/person does not need assessment + logger.trace("person {} does not need assessments", pta.getPerson()); + ptai.remove(); + } + } + + final Set tasksToAssess = ota.getTasksToAssess(); + for (final Iterator ttai = tasksToAssess.iterator(); ttai.hasNext();) { + final Task tta = ttai.next(); + if (!allTasksToAssess.containsKey(tta) || allTasksToAssess.get(tta).isEmpty()) { + // Task does not need assessment + logger.trace("task {} does not need assessments", tta); + ttai.remove(); + } + } + + if (positionsToAssess.isEmpty() && tasksToAssess.isEmpty()) { + // Nothing to assess by this position + logger.trace("position {} has no pending assessments", otabp.getKey()); + otabpi.remove(); + } + } + } + + private void loadPeopleToBeNotified(final Map context, + final Map objectsToAssessByPosition, + final Map> allPositionsToAssess, + final Map> allTasksToAssess) { + final CompletableFuture[] allFutures = + objectsToAssessByPosition.entrySet().stream().map(otabp -> { + final Position pos = otabp.getKey(); + // Get the person to be notified + return pos.loadPerson(context).thenApply(advisor -> { + final ObjectsToAssess ota = otabp.getValue(); + final Set positionsToAssess = ota.getPositionsToAssess(); + final Set tasksToAssess = ota.getTasksToAssess(); + logger.info("advisor {} should do assessments:", advisor); + positionsToAssess.stream() + .forEach(pta -> logger.info(" - {} for position {} held by person {}", + allPositionsToAssess.get(pta), pta, pta.getPerson())); + tasksToAssess.stream() + .forEach(tta -> logger.info(" - {} for task {}", allTasksToAssess.get(tta), tta)); + sendEmail(advisor, positionsToAssess, tasksToAssess); + return null; + }); + }).toArray(CompletableFuture[]::new); + // Wait for our futures to complete before returning + CompletableFuture.allOf(allFutures).join(); + } + + private void sendEmail(Person advisor, final Set positionsToAssess, + final Set tasksToAssess) { + final AnetEmail email = new AnetEmail(); + final PendingAssessmentsNotificationEmail action = new PendingAssessmentsNotificationEmail(); + action.setAdvisor(advisor); + action.setPositionsToAssess(positionsToAssess); + action.setTasksToAssess(tasksToAssess); + email.setAction(action); + email.setToAddresses(Collections.singletonList(advisor.getEmailAddress())); + AnetEmailWorker.sendEmailAsync(email); + } + + private static class ObjectsToAssess { + private final Set positionsToAssess; + private final Set tasksToAssess; + + public ObjectsToAssess(Set positionsToAssess, Set tasksToAssess) { + this.positionsToAssess = + new HashSet<>(positionsToAssess == null ? Collections.emptySet() : positionsToAssess); + this.tasksToAssess = + new HashSet<>(tasksToAssess == null ? Collections.emptySet() : tasksToAssess); + } + + public Set getPositionsToAssess() { + return positionsToAssess; + } + + public Set getTasksToAssess() { + return tasksToAssess; + } + } + +} diff --git a/src/main/resources/emails/pendingAssessmentsNotification.ftlh b/src/main/resources/emails/pendingAssessmentsNotification.ftlh new file mode 100644 index 0000000000..4eccbde0a4 --- /dev/null +++ b/src/main/resources/emails/pendingAssessmentsNotification.ftlh @@ -0,0 +1,65 @@ + + + + +

Classification: ${SECURITY_BANNER_TEXT}

+

Dear ${advisor.name},

+ +<#if tasks?has_content> +
+

The following ${fields.task.longLabel} for which you are responsible, have their periodic assessment due:

+ <#list tasks as task> + + +
+ + +<#if positions?has_content> + + + +

Please create your periodic assessment(s).

+ +

ANET Support Team +<#if SUPPORT_EMAIL_ADDR??> +
${SUPPORT_EMAIL_ADDR} +

+ + + diff --git a/src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java b/src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java new file mode 100644 index 0000000000..770fd301a6 --- /dev/null +++ b/src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java @@ -0,0 +1,223 @@ +package mil.dds.anet.test.integration.db; + +import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.common.collect.ImmutableSet; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.AnetEmail; +import mil.dds.anet.beans.JobHistory; +import mil.dds.anet.config.AnetConfiguration; +import mil.dds.anet.database.EmailDao; +import mil.dds.anet.database.JobHistoryDao; +import mil.dds.anet.database.PositionDao; +import mil.dds.anet.database.TaskDao; +import mil.dds.anet.emails.PendingAssessmentsNotificationEmail; +import mil.dds.anet.test.integration.config.AnetTestConfiguration; +import mil.dds.anet.test.integration.utils.FakeSmtpServer; +import mil.dds.anet.test.integration.utils.TestApp; +import mil.dds.anet.threads.AnetEmailWorker; +import mil.dds.anet.threads.PendingAssessmentsNotificationWorker; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TestApp.class) +public class PendingAssessmentsNotificationWorkerTest { + + private static PendingAssessmentsNotificationWorker pendingAssessmentsNotificationWorker; + private static AnetEmailWorker emailWorker; + private static FakeSmtpServer emailServer; + + private static boolean executeEmailServerTests; + + @BeforeAll + public static void setUpClass() throws Exception { + final DropwizardAppExtension app = TestApp.app; + if (app.getConfiguration().getSmtp().isDisabled()) { + fail("'ANET_SMTP_DISABLE' system environment variable must have value 'false' to run test."); + } + + executeEmailServerTests = Boolean.parseBoolean( + AnetTestConfiguration.getConfiguration().get("emailServerTestsExecute").toString()); + + pendingAssessmentsNotificationWorker = + new PendingAssessmentsNotificationWorker(app.getConfiguration()); + emailWorker = + new AnetEmailWorker(app.getConfiguration(), AnetObjectEngine.getInstance().getEmailDao()); + emailServer = new FakeSmtpServer(app.getConfiguration().getSmtp()); + + // Flush all assessment notifications + pendingAssessmentsNotificationWorker.run(); + // Clear the email server before starting testing + flushEmail(); + } + + @AfterAll + public static void tearDownClass() throws Exception { + // Clear the email server after testing + flushEmail(); + + emailWorker = null; + AnetEmailWorker.setInstance(null); + } + + @Test + public void testGetAssessmentDate() { + final Object[][] testData = new Object[][] { + // each item has: { test date, recurrence, expected assessment date } + // daily tests + {"2004-02-28T23:59:59.999Z", Recurrence.DAILY, "2004-02-27T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.DAILY, "2004-02-28T00:00:00.000Z"}, + {"2004-02-29T23:59:59.999Z", Recurrence.DAILY, "2004-02-28T00:00:00.000Z"}, + {"2004-03-01T00:00:00.000Z", Recurrence.DAILY, "2004-02-29T00:00:00.000Z"}, + // weekly tests + {"2004-02-28T23:59:59.999Z", Recurrence.WEEKLY, "2004-02-16T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.WEEKLY, "2004-02-16T00:00:00.000Z"}, + {"2004-02-29T23:59:59.999Z", Recurrence.WEEKLY, "2004-02-16T00:00:00.000Z"}, + {"2004-03-01T00:00:00.000Z", Recurrence.WEEKLY, "2004-02-23T00:00:00.000Z"}, + // biweekly tests + {"2004-02-28T23:59:59.999Z", Recurrence.BIWEEKLY, "2004-02-09T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.BIWEEKLY, "2004-02-09T00:00:00.000Z"}, + {"2004-02-29T23:59:59.999Z", Recurrence.BIWEEKLY, "2004-02-09T00:00:00.000Z"}, + {"2004-03-01T00:00:00.000Z", Recurrence.BIWEEKLY, "2004-02-09T00:00:00.000Z"}, + {"2004-03-07T23:59:59.999Z", Recurrence.BIWEEKLY, "2004-02-09T00:00:00.000Z"}, + {"2004-03-08T00:00:00.000Z", Recurrence.BIWEEKLY, "2004-02-23T00:00:00.000Z"}, + {"2021-01-03T23:59:59.999Z", Recurrence.BIWEEKLY, "2020-12-07T00:00:00.000Z"}, + {"2021-01-04T00:00:00.000Z", Recurrence.BIWEEKLY, "2020-12-21T00:00:00.000Z"}, + {"2021-01-18T00:00:00.000Z", Recurrence.BIWEEKLY, "2021-01-04T00:00:00.000Z"}, + {"2021-01-24T23:59:59.999Z", Recurrence.BIWEEKLY, "2021-01-04T00:00:00.000Z"}, + {"2021-01-25T00:00:00.000Z", Recurrence.BIWEEKLY, "2021-01-04T00:00:00.000Z"}, + {"2021-01-31T23:59:59.999Z", Recurrence.BIWEEKLY, "2021-01-04T00:00:00.000Z"}, + {"2021-02-01T00:00:00.000Z", Recurrence.BIWEEKLY, "2021-01-18T00:00:00.000Z"}, + // semimonthly tests + {"2004-02-28T23:59:59.999Z", Recurrence.SEMIMONTHLY, "2004-02-01T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.SEMIMONTHLY, "2004-02-01T00:00:00.000Z"}, + {"2004-02-29T23:59:59.999Z", Recurrence.SEMIMONTHLY, "2004-02-01T00:00:00.000Z"}, + {"2004-03-01T00:00:00.000Z", Recurrence.SEMIMONTHLY, "2004-02-15T00:00:00.000Z"}, + // monthly tests + {"2004-02-28T23:59:59.999Z", Recurrence.MONTHLY, "2004-01-01T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.MONTHLY, "2004-01-01T00:00:00.000Z"}, + {"2004-02-29T23:59:59.999Z", Recurrence.MONTHLY, "2004-01-01T00:00:00.000Z"}, + {"2004-03-01T00:00:00.000Z", Recurrence.MONTHLY, "2004-02-01T00:00:00.000Z"}, + // quarterly tests + {"2004-02-29T00:00:00.000Z", Recurrence.QUARTERLY, "2003-10-01T00:00:00.000Z"}, + {"2004-03-31T23:59:59.999Z", Recurrence.QUARTERLY, "2003-10-01T00:00:00.000Z"}, + {"2004-04-01T00:00:00.000Z", Recurrence.QUARTERLY, "2004-01-01T00:00:00.000Z"}, + // semiannually tests + {"2004-02-29T00:00:00.000Z", Recurrence.SEMIANNUALLY, "2003-07-01T00:00:00.000Z"}, + {"2004-06-30T23:59:59.999Z", Recurrence.SEMIANNUALLY, "2003-07-01T00:00:00.000Z"}, + {"2004-07-01T00:00:00.000Z", Recurrence.SEMIANNUALLY, "2004-01-01T00:00:00.000Z"}, + // annually tests + {"2003-12-31T23:59:59.999Z", Recurrence.ANNUALLY, "2002-01-01T00:00:00.000Z"}, + {"2004-01-01T00:00:00.000Z", Recurrence.ANNUALLY, "2003-01-01T00:00:00.000Z"}, + {"2004-02-29T00:00:00.000Z", Recurrence.ANNUALLY, "2003-01-01T00:00:00.000Z"}, + // end + }; + for (final Object[] testItem : testData) { + assertThat(getAssessmentDate(Instant.parse((String) testItem[0]), (Recurrence) testItem[1])) + .isEqualTo(Instant.parse((String) testItem[2])); + } + } + + @Test + public void testNoAssessments() { + // There should be no new pending assessments since the last run + testPendingAssessmentsNotificationWorker(0); + } + + @Test + public void testInitialDataAssessments() { + final JobHistoryDao jobHistoryDao = AnetObjectEngine.getInstance().getJobHistoryDao(); + final JobHistory jobHistory = + jobHistoryDao.getByJobName(pendingAssessmentsNotificationWorker.getClass().getSimpleName()); + if (jobHistory != null) { + // Wipe out last run + jobHistory.setLastRun(Instant.EPOCH); + jobHistoryDao.update(jobHistory); + } + + // From our initial data we should get 6 pending assessments + final List emails = testPendingAssessmentsNotificationWorker(6); + // Check the actual emails + for (final AnetEmail email : emails) { + assertThat(email.getToAddresses()).hasSize(1); + final String to = email.getToAddresses().get(0); + final PendingAssessmentsNotificationEmail action = + (PendingAssessmentsNotificationEmail) email.getAction(); + switch (to) { + case "hunter+jack@dds.mil": + // Jack should assess task 2.B + assertAssessments(action, Collections.emptySet(), ImmutableSet.of("2.B")); + break; + case "hunter+erin@dds.mil": + // Erin should assess position Planning Captain + assertAssessments(action, ImmutableSet.of("Planning Captain"), Collections.emptySet()); + break; + case "hunter+henry@dds.mil": + // Henry should assess task 2.A + assertAssessments(action, Collections.emptySet(), ImmutableSet.of("2.A")); + break; + case "hunter+liz@dds.mil": + // Elizabeth should assess position Cost Adder - MoD and task 1.1.A + assertAssessments(action, ImmutableSet.of("Cost Adder - MoD"), ImmutableSet.of("1.1.A")); + break; + case "hunter+bob@dds.mil": + // Bob should assess task 1.1 + assertAssessments(action, Collections.emptySet(), ImmutableSet.of("1.1")); + break; + case "hunter+andrew@dds.mil": + // Andrew should assess tasks 1.1.A, 1.2.A and 1.2.B + assertAssessments(action, Collections.emptySet(), + ImmutableSet.of("1.1.A", "1.2.A", "1.2.B")); + break; + default: + fail("Unknown to address: " + to); + } + } + } + + private void assertAssessments(final PendingAssessmentsNotificationEmail action, + final Set expectedPositions, final Set expectedTasks) { + assertThat(action.getPositionUuidsToAssess()).hasSize(expectedPositions.size()); + final PositionDao positionDao = AnetObjectEngine.getInstance().getPositionDao(); + final Set actualPositions = action.getPositionUuidsToAssess().stream() + .map(positionUuid -> positionDao.getByUuid(positionUuid).getName()) + .collect(Collectors.toSet()); + assertThat(actualPositions).isEqualTo(expectedPositions); + + assertThat(action.getTaskUuidsToAssess()).hasSize(expectedTasks.size()); + final TaskDao taskDao = AnetObjectEngine.getInstance().getTaskDao(); + final Set actualTasks = action.getTaskUuidsToAssess().stream() + .map(taskUuid -> taskDao.getByUuid(taskUuid).getShortName()).collect(Collectors.toSet()); + assertThat(actualTasks).isEqualTo(expectedTasks); + } + + private List testPendingAssessmentsNotificationWorker(final int expectedCount) { + final EmailDao emailDao = AnetObjectEngine.getInstance().getEmailDao(); + final int prevEmailSize = emailDao.getAll().size(); + pendingAssessmentsNotificationWorker.run(); + final List emails = emailDao.getAll(); + assertThat(emails).hasSize(prevEmailSize + expectedCount); + return emails.subList(prevEmailSize, emails.size()); + } + + private static void flushEmail() throws Exception { + assumeTrue(executeEmailServerTests, "Email server tests configured to be skipped."); + + // Flush all messages + emailWorker.run(); + // Clear the email server + emailServer.clearEmailServer(); + } + +}