diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e7b06e576d45ed4641293c3af15918f09f254688..e7820db3dd4e4a7c62f6c368d534be254c1e3daf 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -145,19 +145,19 @@ build-env:
- docker push $CI_REGISTRY/$CI_PROJECT_PATH/build-env
checkstyle:
- image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+ image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
stage: test
script:
- ./gradlew checkstyleMain checkstyleTest
spotbugs:
- image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+ image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
stage: test
script:
- ./gradlew :spotbugsMain :core:spotbugsMain :worker:spotbugsMain
test:
- image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+ image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
stage: test
script:
- ./gradlew test
@@ -189,7 +189,7 @@ test-coverage:
cobertura: build/reports/jacoco/jacocoRootReport/cobertura.xml
jar-core:
- image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+ image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
stage: jar
script:
- ./gradlew bootJar
@@ -199,7 +199,7 @@ jar-core:
expire_in: 1h
jar-worker:
- image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+ image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
stage: jar
script:
- ./gradlew :worker:jar
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 132b9a167de708cd7591d1e86e5e76aa6a1f4208..eccf2e382b2d63bfdf1da0a5dc04f54f3cefb096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+* Submit script API integration (!177)
### Changed
+* PyLint is now an external dependency (!177)
### Fixed
* Some analyzers were not loaded (!176)
diff --git a/build-env.Dockerfile b/build-env.Dockerfile
index 6fa8514a0993ed0e6916c3720e52c51ba9b9f54d..a58e771d627eb8fc4403589fb5c381c0ba5bd82f 100644
--- a/build-env.Dockerfile
+++ b/build-env.Dockerfile
@@ -12,7 +12,7 @@ RUN sh -c 'apt-get update \
libssl-dev git python3-pip curl \
&& curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \
&& apt-get install -y nodejs \
- && pip3 install profanity-check \
+ && pip3 install profanity-check pylint==2.14.5 \
&& gem install -N github-linguist \
&& git config --global user.email 'auta-build-env+noreply@auta.ewi.tudelft.nl' \
&& git config --global user.name 'auta-build-env' \
diff --git a/build.gradle b/build.gradle
index 8be069aa8854291766703fe104d0fe764bf22d68..69670a47bb569251891f60c318f0d57d4481b279 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,7 +12,7 @@ buildscript {
}
plugins {
- id "com.github.spotbugs-base" version "5.0.4" apply false
+ id "com.github.spotbugs-base" version "5.0.10" apply false
id "com.github.hierynomus.license-report" version "0.15.0"
}
@@ -73,7 +73,7 @@ allprojects {
}
spotbugs {
- toolVersion = '3.1.12'
+ toolVersion = '4.7.1'
showProgress = true
effort = 'max'
diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml
index e3eac59b513ab89e5454c1f12b06534b034d24ce..d98aad57d0eee555233c83bc87fc101de8e9fe95 100644
--- a/config/checkstyle/suppressions.xml
+++ b/config/checkstyle/suppressions.xml
@@ -17,4 +17,6 @@
<suppress checks="ParameterNumber" files="CPMController.java" />
+ <suppress checks="VisibilityModifier" files=".*Dto\.java$" />
+
</suppressions>
diff --git a/config/spotbugs/suppressions.xml b/config/spotbugs/suppressions.xml
index 8ea457515d9e99bd7a709cf390e9fad6dbb59e08..4b41cb2f36e2f6d9ccb708eb99cc8b9337d54ea6 100644
--- a/config/spotbugs/suppressions.xml
+++ b/config/spotbugs/suppressions.xml
@@ -1,10 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<FindBugsFilter
- xmlns="http://findbugs.sourceforge.net/filter/3.0.0"
+ xmlns="https://github.com/spotbugs/filter/3.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:schemaLocation="http://findbugs.sourceforge.net/filter/3.0.0 https://raw.githubusercontent.com/findbugsproject/findbugs/master/findbugs/etc/findbugsfilter.xsd">
+ xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd">
<Match>
<!-- Disable SpotBugs for ANTLR generated classes -->
<Class name="~nl\.tudelft\.ewi\.auta\.checker\.grammar.*" />
</Match>
+
+ <Match>
+ <!-- "may expose internal representation by storing an externally mutable object" is more or
+ less impossible to fix without a rewrite -->
+ <Or>
+ <Bug pattern="EI_EXPOSE_REP" />
+ <Bug pattern="EI_EXPOSE_REP2" />
+ </Or>
+ </Match>
</FindBugsFilter>
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java b/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
index 5a3a43109d87fd23b9c02c714817e73aebfcb300..f6aea64e62ef7ae54bc55f52df7f3d53e81b9bfa 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
@@ -39,6 +39,8 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
+import org.springframework.web.multipart.MultipartResolver;
+import org.springframework.web.multipart.commons.CommonsMultipartResolver;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -416,4 +418,9 @@ public class Core implements WebMvcConfigurer {
public MetricFixturesProvider metricFixturesProviderFactory(final Gson gson) {
return new MetricFixturesProvider(gson);
}
+
+ @Bean
+ public MultipartResolver multipartResolverFactory() {
+ return new CommonsMultipartResolver();
+ }
}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
new file mode 100644
index 0000000000000000000000000000000000000000..c68325a9d15ff805ddc338dce5e42d0079612941
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
@@ -0,0 +1,468 @@
+package nl.tudelft.ewi.auta.core.controller;
+
+import com.google.gson.Gson;
+import freemarker.template.TemplateException;
+import nl.tudelft.ewi.auta.common.model.metric.Verdict;
+import nl.tudelft.ewi.auta.core.database.EntityContainer;
+import nl.tudelft.ewi.auta.core.database.Repositories;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppSubmissionDto;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppFeedbackDto;
+import nl.tudelft.ewi.auta.core.jobs.JobQueue;
+import nl.tudelft.ewi.auta.core.model.FileStore;
+import nl.tudelft.ewi.auta.core.model.Job;
+import nl.tudelft.ewi.auta.core.model.Submission;
+import nl.tudelft.ewi.auta.core.model.SubmitAppMetadata;
+import nl.tudelft.ewi.auta.core.report.ReportCreatedEvent;
+import nl.tudelft.ewi.auta.core.report.SubmitAppReportGenerator;
+import nl.tudelft.ewi.auta.core.response.Response;
+import nl.tudelft.ewi.auta.core.response.exception.InvalidFileNameException;
+import nl.tudelft.ewi.auta.core.response.exception.NoFileException;
+import nl.tudelft.ewi.auta.core.response.exception.NoSuchSubmissionException;
+import nl.tudelft.ewi.auta.srf.model.AccessLevel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationListener;
+import org.springframework.data.util.Pair;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+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.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A controller interfacing with Submit.
+ */
+@RestController
+public class SubmitAppController extends ControllerBase
+ implements ApplicationListener<ReportCreatedEvent> {
+ private static final Logger logger = LoggerFactory.getLogger(SubmitAppController.class);
+
+ /**
+ * The repositories.
+ */
+ private final Repositories repositories;
+
+ /**
+ * The file repository for uploaded files.
+ */
+ private final FileStore files;
+
+ /**
+ * The queue of job messages to be sent to the workers.
+ */
+ private final JobQueue queue;
+
+ /**
+ * The report generator.
+ */
+ private final SubmitAppReportGenerator reportGenerator;
+
+ /**
+ * The JSON serialization service.
+ */
+ private final Gson gson;
+
+ /**
+ * The access levels to use when generating feedback for the student.
+ */
+ private static final Map<AccessLevel, String> SUBMIT_ACCESS_LEVEL_MAP = Map.of(
+ AccessLevel.PUBLIC, "STUDENT",
+ AccessLevel.SUBMITTER, "STUDENT",
+ AccessLevel.EDUCATIONAL, "TA",
+ AccessLevel.INSTRUCTOR, "TEACHER"
+ );
+
+ /**
+ * Submit grade scheme ranges.
+ */
+ private static final Map<String, Pair<Integer, Integer>> SUBMIT_GRADE_SCHEME_RANGE = Map.of(
+ "PASS_FAIL", Pair.of(0, 1),
+ "DUTCH_GRADE", Pair.of(10, 100),
+ "DUTCH_GRADE_1000", Pair.of(100, 1000),
+ "SCORE_OUT_OF_10", Pair.of(0, 10),
+ "SCORE_OUT_OF_100", Pair.of(0, 100),
+ "SCORE_OUT_OF_1000", Pair.of(0, 1000),
+ "SCORE_OUT_OF_10000", Pair.of(0, 10000),
+ "UNLIMITED", Pair.of(0, Integer.MAX_VALUE)
+ );
+
+ /**
+ * Creates a new Submit integration controller.
+ *
+ * @param repositories the repositories
+ * @param files the file store
+ * @param queue the job queue
+ * @param reportGenerator the report generator
+ * @param gson the JSON serializer
+ */
+ public SubmitAppController(
+ final Repositories repositories,
+ final FileStore files,
+ final JobQueue queue,
+ final SubmitAppReportGenerator reportGenerator,
+ final Gson gson
+ ) {
+ this.repositories = repositories;
+ this.files = files;
+ this.queue = queue;
+ this.reportGenerator = reportGenerator;
+ this.gson = gson;
+ }
+
+ /**
+ * Receives a submission from Submit.
+ *
+ * This follows the
+ * <a href="https://gitlab.ewi.tudelft.nl/eip/labrador/submit/-/blob/7a0dd3e996fff2866a0e9d03807b356ce26c6002/docs/ScriptApi.md">
+ * Submit Script API</a>.
+ *
+ * @param aid the assignment's ID
+ * @param submissionDto the submission from Submit
+ *
+ * @return a 201 Created response containing an id field with the submission's ID, or an error
+ *
+ * @throws IOException if the uploaded file could not be processed
+ * @throws URISyntaxException if no valid URL could be generated for the submission
+ */
+ @PostMapping(
+ path = "/api/v1/assignment/{aid}/submission/from-submit",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE
+ )
+ @SuppressWarnings("ParameterNumber")
+ public ResponseEntity<Response> receiveSubmission(
+ final @PathVariable String aid,
+ final @RequestParam("file") MultipartFile file,
+ final @RequestParam("submissionId") long submissionId,
+ final @RequestParam("scriptId") long scriptId,
+ final @RequestParam("key") String key,
+ final @RequestParam("gradeScheme") String gradeScheme,
+ final @RequestParam("url") String url,
+ final @ModelAttribute SubmitAppSubmissionDto submissionDto
+ ) throws IOException, URISyntaxException {
+ final var res = new Response();
+
+ // ??? DTO is not being mapped properly by @ModelAttribute...
+ submissionDto.file = file;
+ submissionDto.submissionId = submissionId;
+ submissionDto.scriptId = scriptId;
+ submissionDto.key = key;
+ submissionDto.gradeScheme = gradeScheme;
+ submissionDto.url = url;
+
+ logger.debug(
+ "Receiving submission from Submit, submission ID {}, script ID {}, filename {}",
+ submissionDto.submissionId,
+ submissionDto.scriptId,
+ submissionDto.file.getOriginalFilename()
+ );
+
+ if (submissionDto.file == null) {
+ throw new NoFileException("The submission does not contain a file");
+ }
+
+ @Nullable
+ final var originalFilename = submissionDto.file.getOriginalFilename();
+ if (originalFilename == null || originalFilename.isEmpty()) {
+ throw new InvalidFileNameException("The file has no name");
+ }
+
+ final var submissionRepository = this.repositories.getSubmissionRepository();
+ final var assignmentRepository = this.repositories.getAssignmentRepository();
+
+ final var assignment = assignmentRepository.findExisting(aid);
+
+ final var extension = this.getExtension(originalFilename);
+ final String filename = submissionDto.submissionId + extension;
+
+ final var temp = Files.createTempFile("auta-upload-", extension);
+ submissionDto.file.transferTo(temp.toFile());
+ this.files.add(filename, temp);
+
+ var submission = new Submission();
+ submission.setName("submit-submission-" + submissionDto.submissionId);
+ submission.setAssignmentId(aid);
+ submission.setContents("/" + filename);
+ submission.getPipelineLog().setSubmitted(Instant.now());
+ submission.setSubmitAppMetadata(new SubmitAppMetadata(
+ submissionDto.submissionId,
+ submissionDto.scriptId,
+ submissionDto.key,
+ submissionDto.gradeScheme,
+ submissionDto.url
+ ));
+
+ submission = submissionRepository.save(submission);
+ @Nullable
+ final var sid = submission.getId();
+ Objects.requireNonNull(sid);
+ res.put("id", sid);
+
+ this.queue.add(new Job(assignment, submission));
+
+ return ResponseEntity.created(this.generateSubmissionUri(aid, sid)).body(res);
+ }
+
+ /**
+ * Sends a finished report to Submit.
+ *
+ * @param event the event
+ */
+ @Override
+ public void onApplicationEvent(final ReportCreatedEvent event) {
+ final var sid = event.getSubmissionId();
+ final var submissionRepo = this.repositories.getSubmissionRepository();
+ final var submission = submissionRepo.findById(sid)
+ .orElseThrow(() -> new NoSuchSubmissionException("No submission with id " + sid));
+
+ @Nullable
+ final var submitMetadata = submission.getSubmitAppMetadata();
+ if (submitMetadata == null) {
+ return;
+ }
+
+ final var entityRepository = this.repositories.getEntityRepository();
+ @Nullable
+ final var entityContainer = entityRepository.findByParentIds(
+ submission.getId(), submission.getAssignmentId()
+ ).orElse(null);
+ if (entityContainer == null) {
+ return;
+ }
+
+ this.generateAndSendFeedback(entityContainer, submitMetadata);
+ }
+
+ /**
+ * Generates reports for each access level and sends them to Submit.
+ *
+ * @param entityContainer the container for the analyzed entity
+ * @param submitMetadata metadata on where and how to send the feedback
+ */
+ private void generateAndSendFeedback(
+ final EntityContainer entityContainer, final SubmitAppMetadata submitMetadata
+ ) {
+ try {
+ final var url = new URL(submitMetadata.getUrl());
+
+ if (entityContainer.hadException()) {
+ this.sendError(url, entityContainer, submitMetadata);
+ return;
+ }
+
+ SUBMIT_ACCESS_LEVEL_MAP.entrySet().stream().forEach(e -> {
+ try {
+ final var report = this.reportGenerator.generateReport(
+ entityContainer, Set.of(e.getKey())
+ ).trim();
+
+ this.sendFeedback(url, report, e.getValue(), submitMetadata, null, true);
+ } catch (final IOException | TemplateException ex) {
+ logger.error("Could not template report", ex);
+ }
+ });
+
+ this.sendScore(url, submitMetadata, entityContainer.getVerdict());
+ } catch (final MalformedURLException ex) {
+ logger.error("Bad Submit callback URL {}", submitMetadata.getUrl(), ex);
+ }
+ }
+
+ /**
+ * Sends an internal error to Submit.
+ *
+ * @param url the URL to send the error to
+ * @param entityContainer the container of the entity that caused the error
+ * @param submitMetadata metadata to be attached to the error message
+ */
+ private void sendError(
+ final URL url,
+ final EntityContainer entityContainer,
+ final SubmitAppMetadata submitMetadata
+ ) {
+ // Send the first bit of feedback with a "true" success, because sending an error locks
+ // down the submission's feedback
+ this.sendFeedback(
+ url, "The script encountered an internal error and could not finish checking "
+ + "your submission.",
+ "STUDENT", submitMetadata, null, true
+ );
+ this.sendFeedback(
+ url, entityContainer.getExceptionMessage(), "TA", submitMetadata, null, false
+ );
+ }
+
+ /**
+ * Sends a report to Submit.
+ *
+ * If the report is empty, this does nothing.
+ *
+ * @param url the URL to send the feedback to
+ * @param report the report to send
+ * @param visibleFor for whom the report should be visible
+ * @param submitMetadata metadata to be attached to the feedback
+ * @param score the final score, if any
+ * @param success whether the feedback is part of a successful run (true) or a script failure
+ * (false)
+ */
+ private void sendFeedback(
+ final URL url,
+ final @Nullable String report,
+ final String visibleFor,
+ final SubmitAppMetadata submitMetadata,
+ final @Nullable Integer score,
+ final boolean success
+ ) {
+ if (report != null && report.isEmpty()) {
+ return;
+ }
+
+ try {
+ final var conn = (HttpURLConnection) url.openConnection();
+ conn.setDoOutput(true);
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json");
+ try (
+ var out = conn.getOutputStream();
+ var writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)
+ ) {
+ final var body = new SubmitAppFeedbackDto();
+ body.submissionId = submitMetadata.getSubmissionId();
+ body.scriptId = submitMetadata.getScriptId();
+ body.key = submitMetadata.getKey();
+ body.visibleFor = visibleFor;
+ body.textualFeedback = report;
+ body.score = score;
+ body.success = success;
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Sending feedback object to {}:\n{}", url,
+ this.gson.toJson(body)
+ .replaceAll("\"key\":\".*?\"", "\"key\":\"<hidden>\"")
+ );
+ }
+
+ this.gson.toJson(body, writer);
+ }
+
+ final var status = conn.getResponseCode();
+ if (status < 200 || status >= 300) {
+ this.handleError(conn);
+ }
+ } catch (final IOException ex) {
+ logger.error("Could not send report to Submit", ex);
+ }
+ }
+
+ /**
+ * Handles errors received when sending feedback.
+ *
+ * @param conn the connection the feedback was sent on
+ *
+ * @throws IOException
+ */
+ private void handleError(final HttpURLConnection conn) throws IOException {
+ final var status = conn.getResponseCode();
+
+ try (var err = conn.getErrorStream()) {
+ Charset encoding = StandardCharsets.UTF_8;
+ final var responseCharset = conn.getContentEncoding();
+
+ try {
+ encoding = Charset.forName(responseCharset);
+ } catch (final UnsupportedCharsetException ex) {
+ logger.warn("Error response uses unsupported charset {}; defaulting to {}",
+ responseCharset, encoding.name()
+ );
+ }
+
+ logger.error("Submit returned unexpected status {} {}. Response is:\n{}",
+ status, conn.getResponseMessage(),
+ new String(err.readNBytes(65536), encoding)
+ );
+
+ throw new IOException("Submit returned unexpected status " + status);
+ }
+ }
+
+ /**
+ * Sends the score matching the given verdict to Submit.
+ *
+ * @param url the URL to post the score to
+ * @param submitMetadata metadata to attach to the score
+ * @param verdict the verdict mapping to the score
+ */
+ private void sendScore(
+ final URL url,
+ final SubmitAppMetadata submitMetadata,
+ final Verdict verdict
+ ) {
+ final int score;
+ final var range = SUBMIT_GRADE_SCHEME_RANGE.get(submitMetadata.getGradeScheme());
+ if (verdict == Verdict.PASS || verdict == Verdict.WARN) {
+ score = range.getSecond();
+ } else if (verdict == Verdict.FAIL) {
+ score = range.getFirst();
+ } else {
+ score = -1;
+ }
+
+ logger.info("Sending score {} to Submit for submission {} as script {}",
+ score, submitMetadata.getSubmissionId(), submitMetadata.getScriptId()
+ );
+
+ this.sendFeedback(url, null, "STUDENT", submitMetadata, score, true);
+ }
+
+ /**
+ * Generates a URI to a submission.
+ *
+ * @param aid the assignment ID
+ * @param sid the submission ID
+ *
+ * @return the URI
+ *
+ * @throws URISyntaxException if the final URI violates the URI specification
+ */
+ @Nonnull
+ private URI generateSubmissionUri(
+ final String aid, final String sid
+ ) throws URISyntaxException {
+ return new URI("/api/v1/assignment/" + aid + "/submission/" + sid);
+ }
+
+ /**
+ * Gets the extension of a filename.
+ *
+ * @param filename the filename
+ * @return the extension of the file
+ */
+ private String getExtension(final String filename) {
+ if (filename.lastIndexOf(".") != -1 && filename.lastIndexOf(".") != 0) {
+ return filename.substring(filename.lastIndexOf("."));
+ } else {
+ return "";
+ }
+ }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java
new file mode 100644
index 0000000000000000000000000000000000000000..e09c0684841185702e7be62999535c7a4af4f1b7
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java
@@ -0,0 +1,53 @@
+package nl.tudelft.ewi.auta.core.dto;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import javax.annotation.Nullable;
+
+/**
+ * A DTO for sending Submit feedback.
+ */
+@SuppressFBWarnings(
+ value = {"URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD"},
+ justification = "false positive; values are read by GSON serialization"
+)
+public class SubmitAppFeedbackDto {
+ /**
+ * The Submit submission ID.
+ */
+ public long submissionId = -1;
+
+ /**
+ * The Submit script ID for the script that triggered this analysis.
+ */
+ public long scriptId = -1;
+
+ /**
+ * The authorization key.
+ */
+ public String key = "";
+
+ /**
+ * The feedback access level.
+ */
+ public String visibleFor = "NOBODY";
+
+ /**
+ * The feedback itself.
+ */
+ @Nullable
+ public String textualFeedback = "";
+
+ /**
+ * The associated score.
+ *
+ * If this is non-null, this is the last object than can be sent for this submission and key.
+ */
+ @Nullable
+ public Integer score = null;
+
+ /**
+ * Whether analysis succeeded (true) or encountered an error (false).
+ */
+ public boolean success = true;
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java
new file mode 100644
index 0000000000000000000000000000000000000000..73587219e13ec50d48caee152b6ea0c13e260152
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java
@@ -0,0 +1,41 @@
+package nl.tudelft.ewi.auta.core.dto;
+
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Nullable;
+
+/**
+ * A DTO describing Submit script API requests bodies.
+ */
+public class SubmitAppSubmissionDto {
+ /**
+ * The submitted file.
+ */
+ @Nullable
+ public MultipartFile file = null;
+
+ /**
+ * The Submit submission ID.
+ */
+ public long submissionId = -1;
+
+ /**
+ * The Submit script ID for the script that called AuTA.
+ */
+ public long scriptId = -1;
+
+ /**
+ * The feedback authorization key.
+ */
+ public String key = "";
+
+ /**
+ * The grading scheme to use.
+ */
+ public String gradeScheme = "";
+
+ /**
+ * The URL to post feedback to.
+ */
+ public String url = "";
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java
new file mode 100644
index 0000000000000000000000000000000000000000..fd5316a2e8509daf282b6fc58f831e99bbe27a7d
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Data transfer objects.
+ */
+@NonnullByDefault
+package nl.tudelft.ewi.auta.core.dto;
+
+import nl.tudelft.ewi.auta.common.annotation.NonnullByDefault;
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
index 6844e6883fd49918e03d43d9c3d886a34f7ee13c..267836c93da9ede87d2247b9b6f376bfee3abe85 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
@@ -59,52 +59,44 @@ public class Report extends HashMap<String, Object> {
*/
@SuppressWarnings("unchecked")
public String toPrettyString() {
- try {
- var staticReport = new StringBuilder();
- var staticResults = (List<Map<String, Object>>) this.get("static");
-
- for (var files : staticResults) {
- StringBuilder metricString = new StringBuilder();
- StringBuilder warningString = new StringBuilder();
- StringBuilder failureString = new StringBuilder();
-
- var fileName = (String) files.get("filename");
- var fileReport = (List<Map<String, Object>>) files.get("report");
-
- for (var metric : fileReport) {
- if (metric.containsKey("error")) {
- continue;
- }
- warningString.append(this.getWarningOrFailureString("warnings", metric));
- failureString.append(this.getWarningOrFailureString("failures", metric));
- }
+ var staticReport = new StringBuilder();
+ var staticResults = (List<Map<String, Object>>) this.get("static");
- //Only append metric results if there are failures or warnings
- if (warningString.length() + failureString.length() > 0) {
- staticReport.append("<pre style='background-color:#fff; line-height:10px'>")
- .append(fileName).append('\n');
- if (warningString.length() > 0) {
- staticReport.append(" <b>warnings:</b>")
- .append('\n')
- .append(warningString);
- }
- if (failureString.length() > 0) {
- staticReport.append(" <b>failures:</b>")
- .append('\n')
- .append(failureString);
- }
- staticReport.append(metricString).append("</pre>");
- }
+ for (var files : staticResults) {
+ StringBuilder metricString = new StringBuilder();
+ StringBuilder warningString = new StringBuilder();
+ StringBuilder failureString = new StringBuilder();
+ var fileName = (String) files.get("filename");
+ var fileReport = (List<Map<String, Object>>) files.get("report");
+
+ for (var metric : fileReport) {
+ if (metric.containsKey("error")) {
+ continue;
+ }
+ warningString.append(this.getWarningOrFailureString("warnings", metric));
+ failureString.append(this.getWarningOrFailureString("failures", metric));
}
- return staticReport.toString();
- } catch (NullPointerException ex) {
- logger.error("NullPointer exception occurred while printing report", ex);
- if (this.get("static") != null) {
- return this.get("static").toString();
+
+ //Only append metric results if there are failures or warnings
+ if (warningString.length() + failureString.length() > 0) {
+ staticReport.append("<pre style='background-color:#fff; line-height:10px'>")
+ .append(fileName).append('\n');
+ if (warningString.length() > 0) {
+ staticReport.append(" <b>warnings:</b>")
+ .append('\n')
+ .append(warningString);
+ }
+ if (failureString.length() > 0) {
+ staticReport.append(" <b>failures:</b>")
+ .append('\n')
+ .append(failureString);
+ }
+ staticReport.append(metricString).append("</pre>");
}
- return "Invalid report";
+
}
+ return staticReport.toString();
}
/**
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
index b3c4e5662149cab3728649dde5c5f568778ae028..ba1cb300233f6d7f5d7db4eba11160f16e54edf1 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
@@ -44,6 +44,12 @@ public class Submission {
@Nullable
private String cpmVerificationCode = null;
+ /**
+ * Submission metadata supplied by Submit.
+ */
+ @Nullable
+ private SubmitAppMetadata submitAppMetadata = null;
+
/**
* Whether the submission's job was canceled.
*/
@@ -90,6 +96,16 @@ public class Submission {
this.name = other.name;
this.contents = other.contents;
this.cpmVerificationCode = other.cpmVerificationCode;
+ this.canceled = other.canceled;
+ if (other.submitAppMetadata != null) {
+ this.submitAppMetadata = new SubmitAppMetadata(
+ other.submitAppMetadata.getSubmissionId(),
+ other.submitAppMetadata.getScriptId(),
+ other.submitAppMetadata.getKey(),
+ other.submitAppMetadata.getGradeScheme(),
+ other.submitAppMetadata.getUrl()
+ );
+ }
this.pipelineLog = new SubmissionPipelineLog(this.pipelineLog);
}
@@ -182,6 +198,25 @@ public class Submission {
this.cpmVerificationCode = cpmVerificationCode;
}
+ /**
+ * Returns submission metadata supplied by Submit.
+ *
+ * @return Submit metadata
+ */
+ @Nullable
+ public SubmitAppMetadata getSubmitAppMetadata() {
+ return this.submitAppMetadata;
+ }
+
+ /**
+ * Sets submission metadata supplied by Submit.
+ *
+ * @param submitAppMetadata Submit metadata
+ */
+ public void setSubmitAppMetadata(final @Nullable SubmitAppMetadata submitAppMetadata) {
+ this.submitAppMetadata = submitAppMetadata;
+ }
+
/**
* Returns whether the submission's job was canceled.
*
@@ -231,6 +266,7 @@ public class Submission {
&& Objects.equals(this.name, other.name)
&& Objects.equals(this.contents, other.contents)
&& Objects.equals(this.cpmVerificationCode, other.cpmVerificationCode)
+ && Objects.equals(this.submitAppMetadata, other.submitAppMetadata)
&& Objects.equals(this.pipelineLog, other.pipelineLog)
&& this.canceled == other.canceled;
}
@@ -243,6 +279,7 @@ public class Submission {
this.name,
this.contents,
this.cpmVerificationCode,
+ this.submitAppMetadata,
this.pipelineLog,
this.canceled
);
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java
new file mode 100644
index 0000000000000000000000000000000000000000..2895f064a30ab525eb0549d3c4398c4db977caaa
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java
@@ -0,0 +1,184 @@
+package nl.tudelft.ewi.auta.core.model;
+
+import java.util.Objects;
+
+/**
+ * Submission metadata as supplied by Submit.
+ */
+public class SubmitAppMetadata {
+ /**
+ * Submit's submission ID.
+ */
+ private long submissionId;
+
+ /**
+ * Submit's script ID for the script that called AuTA.
+ */
+ private long scriptId;
+
+ /**
+ * The key to authenticate with when returning feedback.
+ */
+ private String key;
+
+ /**
+ * The grade scheme to use.
+ *
+ * This is one of the values listed in the "Allowed scores" table on
+ * https://gitlab.ewi.tudelft.nl/eip/labrador/submit/-/blob/8344af40/docs/ScriptApi.md
+ * For future extensibility, any value is accepted here.
+ */
+ private String gradeScheme;
+
+ /**
+ * The URL to post feedback to.
+ */
+ private String url;
+
+ /**
+ * Creates a new Submit metadata object.
+ *
+ * The members are initialized to (likely) invalid values. IDs are -1 and the key and grade
+ * scheme are empty strings.
+ */
+ public SubmitAppMetadata() {
+ this.submissionId = -1;
+ this.scriptId = -1;
+ this.key = "";
+ this.gradeScheme = "";
+ this.url = "";
+ }
+
+ /**
+ * Creates a new Submit metadata object.
+ *
+ * @param submissionId the Submit submission ID
+ * @param scriptId the ID of the script calling AuTA
+ * @param key the key to authenticate with when uploading feedback
+ * @param gradeScheme the grade scheme to use
+ * @param url the URL to send feedback to
+ */
+ public SubmitAppMetadata(
+ final long submissionId,
+ final long scriptId,
+ final String key,
+ final String gradeScheme,
+ final String url
+ ) {
+ this.submissionId = submissionId;
+ this.scriptId = scriptId;
+ this.key = key;
+ this.gradeScheme = gradeScheme;
+ this.url = url;
+ }
+
+ /**
+ * Returns the Submit submission ID.
+ *
+ * @return the submission ID
+ */
+ public long getSubmissionId() {
+ return this.submissionId;
+ }
+
+ /**
+ * Sets the Submit submission ID.
+ *
+ * @param submissionId the submission ID
+ */
+ public void setSubmissionId(final long submissionId) {
+ this.submissionId = submissionId;
+ }
+
+ /**
+ * Returns the ID of the script that called AuTA.
+ *
+ * @return the script ID
+ */
+ public long getScriptId() {
+ return this.scriptId;
+ }
+
+ /**
+ * Sets the ID of the script that called AuTA.
+ *
+ * @param scriptId the script ID
+ */
+ public void setScriptId(final long scriptId) {
+ this.scriptId = scriptId;
+ }
+
+ /**
+ * Returns the key to authenticate with when returning feedback.
+ *
+ * @return the authentication key
+ */
+ public String getKey() {
+ return this.key;
+ }
+
+ /**
+ * Sets the key to authenticate with when returning feedback.
+ *
+ * @param key the authentication key
+ */
+ public void setKey(final String key) {
+ this.key = key;
+ }
+
+ /**
+ * Returns the grade scheme to use.
+ *
+ * @return the grade scheme
+ */
+ public String getGradeScheme() {
+ return this.gradeScheme;
+ }
+
+ /**
+ * Sets the grade scheme to use.
+ *
+ * @param gradeScheme the grade scheme
+ */
+ public void setGradeScheme(final String gradeScheme) {
+ this.gradeScheme = gradeScheme;
+ }
+
+ /**
+ * Returns the URL to send feedback to.
+ *
+ * @return the URL
+ */
+ public String getUrl() {
+ return this.url;
+ }
+
+ /**
+ * Sets the URL to send feedback to.
+ *
+ * @param url the URL
+ */
+ public void setUrl(final String url) {
+ this.url = url;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof SubmitAppMetadata)) {
+ return false;
+ }
+
+ final var other = (SubmitAppMetadata) obj;
+
+ return this.submissionId == other.submissionId
+ && this.scriptId == other.scriptId
+ && Objects.equals(this.key, other.key)
+ && Objects.equals(this.gradeScheme, other.gradeScheme)
+ && Objects.equals(this.url, other.url);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.submissionId, this.scriptId, this.key, this.gradeScheme, this.url);
+ }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java b/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
index bb3297aebc704d478166cbbe012a98317ae5d829..b859d52ed023dd16ccc1cc6776345d3e6f708efe 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
@@ -105,7 +105,8 @@ public class ReportAuthorizationFilter {
final var filteredNotes = new NoteCollection();
filteredNotes.setNotes(report.getNotes().getNotes().stream()
.filter(n ->
- n.getAccessLevel() == null || accessLevels.contains(n.getAccessLevel())
+ (n.getAccessLevel() == null && accessLevels.contains(AccessLevel.PUBLIC))
+ || (n.getAccessLevel() != null && accessLevels.contains(n.getAccessLevel()))
)
.collect(Collectors.toUnmodifiableList())
);
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java b/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java
new file mode 100644
index 0000000000000000000000000000000000000000..960c4f8c9990fba61651e22b69fe4e689ba3212a
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java
@@ -0,0 +1,38 @@
+package nl.tudelft.ewi.auta.core.report;
+
+import nl.tudelft.ewi.auta.core.authentication.UserProvider;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;
+
+/**
+ * Generates reports for consumption by Submit.
+ *
+ * Follows a Markdown-like style.
+ */
+@Service
+public class SubmitAppReportGenerator extends GenericReportGenerator {
+ /**
+ * Creates a new Submit report generator.
+ *
+ * @param configurer the freemarker configurer
+ * @param reportAuthorizationFilter the report authorization filter
+ * @param userProvider the user provider to determine the current authorization level
+ */
+ public SubmitAppReportGenerator(
+ final FreeMarkerConfigurer configurer,
+ final ReportAuthorizationFilter reportAuthorizationFilter,
+ final UserProvider userProvider
+ ) {
+ super(configurer, reportAuthorizationFilter, userProvider);
+ }
+
+ /**
+ * Returns the template name.
+ *
+ * @return the template name
+ */
+ @Override
+ public String getTemplateName() {
+ return "submit/submit-report.ftlh";
+ }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java b/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
index 1336f69e0909a89f3e0df8a03a001bdcb6870423..6edb4cddbf6aa4a388419beede11aa2b440f66a7 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
@@ -178,7 +178,12 @@ public enum ErrorCode {
/**
* The submitted file has an invalid name.
*/
- INVALID_FILE_NAME(HttpStatus.UNPROCESSABLE_ENTITY);
+ INVALID_FILE_NAME(HttpStatus.UNPROCESSABLE_ENTITY),
+
+ /**
+ * The submission contained no file.
+ */
+ NO_FILE(HttpStatus.BAD_REQUEST);
/**
* The associated HTTP status code.
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java b/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java
new file mode 100644
index 0000000000000000000000000000000000000000..53eaf1e8e4af7401a3c608f09742ec2bb729c49e
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java
@@ -0,0 +1,45 @@
+package nl.tudelft.ewi.auta.core.response.exception;
+
+import nl.tudelft.ewi.auta.core.response.ErrorCode;
+
+/**
+ * An exception indicating that the submission did not contain a file.
+ */
+public class NoFileException extends ApiException {
+ /**
+ * Creates a no file exception.
+ */
+ public NoFileException() {
+ }
+
+ /**
+ * Creates a no file exception with a message.
+ * @param message the message
+ */
+ public NoFileException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a no file exception with a cause.
+ * @param cause the cause
+ */
+ public NoFileException(final Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * Creates a no file exception with a cause and a message.
+ *
+ * @param message the message
+ * @param cause the cause
+ */
+ public NoFileException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ @Override
+ public ErrorCode getErrorCode() {
+ return ErrorCode.NO_FILE;
+ }
+}
diff --git a/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh b/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh
new file mode 100644
index 0000000000000000000000000000000000000000..695366d89dbb3c1bc05ff4cb5e96195341037b33
--- /dev/null
+++ b/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh
@@ -0,0 +1,56 @@
+<#-- @ftlvariable name="error" type="String" -->
+<#-- @ftlvariable name="tips" type="java.util.Map<String, java.util.List>" -->
+<#-- @ftlvariable name="warnings" type="java.util.Map<String, java.util.List>" -->
+<#-- @ftlvariable name="failures" type="java.util.Map<String, java.util.List>" -->
+<#if error??>
+## A server-side error occurred. Please show this message to your TA
+${error}
+<#else>
+<#list tips as tip, entityNames>
+## Tip
+${tip}
+
+This applies to:
+<#list entityNames as entityName>
+* ${entityName}
+</#list>
+</#list>
+
+<#if failures?has_content>
+## Failures
+<#list failures as entityName, failureMessages>
+<#if failureMessages?has_content>
+ ### ${entityName}
+<#list failureMessages as msg>
+ ${msg}
+</#list>
+</#if>
+</#list>
+</#if>
+
+<#if warnings?has_content>
+## Warnings
+<#list warnings as entityName, warningMessages>
+<#if warningMessages?has_content>
+ ### ${entityName}
+<#list warningMessages as msg>
+ ${msg}
+</#list>
+</#if>
+</#list>
+</#if>
+
+<#if info?has_content>
+## Info
+<#list info as entityName, infoMessage>
+<#if infoMessage?has_content>
+ ### ${entityName}
+<#list infoMessage as msg>
+<#noautoesc>
+ #### ${msg}
+</#noautoesc>
+</#list>
+</#if>
+</#list>
+</#if>
+</#if>
diff --git a/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java b/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..03c2dcd7d4726a96024fc6f1fe79ef397d89d867
--- /dev/null
+++ b/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java
@@ -0,0 +1,251 @@
+package nl.tudelft.ewi.auta.core.controller;
+
+import com.google.gson.Gson;
+import nl.tudelft.ewi.auta.common.model.entity.ProjectEntity;
+import nl.tudelft.ewi.auta.core.database.AssignmentRepository;
+import nl.tudelft.ewi.auta.core.database.EntityContainer;
+import nl.tudelft.ewi.auta.core.database.EntityRepository;
+import nl.tudelft.ewi.auta.core.database.Repositories;
+import nl.tudelft.ewi.auta.core.database.RepositoriesTestHelper;
+import nl.tudelft.ewi.auta.core.database.SubmissionRepository;
+import nl.tudelft.ewi.auta.core.jobs.JobQueue;
+import nl.tudelft.ewi.auta.core.model.Assignment;
+import nl.tudelft.ewi.auta.core.model.FileStore;
+import nl.tudelft.ewi.auta.core.model.Job;
+import nl.tudelft.ewi.auta.core.model.Submission;
+import nl.tudelft.ewi.auta.core.report.ReportCreatedEvent;
+import nl.tudelft.ewi.auta.core.report.SubmitAppReportGenerator;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.stubbing.Answer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.annotation.Nullable;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+public class SubmitAppControllerTest {
+ private static final Logger logger = LoggerFactory.getLogger(SubmitAppControllerTest.class);
+
+ private static final String ASSIGNMENT_NAME = "lF0eNrA6VoFf";
+ private static final String SUBMISSION_NAME = "S39ICf6UfnyGAot0a";
+ private static final String ASSIGNMENT_ID = "0z6KG0xPG46CBA";
+ private static final String SUBMISSION_ID = "MBa9havWR6uRCtkPH";
+ private static final String RESULTS_CONTAINER_ID = "UKvuYgFqTksO";
+ private static final String FILENAME = "YDZ4b2Mw2T5pVxds";
+ private static final String SUBMIT_REPORT = "Xq4n0W90TpTm7GUF9qzvwsAo";
+
+ private MockMvc mvc;
+
+ @Mock
+ private FileStore fileStore;
+
+ @Mock
+ private JobQueue submissionQueue;
+
+ private Repositories repositories;
+
+ @Mock
+ private SubmitAppReportGenerator reportGenerator;
+
+ @Nullable
+ private Submission submission;
+
+ private Assignment assignment;
+
+ private SubmitAppController controller;
+
+ private CyclicBarrier asyncReportBarrier = new CyclicBarrier(2);
+
+ private ExecutorService asyncReportExecutor = Executors.newSingleThreadExecutor();
+
+ @BeforeEach
+ public void before() {
+ MockitoAnnotations.initMocks(this);
+
+ final var gson = new Gson();
+
+ this.repositories = new Repositories(
+ mock(EntityRepository.class),
+ mock(SubmissionRepository.class),
+ mock(AssignmentRepository.class),
+ RepositoriesTestHelper.getMockIdentityRepository(),
+ RepositoriesTestHelper.getMockCourseRepository()
+ );
+
+ this.assignment = new Assignment();
+ this.assignment.setId(ASSIGNMENT_ID);
+ this.assignment.setName(ASSIGNMENT_NAME);
+ this.assignment.setAllowedLanguages(Set.of("COBOL", "MUMPS", "ALGOL-68"));
+
+ final var assignmentRepo = this.repositories.getAssignmentRepository();
+ when(assignmentRepo.findById(eq(ASSIGNMENT_ID))).thenReturn(Optional.of(this.assignment));
+ doCallRealMethod().when(assignmentRepo).findExisting(any());
+
+ this.submission = null;
+
+ final var submissionRepo = this.repositories.getSubmissionRepository();
+
+ when(submissionRepo.save(any(Submission.class)))
+ .thenAnswer((Answer<Submission>) invocation -> {
+ final Submission newSubmission = invocation.getArgument(0);
+ this.submission = newSubmission;
+ newSubmission.setId(SUBMISSION_ID);
+ return newSubmission;
+ });
+
+ when(submissionRepo.findByAssignmentIdEqualsAndIdEquals(
+ eq(ASSIGNMENT_ID), eq(SUBMISSION_ID))
+ )
+ .then(invocation -> {
+ if (this.submission != null
+ && Objects.equals(this.submission.getId(), SUBMISSION_ID)
+ && Objects.equals(this.submission.getAssignmentId(), ASSIGNMENT_ID)) {
+ return Optional.of(this.submission);
+ }
+
+ return Optional.empty();
+ });
+
+ when(submissionRepo.findById(eq(SUBMISSION_ID)))
+ .then(invocation -> {
+ if (this.submission != null
+ && Objects.equals(this.submission.getId(), SUBMISSION_ID)) {
+ return Optional.of(this.submission);
+ }
+
+ return Optional.empty();
+ });
+
+ doCallRealMethod().when(submissionRepo).findExisting(any());
+ doCallRealMethod().when(submissionRepo).findExisting(any(), any());
+
+ when(this.submissionQueue.add(any())).then(invocation -> {
+ this.asyncReportExecutor.submit(() -> {
+ try {
+ final Job job = invocation.getArgument(0);
+ submissionRepo.findExisting(
+ job.getAssignment().getId(), job.getSubmission().getId()
+ );
+
+ var results = new ProjectEntity();
+ var resultsContainer = new EntityContainer(
+ results, SUBMISSION_ID, false, null, ASSIGNMENT_ID
+ );
+ resultsContainer.setId(RESULTS_CONTAINER_ID);
+
+ try {
+ when(this.reportGenerator.generateReport(eq(resultsContainer), any()))
+ .thenReturn(SUBMIT_REPORT);
+ } catch (final RuntimeException ex) {
+ throw ex;
+ } catch (final Exception ex) {
+ throw new RuntimeException(ex);
+ }
+
+ Mockito.when(this.repositories.getEntityRepository()
+ .findByParentIds(eq(SUBMISSION_ID), eq(ASSIGNMENT_ID)))
+ .thenReturn(Optional.of(resultsContainer));
+
+ this.controller.onApplicationEvent(
+ new ReportCreatedEvent(this.submission, SUBMISSION_ID)
+ );
+ } catch (final Exception ex) {
+ logger.error("Event handler failed", ex);
+ fail();
+ } finally {
+ try {
+ this.asyncReportBarrier.await();
+ } catch (final InterruptedException | BrokenBarrierException ex) {
+ logger.warn("Could not signal barrier", ex);
+ }
+ }
+ });
+
+ return true;
+ });
+
+ this.controller = new SubmitAppController(
+ this.repositories,
+ this.fileStore,
+ this.submissionQueue,
+ this.reportGenerator,
+ gson
+ );
+
+ this.mvc = MockMvcBuilders.standaloneSetup(this.controller)
+ .setControllerAdvice(new RestExceptionHandler())
+ .build();
+ }
+
+ @Test
+ public void testSubmit() throws Exception {
+ var mockMultipartFile = new MockMultipartFile("file", "submission.zip",
+ "application/zip", "6YxR0U6Y3wn5hYR".getBytes(StandardCharsets.UTF_8));
+
+ final var submitSubmissionId = 293;
+ final var submitScriptId = 410;
+ final var submitKey = "yAYM5roNXqT8DkFfC";
+ final var gradeScheme = "PASS_FAIL";
+ final var url = "https://example.org/";
+
+ this.mvc.perform(MockMvcRequestBuilders.multipart(
+ "/api/v1/assignment/" + ASSIGNMENT_ID + "/submission/from-submit")
+ .file(mockMultipartFile)
+ .param("submissionId", String.valueOf(submitSubmissionId))
+ .param("scriptId", String.valueOf(submitScriptId))
+ .param("key", submitKey)
+ .param("gradeScheme", gradeScheme)
+ .param("url", url)
+ )
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Location",
+ "/api/v1/assignment/" + ASSIGNMENT_ID + "/submission/" + SUBMISSION_ID
+ ))
+ .andExpect(content().string(new BaseMatcher<>() {
+ @Override
+ public boolean matches(final Object item) {
+ final var obj = new Gson().fromJson((String) item, Map.class);
+ return Objects.equals(obj.get("id"), SUBMISSION_ID);
+ }
+
+ @Override
+ public void describeTo(final Description description) {
+ description.appendText("correct submission ID in JSON object");
+ }
+ }));
+
+ this.asyncReportBarrier.await();
+
+ verify(this.reportGenerator, atLeastOnce()).generateReport(any(), any());
+ }
+}
diff --git a/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java b/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
index 918da1585ca338fc03223f75a7b236eee4190a18..cd088c8dda17680197e5aa8f3ad44c48249cbf89 100644
--- a/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
+++ b/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
@@ -14,6 +14,7 @@ import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.withSettings;
@@ -135,6 +136,7 @@ public final class RepositoriesTestHelper {
doReturn(Optional.empty()).when(m).findById(any());
doReturn(Optional.empty()).when(m).findByAssignmentIdEqualsAndIdEquals(any(), any());
+ doCallRealMethod().when(m).findExisting(any(), any());
return m;
}
@@ -164,6 +166,7 @@ public final class RepositoriesTestHelper {
}).when(m).save(any());
doReturn(Optional.empty()).when(m).findById(any());
+ doCallRealMethod().when(m).findExisting(any());
return m;
}
diff --git a/slim-worker.Dockerfile b/slim-worker.Dockerfile
index 2fcc3d4ca9f5fa595a57402a863c0982cae16f30..bfaa17948a31f1ee671851b8d8bfdab93fb471b6 100644
--- a/slim-worker.Dockerfile
+++ b/slim-worker.Dockerfile
@@ -10,7 +10,7 @@ RUN sh -c 'groupadd -g 215 auta \
&& mkdir -p /usr/share/man/man1 \
&& apt install -y openjdk-11-jre-headless python3 ruby ruby-dev build-essential libicu67 \
libicu-dev zlib1g zlib1g-dev cmake pkg-config libssl1.1 libssl-dev git python3-pip \
- && pip3 install profanity-check \
+ && pip3 install profanity-check pylint==2.14.5 \
&& gem install -N github-linguist \
&& git config --global user.email 'auta-build-env+noreply@auta.ewi.tudelft.nl' \
&& git config --global user.name 'auta-build-env' \
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
index af4875bc0bc27a9f08d1e9e5cc2cc88922d6a39c..12ea5c735297efbaa696e803fbe614ebc5a0956c 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
@@ -10,6 +10,7 @@ import java.util.Set;
import java.util.Stack;
import java.util.regex.Pattern;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import nl.tudelft.ewi.auta.common.annotation.Unmodifiable;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.misc.Interval;
@@ -593,6 +594,7 @@ public class AttAsmReader {
* is a target for an unconditional jump or a return instruction
*/
@Contract(pure = true)
+ @SuppressFBWarnings(value = {"ES_COMPARING_STRINGS_WITH_EQ"}, justification = "see comment")
public boolean isConditional() {
// noinspection StringEquality - identity is ensured by constructor
return this.falsyTarget != this.truthyTarget;
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
index c916c1530dfa1c5d58b2a4c3f38132f6d73797a4..f187578593dffb0c7e94344e8388267e74043c44 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
@@ -12,16 +12,12 @@ import nl.tudelft.ewi.auta.common.model.metric.MetricName;
import nl.tudelft.ewi.auta.common.model.metric.PyLintResultMetric;
import nl.tudelft.ewi.auta.worker.Job;
import nl.tudelft.ewi.auta.worker.config.WorkerSettings;
-import nl.tudelft.ewi.auta.worker.tool.python.Python;
import nl.tudelft.ewi.auta.worker.tool.python.PythonProcessException;
-import nl.tudelft.ewi.auta.worker.files.Unpacker;
-import org.apache.commons.compress.utils.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
-import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -34,50 +30,22 @@ public class PyLint extends JobAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(PyLint.class);
- /**
- * The path to the radon files.
- */
- private final Path pyLintPath;
-
/**
* The worker settings.
*/
private final WorkerSettings settings;
- /**
- * The Python instance PyLint will run in.
- */
- private final Python python;
-
/**
* Constructor for the Radon analyzer.
*
* @param workerSettings the worker settings
- * @param unzipper the job unzipper
- * @param python the python instance to run PyLint in
*
* @throws IOException if a file is not found
*/
public PyLint(
- final WorkerSettings workerSettings,
- final Unpacker unzipper,
- final Python python
- ) throws IOException {
+ final WorkerSettings workerSettings
+ ) {
this.settings = workerSettings;
- this.python = python;
- final var path = Files.createTempDirectory(this.settings.getTemp(), "pylint");
- final var zipPath = Files.createTempFile(this.settings.getTemp(), "stream", ".zip");
-
- try (var in = PyLint.class.getResourceAsStream(
- "/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip");
- var out = Files.newOutputStream(zipPath)) {
- IOUtils.copy(in, out);
- }
-
- // Unzip PyLint for usage
- unzipper.unpack(zipPath, path);
- this.pyLintPath = path;
- Files.delete(zipPath);
}
@@ -100,11 +68,11 @@ public class PyLint extends JobAnalyzer {
final var fileNames = this.getFileNames(victim);
// For every module
- for (var i = 0; i < fileNames.size(); i++) {
+ for (final var fileName : fileNames) {
// Prepare the command to run PyLint as tool in a separate process
- final var process = this.python.prepare(
- this.pyLintPath, "pylint", "--output-format=json", fileNames.get(i)
- );
+ final var process =
+ new ProcessBuilder("pylint", "--output-format=json", fileName);
+ process.redirectError(ProcessBuilder.Redirect.INHERIT);
// Set the right environment variables and the working directory
final var tempOutputFile = Files.createTempFile(
@@ -121,7 +89,7 @@ public class PyLint extends JobAnalyzer {
logger.warn("The Python process for PyLint timed out during execution");
throw new PythonProcessException(
"The Python process for PyLint timed out during execution, "
- + "the module that was provided might have been to large"
+ + "the module that was provided might have been to large"
);
}
@@ -139,12 +107,12 @@ public class PyLint extends JobAnalyzer {
}
}
- var moduleName = fileNames.get(i).replace("\\", "/");
+ var moduleName = fileName.replace("\\", "/");
moduleName = moduleName.substring(moduleName.lastIndexOf("/") + 1);
// Create the module entity
final var moduleEntity = new Entity(victim.getProject(), moduleName,
- false, EntityLevel.MODULE);
+ false, EntityLevel.MODULE);
final var moduleEntityWithMetrics = this.createModuleEntityStructure(
moduleEntity, pyLintResults
);
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java b/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
index 7a7da1be62ab656dd248d3ec5e6eb3472280e6cd..e233c369d62d7d604b2b76081345fa25d360154a 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
@@ -4,6 +4,7 @@ import org.jetbrains.annotations.Contract;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -33,27 +34,35 @@ public class Python {
/**
* Prepares a python process for executing.
*
- * @param toolDir the directory the tool is installed in
- * @param workingDir the working directory for the tool
+ * @param toolDir the directory the tool is installed in or null to use the global site packages
+ * @param workingDir the working directory for the tool or null to inherit the current WD
* @param cmd any command line arguments (excluding the executable)
*
* @return a process builder ready to execute the command
*/
- public ProcessBuilder prepare(final Path toolDir, final Path workingDir, final String... cmd) {
+ public ProcessBuilder prepare(
+ final @Nullable Path toolDir,
+ final @Nullable Path workingDir,
+ final String... cmd
+ ) {
final var fcmd = new ArrayList<String>();
fcmd.add(this.info.getPath().toAbsolutePath().toString());
fcmd.addAll(Arrays.asList(cmd));
final var process = new ProcessBuilder(fcmd);
- process.environment().put("PYTHONPATH", toolDir.toAbsolutePath().toString());
- process.directory(workingDir.toFile());
+ if (toolDir != null) {
+ process.environment().put("PYTHONPATH", toolDir.toAbsolutePath().toString());
+ }
+ if (workingDir != null) {
+ process.directory(workingDir.toFile());
+ }
process.redirectError(ProcessBuilder.Redirect.INHERIT);
if (logger.isTraceEnabled()) {
logger.trace("Starting `{}` as tool in {} working in or on {}",
String.join(" ", fcmd),
- toolDir.toAbsolutePath(),
- workingDir.toAbsolutePath()
+ toolDir,
+ workingDir
);
}
@@ -65,20 +74,20 @@ public class Python {
*
* The working directory is set to the tool directory.
*
- * @param toolDir the directory the tool is installed in
+ * @param toolDir the directory the tool is installed in or null to use the global site-packages
* @param cmd any command line arguments (excluding the executable)
*
* @return a process builder ready to execute the command
*/
- public ProcessBuilder prepare(final Path toolDir, final String... cmd) {
+ public ProcessBuilder prepare(final @Nullable Path toolDir, final String... cmd) {
return this.prepare(toolDir, toolDir, cmd);
}
/**
* Executes a python process.
*
- * @param toolDir the directory the tool is installed in
- * @param workingDir the working directory for the tool
+ * @param toolDir the directory the tool is installed in or null to use the global site-packages
+ * @param workingDir the working directory for the tool or null to inherit the current WD
* @param cmd any command line arguments (excluding the executable)
*
* @return the running executable
@@ -86,7 +95,7 @@ public class Python {
* @throws IOException if an I/O error occurs while communicating with the process
*/
public Process run(
- final Path toolDir, final Path workingDir, final String... cmd
+ final @Nullable Path toolDir, final @Nullable Path workingDir, final String... cmd
) throws IOException {
return this.prepare(toolDir, workingDir, cmd).start();
}
@@ -96,14 +105,14 @@ public class Python {
*
* The working directory is set to the tool directory.
*
- * @param toolDir the directory the tool is installed in
+ * @param toolDir the directory the tool is installed in or null to use the global site-packages
* @param cmd any command line arguments (excluding the executable)
*
* @return the running executable
*
* @throws IOException if an I/O error occurs while communicating with the process
*/
- public Process run(final Path toolDir, final String... cmd) throws IOException {
+ public Process run(final @Nullable Path toolDir, final String... cmd) throws IOException {
return this.run(toolDir, toolDir, cmd);
}
}
diff --git a/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip b/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip
index 99394b9679362512aec2fd5de436a027b6bfdf52..8a6a8cbdf9f88d5d536ca841735e60da4228e71e 100644
Binary files a/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip and b/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip differ
diff --git a/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java b/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
index 561bccbe6cb759fe5153ccd92a88f7ece2f663cd..18571fcdb84300b113009c3a38a5282aa3ee7a33 100644
--- a/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
+++ b/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
@@ -10,8 +10,6 @@ import nl.tudelft.ewi.auta.worker.config.WorkerSettings;
import nl.tudelft.ewi.auta.worker.files.UnicodePathEncoder;
import nl.tudelft.ewi.auta.worker.files.Unpacker;
import nl.tudelft.ewi.auta.worker.jobconfig.JobConfig;
-import nl.tudelft.ewi.auta.worker.tool.python.Python;
-import nl.tudelft.ewi.auta.worker.tool.python.PythonDetector;
import org.apache.commons.compress.utils.IOUtils;
import org.junit.jupiter.api.Test;
@@ -36,8 +34,6 @@ public class PyLintTest {
final var tempDir = Paths.get(System.getProperty("java.io.tmpdir"));
final var zipPath = Files.createTempFile(tempDir, "pyFilesForTesting", ".zip");
- final var python = new Python(new PythonDetector().findPython());
-
try (var in = PyLintTest.class
.getResourceAsStream("/lizard.zip");
var out = Files.newOutputStream(zipPath)) {
@@ -64,8 +60,7 @@ public class PyLintTest {
"name", Collections.singletonList("Otto"),
"api-token", List.of("TOKEN!!!")));
- final var pyLint = new PyLint(settings,
- new Unpacker(settings, new UnicodePathEncoder()), python);
+ final var pyLint = new PyLint(settings);
pyLint.analyze(job, new HashMap<>());
assertThat(job.getProject().getAllChildren()).hasSizeGreaterThan(100);
}
@@ -101,9 +96,7 @@ public class PyLintTest {
"name", Collections.singletonList("Otto"),
"api-token", List.of("TOKEN!!!")));
- final var python = new Python(new PythonDetector().findPython());
- final var pyLint = new PyLint(settings, new Unpacker(settings, new UnicodePathEncoder()),
- python);
+ final var pyLint = new PyLint(settings);
pyLint.analyze(job, Collections.emptyMap());
assertThat(job.getProject().getLevel()).isEqualTo(EntityLevel.PROJECT);