diff --git a/apps/steward-app/src/main/scala/net/shrine/steward/Boot.scala b/apps/steward-app/src/main/scala/net/shrine/steward/Boot.scala index 503a09a0c..a83397ed6 100644 --- a/apps/steward-app/src/main/scala/net/shrine/steward/Boot.scala +++ b/apps/steward-app/src/main/scala/net/shrine/steward/Boot.scala @@ -1,71 +1,83 @@ package net.shrine.steward import akka.actor.{ActorSystem, Props} import net.shrine.config.{ConfigExtensions, DurationConfigParser} import net.shrine.log.Loggable import net.shrine.problem.{AbstractProblem, ProblemSources} import net.shrine.steward.db.StewardDatabase import net.shrine.steward.email.{AuditEmailer, AuditEmailerActor} import spray.servlet.WebBoot import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ import scala.language.postfixOps import scala.util.control.NonFatal // this class is instantiated by the servlet initializer // it needs to have a default constructor and implement // the spray.servlet.WebBoot trait class Boot extends WebBoot with Loggable { info(s"StewardActors akka daemonic config is ${StewardConfigSource.config.getString("akka.daemonic")}") val warmUp:Unit = StewardDatabase.warmUp() // we need an ActorSystem to host our application in val system = ActorSystem("StewardActors",StewardConfigSource.config) // the service actor replies to incoming HttpRequests val serviceActor = system.actorOf(Props[StewardServiceActor]) // if sending email alerts is on start a periodic polling of the database at a fixed time every day. // if either the volume or time conditions are met, send an email to the data steward asking for an audit val config = StewardConfigSource.config val emailConfig = config.getConfig("shrine.steward.emailDataSteward") if(emailConfig.getBoolean("sendAuditEmails") && AuditEmailer.configCheck(config)) { try { - system.scheduler.schedule(initialDelay = 0 milliseconds, //todo figure out how to handle the initial delay - interval = emailConfig.get("interval", DurationConfigParser.parseDuration), + val interval = emailConfig.get("interval", DurationConfigParser.parseDuration) + + system.scheduler.schedule(initialDelay = initialDelayToSendEmail(emailConfig.get("timeAfterMidnight",DurationConfigParser.parseDuration),interval), + interval = interval, receiver = system.actorOf(Props[AuditEmailerActor]), "tick") } catch { case NonFatal(x) => CannotStartAuditEmailActor(x) case x:ExceptionInInitializerError => CannotStartAuditEmailActor(x) } } - //todo use this to figure out what if any initial delay should be. Maybe if the interval is >= 1 day then the delay will send the email so many hours passed either the previous or the next midnight - def previousMidnight: Long = { - import java.util.Calendar - val c = Calendar.getInstance() - val now = c.getTimeInMillis() - c.set(Calendar.HOUR_OF_DAY, 0) - c.set(Calendar.MINUTE, 0) - c.set(Calendar.SECOND, 0) - c.set(Calendar.MILLISECOND, 0) - c.getTimeInMillis + def initialDelayToSendEmail(timeFromMidnight:Duration,interval:Duration): FiniteDuration = { + if(interval >= (1 day)) { + + import java.util.Calendar + val c = Calendar.getInstance() + val now = c.getTimeInMillis + + val previousMidnight = { + c.set(Calendar.HOUR_OF_DAY, 0) + c.set(Calendar.MINUTE, 0) + c.set(Calendar.SECOND, 0) + c.set(Calendar.MILLISECOND, 0) + c.getTimeInMillis + } + val timeToSendToday = previousMidnight + timeFromMidnight.toMillis + + if (timeToSendToday > now) timeToSendToday milliseconds + else timeToSendToday + (1 day).toMillis milliseconds + } + else 0 milliseconds //if we're testing then don't delay that first send } } case class CannotStartAuditEmailActor(ex:Throwable) extends AbstractProblem(ProblemSources.Dsa) { override def summary: String = "The DSA could not start an Actor to email audit requests due to an exception." override def description: String = s"The DSA will not email audit requests due to ${throwable.get}" override def throwable = Some(ex) } diff --git a/apps/steward-app/src/main/scala/net/shrine/steward/email/AuditEmailer.scala b/apps/steward-app/src/main/scala/net/shrine/steward/email/AuditEmailer.scala index 8b4e80492..6ab186eaa 100644 --- a/apps/steward-app/src/main/scala/net/shrine/steward/email/AuditEmailer.scala +++ b/apps/steward-app/src/main/scala/net/shrine/steward/email/AuditEmailer.scala @@ -1,144 +1,144 @@ package net.shrine.steward.email import java.util.Date import javax.mail.internet.InternetAddress import akka.actor.Actor import com.typesafe.config.Config import courier.{Envelope, Mailer, Text} import net.shrine.authorization.steward.ResearcherToAudit import net.shrine.config.{ConfigExtensions, DurationConfigParser} import net.shrine.email.ConfiguredMailer import net.shrine.log.Log import net.shrine.problem.{AbstractProblem, ProblemSources} import net.shrine.steward.StewardConfigSource import net.shrine.steward.db.StewardDatabase import scala.concurrent.Await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.blocking import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration._ import scala.util.control.NonFatal import scala.xml.NodeSeq /** * @author david * @since 1.22 */ case class AuditEmailer(maxQueryCountBetweenAudits:Int, minTimeBetweenAudits:FiniteDuration, researcherLineTemplate:String, emailTemplate:String, emailSubject:String, from:InternetAddress, to:InternetAddress, - stewardBaseUrl:Option[String], + stewardBaseUrl:Option[String], //todo not an option mailer:Mailer ) { def audit() = { //gather a list of users to audit val now = System.currentTimeMillis() val researchersToAudit: Seq[ResearcherToAudit] = StewardDatabase.db.selectResearchersToAudit(maxQueryCountBetweenAudits, minTimeBetweenAudits, now) if (researchersToAudit.nonEmpty){ val auditLines = researchersToAudit.sortBy(_.count).reverse.map { researcher => researcherLineTemplate.replaceAll("FULLNAME",researcher.researcher.fullName) .replaceAll("USERNAME",researcher.researcher.userName) .replaceAll("COUNT",researcher.count.toString) .replaceAll("LAST_AUDIT_DATE",new Date(researcher.leastRecentQueryDate).toString) }.mkString("\n") //build up the email body val withLines = emailTemplate.replaceAll("AUDIT_LINES",auditLines) val withBaseUrl = stewardBaseUrl.fold(withLines)(withLines.replaceAll("STEWARD_BASE_URL",_)) val emailBody = Text(withBaseUrl) val envelope:Envelope = Envelope.from(from).to(to).subject(emailSubject).content(emailBody) Log.debug(s"About to send $envelope .") //send the email val future = mailer(envelope) try { blocking { Await.result(future, 60.seconds) } StewardDatabase.db.logAuditRequests(researchersToAudit, now) Log.info(s"Sent and logged $envelope .") } catch { case NonFatal(x) => CouldNotSendAuditEmail(envelope,x) } } } } object AuditEmailer { /** * * @param config All of shrine.conf */ def apply(config:Config):AuditEmailer = { val config = StewardConfigSource.config val emailConfig = config.getConfig("shrine.steward.emailDataSteward") AuditEmailer( maxQueryCountBetweenAudits = emailConfig.getInt("maxQueryCountBetweenAudits"), minTimeBetweenAudits = emailConfig.get("minTimeBetweenAudits", DurationConfigParser.parseDuration), researcherLineTemplate = emailConfig.getString("researcherLine"), emailTemplate = emailConfig.getString("emailBody"), emailSubject = emailConfig.getString("subject"), from = emailConfig.get("from", new InternetAddress(_)), to = emailConfig.get("to", new InternetAddress(_)), - stewardBaseUrl = config.getOption("stewardBaseUrl", _.getString), + stewardBaseUrl = config.getOption("shrine.queryEntryPoint.shrineSteward.stewardBaseUrl", _.getString), mailer = ConfiguredMailer.createMailerFromConfig(config.getConfig("shrine.email"))) } /** * Check the emailer's config, log any problems * * @param config All of shrine.conf */ def configCheck(config:Config):Boolean = try { val autoEmailer = apply(config) Log.info(s"DSA will request audits from ${autoEmailer.to}") true } catch { case NonFatal(x) => CannotConfigureAuditEmailer(x) false } } class AuditEmailerActor extends Actor { override def receive: Receive = {case _ => val config = StewardConfigSource.config AuditEmailer(config).audit() } } case class CannotConfigureAuditEmailer(ex:Throwable) extends AbstractProblem(ProblemSources.Dsa) { override def summary: String = "The DSA will not email audit requests due to a misconfiguration." override def description: String = s"The DSA will not email audit requests due to ${throwable.get}" override def throwable = Some(ex) } case class CouldNotSendAuditEmail(envelope:Envelope,ex:Throwable) extends AbstractProblem(ProblemSources.Dsa) { override def summary: String = "The DSA was not able to send an audit email." override def description: String = s"The DSA was not able to send an audit request to ${envelope.to} due to ${throwable.get}" override def throwable = Some(ex) override def detailsXml:NodeSeq = <details> {s"Could not send $envelope"} {throwableDetail} </details> } \ No newline at end of file diff --git a/apps/steward-war/src/main/resources/reference.conf b/apps/steward-war/src/main/resources/reference.conf index 98f5c269e..a98b35283 100644 --- a/apps/steward-war/src/main/resources/reference.conf +++ b/apps/steward-war/src/main/resources/reference.conf @@ -1,108 +1,109 @@ shrine { steward { createTopicsMode = Pending //Can be Pending, Approved, or TopcisIgnoredJustLog //Pending - new topics start in the Pending state; researchers must wait for the Steward to approve them //Approved - new topics start in the Approved state; researchers can use them immediately //TopicsIgnoredJustLog - all queries are logged and approved; researchers don't need to create topics emailDataSteward { sendAuditEmails = true interval = "1 day" //Audit researchers daily + timeAfterMidnight = "6 hours" //Audit researchers at 6 am. If the interval is less than 1 day then this delay is ignored. maxQueryCountBetweenAudits = 30 //If a researcher runs more than this many queries since the last audit audit her minTimeBetweenAudits = "30 days" //If a researcher runs at least one query, audit those queries if this much time has passed //provide the email address of the shrine node system admin, to handle bounces and invalid addresses //from = "shrine-admin@example.com" //provide the email address of the shrine node system admin, to handle bounces and invalid addresses //to = "shrine-steward@example.com" subject = "Audit SHRINE researchers" //The baseUrl for the data steward to be substituted in to email text. Must be supplied if it is used in the email text. //stewardBaseUrl = "https://example.com:8443/steward/" //Text to use for the email audit. // AUDIT_LINES will be replaced by a researcherLine for each researcher to audit. // STEWARD_BASE_URL will be replaced by the value in stewardBaseUrl if available. emailBody = """Please audit the following users at STEWARD_BASE_URL at your earliest convinience: AUDIT_LINES""" //Text to use per researcher to audit. //FULLNAME, USERNAME, COUNT and LAST_AUDIT_DATE will be replaced with appropriate text. researcherLine = "FULLNAME (USERNAME) has run COUNT queries since LAST_AUDIT_DATE." } database { dataSourceFrom = "JNDI" //Can be JNDI or testDataSource . Use testDataSource for tests, JNDI everywhere else jndiDataSourceName = "java:comp/env/jdbc/stewardDB" //or leave out for tests slickProfileClassName = "slick.driver.MySQLDriver$" // Can be // slick.driver.H2Driver$ // slick.driver.MySQLDriver$ // slick.driver.PostgresDriver$ // slick.driver.SQLServerDriver$ // slick.driver.JdbcDriver$ // freeslick.OracleProfile$ // freeslick.MSSQLServerProfile$ // // (Yes, with the $ on the end) // For testing without JNDI // testDataSource { //typical test settings for unit tests //driverClassName = "org.h2.Driver" //url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" //H2 embedded in-memory for unit tests //url = "jdbc:h2:~/stewardTest.h2" //H2 embedded on disk at ~/test // } createTablesOnStart = false //for testing with H2 in memory, when not running unit tests. Set to false normally } gruntWatch = false //false for production, true for mvn tomcat7:run . Allows the client javascript and html files to be loaded via gruntWatch . } pmEndpoint { url = "http://changeme.com/i2b2/services/PMService/getServices" //"http://services.i2b2.org/i2b2/services/PMService/getServices" acceptAllCerts = true timeout { seconds = 10 } } authenticate { realm = "SHRINE Steward API" usersource { type = "PmUserSource" //Must be ConfigUserSource (for isolated testing) or PmUserSource (for everything else) domain = "set shrine.authenticate.usersource.domain to the PM authentication domain in steward.conf" //"i2b2demo" } } // If the pmEndpoint acceptAllCerts = false then you need to supply a keystore // keystore { // file = "shrine.keystore" // password = "chiptesting" // privateKeyAlias = "test-cert" // keyStoreType = "JKS" // caCertAliases = [carra ca] // } } //todo typesafe config precedence seems to do the right thing, but I haven't found the rules that say this reference.conf should override others akka { loglevel = INFO // log-config-on-start = on loggers = ["akka.event.slf4j.Slf4jLogger"] // logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" // Toggles whether the threads created by this ActorSystem should be daemons or not daemonic = on } spray.servlet { boot-class = "net.shrine.steward.Boot" request-timeout = 30s }