From 4c6f923f5fc5a4aecc55907e76edd845448453bf Mon Sep 17 00:00:00 2001
From: Matthew Jacobson
Date: Tue, 25 Sep 2018 19:52:59 -0400
Subject: [PATCH] Working draft, minimal styling
---
.gitignore | 5 +-
pom.xml | 5 +
.../idrbind/controllers/JobController.java | 123 +++++++++
.../idrbind/controllers/MainController.java | 47 ++++
.../jacobsonmt/idrbind/model/IDRBindJob.java | 194 ++++++++++++++
.../idrbind/model/IDRBindJobResult.java | 15 ++
.../idrbind/model/PurgeOldJobs.java | 29 ++
.../jacobsonmt/idrbind/rest/JobEndpoint.java | 79 ++++++
.../idrbind/services/EmailService.java | 96 +++++++
.../idrbind/services/JobManager.java | 253 ++++++++++++++++++
.../idrbind/settings/ApplicationSettings.java | 28 ++
.../jacobsonmt/idrbind/settings/Messages.java | 27 ++
.../idrbind/settings/SiteSettings.java | 28 ++
src/main/resources/application.properties | 66 +++++
.../static/img/UBC-logo-signature-blue.gif | Bin 0 -> 7063 bytes
src/main/resources/static/js/queue.js | 8 +
src/main/resources/templates/contact.html | 41 +++
.../resources/templates/documentation.html | 41 +++
src/main/resources/templates/faq.html | 41 +++
.../resources/templates/fragments/footer.html | 2 +-
.../resources/templates/fragments/header.html | 5 +-
.../templates/fragments/job-table.html | 37 +++
.../resources/templates/fragments/title.html | 5 +-
src/main/resources/templates/index.html | 28 +-
src/main/resources/templates/job.html | 84 ++++++
src/main/resources/templates/queue.html | 49 ++++
26 files changed, 1327 insertions(+), 9 deletions(-)
create mode 100644 src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/services/EmailService.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/services/JobManager.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/Messages.java
create mode 100644 src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java
create mode 100644 src/main/resources/static/img/UBC-logo-signature-blue.gif
create mode 100644 src/main/resources/static/js/queue.js
create mode 100644 src/main/resources/templates/contact.html
create mode 100644 src/main/resources/templates/documentation.html
create mode 100644 src/main/resources/templates/faq.html
create mode 100644 src/main/resources/templates/fragments/job-table.html
create mode 100644 src/main/resources/templates/job.html
create mode 100644 src/main/resources/templates/queue.html
diff --git a/.gitignore b/.gitignore
index 82eca33..aaf55ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,4 +22,7 @@
/nbbuild/
/dist/
/nbdist/
-/.nb-gradle/
\ No newline at end of file
+/.nb-gradle/
+
+/application.properties
+
diff --git a/pom.xml b/pom.xml
index 065916b..44bdabe 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,6 +70,11 @@
spring-boot-starter-test
test
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
diff --git a/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java b/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java
new file mode 100644
index 0000000..094237c
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/controllers/JobController.java
@@ -0,0 +1,123 @@
+package com.jacobsonmt.idrbind.controllers;
+
+import com.jacobsonmt.idrbind.model.IDRBindJob;
+import com.jacobsonmt.idrbind.model.IDRBindJobResult;
+import com.jacobsonmt.idrbind.services.JobManager;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+@Log4j2
+@Controller
+public class JobController {
+
+ @Autowired
+ private JobManager jobManager;
+
+
+
+ @PostMapping("/")
+ public String handleFileUpload(@RequestParam("pdbFile") MultipartFile pdbFile,
+ @RequestParam("sequence") MultipartFile sequence,
+ @RequestParam("label") String label,
+ @RequestParam(value = "email", required = false, defaultValue = "") String email,
+ @RequestParam(value = "hidden", required = false, defaultValue = "false") boolean hidden,
+ HttpServletRequest request,
+ RedirectAttributes redirectAttributes) throws IOException {
+
+ String ipAddress = request.getHeader( "X-FORWARDED-FOR" );
+ if ( ipAddress == null ) {
+ ipAddress = request.getRemoteAddr();
+ }
+
+ IDRBindJob job = jobManager.createJob( ipAddress,
+ label,
+ IDRBindJob.inputStreamToString( pdbFile.getInputStream() ),
+ IDRBindJob.inputStreamToString( sequence.getInputStream() ),
+ email,
+ hidden );
+ jobManager.submit( job );
+
+ redirectAttributes.addFlashAttribute("message",
+ "Job Submitted! View job here.");
+
+ return "redirect:/";
+ }
+
+ @GetMapping("/job/{jobId}")
+ public String job( @PathVariable("jobId") String jobId,
+ Model model) throws IOException {
+
+ IDRBindJob job = jobManager.getSavedJob( jobId );
+
+ if (job==null) {
+ return "/";
+ }
+
+ model.addAttribute("job", jobManager.getSavedJob( jobId ).toValueObject( true ) );
+
+ return "job";
+ }
+
+ @GetMapping("/job/{jobId}/resultPDB")
+ public ResponseEntity jobResultPDB( @PathVariable("jobId") String jobId) {
+ IDRBindJob job = jobManager.getSavedJob( jobId );
+
+ // test for not null and complete
+ if ( job != null && job.isComplete() && !job.isFailed() ) {
+ try {
+ IDRBindJobResult result = job.getFuture().get( 1, TimeUnit.SECONDS );
+
+ return ResponseEntity.ok()
+ .contentType( MediaType.parseMediaType("application/octet-stream"))
+ .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + job.getLabel() + "-result.pdb\"")
+ .body(result.getResultPDB());
+
+ } catch ( InterruptedException | ExecutionException | TimeoutException e ) {
+ log.warn( e );
+ ResponseEntity.status( 500 ).body( "" );
+ }
+ }
+ return ResponseEntity.badRequest().body( "" );
+ }
+
+ @GetMapping("/job/{jobId}/resultCSV")
+ public ResponseEntity jobResultCSV( @PathVariable("jobId") String jobId) {
+ IDRBindJob job = jobManager.getSavedJob( jobId );
+
+ // test for not null and complete
+ if ( job != null && job.isComplete() && !job.isFailed() ) {
+ try {
+ IDRBindJobResult result = job.getFuture().get( 1, TimeUnit.SECONDS );
+
+ return ResponseEntity.ok()
+ .contentType( MediaType.parseMediaType("application/octet-stream"))
+ .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + job.getLabel() + "-result.csv\"")
+ .body(result.getResultCSV());
+
+ } catch ( InterruptedException | ExecutionException | TimeoutException e ) {
+ log.warn( e );
+ ResponseEntity.status( 500 ).body( "" );
+ }
+ }
+ return ResponseEntity.badRequest().body( "" );
+ }
+
+
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java b/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java
new file mode 100644
index 0000000..48738bb
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/controllers/MainController.java
@@ -0,0 +1,47 @@
+package com.jacobsonmt.idrbind.controllers;
+
+import com.jacobsonmt.idrbind.services.JobManager;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.io.IOException;
+
+@Log4j2
+@Controller
+public class MainController {
+
+ @Autowired
+ private JobManager jobManager;
+
+ @GetMapping("/")
+ public String index( Model model) throws IOException {
+
+ return "index";
+ }
+
+ @GetMapping("/queue")
+ public String queue( Model model) throws IOException {
+
+ model.addAttribute("jobs", jobManager.listPublicJobs() );
+
+ return "queue";
+ }
+
+ @GetMapping("/documentation")
+ public String documentation( Model model) throws IOException {
+ return "documentation";
+ }
+
+ @GetMapping("/faq")
+ public String faq( Model model) throws IOException {
+ return "faq";
+ }
+
+ @GetMapping("/contact")
+ public String contact( Model model) throws IOException {
+ return "contact";
+ }
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java
new file mode 100644
index 0000000..64ff3c1
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJob.java
@@ -0,0 +1,194 @@
+package com.jacobsonmt.idrbind.model;
+
+import com.jacobsonmt.idrbind.services.JobManager;
+import lombok.*;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.util.StopWatch;
+
+import javax.ws.rs.core.StreamingOutput;
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.concurrent.*;
+
+@Log4j2
+@Getter
+@Setter
+@Builder
+@ToString(of = {"jobId", "userId", "label", "hidden"})
+@EqualsAndHashCode(of = {"jobId"})
+public class IDRBindJob implements Callable {
+
+ // Path to resources
+ private String command;
+ private String commandWorkingDirectory;
+ private String inputPDBFullPath;
+ private String inputProteinChainFullPath;
+ private String outputScoredPDBFullPath;
+ private String outputCSVFullPath;
+
+
+ // Information on creation of job
+ private String userId;
+ private String jobId;
+ private String label;
+ private String inputPDBContent;
+ private String inputProteinChainIds;
+ @Builder.Default private boolean hidden = true;
+ private Date submittedDate;
+ private String email;
+
+ // Information on running / completion
+ @Builder.Default private boolean running = false;
+ @Builder.Default private boolean failed = false;
+ @Builder.Default private boolean complete = false;
+ private Integer position;
+ private String status;
+
+ // Results
+ private Future future;
+ private StreamingOutput resultFile;
+ private long executionTime;
+
+ // Saving Job information / results for later
+ @Builder.Default private boolean saved = false;
+ private Long saveExpiredDate;
+
+ // Back-reference to owning JobManager
+ private JobManager jobManager;
+
+ @Override
+ public IDRBindJobResult call() throws Exception {
+
+ try {
+
+ log.info( "Starting job (" + label + ") for user: (" + userId + ")" );
+
+ this.running = true;
+ this.status = "Processing";
+
+ jobManager.onJobStart( this );
+
+ // Write content to input
+ File pdbFile = new File( inputPDBFullPath );
+ writeToFile( pdbFile, inputPDBContent );
+
+ File chainFile = new File( inputProteinChainFullPath );
+ writeToFile( chainFile, inputProteinChainIds );
+
+ // Execute script
+ StopWatch sw = new StopWatch();
+ sw.start();
+ executeCommand( "./" + command, commandWorkingDirectory );
+ sw.stop();
+ this.executionTime = sw.getTotalTimeMillis() / 1000;
+
+ // Get output
+ String resultPDB = inputStreamToString( new FileInputStream( outputScoredPDBFullPath ) );
+ String resultCSV = inputStreamToString( new FileInputStream( outputCSVFullPath ) );
+
+ log.info( "Finished job (" + label + ") for user: (" + userId + ")" );
+ this.running = false;
+ this.complete = true;
+
+ jobManager.onJobComplete( this );
+ jobManager = null;
+
+ return new IDRBindJobResult( resultPDB, resultCSV );
+ } catch ( Exception e ) {
+ log.error( e );
+ this.complete = true;
+ this.running = false;
+ this.failed = true;
+ jobManager = null;
+ this.status = "Failed";
+ return new IDRBindJobResult( "", "" );
+ }
+
+ }
+
+ private static void writeToFile(File file, String fileContents) throws IOException {
+
+ try (FileOutputStream fop = new FileOutputStream( file )) {
+
+ // if file doesn't exists, then create it
+ if (!file.exists()) {
+ file.createNewFile();
+ }
+
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fop));
+ writer.write(fileContents);
+ writer.newLine();
+ writer.flush();
+ writer.close();
+
+ }
+
+ }
+
+ private static String executeCommand( String command, String path ) {
+
+ StringBuffer output = new StringBuffer();
+
+ Process p;
+ try {
+ p = Runtime.getRuntime().exec( command, null, new File( path ) );
+ p.waitFor();
+ BufferedReader reader = new BufferedReader( new InputStreamReader( p.getInputStream() ) );
+
+ String line = "";
+ while ( ( line = reader.readLine() ) != null ) {
+ output.append( line + "\r\n" );
+ }
+
+ } catch ( Exception e ) {
+ e.printStackTrace();
+ }
+
+ return output.toString();
+ }
+
+ public static String inputStreamToString(InputStream inputStream) throws IOException {
+ StringBuilder textBuilder = new StringBuilder();
+ try (Reader reader = new BufferedReader(new InputStreamReader
+ (inputStream, Charset.forName(StandardCharsets.UTF_8.name())))) {
+ int c = 0;
+ while ((c = reader.read()) != -1) {
+ textBuilder.append((char) c);
+ }
+ }
+ return textBuilder.toString();
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static final class IDRBindJobVO {
+ private final String jobId;
+ private final String label;
+ private final String status;
+ private final boolean running;
+ private final boolean failed;
+ private final boolean complete;
+ private Integer position;
+ private final String email;
+ private final boolean hidden;
+ private final Date submitted;
+ private final IDRBindJobResult result;
+ }
+
+ public IDRBindJobVO toValueObject(boolean obfuscateEmail) {
+
+ IDRBindJobResult result = null;
+ if ( this.isComplete() ) {
+ try {
+ result = this.getFuture().get( 1, TimeUnit.SECONDS );
+ } catch ( InterruptedException | ExecutionException | TimeoutException e ) {
+ log.error( e );
+ }
+ }
+
+ return new IDRBindJobVO( jobId, label, status, running, failed, complete, position, obfuscateEmail ? email.replaceAll("(\\w{0,3})(\\w+.*)(@.*)", "$1****$3") : email, hidden, submittedDate, result );
+ }
+
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java
new file mode 100644
index 0000000..b3efb1f
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/model/IDRBindJobResult.java
@@ -0,0 +1,15 @@
+package com.jacobsonmt.idrbind.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public final class IDRBindJobResult {
+
+ private final String resultPDB;
+ private final String resultCSV;
+
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java b/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java
new file mode 100644
index 0000000..5954a6e
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/model/PurgeOldJobs.java
@@ -0,0 +1,29 @@
+package com.jacobsonmt.idrbind.model;
+
+import java.util.Iterator;
+import java.util.Map;
+
+public class PurgeOldJobs implements Runnable {
+
+ private Map savedJobs;
+
+ public PurgeOldJobs( Map savedJobs ) {
+ this.savedJobs = savedJobs;
+ }
+
+ @Override
+ public void run() {
+ synchronized ( savedJobs ) {
+ for ( Iterator> it = savedJobs.entrySet().iterator(); it.hasNext(); ) {
+ Map.Entry entry = it.next();
+ IDRBindJob job = entry.getValue();
+ if ( job.isComplete() && System.currentTimeMillis() > job.getSaveExpiredDate() ) {
+ job.setSaved( false );
+ job.setSaveExpiredDate( null );
+ it.remove();
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java b/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java
new file mode 100644
index 0000000..2daa5eb
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/rest/JobEndpoint.java
@@ -0,0 +1,79 @@
+package com.jacobsonmt.idrbind.rest;
+
+import com.jacobsonmt.idrbind.model.IDRBindJob;
+import com.jacobsonmt.idrbind.services.JobManager;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+
+@Log4j2
+@RequestMapping("/api")
+@RestController
+public class JobEndpoint {
+
+ @Autowired
+ private JobManager jobManager;
+
+ @RequestMapping(value = "/job/{jobId}", method = RequestMethod.GET, produces = {MediaType.APPLICATION_JSON_VALUE})
+ public IDRBindJob.IDRBindJobVO getJob(@PathVariable String jobId) {
+ return createJobValueObject( jobManager.getSavedJob( jobId ) );
+ }
+
+ @RequestMapping(value = "/job", method = RequestMethod.GET, produces = {MediaType.APPLICATION_JSON_VALUE})
+ public IDRBindJob.IDRBindJobVO getJob2(@RequestParam(value = "jobId") String jobId) {
+ return createJobValueObject( jobManager.getSavedJob( jobId ) );
+ }
+
+ @RequestMapping(value = "/job/{jobId}/status", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE})
+ public String getJobStatus(@PathVariable String jobId) {
+ IDRBindJob job = jobManager.getSavedJob( jobId );
+ if ( job != null ) {
+ return job.getStatus();
+ }
+
+ log.info( "Job Not Found" );
+ return "Job Not Found";
+ }
+
+ @RequestMapping(value = "/job/status", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE})
+ public String getJobStatus2(@RequestParam(value = "jobId") String jobId) {
+ IDRBindJob job = jobManager.getSavedJob( jobId );
+ if ( job != null ) {
+ return job.getStatus();
+ }
+
+ log.info( "Job Not Found" );
+ return "Job Not Found";
+ }
+
+ @RequestMapping(value = "/submitJob", method = RequestMethod.GET, produces = {MediaType.TEXT_PLAIN_VALUE})
+ public String submitJob(@RequestParam(value = "label") String label,
+ @RequestParam(value = "pdbContent") String pdbContent,
+ @RequestParam(value = "proteinChain") String proteinChain,
+ @RequestParam(value = "email", required = false, defaultValue = "") String email,
+ @RequestParam(value = "hidden", required = false, defaultValue = "false") boolean hidden,
+ HttpServletRequest request
+ ) {
+ String ipAddress = request.getHeader( "X-FORWARDED-FOR" );
+ if ( ipAddress == null ) {
+ ipAddress = request.getRemoteAddr();
+ }
+
+ IDRBindJob job = jobManager.createJob( label, label, pdbContent, proteinChain, email, hidden );
+ jobManager.submit( job );
+ log.info( "Job Submitted: " + job.getJobId() );
+ return "Job Submitted: " + job.getJobId();
+ }
+
+ private IDRBindJob.IDRBindJobVO createJobValueObject( IDRBindJob job) {
+ if ( job == null ) {
+ return null;
+ }
+ return job.toValueObject(true);
+ }
+
+
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java b/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java
new file mode 100644
index 0000000..d5d6fdd
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/services/EmailService.java
@@ -0,0 +1,96 @@
+package com.jacobsonmt.idrbind.services;
+
+import com.jacobsonmt.idrbind.model.IDRBindJob;
+import com.jacobsonmt.idrbind.settings.SiteSettings;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.mail.MessagingException;
+import javax.mail.internet.MimeMessage;
+import javax.servlet.http.HttpServletRequest;
+
+@Service
+public class EmailService {
+
+ @Autowired
+ private JavaMailSender emailSender;
+
+ @Autowired
+ SiteSettings siteSettings;
+
+ private void sendMessage( String subject, String content, String to ) throws MessagingException {
+ sendMessage( subject, content, to, null );
+ }
+
+ private void sendMessage( String subject, String content, String to, MultipartFile attachment ) throws MessagingException {
+
+ MimeMessage message = emailSender.createMimeMessage();
+
+ MimeMessageHelper helper = new MimeMessageHelper( message, true );
+
+ helper.setSubject( subject );
+ helper.setText( content, true );
+ helper.setTo( to );
+ helper.setFrom( siteSettings.getAdminEmail() );
+
+ if ( attachment != null ) {
+ helper.addAttachment( attachment.getOriginalFilename(), attachment );
+ }
+
+ emailSender.send( message );
+
+ }
+
+ public void sendSupportMessage( String message, String name, String email, HttpServletRequest request,
+ MultipartFile attachment ) throws MessagingException {
+ String content =
+ "Name: " + name + "\r\n" +
+ "Email: " + email + "\r\n" +
+ "User-Agent: " + request.getHeader( "User-Agent" ) + "\r\n" +
+ "Message: " + message + "\r\n" +
+ "File Attached: " + String.valueOf( attachment != null && !attachment.getOriginalFilename().equals( "" ) );
+
+ sendMessage( "IDR Bind Help - Contact Support", content, siteSettings.getAdminEmail(), attachment );
+ }
+
+ public void sendJobStartMessage( IDRBindJob job ) throws MessagingException {
+ if ( job.getEmail() == null || job.getEmail().isEmpty() ) {
+ return;
+ }
+ StringBuilder content = new StringBuilder();
+ content.append( "Job Submitted
" );
+ content.append( "Label: " + job.getLabel() + "
" );
+ content.append( "Submitted: " + job.getSubmittedDate() + "
" );
+ content.append( "Status: " + job.getStatus() + "
" );
+ if ( job.isSaved() ) {
+ content.append( "Saved Link: " + ""
+ + siteSettings.getFullUrl() + "job/" + job.getJobId() + "'
" );
+ }
+
+ sendMessage( "IDB Bind - Job Submitted", content.toString(), job.getEmail() );
+ }
+
+ public void sendJobCompletionMessage( IDRBindJob job ) throws MessagingException {
+ if ( job.getEmail() == null || job.getEmail().isEmpty() ) {
+ return;
+ }
+ StringBuilder content = new StringBuilder();
+ content.append( "Job Complete
" );
+ content.append( "Label: " + job.getLabel() + "
" );
+ content.append( "Submitted: " + job.getSubmittedDate() + "
" );
+ content.append( "Status: " + job.getStatus() + "
" );
+ if ( job.isSaved() ) {
+ content.append( "Saved Link: " + ""
+ + siteSettings.getFullUrl() + "job/" + job.getJobId() + "'
" );
+ }
+
+ sendMessage( "IDB Bind - Job Complete", content.toString(), job.getEmail() );
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java b/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java
new file mode 100644
index 0000000..3edeb46
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/services/JobManager.java
@@ -0,0 +1,253 @@
+package com.jacobsonmt.idrbind.services;
+
+import com.jacobsonmt.idrbind.model.IDRBindJob;
+import com.jacobsonmt.idrbind.model.IDRBindJobResult;
+import com.jacobsonmt.idrbind.model.PurgeOldJobs;
+import com.jacobsonmt.idrbind.settings.ApplicationSettings;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.mail.MessagingException;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Log4j2
+@Service
+public class JobManager {
+
+ @Autowired
+ ApplicationSettings applicationSettings;
+
+ @Autowired
+ EmailService emailService;
+
+ // Main executor to process jobs
+ private ExecutorService executor;
+
+ // Contains a copy of the processing queue of jobs internal to executor.
+ // It is non-trivial to extract a list of running/waiting jobs in the executor
+ // so we maintain a copy in sync with the real thing.
+ private List jobQueueMirror = new LinkedList<>();
+
+ // Secondary user queues or waiting lines. One specific to each user/session.
+ private Map> userQueues = new ConcurrentHashMap<>();
+
+ // Contains map of token to saved job for future viewing
+ private Map savedJobs = new ConcurrentHashMap<>();
+
+ // Used to periodically purge the old saved jobs
+ private ScheduledExecutorService scheduler;
+
+ @PostConstruct
+ private void initialize() {
+ executor = Executors.newSingleThreadExecutor();
+ if ( applicationSettings.isPurgeSavedJobs() ) {
+ scheduler = Executors.newSingleThreadScheduledExecutor();
+ // Checks every hour for old jobs
+ scheduler.scheduleAtFixedRate( new PurgeOldJobs( savedJobs ), 0,
+ applicationSettings.getPurgeSavedJobsTimeHours(), TimeUnit.HOURS );
+ }
+
+ }
+
+ @PreDestroy
+ public void destroy() {
+ log.info( "JobManager destroyed" );
+ executor.shutdownNow();
+ scheduler.shutdownNow();
+ }
+
+ public IDRBindJob createJob( String userId,
+ String label,
+ String inputPDBContent,
+ String inputProteinChainIds,
+ String email,
+ boolean hidden ) {
+ IDRBindJob.IDRBindJobBuilder jobBuilder = IDRBindJob.builder();
+
+ // Static Resources
+ jobBuilder.command( applicationSettings.getCommand() );
+ jobBuilder.commandWorkingDirectory( applicationSettings.getCommandWorkingDirectory() );
+ jobBuilder.inputPDBFullPath( applicationSettings.getInputPDBPath() );
+ jobBuilder.inputProteinChainFullPath( applicationSettings.getInputChainPath() );
+ jobBuilder.outputScoredPDBFullPath( applicationSettings.getOutputScoredPDBPath() );
+ jobBuilder.outputCSVFullPath( applicationSettings.getOutputCSVPath() );
+
+ // Generated
+ jobBuilder.jobId( UUID.randomUUID().toString() );
+
+ // User Inputs
+ jobBuilder.userId( userId );
+ jobBuilder.label( label );
+ jobBuilder.inputPDBContent( inputPDBContent );
+ jobBuilder.inputProteinChainIds( inputProteinChainIds );
+ jobBuilder.hidden( hidden );
+ jobBuilder.email( email );
+
+ IDRBindJob job = jobBuilder.build();
+
+ boolean validation = validateJob( job );
+
+ if ( !validation ) {
+ job.setComplete( true );
+ job.setFailed( true );
+ job.setStatus( "Failed" );
+ }
+
+ return job;
+
+ }
+
+ private void submitToProcessQueue( IDRBindJob job ) {
+ synchronized ( jobQueueMirror ) {
+ log.info( "Submitting job (" + job.getJobId() + ") for user: (" + job.getUserId() + ") to process queue" );
+ job.setJobManager( this );
+ Future future = executor.submit( job );
+ job.setFuture( future );
+ jobQueueMirror.add( job );
+ job.setStatus( "Position: " + Integer.toString( jobQueueMirror.size() ) );
+ job.setPosition( jobQueueMirror.size() );
+ }
+ }
+
+ private void submitToUserQueue( IDRBindJob job ) {
+ log.info( "Submitting job (" + job.getJobId() + ") for user: (" + job.getUserId() + ") to user queue" );
+
+ Queue jobs = userQueues.computeIfAbsent( job.getUserId(), k -> new LinkedList<>() );
+
+ if ( jobs.size() > applicationSettings.getUserJobLimit() ) {
+ log.info( "Too many jobs (" + job.getJobId() + ") for user: (" + job.getUserId() + ")");
+ return;
+ }
+
+ synchronized ( jobs ) {
+
+ if ( !jobs.contains( job ) ) {
+ jobs.add( job );
+ job.setStatus( "Pending" );
+ saveJob( job );
+ submitJobFromUserQueue( job.getUserId() );
+ }
+ }
+ }
+
+ private void submitJobFromUserQueue( String userId ) {
+ int cnt = 0;
+ synchronized ( jobQueueMirror ) {
+
+ for ( IDRBindJob job : jobQueueMirror ) {
+ if ( job.getUserId().equals( userId ) ) cnt++;
+ }
+ }
+
+ if ( cnt < applicationSettings.getUserProcessLimit() ) {
+
+ Queue jobs = userQueues.get( userId );
+
+ if ( jobs != null ) {
+ IDRBindJob job;
+ synchronized ( jobs ) {
+ job = jobs.poll();
+ }
+ if ( job != null ) {
+ job.setSubmittedDate( new Date() );
+ submitToProcessQueue( job );
+ }
+ }
+ }
+
+ }
+
+ private void updatePositions( String userId ) {
+ synchronized ( jobQueueMirror ) {
+ int idx = 1;
+
+ for ( Iterator iterator = jobQueueMirror.iterator(); iterator.hasNext(); ) {
+ IDRBindJob job = iterator.next();
+
+ if ( job.isRunning() ) {
+ job.setStatus( "Processing" );
+ idx++;
+ } else if ( job.isComplete() ) {
+ job.setStatus( "Completed in " + job.getExecutionTime() + "s" );
+ job.setPosition( null );
+ iterator.remove();
+ } else {
+ job.setStatus( "Position: " + Integer.toString( idx ) );
+ job.setPosition( idx );
+ idx++;
+ }
+ }
+ }
+ }
+
+ public void submit( IDRBindJob job ) {
+ submitToUserQueue( job );
+ }
+
+ public IDRBindJob getSavedJob( String jobId ) {
+ IDRBindJob job = savedJobs.get( jobId );
+ if ( job !=null ) {
+ // Reset purge datetime
+ job.setSaveExpiredDate( System.currentTimeMillis() + applicationSettings.getPurgeAfterHours() * 60 * 60 * 1000 );
+ }
+ return job;
+ }
+
+ private String saveJob( IDRBindJob job ) {
+ synchronized ( savedJobs ) {
+ job.setSaved( true );
+ savedJobs.put( job.getJobId(), job );
+ return job.getJobId();
+ }
+ }
+
+ private boolean validateJob( IDRBindJob job ) {
+ return true;
+ }
+
+ public void onJobStart( IDRBindJob job ) {
+ if ( applicationSettings.isEmailOnJobStart() && job.getEmail() != null && !job.getEmail().isEmpty() ) {
+ try {
+ emailService.sendJobStartMessage( job );
+ } catch ( MessagingException e ) {
+ log.error( e );
+ }
+ }
+ }
+
+ public void onJobComplete( IDRBindJob job ) {
+ job.setSaveExpiredDate( System.currentTimeMillis() + applicationSettings.getPurgeAfterHours() * 60 * 60 * 1000 );
+ updatePositions( job.getUserId() );
+ if ( job.getEmail() != null && !job.getEmail().isEmpty() ) {
+ try {
+ emailService.sendJobCompletionMessage( job );
+ } catch ( MessagingException e ) {
+ log.error( e );
+ }
+ }
+ // Add new job for given session
+ submitJobFromUserQueue( job.getUserId() );
+ log.info( String.format( "Jobs in queue: %d", jobQueueMirror.size() ) );
+ }
+
+ public List listPublicJobs() {
+ return Stream.concat(jobQueueMirror.stream(), savedJobs.values().stream())
+ .distinct()
+ .filter( j -> !j.isHidden() )
+ .map( j -> j.toValueObject( true ) )
+ .sorted(
+ Comparator.comparing(IDRBindJob.IDRBindJobVO::getPosition, Comparator.nullsLast(Integer::compareTo))
+ .thenComparing(IDRBindJob.IDRBindJobVO::getSubmitted, Comparator.nullsLast(Date::compareTo).reversed())
+ .thenComparing(IDRBindJob.IDRBindJobVO::getStatus, String::compareToIgnoreCase)
+ )
+ .collect( Collectors.toList() );
+ }
+
+
+}
diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java b/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java
new file mode 100644
index 0000000..52d1685
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/settings/ApplicationSettings.java
@@ -0,0 +1,28 @@
+package com.jacobsonmt.idrbind.settings;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "idrbind.settings")
+@Getter
+@Setter
+public class ApplicationSettings {
+
+ private String command;
+ private String commandWorkingDirectory;
+ private String inputPDBPath;
+ private String inputChainPath;
+ private String outputScoredPDBPath;
+ private String outputCSVPath;
+ private int concurrentJobs = 1;
+ private int userProcessLimit = 2;
+ private int userJobLimit = 200;
+ private boolean purgeSavedJobs = true;
+ private int purgeSavedJobsTimeHours = 1;
+ private int purgeAfterHours = 24;
+ private boolean emailOnJobStart = true;
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java b/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java
new file mode 100644
index 0000000..128d1c8
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/settings/Messages.java
@@ -0,0 +1,27 @@
+package com.jacobsonmt.idrbind.settings;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "idrbind.messages")
+@Getter
+@Setter
+public class Messages {
+
+ @Getter
+ @Setter
+ public static class EmailMessages {
+ private String submit;
+ private String complete;
+ private String fail;
+ }
+
+ private EmailMessages email;
+
+
+ private String title;
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java b/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java
new file mode 100644
index 0000000..f52c90c
--- /dev/null
+++ b/src/main/java/com/jacobsonmt/idrbind/settings/SiteSettings.java
@@ -0,0 +1,28 @@
+package com.jacobsonmt.idrbind.settings;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import javax.validation.constraints.Email;
+
+@Component
+@ConfigurationProperties(prefix = "idrbind.site")
+@Getter
+@Setter
+public class SiteSettings {
+
+ private String host;
+ private String context;
+
+ @Email
+ private String contactEmail;
+ @Email
+ private String adminEmail;
+
+ public String getFullUrl() {
+ return host + context + (context.endsWith( "/" ) ? "" : "/");
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index e69de29..19ac57f 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -0,0 +1,66 @@
+# ==============================================================
+# = Spring Email
+# ==============================================================
+spring.mail.default-encoding=UTF-8
+spring.mail.host=localhost
+spring.mail.username=XXXXXX
+spring.mail.password=
+#spring.mail.port=587
+spring.mail.protocol=smtp
+spring.mail.test-connection=false
+spring.mail.properties.mail.smtp.auth=true
+spring.mail.properties.mail.smtp.starttls.enable=true
+
+# ==============================================================
+# = Application Specific Defaults
+# ==============================================================
+
+# Job command name
+idrbind.settings.command=test.sh
+idrbind.settings.command-working-directory=/home/test/idr/bin/
+idrbind.settings.input-pdb-path=/home/test/idr/input/input.pdb
+idrbind.settings.input-chain-path=/home/test/idr/input/chain.txt
+idrbind.settings.output-scored-pdb-path=/home/test/idr/output/scored-pdb.txt
+idrbind.settings.output-csv-path=/home/test/idr/output/output.csv
+
+# Number of jobs to process at once
+idrbind.settings.concurrent-jobs=1
+
+# Maximum number of jobs a user can have in the processing queue
+idrbind.settings.user-process-limit=2
+
+# Maxmimum number of jobs a user can have in total (processing+user queue)
+idrbind.settings.user-job-limit=200
+
+# Periodically destroy old saved jobs?
+idrbind.settings.purge-saved-jobs=true
+# Check for old jobs this often in hours
+idrbind.settings.purge-saved-jobs-time-hours=1
+# After how many hours of inactivity is a job considered ready to purge?
+idrbind.settings.purge-after-hours=24
+
+# Send email on job start
+idrbind.settings.email-on-job-start=true
+
+### Domain & URL Configuration ###
+idrbind.site.host=http://www.idrbind.ca
+idrbind.site.context=
+
+### Emails ###
+idrbind.site.contact-email=idrbind-help@gmail.ca
+idrbind.site.admin-email=JacobsonMT@gmail.com
+
+### Google Analytics ###
+ga.tracker=
+ga.domain=
+
+# ==============================================================
+# = Custom Messages
+# ==============================================================
+
+idrbind.messages.email.submit=Job Submitted
+idrbind.messages.email.complete=Job Completed
+idrbind.messages.email.fail=Job Failed
+
+
+idrbind.messages.title=IDR Bind
diff --git a/src/main/resources/static/img/UBC-logo-signature-blue.gif b/src/main/resources/static/img/UBC-logo-signature-blue.gif
new file mode 100644
index 0000000000000000000000000000000000000000..be7e22b44b2d8ca9648e2ae35bc23091dd49f3be
GIT binary patch
literal 7063
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
+
+
+
+ IDR Bind - Job Queue
+
+
+
+
+
+
+
+
+
+
+
+
+...
+
+
+
+
...
+
+
+ Contact Us
+
+
+
+
+
+
+...
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/documentation.html b/src/main/resources/templates/documentation.html
new file mode 100644
index 0000000..4eed2ce
--- /dev/null
+++ b/src/main/resources/templates/documentation.html
@@ -0,0 +1,41 @@
+
+
+
+
+ IDR Bind - Job Queue
+
+
+
+
+
+
+
+
+
+
+
+
+...
+
+
+
+
...
+
+
+ Documentation
+
+
+
+
+
+
+...
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/faq.html b/src/main/resources/templates/faq.html
new file mode 100644
index 0000000..9e20617
--- /dev/null
+++ b/src/main/resources/templates/faq.html
@@ -0,0 +1,41 @@
+
+
+
+
+ IDR Bind - Job Queue
+
+
+
+
+
+
+
+
+
+
+
+
+...
+
+
+
+
...
+
+
+ FAQ
+
+
+
+
+
+
+...
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html
index 2b43179..3ba25e8 100644
--- a/src/main/resources/templates/fragments/footer.html
+++ b/src/main/resources/templates/fragments/footer.html
@@ -5,7 +5,7 @@