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..147f999704f5646226b2ce7fbc46ee0b7896935b --- /dev/null +++ b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java @@ -0,0 +1,329 @@ +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.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.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.RestController; + +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.StandardCharsets; +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" + ) + public ResponseEntity<Response> receiveSubmission( + final @PathVariable String aid, + final @ModelAttribute SubmitAppSubmissionDto submissionDto + ) throws IOException, URISyntaxException { + final var res = new Response(); + + logger.debug("Receiving submission from Submit"); + + @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); + } + + @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); + } + + private void generateAndSendFeedback( + final EntityContainer entityContainer, final SubmitAppMetadata submitMetadata + ) { + try { + final var url = new URL(submitMetadata.getUrl()); + + SUBMIT_ACCESS_LEVEL_MAP.entrySet().stream().forEach(e -> { + try { + final var report = this.reportGenerator.generateReport( + entityContainer, Set.of(e.getKey()) + ); + + this.sendFeedback(url, report, e.getValue(), submitMetadata, null); + } 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); + } + } + + private void sendFeedback( + final URL url, + final String report, + final String visibleFor, + final SubmitAppMetadata submitMetadata, + final @Nullable Integer score + ) { + 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); + var in = conn.getInputStream() + ) { + 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; + this.gson.toJson(body, writer); + + try { + in.close(); + } catch (final IOException ex) { + // Ignore + } + } + } catch (final IOException ex) { + logger.error("Could not send report to Submit", ex); + } + } + + 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; + } + + this.sendFeedback(url, "", "STUDENT", submitMetadata, score); + } + + /** + * 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/").resolve(aid).resolve("submission").resolve(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 ""; + } + } +}