/* ==================================================================
* EmailNodeStaleDataAlertProcessor.java - 15/05/2015 7:23:12 pm
*
* Copyright 2007-2015 SolarNetwork.net Dev Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
* 02111-1307 USA
* ==================================================================
*/
package net.solarnetwork.central.user.alerts;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import net.solarnetwork.central.RepeatableTaskException;
import net.solarnetwork.central.dao.SolarNodeDao;
import net.solarnetwork.central.datum.dao.GeneralNodeDatumDao;
import net.solarnetwork.central.datum.domain.DatumFilterCommand;
import net.solarnetwork.central.datum.domain.GeneralNodeDatumFilterMatch;
import net.solarnetwork.central.domain.FilterResults;
import net.solarnetwork.central.domain.SolarNode;
import net.solarnetwork.central.mail.MailService;
import net.solarnetwork.central.mail.support.BasicMailAddress;
import net.solarnetwork.central.mail.support.ClasspathResourceMessageTemplateDataSource;
import net.solarnetwork.central.user.dao.UserAlertDao;
import net.solarnetwork.central.user.dao.UserAlertSituationDao;
import net.solarnetwork.central.user.dao.UserDao;
import net.solarnetwork.central.user.dao.UserNodeDao;
import net.solarnetwork.central.user.domain.User;
import net.solarnetwork.central.user.domain.UserAlert;
import net.solarnetwork.central.user.domain.UserAlertOptions;
import net.solarnetwork.central.user.domain.UserAlertSituation;
import net.solarnetwork.central.user.domain.UserAlertSituationStatus;
import net.solarnetwork.central.user.domain.UserAlertStatus;
import net.solarnetwork.central.user.domain.UserAlertType;
import net.solarnetwork.central.user.domain.UserNode;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
import org.joda.time.LocalTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
/**
* Process stale data alerts for nodes.
*
* @author matt
* @version 1.2
*/
public class EmailNodeStaleDataAlertProcessor implements UserAlertBatchProcessor {
/** The default value for {@link #getBatchSize()}. */
public static final Integer DEFAULT_BATCH_SIZE = 50;
/** The default value for {@link #getMailTemplateResource()}. */
public static final String DEFAULT_MAIL_TEMPLATE_RESOURCE = "/net/solarnetwork/central/user/alerts/user-alert-NodeStaleData.txt";
/** The default value for {@link #getMailTemplateResolvedResource()}. */
public static final String DEFAULT_MAIL_TEMPLATE_RESOLVED_RESOURCE = "/net/solarnetwork/central/user/alerts/user-alert-NodeStaleData-Resolved.txt";
/**
* A {@code UserAlertSituation} {@code info} key for an associated node ID.
*
* @since 1.1
*/
public static final String SITUATION_INFO_NODE_ID = "nodeId";
/**
* A {@code UserAlertSituation} {@code info} key for an associated source
* ID.
*
* @since 1.1
*/
public static final String SITUATION_INFO_SOURCE_ID = "sourceId";
/**
* A {@code UserAlertSituation} {@code info} key for an associated datum
* creation date.
*
* @since 1.1
*/
public static final String SITUATION_INFO_DATUM_CREATED = "datumCreated";
private final SolarNodeDao solarNodeDao;
private final UserDao userDao;
private final UserNodeDao userNodeDao;
private final UserAlertDao userAlertDao;
private final UserAlertSituationDao userAlertSituationDao;
private final GeneralNodeDatumDao generalNodeDatumDao;
private final MailService mailService;
private Integer batchSize = DEFAULT_BATCH_SIZE;
private final MessageSource messageSource;
private String mailTemplateResource = DEFAULT_MAIL_TEMPLATE_RESOURCE;
private String mailTemplateResolvedResource = DEFAULT_MAIL_TEMPLATE_RESOLVED_RESOURCE;
private DateTimeFormatter timestampFormat = DateTimeFormat.forPattern("d MMM yyyy HH:mm z");
private int initialAlertReminderDelayMinutes = 60;
private int alertReminderFrequencyMultiplier = 4;
// maintain a cache of node data during the execution of the job (cleared after each invocation)
private final Map<Long, SolarNode> nodeCache = new HashMap<Long, SolarNode>(64);
private final Map<Long, List<GeneralNodeDatumFilterMatch>> nodeDataCache = new HashMap<Long, List<GeneralNodeDatumFilterMatch>>(
64);
private final Map<Long, List<GeneralNodeDatumFilterMatch>> userDataCache = new HashMap<Long, List<GeneralNodeDatumFilterMatch>>(
16);
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* Construct with properties.
*
* @param solarNodeDao
* The {@link SolarNodeDao} to use.
* @param userDao
* The {@link UserDao} to use.
* @param userNodeDao
* The {@link UserNodeDao} to use.
* @param userAlertDao
* The {@link UserAlertDao} to use.
* @param userAlertSituationDao
* The {@link UserAlertSituationDao} to use.
* @param generalNodeDatumDao
* The {@link GeneralNodeDatumDao} to use.
* @param mailService
* The {@link MailService} to use.
* @param messageSource
* The {@link MessageSource} to use.
*/
public EmailNodeStaleDataAlertProcessor(SolarNodeDao solarNodeDao, UserDao userDao,
UserNodeDao userNodeDao, UserAlertDao userAlertDao,
UserAlertSituationDao userAlertSituationDao, GeneralNodeDatumDao generalNodeDatumDao,
MailService mailService, MessageSource messageSource) {
super();
this.solarNodeDao = solarNodeDao;
this.userDao = userDao;
this.userNodeDao = userNodeDao;
this.userAlertDao = userAlertDao;
this.userAlertSituationDao = userAlertSituationDao;
this.generalNodeDatumDao = generalNodeDatumDao;
this.mailService = mailService;
this.messageSource = messageSource;
}
/**
* Get the current system time. Exposed to support testing.
*
* @return The current system time.
* @since 1.2
*/
protected long getCurrentTime() {
return System.currentTimeMillis();
}
@Override
public Long processAlerts(Long lastProcessedAlertId, DateTime validDate) {
if ( validDate == null ) {
validDate = new DateTime();
}
List<UserAlert> alerts = userAlertDao.findAlertsToProcess(UserAlertType.NodeStaleData,
lastProcessedAlertId, validDate, batchSize);
Long lastAlertId = null;
final long now = getCurrentTime();
final DateTime nowDateTime = new DateTime(now);
final DateTimeFormatter timeFormatter = DateTimeFormat.forPattern("H:mm");
try {
loadMostRecentNodeData(alerts);
for ( UserAlert alert : alerts ) {
Map<String, Object> alertOptions = alert.getOptions();
if ( alertOptions == null ) {
continue;
}
// extract options
Number age;
String[] sourceIds = null;
try {
age = (Number) alertOptions.get(UserAlertOptions.AGE_THRESHOLD);
@SuppressWarnings("unchecked")
List<String> sources = (List<String>) alertOptions.get(UserAlertOptions.SOURCE_IDS);
if ( sources != null ) {
sourceIds = sources.toArray(new String[sources.size()]);
}
} catch ( ClassCastException e ) {
log.warn("Unexpected option data type in alert {}: {}", alert, e.getMessage());
continue;
}
if ( age == null ) {
log.debug("Skipping alert {} that does not include {} option", alert,
UserAlertOptions.AGE_THRESHOLD);
continue;
}
if ( sourceIds != null ) {
// sort so we can to binarySearch later
Arrays.sort(sourceIds);
}
// look for first stale data matching age + source criteria
final List<Interval> timePeriods = new ArrayList<Interval>(2);
GeneralNodeDatumFilterMatch stale = getFirstStaleDatum(alert, nowDateTime, age,
sourceIds, timeFormatter, timePeriods);
Map<String, Object> staleInfo = new HashMap<String, Object>(4);
if ( stale != null ) {
staleInfo.put(SITUATION_INFO_DATUM_CREATED,
Long.valueOf(stale.getId().getCreated().getMillis()));
staleInfo.put(SITUATION_INFO_NODE_ID, stale.getId().getNodeId());
staleInfo.put(SITUATION_INFO_SOURCE_ID, stale.getId().getSourceId());
}
// get UserAlertSitutation for this alert
UserAlertSituation sit = userAlertSituationDao.getActiveAlertSituationForAlert(alert
.getId());
if ( stale != null ) {
long notifyOffset = 0;
if ( sit == null ) {
sit = new UserAlertSituation();
sit.setCreated(new DateTime(now));
sit.setAlert(alert);
sit.setStatus(UserAlertSituationStatus.Active);
sit.setNotified(new DateTime(now));
sit.setInfo(staleInfo);
} else if ( sit.getNotified().equals(sit.getCreated()) ) {
notifyOffset = (initialAlertReminderDelayMinutes * 60L * 1000L);
} else {
notifyOffset = ((sit.getNotified().getMillis() - sit.getCreated().getMillis()) * alertReminderFrequencyMultiplier);
}
// taper off the alerts so the become less frequent over time
if ( (sit.getNotified().getMillis() + notifyOffset) <= now ) {
sendAlertMail(alert, "user.alert.NodeStaleData.mail.subject",
mailTemplateResource, stale);
sit.setNotified(new DateTime(now));
}
if ( sit.getNotified().getMillis() == now || sit.getInfo() == null
|| !staleInfo.equals(sit.getInfo()) ) {
userAlertSituationDao.store(sit);
}
} else {
// not stale, so mark valid for age span
final boolean withinTimePeriods = withinIntervals(now, timePeriods);
DateTime newValidTo;
if ( !timePeriods.isEmpty() && !withinTimePeriods ) {
// we're not in valid to the start of the next time period
newValidTo = startOfNextTimePeriod(now, timePeriods);
} else {
newValidTo = validDate.plusSeconds(age.intValue());
}
log.debug("Marking alert {} valid to {}", alert.getId(), newValidTo);
userAlertDao.updateValidTo(alert.getId(), newValidTo);
alert.setValidTo(newValidTo);
if ( sit != null && withinTimePeriods ) {
// make Resolved
sit.setStatus(UserAlertSituationStatus.Resolved);
sit.setNotified(new DateTime(now));
userAlertSituationDao.store(sit);
GeneralNodeDatumFilterMatch nonStale = getFirstNonStaleDatum(alert, now, age,
sourceIds);
sendAlertMail(alert, "user.alert.NodeStaleData.Resolved.mail.subject",
mailTemplateResolvedResource, nonStale);
}
}
lastAlertId = alert.getId();
}
} catch ( RuntimeException e ) {
throw new RepeatableTaskException("Error processing user alerts", e, lastAlertId);
} finally {
nodeCache.clear();
nodeDataCache.clear();
userDataCache.clear();
}
// short-circuit performing batch for no results if obvious
if ( alerts.size() < batchSize && lastAlertId != null
&& lastAlertId.equals(alerts.get(alerts.size() - 1).getId()) ) {
// we've finished our batch
lastAlertId = null;
}
return lastAlertId;
}
private List<Interval> parseAlertTimeWindows(final DateTime nowDateTime,
final DateTimeFormatter timeFormatter, final UserAlert alert, final Long nodeId) {
Map<String, Object> alertOptions = alert.getOptions();
if ( alertOptions == null ) {
return null;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> windows = (List<Map<String, Object>>) alertOptions
.get(UserAlertOptions.TIME_WINDOWS);
if ( windows == null ) {
return null;
}
final Long intervalNodeId = (nodeId != null ? nodeId : alert.getNodeId());
List<Interval> timePeriods = new ArrayList<Interval>(windows.size());
for ( Map<String, Object> window : windows ) {
Object s = window.get("timeStart");
Object e = window.get("timeEnd");
if ( s != null && e != null ) {
try {
LocalTime start = timeFormatter.parseLocalTime(s.toString());
LocalTime end = timeFormatter.parseLocalTime(e.toString());
SolarNode node = nodeCache.get(intervalNodeId);
DateTimeZone tz = DateTimeZone.UTC;
if ( node != null ) {
TimeZone nodeTz = node.getTimeZone();
if ( nodeTz != null ) {
tz = DateTimeZone.forTimeZone(nodeTz);
}
} else {
log.warn("Node {} not available, defaulting to UTC time zone", intervalNodeId);
}
DateTime startTimeToday = start.toDateTime(nowDateTime.toDateTime(tz));
DateTime endTimeToday = end.toDateTime(nowDateTime.toDateTime(tz));
timePeriods.add(new Interval(startTimeToday, endTimeToday));
} catch ( IllegalArgumentException t ) {
log.warn("Error parsing time window time: {}", t.getMessage());
}
}
}
if ( timePeriods.size() > 0 ) {
// sort by start dates if there is more than one interval
Collections.sort(timePeriods, new Comparator<Interval>() {
@Override
public int compare(Interval o1, Interval o2) {
return o1.getStart().compareTo(o2.getStart());
}
});
} else {
timePeriods = null;
}
return timePeriods;
}
private void loadMostRecentNodeData(List<UserAlert> alerts) {
// reset cache
nodeCache.clear();
nodeDataCache.clear();
userDataCache.clear();
// keep a reverse node ID -> user ID mapping
Map<Long, Long> nodeUserMapping = new HashMap<Long, Long>();
// get set of unique user IDs and/or node IDs
Set<Long> nodeIds = new HashSet<Long>(alerts.size());
Set<Long> userIds = new HashSet<Long>(alerts.size());
for ( UserAlert alert : alerts ) {
if ( alert.getNodeId() != null ) {
nodeIds.add(alert.getNodeId());
} else {
userIds.add(alert.getUserId());
// need to associate all possible node IDs to this user ID
List<UserNode> nodes = userNodeDao
.findUserNodesForUser(new User(alert.getUserId(), null));
for ( UserNode userNode : nodes ) {
nodeCache.put(userNode.getNode().getId(), userNode.getNode());
nodeUserMapping.put(userNode.getNode().getId(), alert.getUserId());
}
}
}
// load up data for users first, as that might pull in all node data already
if ( userIds.isEmpty() == false ) {
DatumFilterCommand filter = new DatumFilterCommand();
filter.setUserIds(userIds.toArray(new Long[userIds.size()]));
filter.setMostRecent(true);
FilterResults<GeneralNodeDatumFilterMatch> latestNodeData = generalNodeDatumDao
.findFiltered(filter, null, null, null);
for ( GeneralNodeDatumFilterMatch match : latestNodeData.getResults() ) {
// first add to node list
List<GeneralNodeDatumFilterMatch> datumMatches = nodeDataCache.get(match.getId()
.getNodeId());
if ( datumMatches == null ) {
datumMatches = new ArrayList<GeneralNodeDatumFilterMatch>();
nodeDataCache.put(match.getId().getNodeId(), datumMatches);
}
datumMatches.add(match);
// now add match to User list
Long userId = nodeUserMapping.get(match.getId().getNodeId());
if ( userId == null ) {
log.warn("No user ID found for node ID: {}", match.getId().getNodeId());
continue;
}
datumMatches = userDataCache.get(userId);
if ( datumMatches == null ) {
datumMatches = new ArrayList<GeneralNodeDatumFilterMatch>();
userDataCache.put(userId, datumMatches);
}
datumMatches.add(match);
}
log.debug("Loaded most recent datum for users {}: {}", userIds, userDataCache);
}
// we can remove any nodes already fetched via user query
nodeIds.removeAll(nodeUserMapping.keySet());
// for any node IDs still around, query for them now
if ( nodeIds.isEmpty() == false ) {
DatumFilterCommand filter = new DatumFilterCommand();
filter.setNodeIds(nodeIds.toArray(new Long[nodeIds.size()]));
filter.setMostRecent(true);
FilterResults<GeneralNodeDatumFilterMatch> latestNodeData = generalNodeDatumDao
.findFiltered(filter, null, null, null);
for ( GeneralNodeDatumFilterMatch match : latestNodeData.getResults() ) {
List<GeneralNodeDatumFilterMatch> datumMatches = nodeDataCache.get(match.getId()
.getNodeId());
if ( datumMatches == null ) {
datumMatches = new ArrayList<GeneralNodeDatumFilterMatch>();
nodeDataCache.put(match.getId().getNodeId(), datumMatches);
}
if ( !nodeCache.containsKey(match.getId().getNodeId()) ) {
nodeCache
.put(match.getId().getNodeId(), solarNodeDao.get(match.getId().getNodeId()));
}
datumMatches.add(match);
}
log.debug("Loaded most recent datum for nodes {}: {}", nodeIds, nodeDataCache);
}
}
/**
* Get list of most recent datum associated with an alert. Depends on
* {@link #loadMostRecentNodeData(List)} having been already called.
*
* @param alert
* The alert to get the most recent data for.
* @return The associated data, never <em>null</em>.
*/
private List<GeneralNodeDatumFilterMatch> getLatestNodeData(final UserAlert alert) {
List<GeneralNodeDatumFilterMatch> results;
if ( alert.getNodeId() != null ) {
results = nodeDataCache.get(alert.getNodeId());
} else {
results = userDataCache.get(alert.getUserId());
}
return (results == null ? Collections.<GeneralNodeDatumFilterMatch> emptyList() : results);
}
private boolean withinIntervals(final long now, List<Interval> intervals) {
if ( intervals == null ) {
return true;
}
for ( Interval i : intervals ) {
if ( !i.contains(now) ) {
return false;
}
}
return true;
}
private DateTime startOfNextTimePeriod(final long now, List<Interval> intervals) {
if ( intervals == null || intervals.size() < 1 ) {
return new DateTime();
}
Interval found = null;
Interval earliest = null;
for ( Interval i : intervals ) {
if ( i.isAfter(now) && (found == null || found.isAfter(i.getStartMillis())) ) {
// this time period starts later than now, so that is the next period to work with
found = i;
}
if ( earliest == null || earliest.isAfter(i.getStartMillis()) ) {
earliest = i;
}
}
if ( found != null ) {
return found.getStart();
}
// no time period later than now, so make the next period the start of the earliest interval, tomorrow
return earliest.getStart().plusDays(1);
}
private GeneralNodeDatumFilterMatch getFirstStaleDatum(final UserAlert alert, final DateTime now,
final Number age, final String[] sourceIds, final DateTimeFormatter timeFormatter,
final List<Interval> outputIntervals) {
GeneralNodeDatumFilterMatch stale = null;
List<GeneralNodeDatumFilterMatch> latestNodeData = getLatestNodeData(alert);
List<Interval> intervals = new ArrayList<Interval>(2);
if ( alert.getNodeId() != null ) {
try {
intervals = parseAlertTimeWindows(now, timeFormatter, alert, alert.getNodeId());
} catch ( ClassCastException e ) {
log.warn("Unexpected option data type in alert {}: {}", alert, e.getMessage());
}
}
for ( GeneralNodeDatumFilterMatch datum : latestNodeData ) {
List<Interval> nodeIntervals = intervals;
if ( alert.getNodeId() == null ) {
try {
nodeIntervals = parseAlertTimeWindows(now, timeFormatter, alert, datum.getId()
.getNodeId());
if ( nodeIntervals != null ) {
for ( Interval interval : nodeIntervals ) {
if ( !intervals.contains(interval) ) {
intervals.add(interval);
}
}
}
} catch ( ClassCastException e ) {
log.warn("Unexpected option data type in alert {}: {}", alert, e.getMessage());
continue;
}
}
if ( datum.getId().getCreated().getMillis() + (long) (age.doubleValue() * 1000) < now
.getMillis()
&& (sourceIds == null || Arrays.binarySearch(sourceIds, datum.getId().getSourceId()) >= 0)
&& withinIntervals(now.getMillis(), nodeIntervals) ) {
stale = datum;
break;
}
}
if ( intervals != null && outputIntervals != null ) {
outputIntervals.addAll(intervals);
}
return stale;
}
private GeneralNodeDatumFilterMatch getFirstNonStaleDatum(final UserAlert alert, final long now,
final Number age, final String[] sourceIds) {
GeneralNodeDatumFilterMatch nonStale = null;
List<GeneralNodeDatumFilterMatch> latestNodeData = getLatestNodeData(alert);
for ( GeneralNodeDatumFilterMatch datum : latestNodeData ) {
if ( datum.getId().getCreated().getMillis() + (long) (age.doubleValue() * 1000) >= now
&& (sourceIds == null || Arrays.binarySearch(sourceIds, datum.getId().getSourceId()) >= 0) ) {
nonStale = datum;
break;
}
}
return nonStale;
}
private void sendAlertMail(UserAlert alert, String subjectKey, String resourcePath,
GeneralNodeDatumFilterMatch datum) {
if ( alert.getStatus() == UserAlertStatus.Suppressed ) {
// no emails for this alert
log.debug("Alert email suppressed: {}; datum {}; subject {}", alert, datum, subjectKey);
return;
}
User user = userDao.get(alert.getUserId());
SolarNode node = nodeCache.get(datum.getId().getNodeId());
if ( user != null && node != null ) {
BasicMailAddress addr = new BasicMailAddress(user.getName(), user.getEmail());
Locale locale = Locale.US; // TODO: get Locale from User entity
Map<String, Object> model = new HashMap<String, Object>(4);
model.put("alert", alert);
model.put("user", user);
model.put("datum", datum);
// add a formatted datum date to model
DateTimeFormatter dateFormat = timestampFormat.withLocale(locale);
if ( node != null && node.getTimeZone() != null ) {
dateFormat = dateFormat.withZone(DateTimeZone.forTimeZone(node.getTimeZone()));
}
model.put("datumDate", dateFormat.print(datum.getId().getCreated()));
String subject = messageSource.getMessage(subjectKey, new Object[] { datum.getId()
.getNodeId() }, locale);
log.debug("Sending NodeStaleData alert {} to {} with model {}", subject, user.getEmail(),
model);
ClasspathResourceMessageTemplateDataSource msg = new ClasspathResourceMessageTemplateDataSource(
locale, subject, resourcePath, model);
msg.setClassLoader(getClass().getClassLoader());
mailService.sendMail(addr, msg);
}
}
public Integer getBatchSize() {
return batchSize;
}
public void setBatchSize(Integer batchSize) {
this.batchSize = batchSize;
}
public String getMailTemplateResource() {
return mailTemplateResource;
}
public void setMailTemplateResource(String mailTemplateResource) {
this.mailTemplateResource = mailTemplateResource;
}
public DateTimeFormatter getTimestampFormat() {
return timestampFormat;
}
public void setTimestampFormat(DateTimeFormatter timestampFormat) {
this.timestampFormat = timestampFormat;
}
public String getMailTemplateResolvedResource() {
return mailTemplateResolvedResource;
}
public void setMailTemplateResolvedResource(String mailTemplateResolvedResource) {
this.mailTemplateResolvedResource = mailTemplateResolvedResource;
}
public int getInitialAlertReminderDelayMinutes() {
return initialAlertReminderDelayMinutes;
}
public void setInitialAlertReminderDelayMinutes(int initialAlertReminderDelayMinutes) {
this.initialAlertReminderDelayMinutes = initialAlertReminderDelayMinutes;
}
public int getAlertReminderFrequencyMultiplier() {
return alertReminderFrequencyMultiplier;
}
public void setAlertReminderFrequencyMultiplier(int alertReminderFrequencyMultiplier) {
this.alertReminderFrequencyMultiplier = alertReminderFrequencyMultiplier;
}
}