diff --git a/client/src/components/Nav.js b/client/src/components/Nav.js index 7cfeb973c3..786680b637 100644 --- a/client/src/components/Nav.js +++ b/client/src/components/Nav.js @@ -130,9 +130,9 @@ const Nav = ({ id="my-tasks-nav" > {`My ${pluralize(taskShortLabel)}`} - {notifications?.myTasksWithPendingAssessments?.length ? ( + {notifications?.tasksWithPendingAssessments?.length ? ( - {notifications.myTasksWithPendingAssessments.length} + {notifications.tasksWithPendingAssessments.length} ) : null} @@ -142,9 +142,9 @@ const Nav = ({ id="my-counterparts-nav" > My Counterparts - {notifications?.myCounterpartsWithPendingAssessments?.length ? ( + {notifications?.counterpartsWithPendingAssessments?.length ? ( - {notifications.myCounterpartsWithPendingAssessments.length} + {notifications.counterpartsWithPendingAssessments.length} ) : null} @@ -241,7 +241,7 @@ const Nav = ({ Help - {currentUser.isAdmin() /* FIXME: enable this again when nci-agency/anet#1463 is fixed: || currentUser.isSuperUser() */ && ( + {(currentUser.isAdmin() || currentUser.isSuperUser()) && ( {INSIGHTS.map(insight => ( { + const [pageNum, setPageNum] = useState(0) + const positionQuery = Object.assign({}, queryParams, { pageNum }) + const { loading, error, data } = API.useApiQuery(GQL_GET_POSITION_LIST, { + positionQuery + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + if (done) { + return result + } + + const { + pageSize, + pageNum: curPage, + totalCount, + list: positions + } = data.positionList + return ( +
+
+ +
+
+ ) +} + +PendingAssessmentsByPosition.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + style: PropTypes.object +} + +const AdvisorList = ({ + positions, + pageSize, + pageNum, + totalCount, + goToPage +}) => { + if (_isEmpty(positions)) { + return ( + No {Settings.fields.advisor.person.name} with pending assessments + ) + } + const borderStyle = { borderRight: "2px solid #ddd" } + return ( +
+ + + + + + + + + + + + + + + + + + + + {Position.map(positions, pos => { + const nameComponents = [] + pos.name && nameComponents.push(pos.name) + pos.code && nameComponents.push(pos.code) + const notifications = getNotifications(pos) + return ( + + + + + + + + ) + })} + +
+ {Settings.fields.advisor.person.name} + + {Settings.fields.principal.person.name} to assess + {Settings.fields.task.shortLabel} to assess
NamePositionOrganizationNamePositionOrganizationName
+ {pos.person && ( + + )} + + + {nameComponents.join(" - ")} + + + {pos.organization && ( + + )} + + + + +
+
+
+ ) +} + +AdvisorList.propTypes = { + positions: PropTypes.array.isRequired, + totalCount: PropTypes.number, + pageNum: PropTypes.number, + pageSize: PropTypes.number, + goToPage: PropTypes.func +} + +const PrincipalList = ({ positions }) => { + if (_isEmpty(positions)) { + return No {Settings.fields.principal.person.name} to assess + } + return ( + + + {Position.map(positions, pos => { + const nameComponents = [] + pos.name && nameComponents.push(pos.name) + pos.code && nameComponents.push(pos.code) + return ( + + + + + + ) + })} + +
+ {pos.person && } + + + {nameComponents.join(" - ")} + + + {pos.organization && ( + + )} +
+ ) +} + +PrincipalList.propTypes = { + positions: PropTypes.array.isRequired +} + +const TaskList = ({ tasks }) => { + if (_isEmpty(tasks)) { + return No {Settings.fields.task.shortLabel} to assess + } + return ( + + + {Task.map(tasks, task => { + return ( + + + + ) + })} + +
+ + {task.shortName} {task.longName} + +
+ ) +} + +TaskList.propTypes = { + tasks: PropTypes.array.isRequired +} + +export default PendingAssessmentsByPosition diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 6cb605dab4..2d2e581f9b 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -525,6 +525,14 @@ export const searchFilters = function() { options: ["true", "false"], labels: ["Yes", "No"] } + }, + "Has Pending Assessments": { + component: CheckboxFilter, + deserializer: deserializeCheckboxFilter, + props: { + queryKey: "hasPendingAssessments", + msg: "Yes" + } } } } diff --git a/client/src/components/advancedSearch/CheckboxFilter.js b/client/src/components/advancedSearch/CheckboxFilter.js index 2a52d93072..673a718f8d 100644 --- a/client/src/components/advancedSearch/CheckboxFilter.js +++ b/client/src/components/advancedSearch/CheckboxFilter.js @@ -4,7 +4,7 @@ import React from "react" import { Checkbox, FormGroup } from "react-bootstrap" import { deserializeSearchFilter } from "searchUtils" -const CheckboxFilter = ({ asFormField, queryKey, onChange }) => { +const CheckboxFilter = ({ msg, asFormField, queryKey, onChange }) => { const defaultValue = { value: true } const toQuery = val => { return { [queryKey]: val.value } @@ -17,7 +17,6 @@ const CheckboxFilter = ({ asFormField, queryKey, onChange }) => { toQuery )[0] - const msg = "Authorized for me" return !asFormField ? ( <>{msg} ) : ( @@ -29,11 +28,13 @@ const CheckboxFilter = ({ asFormField, queryKey, onChange }) => { ) } CheckboxFilter.propTypes = { + msg: PropTypes.string, queryKey: PropTypes.string.isRequired, onChange: PropTypes.func, // eslint-disable-line react/no-unused-prop-types asFormField: PropTypes.bool } CheckboxFilter.defaultProps = { + msg: "Authorized for me", asFormField: true } diff --git a/client/src/notificationsUtils.js b/client/src/notificationsUtils.js index 810a8b6447..5fa924f855 100644 --- a/client/src/notificationsUtils.js +++ b/client/src/notificationsUtils.js @@ -13,26 +13,24 @@ export const GRAPHQL_NOTIFICATIONS_NOTE_FIELDS = ` } ` -export const getNotifications = currentUser => { - const myCounterpartsWithPendingAssessments = getMyCounterpartsWithPendingAssessments( - currentUser +export const getNotifications = position => { + const counterpartsWithPendingAssessments = getCounterpartsWithPendingAssessments( + position ) - const myTasksWithPendingAssessments = getMyTasksWithPendingAssessments( - currentUser - ) + const tasksWithPendingAssessments = getTasksWithPendingAssessments(position) const notifications = { - myCounterpartsWithPendingAssessments, - myTasksWithPendingAssessments + counterpartsWithPendingAssessments, + tasksWithPendingAssessments } return notifications } -export const getMyTasksWithPendingAssessments = currentUser => { - if (currentUser?.position?.responsibleTasks?.length) { - const taskObjects = currentUser.position.responsibleTasks +export const getTasksWithPendingAssessments = position => { + if (position?.responsibleTasks?.length) { + const taskObjects = position.responsibleTasks .filter(obj => obj) .map(obj => new Task(obj)) taskObjects.forEach(task => { @@ -44,9 +42,9 @@ export const getMyTasksWithPendingAssessments = currentUser => { return [] } -export const getMyCounterpartsWithPendingAssessments = currentUser => { - if (currentUser?.position?.associatedPositions?.length) { - return currentUser.position.associatedPositions.filter(pos => { +export const getCounterpartsWithPendingAssessments = position => { + if (position?.associatedPositions?.length) { + return position.associatedPositions.filter(pos => { if (pos.person) { return Model.hasPendingAssessments(new Person(pos.person)) } diff --git a/client/src/pages/App.js b/client/src/pages/App.js index 2a256c5a52..650ca64631 100644 --- a/client/src/pages/App.js +++ b/client/src/pages/App.js @@ -207,7 +207,7 @@ const App = ({ pageDispatchers, pageProps }) => { ) const currentUser = new Person(data.me) - const notifications = getNotifications(currentUser) + const notifications = getNotifications(currentUser.position) return { currentUser, settings, diff --git a/client/src/pages/insights/Show.js b/client/src/pages/insights/Show.js index 4853136853..38d0b5ad21 100644 --- a/client/src/pages/insights/Show.js +++ b/client/src/pages/insights/Show.js @@ -17,6 +17,7 @@ import { useBoilerplate } from "components/Page" import PendingApprovalReports from "components/PendingApprovalReports" +import PendingAssessmentsByPosition from "components/PendingAssessmentsByPosition" import ReportsByDayOfWeek from "components/ReportsByDayOfWeek" import ReportsByTask from "components/ReportsByTask" import { @@ -31,6 +32,7 @@ import PropTypes from "prop-types" import React, { useContext } from "react" import { connect } from "react-redux" import { useParams } from "react-router-dom" +import { RECURSE_STRATEGY } from "searchUtils" import Settings from "settings" export const NOT_APPROVED_REPORTS = "not-approved-reports" @@ -38,6 +40,7 @@ export const CANCELLED_REPORTS = "cancelled-reports" export const REPORTS_BY_TASK = "reports-by-task" export const REPORTS_BY_DAY_OF_WEEK = "reports-by-day-of-week" export const FUTURE_ENGAGEMENTS_BY_LOCATION = "future-engagements-by-location" +export const PENDING_ASSESSMENTS_BY_POSITION = "pending-assessments-by-position" export const ADVISOR_REPORTS = "advisor-reports" export const INSIGHTS = [ @@ -46,45 +49,57 @@ export const INSIGHTS = [ REPORTS_BY_TASK, FUTURE_ENGAGEMENTS_BY_LOCATION, REPORTS_BY_DAY_OF_WEEK, + PENDING_ASSESSMENTS_BY_POSITION, ADVISOR_REPORTS ] -const _SEARCH_PROPS = Object.assign({}, DEFAULT_SEARCH_PROPS, { +const REPORT_SEARCH_PROPS = Object.assign({}, DEFAULT_SEARCH_PROPS, { onSearchGoToSearchPage: false, searchObjectTypes: [SEARCH_OBJECT_TYPES.REPORTS] }) +const POSITION_SEARCH_PROPS = Object.assign({}, DEFAULT_SEARCH_PROPS, { + onSearchGoToSearchPage: false, + searchObjectTypes: [SEARCH_OBJECT_TYPES.POSITIONS] +}) + export const INSIGHT_DETAILS = { [NOT_APPROVED_REPORTS]: { - searchProps: _SEARCH_PROPS, + searchProps: REPORT_SEARCH_PROPS, component: PendingApprovalReports, navTitle: "Pending Approval Reports", title: "" }, [CANCELLED_REPORTS]: { - searchProps: _SEARCH_PROPS, + searchProps: REPORT_SEARCH_PROPS, component: CancelledEngagementReports, navTitle: "Cancelled Engagement Reports", title: "" }, [REPORTS_BY_TASK]: { - searchProps: _SEARCH_PROPS, + searchProps: REPORT_SEARCH_PROPS, component: ReportsByTask, navTitle: `Reports by ${Settings.fields.task.subLevel.shortLabel}`, title: "" }, [REPORTS_BY_DAY_OF_WEEK]: { - searchProps: _SEARCH_PROPS, + searchProps: REPORT_SEARCH_PROPS, component: ReportsByDayOfWeek, navTitle: "Reports by Day of the Week", title: "" }, [FUTURE_ENGAGEMENTS_BY_LOCATION]: { - searchProps: _SEARCH_PROPS, + searchProps: REPORT_SEARCH_PROPS, component: FutureEngagementsByLocation, navTitle: "Future Engagements by Location", title: "" }, + [PENDING_ASSESSMENTS_BY_POSITION]: { + searchProps: POSITION_SEARCH_PROPS, + component: PendingAssessmentsByPosition, + navTitle: "Pending Assessments by Position", + title: "" + }, [ADVISOR_REPORTS]: { searchProps: DEFAULT_SEARCH_PROPS, component: FilterableAdvisorReportsTable, @@ -94,7 +109,7 @@ export const INSIGHT_DETAILS = { } const InsightsShow = ({ pageDispatchers, searchQuery, setSearchQuery }) => { - const { appSettings } = useContext(AppContext) + const { appSettings, currentUser } = useContext(AppContext) const { insight } = useParams() const flexStyle = { display: "flex", @@ -122,6 +137,14 @@ const InsightsShow = ({ pageDispatchers, searchQuery, setSearchQuery }) => { startDate: getCurrentDateTime(), endDate: getCurrentDateTime().add(14, "days") } + const orgQuery = currentUser.isAdmin() + ? {} + : { + organizationUuid: currentUser.position.organization.uuid, + orgRecurseStrategy: currentUser.isSuperUser() + ? RECURSE_STRATEGY.CHILDREN + : RECURSE_STRATEGY.NONE + } const insightDefaultQueryParams = { [NOT_APPROVED_REPORTS]: { state: [Report.STATE.PENDING_APPROVAL], @@ -148,6 +171,10 @@ const InsightsShow = ({ pageDispatchers, searchQuery, setSearchQuery }) => { .valueOf(), engagementDateEnd: defaultFutureDates.endDate.endOf("day").valueOf() }, + [PENDING_ASSESSMENTS_BY_POSITION]: { + hasPendingAssessments: true, + ...orgQuery + }, [ADVISOR_REPORTS]: {} } let queryParams @@ -212,7 +239,7 @@ const InsightsShow = ({ pageDispatchers, searchQuery, setSearchQuery }) => { function setInsightDefaultSearchQuery() { const queryParams = insightDefaultQueryParams[insight] deserializeQueryParams( - SEARCH_OBJECT_TYPES.REPORTS, + SEARCH_OBJECT_TYPES.POSITIONS, queryParams, deserializeCallback ) diff --git a/client/src/pages/positions/MyCounterparts.js b/client/src/pages/positions/MyCounterparts.js index c10d7be6b0..e246499c6d 100644 --- a/client/src/pages/positions/MyCounterparts.js +++ b/client/src/pages/positions/MyCounterparts.js @@ -18,7 +18,7 @@ const MyCounterparts = ({ pageDispatchers }) => { }) const { currentUser, - notifications: { myCounterpartsWithPendingAssessments } + notifications: { counterpartsWithPendingAssessments } } = useContext(AppContext) return ( @@ -30,7 +30,7 @@ const MyCounterparts = ({ pageDispatchers }) => { id="my-counterparts-with-pending-assessments" title="My Counterparts that have pending assessments" > - + ) diff --git a/client/src/pages/tasks/MyTasks.js b/client/src/pages/tasks/MyTasks.js index 2f49241c03..a1781bed54 100644 --- a/client/src/pages/tasks/MyTasks.js +++ b/client/src/pages/tasks/MyTasks.js @@ -65,7 +65,7 @@ const MyTasks = ({ pageDispatchers, searchQuery }) => { id="my-tasks-with-pending-assessments" title={`${pluralize(taskShortLabel)} that have pending assessments`} > - + ) diff --git a/src/main/java/mil/dds/anet/AnetApplication.java b/src/main/java/mil/dds/anet/AnetApplication.java index 611a37640a..001ee5590c 100644 --- a/src/main/java/mil/dds/anet/AnetApplication.java +++ b/src/main/java/mil/dds/anet/AnetApplication.java @@ -161,7 +161,8 @@ public void run(AnetConfiguration configuration, Environment environment) // The Object Engine is the core place where we store all of the Dao's // You can always grab the engine from anywhere with AnetObjectEngine.getInstance() - final AnetObjectEngine engine = new AnetObjectEngine(dbUrl, this, metricRegistry); + final AnetObjectEngine engine = + new AnetObjectEngine(dbUrl, this, configuration, metricRegistry); environment.servlets().setSessionHandler(new SessionHandler()); if (configuration.isDevelopmentMode()) { @@ -271,18 +272,19 @@ public void run(AnetConfiguration configuration, Environment environment) new AuthorizationGroupResource(engine); final NoteResource noteResource = new NoteResource(engine); final ApprovalStepResource approvalStepResource = new ApprovalStepResource(engine); + final GraphQlResource graphQlResource = injector.getInstance(GraphQlResource.class); + graphQlResource.initialise(engine, configuration, + ImmutableList.of(reportResource, personResource, positionResource, locationResource, + orgResource, taskResource, adminResource, savedSearchResource, tagResource, + authorizationGroupResource, noteResource, approvalStepResource), + metricRegistry); // Register all of the HTTP Resources environment.jersey().register(loggingResource); environment.jersey().register(adminResource); environment.jersey().register(homeResource); environment.jersey().register(new ViewResponseFilter(configuration)); - environment.jersey() - .register(new GraphQlResource(engine, configuration, - ImmutableList.of(reportResource, personResource, positionResource, locationResource, - orgResource, taskResource, adminResource, savedSearchResource, tagResource, - authorizationGroupResource, noteResource, approvalStepResource), - metricRegistry)); + environment.jersey().register(graphQlResource); } private void runAccountDeactivationWorker(final AnetConfiguration configuration, diff --git a/src/main/java/mil/dds/anet/AnetObjectEngine.java b/src/main/java/mil/dds/anet/AnetObjectEngine.java index 056ae340fc..f3a2708bca 100644 --- a/src/main/java/mil/dds/anet/AnetObjectEngine.java +++ b/src/main/java/mil/dds/anet/AnetObjectEngine.java @@ -22,6 +22,7 @@ import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; import mil.dds.anet.beans.search.OrganizationSearchQuery; import mil.dds.anet.beans.search.TaskSearchQuery; +import mil.dds.anet.config.AnetConfiguration; import mil.dds.anet.database.AdminDao; import mil.dds.anet.database.AdminDao.AdminSettingKeys; import mil.dds.anet.database.ApprovalStepDao; @@ -75,11 +76,13 @@ public class AnetObjectEngine { ISearcher searcher; private static AnetObjectEngine instance; + private static AnetConfiguration configuration; private final String dbUrl; private final Injector injector; - public AnetObjectEngine(String dbUrl, Application application, MetricRegistry metricRegistry) { + public AnetObjectEngine(String dbUrl, Application application, AnetConfiguration config, + MetricRegistry metricRegistry) { this.dbUrl = dbUrl; injector = InjectorLookup.getInjector(application).get(); personDao = injector.getInstance(PersonDao.class); @@ -101,6 +104,7 @@ public AnetObjectEngine(String dbUrl, Application application, MetricRegistry jobHistoryDao = injector.getInstance(JobHistoryDao.class); this.metricRegistry = metricRegistry; searcher = Searcher.getSearcher(DaoUtils.getDbType(dbUrl), injector); + configuration = config; instance = this; } @@ -368,6 +372,10 @@ public static AnetObjectEngine getInstance() { return instance; } + public static AnetConfiguration getConfiguration() { + return configuration; + } + public String getAdminSetting(AdminSettingKeys key) { return adminDao.getSetting(key); } diff --git a/src/main/java/mil/dds/anet/MaintenanceCommand.java b/src/main/java/mil/dds/anet/MaintenanceCommand.java index aab9a3f438..6d7205bc30 100644 --- a/src/main/java/mil/dds/anet/MaintenanceCommand.java +++ b/src/main/java/mil/dds/anet/MaintenanceCommand.java @@ -1,8 +1,8 @@ package mil.dds.anet; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.NOTE_PERIOD_START; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.NOTE_RECURRENCE; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.PRINCIPAL_PERSON_ASSESSMENTS; +import static mil.dds.anet.utils.PendingAssessmentsHelper.NOTE_PERIOD_START; +import static mil.dds.anet.utils.PendingAssessmentsHelper.NOTE_RECURRENCE; +import static mil.dds.anet.utils.PendingAssessmentsHelper.PRINCIPAL_PERSON_ASSESSMENTS; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -31,10 +31,10 @@ import mil.dds.anet.database.PersonDao; import mil.dds.anet.database.PositionDao; import mil.dds.anet.database.mappers.MapperUtils; -import mil.dds.anet.threads.PendingAssessmentsNotificationWorker.AssessmentDates; -import mil.dds.anet.threads.PendingAssessmentsNotificationWorker.Recurrence; import mil.dds.anet.utils.AnetAuditLogger; import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.PendingAssessmentsHelper.AssessmentDates; +import mil.dds.anet.utils.PendingAssessmentsHelper.Recurrence; import mil.dds.anet.utils.Utils; import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; diff --git a/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java index b0aedab6ed..4ce688a19b 100644 --- a/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java @@ -33,6 +33,9 @@ public class PositionSearchQuery extends AbstractSearchQuery search(PositionSearchQuery query) { return AnetObjectEngine.getInstance().getSearcher().getPositionSearcher().runSearch(query); } + public CompletableFuture> search(Map context, + PositionSearchQuery query) { + return AnetObjectEngine.getInstance().getSearcher().getPositionSearcher().runSearch(context, + query); + } + public CompletableFuture> getPositionHistory( Map context, String positionUuid) { return new ForeignKeyFetcher() diff --git a/src/main/java/mil/dds/anet/resources/GraphQlResource.java b/src/main/java/mil/dds/anet/resources/GraphQlResource.java index b18f063043..c5bae0d938 100644 --- a/src/main/java/mil/dds/anet/resources/GraphQlResource.java +++ b/src/main/java/mil/dds/anet/resources/GraphQlResource.java @@ -48,6 +48,7 @@ import org.dataloader.DataLoaderRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ru.vyarus.guicey.jdbi3.tx.InTransaction; @Path("/graphql") public class GraphQlResource { @@ -57,9 +58,9 @@ public class GraphQlResource { private static final String MEDIATYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - private final AnetObjectEngine engine; - private final List resources; - private final MetricRegistry metricRegistry; + private AnetObjectEngine engine; + private List resources; + private MetricRegistry metricRegistry; private GraphQLSchema graphqlSchema; private GraphQLSchema graphqlSchemaWithoutIntrospection; @@ -85,7 +86,9 @@ public ResponseBuilder apply(Map json) { } }; - public GraphQlResource(AnetObjectEngine engine, AnetConfiguration config, List resources, + public GraphQlResource() {} + + public void initialise(AnetObjectEngine engine, AnetConfiguration config, List resources, MetricRegistry metricRegistry) { this.engine = engine; this.resources = resources; @@ -204,6 +207,7 @@ public Response graphqlGet(@Auth Person user, @QueryParam("operationName") Strin return graphql(user, operationName, query, new HashMap(), output); } + @InTransaction protected Response graphql(@Auth Person user, String operationName, String query, Map variables, String output) { final ExecutionResult executionResult = dispatchRequest(user, operationName, query, variables); diff --git a/src/main/java/mil/dds/anet/resources/PositionResource.java b/src/main/java/mil/dds/anet/resources/PositionResource.java index cab5a546d5..1375dedf0c 100644 --- a/src/main/java/mil/dds/anet/resources/PositionResource.java +++ b/src/main/java/mil/dds/anet/resources/PositionResource.java @@ -6,6 +6,7 @@ import io.leangen.graphql.annotations.GraphQLRootContext; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response.Status; import mil.dds.anet.AnetObjectEngine; @@ -193,8 +194,10 @@ public Integer deletePersonFromPosition(@GraphQLRootContext Map } @GraphQLQuery(name = "positionList") - public AnetBeanList search(@GraphQLArgument(name = "query") PositionSearchQuery query) { - return dao.search(query); + public CompletableFuture> search( + @GraphQLRootContext Map context, + @GraphQLArgument(name = "query") PositionSearchQuery query) { + return dao.search(context, query); } @GraphQLMutation(name = "deletePosition") diff --git a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java index 98e10dbbd9..4d51039946 100644 --- a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java @@ -1,5 +1,10 @@ package mil.dds.anet.search; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.Position; import mil.dds.anet.beans.lists.AnetBeanList; import mil.dds.anet.beans.search.AbstractBatchParams; @@ -8,6 +13,9 @@ import mil.dds.anet.beans.search.PositionSearchQuery; import mil.dds.anet.database.PositionDao; import mil.dds.anet.database.mappers.PositionMapper; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.PendingAssessmentsHelper; +import org.jdbi.v3.core.Handle; import ru.vyarus.guicey.jdbi3.tx.InTransaction; public abstract class AbstractPositionSearcher @@ -24,6 +32,29 @@ public AnetBeanList runSearch(PositionSearchQuery query) { return qb.buildAndRun(getDbHandle(), query, new PositionMapper()); } + @Override + public CompletableFuture> runSearch(Map context, + PositionSearchQuery query) { + // Asynchronous version of search; should be wrapped in a transaction by the GraphQlResource + // handling the request + final Handle dbHandle = getDbHandle(); + final PositionMapper mapper = new PositionMapper(); + buildQuery(query); + if (!query.getHasPendingAssessments()) { + return CompletableFuture.completedFuture(qb.buildAndRun(dbHandle, query, mapper)); + } + + // Filter to only the positions with pending assessments + final Instant now = Instant.now().atZone(DaoUtils.getServerNativeZoneId()).toInstant(); + return new PendingAssessmentsHelper(AnetObjectEngine.getConfiguration()) + .loadAll(context, now, null, false).thenApply(otaMap -> { + return otaMap.keySet().stream().map(p -> p.getUuid()).collect(Collectors.toList()); + }).thenCompose(positionUuids -> { + qb.addInListClause("positionUuids", "positions.uuid", positionUuids); + return CompletableFuture.completedFuture(qb.buildAndRun(dbHandle, query, mapper)); + }); + } + @Override protected void buildQuery(PositionSearchQuery query) { qb.addSelectClause(PositionDao.POSITIONS_FIELDS); diff --git a/src/main/java/mil/dds/anet/search/IPositionSearcher.java b/src/main/java/mil/dds/anet/search/IPositionSearcher.java index 45967881ce..1fb8631f1f 100644 --- a/src/main/java/mil/dds/anet/search/IPositionSearcher.java +++ b/src/main/java/mil/dds/anet/search/IPositionSearcher.java @@ -1,5 +1,7 @@ package mil.dds.anet.search; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import mil.dds.anet.beans.Position; import mil.dds.anet.beans.lists.AnetBeanList; import mil.dds.anet.beans.search.PositionSearchQuery; @@ -8,4 +10,7 @@ public interface IPositionSearcher { public AnetBeanList runSearch(PositionSearchQuery query); + public CompletableFuture> runSearch(Map context, + PositionSearchQuery query); + } diff --git a/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java b/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java index e2fb772d68..48fb39793f 100644 --- a/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java +++ b/src/main/java/mil/dds/anet/threads/PendingAssessmentsNotificationWorker.java @@ -1,611 +1,22 @@ 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; +import mil.dds.anet.utils.PendingAssessmentsHelper; 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; - } - } - - /** - * Given a reference date and a recurrence, compute: the assessment date of the most recent - * completed assessment period, the date a notification should be sent, and the date a reminder - * should be sent. - */ - public static class AssessmentDates { - private final ZonedDateTime assessmentDate; - private final ZonedDateTime notificationDate; - private final ZonedDateTime reminderDate; - - public AssessmentDates(final Instant referenceDate, final Recurrence recurrence) { - // Compute some period boundaries - final ZonedDateTime zonefulReferenceDate = - referenceDate.atZone(DaoUtils.getServerNativeZoneId()); - final ZonedDateTime bod = zonefulReferenceDate.truncatedTo(ChronoUnit.DAYS); - // Monday is the first day of the week - final TemporalAdjuster firstDayOfWeek = TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY); - final ZonedDateTime bow = - zonefulReferenceDate.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.getServerNativeZoneId()).toInstant() - .atZone(DaoUtils.getServerNativeZoneId()); - final ZonedDateTime bom = zonefulReferenceDate.with(TemporalAdjusters.firstDayOfMonth()) - .truncatedTo(ChronoUnit.DAYS); - final ZonedDateTime boy = zonefulReferenceDate.with(TemporalAdjusters.firstDayOfYear()) - .truncatedTo(ChronoUnit.DAYS); - final int moyLessOne = zonefulReferenceDate.get(ChronoField.MONTH_OF_YEAR) - 1; - - switch (recurrence) { - case DAILY: - notificationDate = bod; - assessmentDate = notificationDate.minus(1, ChronoUnit.DAYS); - reminderDate = null; // no reminders - break; - case WEEKLY: - notificationDate = bow; - assessmentDate = notificationDate.minus(1, ChronoUnit.WEEKS); - reminderDate = notificationDate.plus(3, ChronoUnit.DAYS); - break; - case BIWEEKLY: - notificationDate = bow.minus( - Math.abs(ChronoUnit.WEEKS.between(biWeeklyReferenceDate, bow)) % 2, ChronoUnit.WEEKS); - assessmentDate = notificationDate.minus(2, ChronoUnit.WEEKS); - reminderDate = notificationDate.plus(5, ChronoUnit.DAYS); - break; - case SEMIMONTHLY: // two per month: [1 - 14] and [15 - end-of-month] - final int daysInFirstPeriod = 14; - if (zonefulReferenceDate.get(ChronoField.DAY_OF_MONTH) <= daysInFirstPeriod) { - notificationDate = bom; - assessmentDate = - bom.minus(1, ChronoUnit.MONTHS).plus(daysInFirstPeriod, ChronoUnit.DAYS); - } else { - notificationDate = bom.plus(daysInFirstPeriod, ChronoUnit.DAYS); - assessmentDate = bom; - } - reminderDate = notificationDate.plus(5, ChronoUnit.DAYS); - break; - case MONTHLY: - notificationDate = bom; - assessmentDate = notificationDate.minus(1, ChronoUnit.MONTHS); - reminderDate = notificationDate.plus(1, ChronoUnit.WEEKS); - break; - case QUARTERLY: - final long monthsInQuarter = 3; - final long q = moyLessOne / monthsInQuarter; - notificationDate = boy.plus(q * monthsInQuarter, ChronoUnit.MONTHS); - assessmentDate = notificationDate.minus(monthsInQuarter, ChronoUnit.MONTHS); - reminderDate = notificationDate.plus(4, ChronoUnit.WEEKS); - break; - case SEMIANNUALLY: // two per year: [Jan 1 - Jun 30] and [Jul 1 - Dec 31] - final long monthsInHalfYear = 6; - final long sa = moyLessOne / monthsInHalfYear; - notificationDate = boy.plus(sa * monthsInHalfYear, ChronoUnit.MONTHS); - assessmentDate = notificationDate.minus(monthsInHalfYear, ChronoUnit.MONTHS); - reminderDate = notificationDate.plus(1, ChronoUnit.MONTHS); - break; - case ANNUALLY: - notificationDate = boy; - assessmentDate = notificationDate.minus(1, ChronoUnit.YEARS); - reminderDate = notificationDate.plus(1, ChronoUnit.MONTHS); - break; - default: - // Unknown recurrence - logger.error("Unknown recurrence encountered: {}", recurrence); - assessmentDate = null; - notificationDate = null; - reminderDate = null; - break; - } - } - - /** - * @return the date of the most recent completed assessment period of the given recurrence - * before the given reference date - */ - public Instant getAssessmentDate() { - return getInstant(assessmentDate); - } - - /** - * @return the notification date for the assessment period of the given recurrence and the given - * reference date; may be null meaning: don't send notifications - */ - public Instant getNotificationDate() { - return getInstant(notificationDate); - } - - /** - * @return the reminder date for the assessment period of the given recurrence and the given - * reference date; may be null meaning: don't send reminders - */ - public Instant getReminderDate() { - return getInstant(reminderDate); - } - - private Instant getInstant(final ZonedDateTime zonedDateTime) { - return zonedDateTime == null ? null : zonedDateTime.toInstant(); - } - - @Override - public String toString() { - return "AssessmentDates [assessmentDate=" + assessmentDate + ", notificationDate=" - + notificationDate + ", reminderDate=" + reminderDate + "]"; - } - } - - // Dictionary lookup keys we use - public static final String PRINCIPAL_PERSON_ASSESSMENTS = "fields.principal.person.assessments"; - public static final String TASK_SUB_LEVEL_ASSESSMENTS = "fields.task.subLevel.assessments"; - public static final String TASK_TOP_LEVEL_ASSESSMENTS = "fields.task.topLevel.assessments"; - // JSON fields in task.customFields we use - public static final String TASK_ASSESSMENTS = "assessments"; - public static final String TASK_RECURRENCE = "recurrence"; - // JSON fields in note.text we use - public static final String NOTE_RECURRENCE = "__recurrence"; - public 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); - } - - 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 AssessmentDates assessmentDates = new AssessmentDates(now, recurrence); - // Note that if someone gets assigned a new counterpart or a new task, or the recurrence of - // assessment definitions is changed, this means they may not be notified until the *next* - // period. - if (!shouldAssess(now, lastRun, assessmentDates)) { - logger.debug("recurrence {} does not need checking since last run {}", recurrence, lastRun); - iter.remove(); - } - } - return recurrenceSet; - } - - private boolean shouldAssess(final Instant now, final Instant lastRun, - final AssessmentDates assessmentDates) { - return assessmentDates.getAssessmentDate() != null // no assessment - && (shouldAssess(now, lastRun, assessmentDates.getNotificationDate()) - || shouldAssess(now, lastRun, assessmentDates.getReminderDate())); - } - - private boolean shouldAssess(final Instant now, final Instant lastRun, final Instant date) { - return date != null && (lastRun == null || date.isAfter(lastRun) && !date.isAfter(now)); - } - - 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.getServerNativeZoneId()).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 AssessmentDates assessmentDates = new AssessmentDates(now, recurrence); - if (assessmentDates.getAssessmentDate() == null - || !lastAssessment.isBefore(assessmentDates.getAssessmentDate())) { - // 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; - } + new PendingAssessmentsHelper(config) + .loadAll(context, now, JobHistory.getLastRun(jobHistory), true).join(); } } diff --git a/src/main/java/mil/dds/anet/utils/PendingAssessmentsHelper.java b/src/main/java/mil/dds/anet/utils/PendingAssessmentsHelper.java new file mode 100644 index 0000000000..0b7131bf7b --- /dev/null +++ b/src/main/java/mil/dds/anet/utils/PendingAssessmentsHelper.java @@ -0,0 +1,633 @@ +package mil.dds.anet.utils; + +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.lang.invoke.MethodHandles; +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.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.threads.AnetEmailWorker; +import mil.dds.anet.views.AbstractAnetBean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PendingAssessmentsHelper { + + private static final Logger logger = + LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + // 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; + } + } + + /** + * Given a reference date and a recurrence, compute: the assessment date of the most recent + * completed assessment period, the date a notification should be sent, and the date a reminder + * should be sent. + */ + public static class AssessmentDates { + private final ZonedDateTime assessmentDate; + private final ZonedDateTime notificationDate; + private final ZonedDateTime reminderDate; + + public AssessmentDates(final Instant referenceDate, final Recurrence recurrence) { + // Compute some period boundaries + final ZonedDateTime zonefulReferenceDate = + referenceDate.atZone(DaoUtils.getServerNativeZoneId()); + final ZonedDateTime bod = zonefulReferenceDate.truncatedTo(ChronoUnit.DAYS); + // Monday is the first day of the week + final TemporalAdjuster firstDayOfWeek = TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY); + final ZonedDateTime bow = + zonefulReferenceDate.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.getServerNativeZoneId()).toInstant() + .atZone(DaoUtils.getServerNativeZoneId()); + final ZonedDateTime bom = zonefulReferenceDate.with(TemporalAdjusters.firstDayOfMonth()) + .truncatedTo(ChronoUnit.DAYS); + final ZonedDateTime boy = zonefulReferenceDate.with(TemporalAdjusters.firstDayOfYear()) + .truncatedTo(ChronoUnit.DAYS); + final int moyLessOne = zonefulReferenceDate.get(ChronoField.MONTH_OF_YEAR) - 1; + + switch (recurrence) { + case DAILY: + notificationDate = bod; + assessmentDate = notificationDate.minus(1, ChronoUnit.DAYS); + reminderDate = null; // no reminders + break; + case WEEKLY: + notificationDate = bow; + assessmentDate = notificationDate.minus(1, ChronoUnit.WEEKS); + reminderDate = notificationDate.plus(3, ChronoUnit.DAYS); + break; + case BIWEEKLY: + notificationDate = bow.minus( + Math.abs(ChronoUnit.WEEKS.between(biWeeklyReferenceDate, bow)) % 2, ChronoUnit.WEEKS); + assessmentDate = notificationDate.minus(2, ChronoUnit.WEEKS); + reminderDate = notificationDate.plus(5, ChronoUnit.DAYS); + break; + case SEMIMONTHLY: // two per month: [1 - 14] and [15 - end-of-month] + final int daysInFirstPeriod = 14; + if (zonefulReferenceDate.get(ChronoField.DAY_OF_MONTH) <= daysInFirstPeriod) { + notificationDate = bom; + assessmentDate = + bom.minus(1, ChronoUnit.MONTHS).plus(daysInFirstPeriod, ChronoUnit.DAYS); + } else { + notificationDate = bom.plus(daysInFirstPeriod, ChronoUnit.DAYS); + assessmentDate = bom; + } + reminderDate = notificationDate.plus(5, ChronoUnit.DAYS); + break; + case MONTHLY: + notificationDate = bom; + assessmentDate = notificationDate.minus(1, ChronoUnit.MONTHS); + reminderDate = notificationDate.plus(1, ChronoUnit.WEEKS); + break; + case QUARTERLY: + final long monthsInQuarter = 3; + final long q = moyLessOne / monthsInQuarter; + notificationDate = boy.plus(q * monthsInQuarter, ChronoUnit.MONTHS); + assessmentDate = notificationDate.minus(monthsInQuarter, ChronoUnit.MONTHS); + reminderDate = notificationDate.plus(4, ChronoUnit.WEEKS); + break; + case SEMIANNUALLY: // two per year: [Jan 1 - Jun 30] and [Jul 1 - Dec 31] + final long monthsInHalfYear = 6; + final long sa = moyLessOne / monthsInHalfYear; + notificationDate = boy.plus(sa * monthsInHalfYear, ChronoUnit.MONTHS); + assessmentDate = notificationDate.minus(monthsInHalfYear, ChronoUnit.MONTHS); + reminderDate = notificationDate.plus(1, ChronoUnit.MONTHS); + break; + case ANNUALLY: + notificationDate = boy; + assessmentDate = notificationDate.minus(1, ChronoUnit.YEARS); + reminderDate = notificationDate.plus(1, ChronoUnit.MONTHS); + break; + default: + // Unknown recurrence + logger.error("Unknown recurrence encountered: {}", recurrence); + assessmentDate = null; + notificationDate = null; + reminderDate = null; + break; + } + } + + /** + * @return the date of the most recent completed assessment period of the given recurrence + * before the given reference date + */ + public Instant getAssessmentDate() { + return getInstant(assessmentDate); + } + + /** + * @return the notification date for the assessment period of the given recurrence and the given + * reference date; may be null meaning: don't send notifications + */ + public Instant getNotificationDate() { + return getInstant(notificationDate); + } + + /** + * @return the reminder date for the assessment period of the given recurrence and the given + * reference date; may be null meaning: don't send reminders + */ + public Instant getReminderDate() { + return getInstant(reminderDate); + } + + private Instant getInstant(final ZonedDateTime zonedDateTime) { + return zonedDateTime == null ? null : zonedDateTime.toInstant(); + } + + @Override + public String toString() { + return "AssessmentDates [assessmentDate=" + assessmentDate + ", notificationDate=" + + notificationDate + ", reminderDate=" + reminderDate + "]"; + } + } + + public 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; + } + } + + // Dictionary lookup keys we use + public static final String PRINCIPAL_PERSON_ASSESSMENTS = "fields.principal.person.assessments"; + public static final String TASK_SUB_LEVEL_ASSESSMENTS = "fields.task.subLevel.assessments"; + public static final String TASK_TOP_LEVEL_ASSESSMENTS = "fields.task.topLevel.assessments"; + // JSON fields in task.customFields we use + public static final String TASK_ASSESSMENTS = "assessments"; + public static final String TASK_RECURRENCE = "recurrence"; + // JSON fields in note.text we use + public static final String NOTE_RECURRENCE = "__recurrence"; + public static final String NOTE_PERIOD_START = "__periodStart"; + + private final AnetConfiguration config; + private final PositionDao positionDao; + private final TaskDao taskDao; + + public PendingAssessmentsHelper(final AnetConfiguration config) { + this.config = config; + this.positionDao = AnetObjectEngine.getInstance().getPositionDao(); + this.taskDao = AnetObjectEngine.getInstance().getTaskDao(); + } + + public CompletableFuture> loadAll( + final Map context, final Instant now, final Instant lastRun, + final boolean sendEmail) { + final Set recurrenceSet = getRecurrenceSet(now, lastRun); + if (recurrenceSet.isEmpty()) { + logger.debug("Nothing to do, now new recurrences since last run"); + return CompletableFuture.completedFuture(new HashMap<>()); + } + + // 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<>(); + return preparePositionAssessmentMap(context, globalPositionAssessmentRecurrence, + objectsToAssessByPosition).thenCompose(allPositionsToAssess -> { + logger.trace("the following positions need to be checked for missing assessments: {}", + allPositionsToAssess); + return prepareTaskAssessmentMap(context, taskAssessmentRecurrence, + objectsToAssessByPosition).thenCompose(allTasksToAssess -> { + 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 + return filterPositionsToAssessOnPerson(context, allPositionsToAssess) + .thenCompose(b1 -> + // Process the existing assessments for positions to assess + processExistingAssessments(context, now, recurrenceSet, allPositionsToAssess) + .thenCompose(b2 -> + // Process the existing assessments for tasks to assess + processExistingAssessments(context, now, recurrenceSet, allTasksToAssess) + .thenCompose(b3 -> { + // Now filter out the ones that don't need assessments + filterObjectsToAssess(objectsToAssessByPosition, allPositionsToAssess, + allTasksToAssess); + if (!sendEmail) { + return CompletableFuture.completedFuture(objectsToAssessByPosition); + } + // Load the people who should receive a notification email + return loadPeopleToBeNotified(context, objectsToAssessByPosition, + allPositionsToAssess, allTasksToAssess).thenCompose( + b4 -> CompletableFuture.completedFuture(objectsToAssessByPosition)); + }))); + }); + }); + } + + 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 AssessmentDates assessmentDates = new AssessmentDates(now, recurrence); + // Note that if someone gets assigned a new counterpart or a new task, or the recurrence of + // assessment definitions is changed, this means they may not be notified until the *next* + // period. + if (!shouldAssess(now, lastRun, assessmentDates)) { + logger.debug("recurrence {} does not need checking since last run {}", recurrence, lastRun); + iter.remove(); + } + } + return recurrenceSet; + } + + private boolean shouldAssess(final Instant now, final Instant lastRun, + final AssessmentDates assessmentDates) { + return assessmentDates.getAssessmentDate() != null // no assessment + && (shouldAssess(now, lastRun, assessmentDates.getNotificationDate()) + || shouldAssess(now, lastRun, assessmentDates.getReminderDate())); + } + + private boolean shouldAssess(final Instant now, final Instant lastRun, final Instant date) { + return date != null && (lastRun == null || date.isAfter(lastRun) && !date.isAfter(now)); + } + + 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 CompletableFuture>> 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 + return CompletableFuture.allOf(allFutures) + .thenCompose(v -> CompletableFuture.completedFuture(allPositionsToAssess)); + } + + private CompletableFuture>> 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 + return CompletableFuture.allOf(allFutures) + .thenCompose(v -> CompletableFuture.completedFuture(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 CompletableFuture 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 + return CompletableFuture.allOf(allFutures).thenCompose(v -> { + // 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(); + } + } + return CompletableFuture.completedFuture(true); + }); + } + + private CompletableFuture 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 (otherwise the object itself) + 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.getServerNativeZoneId()).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 AssessmentDates assessmentDates = new AssessmentDates(now, recurrence); + if (assessmentDates.getAssessmentDate() == null + || !lastAssessment.isBefore(assessmentDates.getAssessmentDate())) { + // 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 + return CompletableFuture.allOf(allFutures).thenCompose(v -> { + return CompletableFuture.completedFuture(true); + }); + } + + 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 CompletableFuture 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 + return CompletableFuture.allOf(allFutures).thenCompose(v -> { + return CompletableFuture.completedFuture(true); + }); + } + + 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); + } + +} diff --git a/src/test/java/mil/dds/anet/test/integration/commands/MaintenanceCommandTest.java b/src/test/java/mil/dds/anet/test/integration/commands/MaintenanceCommandTest.java index 060addce31..09a40c7c1b 100644 --- a/src/test/java/mil/dds/anet/test/integration/commands/MaintenanceCommandTest.java +++ b/src/test/java/mil/dds/anet/test/integration/commands/MaintenanceCommandTest.java @@ -1,7 +1,7 @@ package mil.dds.anet.test.integration.commands; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.NOTE_PERIOD_START; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.NOTE_RECURRENCE; +import static mil.dds.anet.utils.PendingAssessmentsHelper.NOTE_PERIOD_START; +import static mil.dds.anet.utils.PendingAssessmentsHelper.NOTE_RECURRENCE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; 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 index c6124b704a..d9f84ce95c 100644 --- a/src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java +++ b/src/test/java/mil/dds/anet/test/integration/db/PendingAssessmentsNotificationWorkerTest.java @@ -1,6 +1,6 @@ package mil.dds.anet.test.integration.db; -import static mil.dds.anet.threads.PendingAssessmentsNotificationWorker.*; +import static mil.dds.anet.utils.PendingAssessmentsHelper.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; diff --git a/src/test/java/mil/dds/anet/test/resources/PositionResourceTest.java b/src/test/java/mil/dds/anet/test/resources/PositionResourceTest.java index ee188cd25b..e5b882a30c 100644 --- a/src/test/java/mil/dds/anet/test/resources/PositionResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/PositionResourceTest.java @@ -12,6 +12,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import javax.ws.rs.BadRequestException; import javax.ws.rs.ForbiddenException; @@ -32,9 +33,11 @@ import mil.dds.anet.test.beans.OrganizationTest; import mil.dds.anet.test.beans.PositionTest; import mil.dds.anet.test.resources.utils.GraphQlResponse; +import mil.dds.anet.utils.Utils; import org.junit.jupiter.api.Test; public class PositionResourceTest extends AbstractResourceTest { + private static final String ORGANIZATION_FIELDS = "uuid shortName"; private static final String PERSON_FIELDS = "uuid name role"; private static final String POSITION_FIELDS = "uuid name code type status"; @@ -42,6 +45,15 @@ public class PositionResourceTest extends AbstractResourceTest { + " } organization { " + ORGANIZATION_FIELDS + " }"; private static final String PREVIOUS_PEOPLE_FIELDS = " previousPeople { startTime endTime position { uuid } person { uuid name rank role } }"; + private static final String GRAPHQL_NOTIFICATIONS_NOTE_FIELDS = + "customFields notes { noteRelatedObjects { noteUuid } createdAt type text }"; + private static final String PENDING_ASSESSMENTS_FIELDS = + " associatedPositions { uuid name code type status organization { uuid shortName }" + + " location { uuid name } person { uuid name rank avatar(size: 32) " + + GRAPHQL_NOTIFICATIONS_NOTE_FIELDS + " } }" + + " responsibleTasks(query: {status: ACTIVE}) { uuid shortName longName customFieldRef1 {" + + " uuid } " + GRAPHQL_NOTIFICATIONS_NOTE_FIELDS + " }"; + private static final String PA_FIELDS = FIELDS + PENDING_ASSESSMENTS_FIELDS; @Test public void positionTest() { @@ -484,6 +496,64 @@ public void searchTest() { .isEqualTo(searchResults.size()); } + @Test + public void searchPendingAssessmentsTestAll() { + final Person erin = getRegularUser(); + final PositionSearchQuery query = new PositionSearchQuery(); + query.setHasPendingAssessments(true); + + // Search all organizations + final Map searchResults = graphQLHelper.searchObjectsGeneric(erin, + "positionList", "query", "PositionSearchQueryInput", PA_FIELDS, query); + assertThat(searchResults).isNotEmpty(); + @SuppressWarnings("unchecked") + final List> list = (List>) searchResults.get("list"); + final Set uuids = + list.stream().map(p -> (String) p.get("uuid")).collect(Collectors.toSet()); + // EF 1 + assertThat(uuids).contains(getAndrewAnderson().getPosition().getUuid()); + // EF 1.1 + assertThat(uuids).contains(getBobBobtown().getPosition().getUuid()); + // EF 2.1 + assertThat(uuids).contains(getJackJackson().getPosition().getUuid()); + // EF 2.2 + assertThat(uuids).contains(erin.getPosition().getUuid()); + // Each entry should have associatedPositions or responsibleTasks (or both) + assertThat( + list.stream().filter(p -> !Utils.isEmptyOrNull((List) p.get("associatedPositions")) + || !Utils.isEmptyOrNull((List) p.get("responsibleTasks")))).hasSameSizeAs(list); + } + + @Test + public void searchPendingAssessmentsTestEf1() { + final Person erin = getRegularUser(); + final PositionSearchQuery query = new PositionSearchQuery(); + query.setHasPendingAssessments(true); + + // Search EF 1 and below + query.setOrganizationUuid(getAndrewAnderson().getPosition().getOrganizationUuid()); + query.setOrgRecurseStrategy(RecurseStrategy.CHILDREN); + Map searchResults = graphQLHelper.searchObjectsGeneric(erin, "positionList", + "query", "PositionSearchQueryInput", PA_FIELDS, query); + assertThat(searchResults).isNotEmpty(); + @SuppressWarnings("unchecked") + final List> list = (List>) searchResults.get("list"); + final Set uuids = + list.stream().map(p -> (String) p.get("uuid")).collect(Collectors.toSet()); + // EF 1 + assertThat(uuids).contains(getAndrewAnderson().getPosition().getUuid()); + // EF 1.1 + assertThat(uuids).contains(getBobBobtown().getPosition().getUuid()); + // EF 2.1 + assertThat(uuids).doesNotContain(getJackJackson().getPosition().getUuid()); + // EF 2.2 + assertThat(uuids).doesNotContain(erin.getPosition().getUuid()); + // Each entry should have associatedPositions or responsibleTasks (or both) + assertThat( + list.stream().filter(p -> !Utils.isEmptyOrNull((List) p.get("associatedPositions")) + || !Utils.isEmptyOrNull((List) p.get("responsibleTasks")))).hasSameSizeAs(list); + } + @Test public void createPositionTest() { // Create a new position and designate the person upfront diff --git a/src/test/java/mil/dds/anet/test/resources/utils/GraphQlHelper.java b/src/test/java/mil/dds/anet/test/resources/utils/GraphQlHelper.java index a5cf6a377a..7ec7ce2d31 100644 --- a/src/test/java/mil/dds/anet/test/resources/utils/GraphQlHelper.java +++ b/src/test/java/mil/dds/anet/test/resources/utils/GraphQlHelper.java @@ -61,7 +61,7 @@ public T getObject(Person user, String getQuery, St /** * @return the requested object of any type */ - public T getObjectOfType(Person user, String query, + public T getObjectOfType(Person user, String query, TypeReference> responseType) { return graphQlClient.doGraphQlQuery(user, query, null, responseType); } @@ -142,8 +142,7 @@ public T updateObject(Person user, String updateQue /** * @return the number of objects deleted */ - public Integer deleteObject(Person user, String deleteQuery, - String uuid) { + public Integer deleteObject(Person user, String deleteQuery, String uuid) { final String q = String.format(updateFmt, "uuid", "String", deleteQuery); return graphQlClient.doGraphQlQuery(user, q, "uuid", uuid, new TypeReference>() {}); @@ -159,4 +158,14 @@ public AnetBeanList searchObjects(Person user, S return graphQlClient.doGraphQlQuery(user, q, paramName, param, responseType); } + /** + * @return the object list matching the search query + */ + public Map searchObjectsGeneric(Person user, String searchQuery, String paramName, + String paramType, String fields, AbstractSearchQuery param) { + final String q = String.format(searchFmt, paramName, paramType, searchQuery, fields); + return graphQlClient.doGraphQlQuery(user, q, paramName, param, + new TypeReference>>() {}); + } + }