diff --git a/docs/admin/setup/athena.rst b/docs/admin/setup/athena.rst index 70c0b5571a3d..03643ff6e1ac 100644 --- a/docs/admin/setup/athena.rst +++ b/docs/admin/setup/athena.rst @@ -21,10 +21,14 @@ HTTP. We need to extend the configuration in the file .. code:: yaml artemis: - # ... - athena: + # ... + athena: url: http://localhost:5000 secret: abcdef12345 + modules: + # See https://github.com/ls1intum/Athena for a list of available modules + text: module_text_cofee + programming: module_programming_themisml The secret can be any string. For more detailed instructions on how to set it up in Athena, refer to the Athena documentation_. diff --git a/docs/dev/setup/server.rst b/docs/dev/setup/server.rst index 9fb26e481e6e..767b13f7dea1 100644 --- a/docs/dev/setup/server.rst +++ b/docs/dev/setup/server.rst @@ -76,8 +76,7 @@ You can override the following configuration options in this file. name: Artemis email: artemis@in.tum.de athena: - url: http://localhost:5000 - secret: abcdef12345 + # If you want to use Athena, look at the dedicated section in the config. Change all entries with ``<...>`` with proper values, e.g. your TUM Online account credentials to connect to the given instances of JIRA, diff --git a/src/main/java/de/tum/in/www1/artemis/config/Constants.java b/src/main/java/de/tum/in/www1/artemis/config/Constants.java index e2c37b816188..cacd23b36238 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/Constants.java +++ b/src/main/java/de/tum/in/www1/artemis/config/Constants.java @@ -65,6 +65,8 @@ public final class Constants { public static final String APOLLON_CONVERSION_API_PATH = "/api/apollon/convert-to-pdf"; + public static final String ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH = "/api/public/athena/programming-exercises/"; + // short names should have at least 3 characters and must start with a letter public static final String SHORT_NAME_REGEX = "^[a-zA-Z][a-zA-Z0-9]{2,}"; diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java index daab14a409da..72145c1c0f67 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java @@ -101,6 +101,9 @@ public abstract class Exercise extends BaseExercise implements LearningObject { @Column(name = "second_correction_enabled") private Boolean secondCorrectionEnabled = false; + @Column(name = "feedback_suggestions_enabled") // enables Athena + private Boolean feedbackSuggestionsEnabled = false; + @ManyToOne @JsonView(QuizView.Before.class) private Course course; @@ -780,6 +783,14 @@ public void setSecondCorrectionEnabled(boolean secondCorrectionEnabled) { this.secondCorrectionEnabled = secondCorrectionEnabled; } + public boolean getFeedbackSuggestionsEnabled() { + return Boolean.TRUE.equals(feedbackSuggestionsEnabled); + } + + public void setFeedbackSuggestionsEnabled(boolean feedbackSuggestionsEnabled) { + this.feedbackSuggestionsEnabled = feedbackSuggestionsEnabled; + } + public List getGradingCriteria() { return gradingCriteria; } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Feedback.java b/src/main/java/de/tum/in/www1/artemis/domain/Feedback.java index 4031fbe4f48c..0aaadfaee302 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Feedback.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Feedback.java @@ -373,6 +373,17 @@ public boolean isTestFeedback() { return this.type == FeedbackType.AUTOMATIC && !isStaticCodeAnalysisFeedback() && !isSubmissionPolicyFeedback(); } + /** + * Checks whether the feedback was given manually by a tutor. + * (This includes feedback that is automatically created by Athena and approved by tutors.) + * + * @return true if it is a manual feedback else false + */ + @JsonIgnore + public boolean isManualFeedback() { + return this.type == FeedbackType.MANUAL || this.type == FeedbackType.MANUAL_UNREFERENCED; + } + /** * Returns the Artemis static code analysis category to which this feedback belongs. The method returns an empty * String, if the feedback is not static code analysis feedback. diff --git a/src/main/java/de/tum/in/www1/artemis/domain/TextBlockRef.java b/src/main/java/de/tum/in/www1/artemis/domain/TextBlockRef.java deleted file mode 100644 index ab572fa34188..000000000000 --- a/src/main/java/de/tum/in/www1/artemis/domain/TextBlockRef.java +++ /dev/null @@ -1,7 +0,0 @@ -package de.tum.in.www1.artemis.domain; - -/** - * A TextBlockRef. It is a combining class for TextBlock and Feedback. - */ -public record TextBlockRef(TextBlock block, Feedback feedback) { -} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/TextExercise.java b/src/main/java/de/tum/in/www1/artemis/domain/TextExercise.java index 41a8a2899521..cea5221dc5c2 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/TextExercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/TextExercise.java @@ -4,10 +4,8 @@ import javax.persistence.*; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; /** @@ -35,21 +33,6 @@ public void setExampleSolution(String exampleSolution) { this.exampleSolution = exampleSolution; } - @JsonIgnore - public boolean isFeedbackSuggestionsEnabled() { - return getAssessmentType() == AssessmentType.SEMI_AUTOMATIC; - } - - /** - * Disable feedback suggestions for this exercise by setting the assessment type to MANUAL. - * Only changes the assessment type if feedback suggestions are currently enabled. - */ - public void disableFeedbackSuggestions() { - if (isFeedbackSuggestionsEnabled()) { - setAssessmentType(AssessmentType.MANUAL); - } - } - /** * set all sensitive information to null, so no info with respect to the solution gets leaked to students through json */ diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java index 22b542e8cfc7..673124f85379 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java @@ -528,6 +528,24 @@ default boolean toggleSecondCorrection(Exercise exercise) { """) Set getAllExercisesUserParticipatedInWithEagerParticipationsSubmissionsResultsFeedbacksTestCasesByUserId(long userId); + /** + * Finds all exercises filtered by feedback suggestions and due date. + * + * @param feedbackSuggestionsEnabled - filter by feedback suggestions enabled + * @param dueDate - filter by due date + * @return Set of Exercises + */ + Set findByFeedbackSuggestionsEnabledAndDueDateIsAfter(boolean feedbackSuggestionsEnabled, ZonedDateTime dueDate); + + /** + * Find all exercises feedback suggestions (Athena) and with *Due Date* in the future. + * + * @return Set of Exercises + */ + default Set findAllFeedbackSuggestionsEnabledExercisesWithFutureDueDate() { + return findByFeedbackSuggestionsEnabledAndDueDateIsAfter(true, ZonedDateTime.now()); + } + /** * For an explanation, see {@link de.tum.in.www1.artemis.web.rest.ExamResource#getAllExercisesWithPotentialPlagiarismForExam(long,long)} * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseRepository.java index 55ab9ff56391..25e4ec3626d1 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseRepository.java @@ -448,6 +448,23 @@ default ProgrammingExercise findByIdElseThrow(Long programmingExerciseId) throws return findById(programmingExerciseId).orElseThrow(() -> new EntityNotFoundException("Programming Exercise", programmingExerciseId)); } + /** + * Find a programming exercise by its id, with grading criteria loaded, and throw an EntityNotFoundException if it cannot be found + * + * @param exerciseId of the programming exercise. + * @return The programming exercise related to the given id + */ + @Query(""" + SELECT DISTINCT e FROM ProgrammingExercise e + LEFT JOIN FETCH e.gradingCriteria + WHERE e.id = :exerciseId + """) + Optional findByIdWithGradingCriteria(long exerciseId); + + default ProgrammingExercise findByIdWithGradingCriteriaElseThrow(long exerciseId) { + return findByIdWithGradingCriteria(exerciseId).orElseThrow(() -> new EntityNotFoundException("Programming Exercise", exerciseId)); + } + /** * Find a programming exercise by its id and fetch related plagiarism detection config. * Throws an EntityNotFoundException if the exercise cannot be found. diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java index e1171ab15a2b..b1ffa775a9a6 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -104,6 +104,9 @@ Optional findWithSubmissionsAndEagerStu List findByExerciseId(Long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "submissions" }) + List findWithSubmissionsById(long participationId); + @EntityGraph(type = LOAD, attributePaths = { "submissions" }) List findWithSubmissionsByExerciseId(Long exerciseId); diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingSubmissionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingSubmissionRepository.java index 03518bd1472a..3a75c06f9450 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingSubmissionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingSubmissionRepository.java @@ -23,6 +23,17 @@ @Repository public interface ProgrammingSubmissionRepository extends JpaRepository { + /** + * Load programming submission only + * + * @param submissionId the submissionId + * @return programming submission + */ + @NotNull + default ProgrammingSubmission findByIdElseThrow(long submissionId) { + return findById(submissionId).orElseThrow(() -> new EntityNotFoundException("ProgrammingSubmission", submissionId)); + } + @EntityGraph(type = LOAD, attributePaths = { "results.feedbacks" }) ProgrammingSubmission findFirstByParticipationIdAndCommitHashOrderByIdDesc(Long participationId, String commitHash); @@ -52,18 +63,12 @@ public interface ProgrammingSubmissionRepository extends JpaRepository findGradedByParticipationIdOrderBySubmissionDateDesc(@Param("participationId") Long participationId, Pageable pageable); - @Query(""" - SELECT s - FROM ProgrammingSubmission s - WHERE s.participation.id = :participationId - AND (s.type <> 'ILLEGAL' OR s.type IS NULL) - ORDER BY s.submissionDate DESC - """) - List findLatestLegalSubmissionForParticipation(@Param("participationId") Long participationId, Pageable pageable); - @EntityGraph(type = LOAD, attributePaths = "results") Optional findWithEagerResultsById(Long submissionId); + @EntityGraph(type = LOAD, attributePaths = "results.feedbacks") + Optional findWithEagerResultsAndFeedbacksById(Long submissionId); + @EntityGraph(type = LOAD, attributePaths = { "results", "results.feedbacks", "results.feedbacks.testCase", "results.assessor" }) Optional findWithEagerResultsFeedbacksTestCasesAssessorById(long submissionId); diff --git a/src/main/java/de/tum/in/www1/artemis/repository/SubmissionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/SubmissionRepository.java index 3d2df9c8bd47..f9bc7103a7bc 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/SubmissionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/SubmissionRepository.java @@ -6,6 +6,8 @@ import java.util.Optional; import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -442,6 +444,34 @@ default Submission findByIdWithResultsElseThrow(long submissionId) { return findWithEagerResultsAndAssessorById(submissionId).orElseThrow(() -> new EntityNotFoundException("Submission", +submissionId)); } + /** + * Gets all latest submitted Submissions, only one per participation + * + * @param exerciseId the ID of the exercise + * @param pageable the pagination information for the query + * @return Page of Submissions + */ + @Query(""" + SELECT s FROM Submission s + WHERE s.participation.exercise.id = :exerciseId + AND s.submitted = true + AND s.submissionDate = ( + SELECT MAX(s2.submissionDate) + FROM Submission s2 + WHERE s2.participation.id = s.participation.id + AND s2.submitted = true + ) + """) + Page findLatestSubmittedSubmissionsByExerciseId(@Param("exerciseId") long exerciseId, Pageable pageable); + + /** + * Gets all submitted Submissions for the given exercise. Note that you usually only want the latest submissions. + * + * @param exerciseId the ID of the exercise + * @return Set of Submissions + */ + Set findByParticipation_ExerciseIdAndSubmittedIsTrue(long exerciseId); + default Submission findByIdElseThrow(long submissionId) { return findById(submissionId).orElseThrow(() -> new EntityNotFoundException("Submission", submissionId)); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/TextExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/TextExerciseRepository.java index 844e68745902..8182524bd3f8 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/TextExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/TextExerciseRepository.java @@ -2,7 +2,6 @@ import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; -import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; @@ -16,7 +15,6 @@ import org.springframework.stereotype.Repository; import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; /** @@ -38,8 +36,6 @@ public interface TextExerciseRepository extends JpaRepository findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesAndPlagiarismDetectionConfigById(Long exerciseId); - List findByAssessmentTypeAndDueDateIsAfter(AssessmentType assessmentType, ZonedDateTime dueDate); - @Query("select textExercise from TextExercise textExercise left join fetch textExercise.exampleSubmissions exampleSubmissions left join fetch exampleSubmissions.submission submission left join fetch submission.results result left join fetch result.feedbacks left join fetch submission.blocks left join fetch result.assessor left join fetch textExercise.teamAssignmentConfig where textExercise.id = :#{#exerciseId}") Optional findByIdWithExampleSubmissionsAndResults(@Param("exerciseId") Long exerciseId); @@ -51,6 +47,18 @@ default TextExercise findByIdElseThrow(long exerciseId) { return findById(exerciseId).orElseThrow(() -> new EntityNotFoundException("Text Exercise", exerciseId)); } + @Query(""" + SELECT DISTINCT e FROM TextExercise e + LEFT JOIN FETCH e.gradingCriteria + WHERE e.id = :exerciseId + """) + Optional findByIdWithGradingCriteria(long exerciseId); + + @NotNull + default TextExercise findByIdWithGradingCriteriaElseThrow(long exerciseId) { + return findByIdWithGradingCriteria(exerciseId).orElseThrow(() -> new EntityNotFoundException("Text Exercise", exerciseId)); + } + @NotNull default TextExercise findByIdWithExampleSubmissionsAndResultsElseThrow(long exerciseId) { return findByIdWithExampleSubmissionsAndResults(exerciseId).orElseThrow(() -> new EntityNotFoundException("Text Exercise", exerciseId)); @@ -60,13 +68,4 @@ default TextExercise findByIdWithExampleSubmissionsAndResultsElseThrow(long exer default TextExercise findByIdWithStudentParticipationsAndSubmissionsElseThrow(long exerciseId) { return findWithStudentParticipationsAndSubmissionsById(exerciseId).orElseThrow(() -> new EntityNotFoundException("Text Exercise", exerciseId)); } - - /** - * Find all exercises with *Due Date* in the future. - * - * @return List of Text Exercises - */ - default List findAllAutomaticAssessmentTextExercisesWithFutureDueDate() { - return findByAssessmentTypeAndDueDateIsAfter(AssessmentType.SEMI_AUTOMATIC, ZonedDateTime.now()); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/TextSubmissionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/TextSubmissionRepository.java index f630ecb6c5df..86e19a689bae 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/TextSubmissionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/TextSubmissionRepository.java @@ -7,8 +7,6 @@ import javax.validation.constraints.NotNull; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -67,16 +65,6 @@ default TextSubmission findByIdElseThrow(long submissionId) { @EntityGraph(type = LOAD, attributePaths = { "blocks" }) Set findByParticipation_ExerciseIdAndSubmittedIsTrue(long exerciseId); - /** - * Gets all TextSubmissions which are submitted and loads all blocks - * - * @param exerciseId the ID of the exercise - * @param pageable the pagination information for the query - * @return Set of Text Submissions - */ - @EntityGraph(type = LOAD, attributePaths = { "blocks" }) - Page findByParticipation_ExerciseIdAndSubmittedIsTrue(long exerciseId, Pageable pageable); - @NotNull default TextSubmission getTextSubmissionWithResultAndTextBlocksAndFeedbackByResultIdElseThrow(long resultId) { return findWithEagerResultAndTextBlocksAndFeedbackByResults_Id(resultId) // TODO should be EntityNotFoundException diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java index 8a04cf8c49ab..f20138de537d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java @@ -25,6 +25,7 @@ import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.exception.EmptyFileException; import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaSubmissionSelectionService; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -44,9 +45,9 @@ public FileUploadSubmissionService(FileUploadSubmissionRepository fileUploadSubm ParticipationService participationService, UserRepository userRepository, StudentParticipationRepository studentParticipationRepository, FileService fileService, AuthorizationCheckService authCheckService, FeedbackRepository feedbackRepository, ExamDateService examDateService, ExerciseDateService exerciseDateService, CourseRepository courseRepository, ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService, - FilePathService filePathService) { + FilePathService filePathService, Optional athenaSubmissionSelectionService) { super(submissionRepository, userRepository, authCheckService, resultRepository, studentParticipationRepository, participationService, feedbackRepository, examDateService, - exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService); + exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService, athenaSubmissionSelectionService); this.fileUploadSubmissionRepository = fileUploadSubmissionRepository; this.fileService = fileService; this.exerciseDateService = exerciseDateService; diff --git a/src/main/java/de/tum/in/www1/artemis/service/ModelingSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/ModelingSubmissionService.java index 0b805f862f76..a89e52c32b26 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ModelingSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ModelingSubmissionService.java @@ -23,6 +23,7 @@ import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.service.compass.CompassService; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaSubmissionSelectionService; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; @@ -45,9 +46,10 @@ public ModelingSubmissionService(ModelingSubmissionRepository modelingSubmission CompassService compassService, UserRepository userRepository, SubmissionVersionService submissionVersionService, ParticipationService participationService, StudentParticipationRepository studentParticipationRepository, AuthorizationCheckService authCheckService, FeedbackRepository feedbackRepository, ExamDateService examDateService, ExerciseDateService exerciseDateService, CourseRepository courseRepository, ParticipationRepository participationRepository, - ModelElementRepository modelElementRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService) { + ModelElementRepository modelElementRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService, + Optional athenaSubmissionSelectionService) { super(submissionRepository, userRepository, authCheckService, resultRepository, studentParticipationRepository, participationService, feedbackRepository, examDateService, - exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService); + exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService, athenaSubmissionSelectionService); this.modelingSubmissionRepository = modelingSubmissionRepository; this.compassService = compassService; this.submissionVersionService = submissionVersionService; diff --git a/src/main/java/de/tum/in/www1/artemis/service/SubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/SubmissionService.java index 7f3f21746e17..35e5b2e38ae1 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/SubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/SubmissionService.java @@ -5,6 +5,7 @@ import java.time.ZonedDateTime; import java.util.*; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -20,6 +21,7 @@ import de.tum.in.www1.artemis.domain.participation.Participation; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaSubmissionSelectionService; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; @@ -59,10 +61,13 @@ public class SubmissionService { protected final FeedbackService feedbackService; + private final Optional athenaSubmissionSelectionService; + public SubmissionService(SubmissionRepository submissionRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, ResultRepository resultRepository, StudentParticipationRepository studentParticipationRepository, ParticipationService participationService, FeedbackRepository feedbackRepository, ExamDateService examDateService, ExerciseDateService exerciseDateService, CourseRepository courseRepository, - ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService) { + ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService, + Optional athenaSubmissionSelectionService) { this.submissionRepository = submissionRepository; this.userRepository = userRepository; this.authCheckService = authCheckService; @@ -76,6 +81,7 @@ public SubmissionService(SubmissionRepository submissionRepository, UserReposito this.participationRepository = participationRepository; this.complaintRepository = complaintRepository; this.feedbackService = feedbackService; + this.athenaSubmissionSelectionService = athenaSubmissionSelectionService; } /** @@ -218,7 +224,58 @@ public Optional getNextAssessableSubmission(Exercise exercise, boole } /** - * Given an exercise id, find a random submission for that exercise which still doesn't have any manual result. + * Given an exercise, find the submission to assess using Athena, if enabled. + * + * @param the submission type + * @param exercise the exercise for which we want to retrieve a submission without manual result + * @param skipAssessmentQueue skip the Athena assessment queue and return a random submission + * @param examMode flag to determine if test runs should be removed. This should be set to true for exam exercises + * @param correctionRound the correction round we want our submission to have results for + * @param findSubmissionById method to find a submission by id + * @return a submission without any manual result or an empty Optional if no submission without manual result could be found + */ + public Optional getAthenaSubmissionToAssess(Exercise exercise, boolean skipAssessmentQueue, boolean examMode, int correctionRound, + Function> findSubmissionById) { + if (exercise.getFeedbackSuggestionsEnabled() && athenaSubmissionSelectionService.isPresent() && !skipAssessmentQueue && correctionRound == 0) { + var assessableSubmissions = getAssessableSubmissions(exercise, examMode, correctionRound); + var athenaSubmissionId = athenaSubmissionSelectionService.get().getProposedSubmissionId(exercise, assessableSubmissions.stream().map(Submission::getId).toList()); + if (athenaSubmissionId.isPresent()) { + var submission = findSubmissionById.apply(athenaSubmissionId.get()); + // Test again if it is still assessable (Athena might have taken some time to respond and another assessment might have started in the meantime): + if (submission.isPresent() && (submission.get().getLatestResult() == null || !submission.get().getLatestResult().isManual())) { + return submission; + } + else { + log.debug("Athena proposed submission {} is not assessable anymore", athenaSubmissionId.get()); + } + } + } + return Optional.empty(); + } + + /** + * Given an exercise, find a submission to assess using Athena (or alternatively randomly). + * + * @param the submission type + * @param exercise the exercise for which we want to retrieve a submission without manual result + * @param skipAssessmentQueue skip the Athena assessment queue and return a random submission + * @param correctionRound the correction round we want our submission to have results for + * @param examMode flag to determine if test runs should be removed. This should be set to true for exam exercises + * @param findSubmissionById method to find a submission by id + * @return a submission without any manual result or an empty Optional if no submission without manual result could be found + */ + public Optional getRandomAssessableSubmission(Exercise exercise, boolean skipAssessmentQueue, boolean examMode, int correctionRound, + Function> findSubmissionById) { + var submissionProposedByAthena = getAthenaSubmissionToAssess(exercise, skipAssessmentQueue, examMode, correctionRound, findSubmissionById); + if (submissionProposedByAthena.isPresent()) { + return submissionProposedByAthena; + } + + return (Optional) getRandomAssessableSubmission(exercise, examMode, correctionRound); + } + + /** + * Given an exercise, find a random submission for that exercise which still doesn't have any manual result. * No manual result means that no user has started an assessment for the corresponding submission yet. * For exam exercises we should also remove the test run participations as these should not be graded by the tutors. * If {@code correctionRound} is bigger than 0, only submissions are shown for which the user has not assessed the first result. diff --git a/src/main/java/de/tum/in/www1/artemis/service/TextExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/TextExerciseImportService.java index 8672565e3303..71cfb927b9ab 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/TextExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/TextExerciseImportService.java @@ -55,7 +55,10 @@ public TextExercise importTextExercise(final TextExercise templateExercise, Text log.debug("Creating a new Exercise based on exercise {}", templateExercise); Map gradingInstructionCopyTracker = new HashMap<>(); TextExercise newExercise = copyTextExerciseBasis(importedExercise, gradingInstructionCopyTracker); - disableFeedbackSuggestionsForExamExercises(newExercise); + if (newExercise.isExamExercise()) { + // Disable feedback suggestions on exam exercises (currently not supported) + newExercise.setFeedbackSuggestionsEnabled(false); + } TextExercise newTextExercise = textExerciseRepository.save(newExercise); @@ -82,17 +85,6 @@ private TextExercise copyTextExerciseBasis(TextExercise importedExercise, Map athenaSubmissionSelectionService; - private final SubmissionVersionService submissionVersionService; private final ExerciseDateService exerciseDateService; public TextSubmissionService(TextSubmissionRepository textSubmissionRepository, SubmissionRepository submissionRepository, StudentParticipationRepository studentParticipationRepository, ParticipationService participationService, ResultRepository resultRepository, - UserRepository userRepository, Optional athenaSubmissionSelectionService, AuthorizationCheckService authCheckService, - SubmissionVersionService submissionVersionService, FeedbackRepository feedbackRepository, ExamDateService examDateService, ExerciseDateService exerciseDateService, - CourseRepository courseRepository, ParticipationRepository participationRepository, ComplaintRepository complaintRepository, FeedbackService feedbackService) { + UserRepository userRepository, AuthorizationCheckService authCheckService, SubmissionVersionService submissionVersionService, FeedbackRepository feedbackRepository, + ExamDateService examDateService, ExerciseDateService exerciseDateService, CourseRepository courseRepository, ParticipationRepository participationRepository, + ComplaintRepository complaintRepository, FeedbackService feedbackService, Optional athenaSubmissionSelectionService) { super(submissionRepository, userRepository, authCheckService, resultRepository, studentParticipationRepository, participationService, feedbackRepository, examDateService, - exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService); + exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService, athenaSubmissionSelectionService); this.textSubmissionRepository = textSubmissionRepository; - this.athenaSubmissionSelectionService = athenaSubmissionSelectionService; this.submissionVersionService = submissionVersionService; this.exerciseDateService = exerciseDateService; } @@ -128,28 +124,8 @@ else if (textExercise.isExamExercise()) { * @return a textSubmission without any manual result or an empty Optional if no submission without manual result could be found */ public Optional getRandomTextSubmissionEligibleForNewAssessment(TextExercise textExercise, boolean skipAssessmentQueue, boolean examMode, int correctionRound) { - // If automatic assessment is enabled and available, try to learn the most possible amount during the first correction round - if (textExercise.isFeedbackSuggestionsEnabled() && athenaSubmissionSelectionService.isPresent() && !skipAssessmentQueue && correctionRound == 0) { - var assessableSubmissions = getAssessableSubmissions(textExercise, examMode, correctionRound); - var athenaSubmissionId = athenaSubmissionSelectionService.get().getProposedSubmissionId(textExercise, assessableSubmissions.stream().map(Submission::getId).toList()); - if (athenaSubmissionId.isPresent()) { - var submission = textSubmissionRepository.findWithEagerResultsAndFeedbackAndTextBlocksById(athenaSubmissionId.get()); - // Test again if it is still assessable (Athena might have taken some time to respond and another assessment might have started in the meantime): - if (submission.isPresent() && (submission.get().getLatestResult() == null || !submission.get().getLatestResult().isManual())) { - return submission; - } - else { - log.debug("Athena proposed submission {} is not assessable anymore", athenaSubmissionId.get()); - } - } - } - - var submissionWithoutResult = super.getRandomAssessableSubmission(textExercise, examMode, correctionRound); - if (submissionWithoutResult.isPresent()) { - TextSubmission textSubmission = (TextSubmission) submissionWithoutResult.get(); - return Optional.of(textSubmission); - } - return Optional.empty(); + return super.getRandomAssessableSubmission(textExercise, skipAssessmentQueue, examMode, correctionRound, + textSubmissionRepository::findByIdWithEagerParticipationExerciseResultAssessor); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaDTOConverter.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaDTOConverter.java new file mode 100644 index 000000000000..ad9aa834bca1 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaDTOConverter.java @@ -0,0 +1,95 @@ +package de.tum.in.www1.artemis.service.connectors.athena; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; +import de.tum.in.www1.artemis.repository.TextBlockRepository; +import de.tum.in.www1.artemis.repository.TextExerciseRepository; +import de.tum.in.www1.artemis.service.dto.athena.*; + +/** + * Service to convert exercises, submissions and feedback to DTOs for Athena. + */ +@Service +public class AthenaDTOConverter { + + @Value("${server.url}") + private String artemisServerUrl; + + private final TextBlockRepository textBlockRepository; + + private final TextExerciseRepository textExerciseRepository; + + private final ProgrammingExerciseRepository programmingExerciseRepository; + + public AthenaDTOConverter(TextBlockRepository textBlockRepository, TextExerciseRepository textExerciseRepository, ProgrammingExerciseRepository programmingExerciseRepository) { + this.textBlockRepository = textBlockRepository; + this.textExerciseRepository = textExerciseRepository; + this.programmingExerciseRepository = programmingExerciseRepository; + } + + /** + * Convert an exercise to a DTO for Athena. + * + * @param exercise the exercise to convert + * @return *ExerciseDTO for Athena + */ + public ExerciseDTO ofExercise(Exercise exercise) { + switch (exercise.getExerciseType()) { + case TEXT -> { + // Fetch text exercise with grade criteria + var textExercise = textExerciseRepository.findByIdWithGradingCriteriaElseThrow(exercise.getId()); + return TextExerciseDTO.of(textExercise); + } + case PROGRAMMING -> { + // Fetch programming exercise with grading criteria + var programmingExercise = programmingExerciseRepository.findByIdWithGradingCriteriaElseThrow(exercise.getId()); + return ProgrammingExerciseDTO.of(programmingExercise, artemisServerUrl); + } + } + throw new IllegalArgumentException("Exercise type not supported: " + exercise.getExerciseType()); + } + + /** + * Convert a submission to a DTO for Athena. + * + * @param exerciseId the id of the exercise the submission belongs to + * @param submission the submission to convert + * @return *SubmissionDTO for Athena + */ + public SubmissionDTO ofSubmission(long exerciseId, Submission submission) { + if (submission instanceof TextSubmission textSubmission) { + return TextSubmissionDTO.of(exerciseId, textSubmission); + } + else if (submission instanceof ProgrammingSubmission programmingSubmission) { + return ProgrammingSubmissionDTO.of(exerciseId, programmingSubmission, artemisServerUrl); + } + throw new IllegalArgumentException("Submission type not supported: " + submission.getType()); + } + + /** + * Convert a feedback to a DTO for Athena. + * + * @param exercise the exercise the feedback belongs to + * @param submissionId the id of the submission the feedback belongs to + * @param feedback the feedback to convert + * @return *FeedbackDTO for Athena + */ + public FeedbackDTO ofFeedback(Exercise exercise, long submissionId, Feedback feedback) { + switch (exercise.getExerciseType()) { + case TEXT -> { + TextBlock feedbackTextBlock = null; + if (feedback.getReference() != null) { + feedbackTextBlock = textBlockRepository.findById(feedback.getReference()).orElse(null); + } + return TextFeedbackDTO.of(exercise.getId(), submissionId, feedback, feedbackTextBlock); + } + case PROGRAMMING -> { + return ProgrammingFeedbackDTO.of(exercise.getId(), submissionId, feedback); + } + } + throw new IllegalArgumentException("Feedback type not supported: " + exercise.getId()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingService.java index 1ac7d6b5a6ec..651c180cee51 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingService.java @@ -2,26 +2,19 @@ import java.util.List; -import javax.validation.constraints.NotNull; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import de.tum.in.www1.artemis.domain.Feedback; -import de.tum.in.www1.artemis.domain.TextBlock; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.exception.NetworkingException; -import de.tum.in.www1.artemis.repository.TextBlockRepository; -import de.tum.in.www1.artemis.service.dto.athena.TextExerciseDTO; -import de.tum.in.www1.artemis.service.dto.athena.TextFeedbackDTO; -import de.tum.in.www1.artemis.service.dto.athena.TextSubmissionDTO; +import de.tum.in.www1.artemis.service.dto.athena.ExerciseDTO; +import de.tum.in.www1.artemis.service.dto.athena.FeedbackDTO; +import de.tum.in.www1.artemis.service.dto.athena.SubmissionDTO; /** * Service for publishing feedback to the Athena service for further processing @@ -33,48 +26,23 @@ public class AthenaFeedbackSendingService { private final Logger log = LoggerFactory.getLogger(AthenaFeedbackSendingService.class); - @Value("${artemis.athena.url}") - private String athenaUrl; - private final AthenaConnector connector; - private final TextBlockRepository textBlockRepository; + private final AthenaModuleUrlHelper athenaModuleUrlHelper; + + private final AthenaDTOConverter athenaDTOConverter; /** * Creates a new service to send feedback to the Athena service - * - * @param textBlockRepository Needed to get start and end indexes of feedbacks - * @param athenaRestTemplate The rest template to use for sending requests to Athena */ - public AthenaFeedbackSendingService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, TextBlockRepository textBlockRepository) { + public AthenaFeedbackSendingService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, AthenaModuleUrlHelper athenaModuleUrlHelper, + AthenaDTOConverter athenaDTOConverter) { connector = new AthenaConnector<>(athenaRestTemplate, ResponseDTO.class); - this.textBlockRepository = textBlockRepository; + this.athenaModuleUrlHelper = athenaModuleUrlHelper; + this.athenaDTOConverter = athenaDTOConverter; } - private static class RequestDTO { - - public TextExerciseDTO exercise; - - public TextSubmissionDTO submission; - - public List feedbacks; - - /** - * Connect feedback and text block to find the correct start and end indexes for transfer when constructing the DTO: - */ - RequestDTO(@NotNull TextExercise exercise, @NotNull TextSubmission submission, @NotNull List feedbacks, TextBlockRepository textBlockRepository) { - this.exercise = TextExerciseDTO.of(exercise); - this.submission = TextSubmissionDTO.of(exercise.getId(), submission); - this.feedbacks = feedbacks.stream().map(feedback -> { - // Give the DTO the text block the feedback is referring to. - // => It will figure out start and end index of the feedback in the text - TextBlock feedbackTextBlock = null; - if (feedback.getReference() != null) { - feedbackTextBlock = textBlockRepository.findById(feedback.getReference()).orElse(null); - } - return TextFeedbackDTO.of(exercise.getId(), submission.getId(), feedback, feedbackTextBlock); - }).toList(); - } + private record RequestDTO(ExerciseDTO exercise, SubmissionDTO submission, List feedbacks) { } private record ResponseDTO(String data) { @@ -89,7 +57,7 @@ private record ResponseDTO(String data) { * @param feedbacks the feedback given by the tutor */ @Async - public void sendFeedback(TextExercise exercise, TextSubmission submission, List feedbacks) { + public void sendFeedback(Exercise exercise, Submission submission, List feedbacks) { sendFeedback(exercise, submission, feedbacks, 1); } @@ -103,12 +71,12 @@ public void sendFeedback(TextExercise exercise, TextSubmission submission, List< * @param maxRetries number of retries before the request will be canceled */ @Async - public void sendFeedback(TextExercise exercise, TextSubmission submission, List feedbacks, int maxRetries) { - if (!exercise.isFeedbackSuggestionsEnabled()) { + public void sendFeedback(Exercise exercise, Submission submission, List feedbacks, int maxRetries) { + if (!exercise.getFeedbackSuggestionsEnabled()) { throw new IllegalArgumentException("The exercise does not have feedback suggestions enabled."); } - log.debug("Start Athena Feedback Sending Service for Text Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); + log.debug("Start Athena Feedback Sending Service for Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); if (feedbacks.isEmpty()) { log.debug("No feedback given for submission #{}.", submission.getId()); @@ -118,9 +86,10 @@ public void sendFeedback(TextExercise exercise, TextSubmission submission, List< log.info("Calling Athena with given feedback."); try { - final RequestDTO request = new RequestDTO(exercise, submission, feedbacks, textBlockRepository); - // TODO: make module selection dynamic (based on exercise) - ResponseDTO response = connector.invokeWithRetry(athenaUrl + "/modules/text/module_text_cofee/feedbacks", request, maxRetries); + // Only send manual feedback from tutors to Athena + final RequestDTO request = new RequestDTO(athenaDTOConverter.ofExercise(exercise), athenaDTOConverter.ofSubmission(exercise.getId(), submission), + feedbacks.stream().filter(Feedback::isManualFeedback).map((feedback) -> athenaDTOConverter.ofFeedback(exercise, submission.getId(), feedback)).toList()); + ResponseDTO response = connector.invokeWithRetry(athenaModuleUrlHelper.getAthenaModuleUrl(exercise.getExerciseType()) + "/feedbacks", request, maxRetries); log.info("Athena responded to feedback: {}", response.data); } catch (NetworkingException networkingException) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsService.java index b4b509648d4e..a2d6d39ed7bc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsService.java @@ -1,27 +1,19 @@ package de.tum.in.www1.artemis.service.connectors.athena; import java.util.List; - -import javax.validation.constraints.NotNull; +import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import de.tum.in.www1.artemis.domain.GradingInstruction; -import de.tum.in.www1.artemis.domain.TextBlockRef; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; -import de.tum.in.www1.artemis.domain.enumeration.FeedbackType; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.exception.NetworkingException; -import de.tum.in.www1.artemis.repository.GradingInstructionRepository; -import de.tum.in.www1.artemis.service.dto.athena.TextExerciseDTO; -import de.tum.in.www1.artemis.service.dto.athena.TextFeedbackDTO; -import de.tum.in.www1.artemis.service.dto.athena.TextSubmissionDTO; +import de.tum.in.www1.artemis.service.dto.athena.*; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; /** * Service for receiving feedback suggestions from the Athena service. @@ -33,71 +25,70 @@ public class AthenaFeedbackSuggestionsService { private final Logger log = LoggerFactory.getLogger(AthenaFeedbackSuggestionsService.class); - @Value("${artemis.athena.url}") - private String athenaUrl; + private final AthenaConnector textAthenaConnector; + + private final AthenaConnector programmingAthenaConnector; - private final AthenaConnector connector; + private final AthenaModuleUrlHelper athenaModuleUrlHelper; - private final GradingInstructionRepository gradingInstructionRepository; + private final AthenaDTOConverter athenaDTOConverter; /** * Creates a new AthenaFeedbackSuggestionsService to receive feedback suggestions from the Athena service. */ - public AthenaFeedbackSuggestionsService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, GradingInstructionRepository gradingInstructionRepository) { - connector = new AthenaConnector<>(athenaRestTemplate, ResponseDTO.class); - this.gradingInstructionRepository = gradingInstructionRepository; + public AthenaFeedbackSuggestionsService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, AthenaModuleUrlHelper athenaModuleUrlHelper, + AthenaDTOConverter athenaDTOConverter) { + textAthenaConnector = new AthenaConnector<>(athenaRestTemplate, ResponseDTOText.class); + programmingAthenaConnector = new AthenaConnector<>(athenaRestTemplate, ResponseDTOProgramming.class); + this.athenaDTOConverter = athenaDTOConverter; + this.athenaModuleUrlHelper = athenaModuleUrlHelper; } - private static class RequestDTO { - - public TextExerciseDTO exercise; - - public TextSubmissionDTO submission; + private record RequestDTO(ExerciseDTO exercise, SubmissionDTO submission) { + } - RequestDTO(@NotNull TextExercise exercise, @NotNull TextSubmission submission) { - this.exercise = TextExerciseDTO.of(exercise); - this.submission = TextSubmissionDTO.of(exercise.getId(), submission); - } + private record ResponseDTOText(List data) { } - private record ResponseDTO(List data) { + private record ResponseDTOProgramming(List data) { } /** * Calls the remote Athena service to get feedback suggestions for a given submission. * - * @param exercise the exercise the suggestions are fetched for - * @param submission the submission the suggestions are fetched for + * @param exercise the {@link TextExercise} the suggestions are fetched for + * @param submission the {@link TextSubmission} the suggestions are fetched for * @return a list of feedback suggestions */ - public List getFeedbackSuggestions(TextExercise exercise, TextSubmission submission) throws NetworkingException { - log.debug("Start Athena Feedback Suggestions Service for Text Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); - - log.info("Calling Athena with exercise and submission."); - - try { - final RequestDTO request = new RequestDTO(exercise, submission); - // TODO: make module selection dynamic (based on exercise) - ResponseDTO response = connector.invokeWithRetry(athenaUrl + "/modules/text/module_text_cofee/feedback_suggestions", request, 0); - log.info("Athena responded to feedback suggestions request: {}", response.data); - return response.data.stream().map((feedbackDTO) -> { - GradingInstruction gradingInstruction = null; - if (feedbackDTO.gradingInstructionId() != null) { - gradingInstruction = gradingInstructionRepository.findById(feedbackDTO.gradingInstructionId()).orElse(null); - } - var ref = feedbackDTO.toTextBlockRef(submission, gradingInstruction); - ref.block().automatic(); - ref.feedback().setType(FeedbackType.AUTOMATIC); - // Add IDs to connect block and ID - ref.block().computeId(); - ref.feedback().setReference(ref.block().getId()); - return ref; - }).toList(); - } - catch (NetworkingException error) { - log.error("Error while calling Athena", error); - throw error; + public List getTextFeedbackSuggestions(TextExercise exercise, TextSubmission submission) throws NetworkingException { + log.debug("Start Athena Feedback Suggestions Service for Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); + + if (!Objects.equals(submission.getParticipation().getExercise().getId(), exercise.getId())) { + log.error("Exercise id {} does not match submission's exercise id {}", exercise.getId(), submission.getParticipation().getExercise().getId()); + throw new ConflictException("Exercise id " + exercise.getId() + " does not match submission's exercise id " + submission.getParticipation().getExercise().getId(), + "Exercise", "exerciseIdDoesNotMatch"); } + + final RequestDTO request = new RequestDTO(athenaDTOConverter.ofExercise(exercise), athenaDTOConverter.ofSubmission(exercise.getId(), submission)); + ResponseDTOText response = textAthenaConnector.invokeWithRetry(athenaModuleUrlHelper.getAthenaModuleUrl(exercise.getExerciseType()) + "/feedback_suggestions", request, 0); + log.info("Athena responded to feedback suggestions request: {}", response.data); + return response.data.stream().toList(); } + /** + * Calls the remote Athena service to get feedback suggestions for a given programming submission. + * + * @param exercise the {@link ProgrammingExercise} the suggestions are fetched for + * @param submission the {@link ProgrammingSubmission} the suggestions are fetched for + * @return a list of feedback suggestions + */ + public List getProgrammingFeedbackSuggestions(ProgrammingExercise exercise, ProgrammingSubmission submission) throws NetworkingException { + log.debug("Start Athena Feedback Suggestions Service for Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); + + final RequestDTO request = new RequestDTO(athenaDTOConverter.ofExercise(exercise), athenaDTOConverter.ofSubmission(exercise.getId(), submission)); + ResponseDTOProgramming response = programmingAthenaConnector.invokeWithRetry(athenaModuleUrlHelper.getAthenaModuleUrl(exercise.getExerciseType()) + "/feedback_suggestions", + request, 0); + log.info("Athena responded to feedback suggestions request: {}", response.data); + return response.data.stream().toList(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaModuleUrlHelper.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaModuleUrlHelper.java new file mode 100644 index 000000000000..2bb542b8f848 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaModuleUrlHelper.java @@ -0,0 +1,40 @@ +package de.tum.in.www1.artemis.service.connectors.athena; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; + +/** + * Service to get the URL for an Athena module, depending on the type of exercise. + */ +@Service +public class AthenaModuleUrlHelper { + + @Value("${artemis.athena.url}") + private String athenaUrl; + + @Value("${artemis.athena.modules.text}") + private String textModuleName; + + @Value("${artemis.athena.modules.programming}") + private String programmingModuleName; + + /** + * Get the URL for an Athena module, depending on the type of exercise. + * + * @param exerciseType The type of exercise + * @return The URL prefix to access the Athena module. Example: "http://athena.example.com/modules/text/module_text_cofee" + */ + public String getAthenaModuleUrl(ExerciseType exerciseType) { + switch (exerciseType) { + case TEXT -> { + return athenaUrl + "/modules/text/" + textModuleName; + } + case PROGRAMMING -> { + return athenaUrl + "/modules/programming/" + programmingModuleName; + } + default -> throw new IllegalArgumentException("Exercise type not supported: " + exerciseType); + } + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportService.java new file mode 100644 index 000000000000..65eaba6ca262 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportService.java @@ -0,0 +1,125 @@ +package de.tum.in.www1.artemis.service.connectors.athena; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; +import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.FileService; +import de.tum.in.www1.artemis.service.export.ProgrammingExerciseExportService; +import de.tum.in.www1.artemis.web.rest.dto.RepositoryExportOptionsDTO; +import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.ServiceUnavailableException; + +/** + * Service for exporting programming exercise repositories for Athena. + */ +@Service +@Profile("athena") +public class AthenaRepositoryExportService { + + private final Logger log = LoggerFactory.getLogger(AthenaRepositoryExportService.class); + + // The downloaded repos should be cloned into another path in order to not interfere with the repo used by the student + // We reuse the same directory as the programming exercise export service for this. + @Value("${artemis.repo-download-clone-path}") + private Path repoDownloadClonePath; + + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final ProgrammingExerciseExportService programmingExerciseExportService; + + private final FileService fileService; + + private final ProgrammingSubmissionRepository programmingSubmissionRepository; + + private final ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository; + + public AthenaRepositoryExportService(ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseExportService programmingExerciseExportService, + FileService fileService, ProgrammingSubmissionRepository programmingSubmissionRepository, + ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository) { + this.programmingExerciseRepository = programmingExerciseRepository; + this.programmingExerciseExportService = programmingExerciseExportService; + this.fileService = fileService; + this.programmingSubmissionRepository = programmingSubmissionRepository; + this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; + } + + /** + * Check if feedback suggestions are enabled for the given exercise, otherwise throw an exception. + * + * @param exercise the exercise to check + * @throws AccessForbiddenException if the feedback suggestions are not enabled for the given exercise + */ + private void checkFeedbackSuggestionsEnabledElseThrow(Exercise exercise) { + if (!exercise.getFeedbackSuggestionsEnabled()) { + log.error("Feedback suggestions are not enabled for exercise {}", exercise.getId()); + throw new ServiceUnavailableException("Feedback suggestions are not enabled for exercise"); + } + } + + /** + * Export the repository for the given exercise and participation to a zip file. + * The ZIP file will be deleted automatically after 15 minutes. + * + * @param exerciseId the id of the exercise to export the repository for + * @param submissionId the id of the submission to export the repository for (only for student repository, otherwise pass null) + * @param repositoryType the type of repository to export. Pass null to export the student repository. + * @return the zip file containing the exported repository + * @throws IOException if the export fails + * @throws AccessForbiddenException if the feedback suggestions are not enabled for the given exercise + */ + public File exportRepository(long exerciseId, Long submissionId, RepositoryType repositoryType) throws IOException { + log.debug("Exporting repository for exercise {}, submission {}", exerciseId, submissionId); + + var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); + checkFeedbackSuggestionsEnabledElseThrow(programmingExercise); + + var exportOptions = new RepositoryExportOptionsDTO(); + exportOptions.setAnonymizeRepository(true); + exportOptions.setExportAllParticipants(false); + exportOptions.setFilterLateSubmissions(true); + exportOptions.setFilterLateSubmissionsDate(programmingExercise.getDueDate()); + exportOptions.setFilterLateSubmissionsIndividualDueDate(false); // Athena currently does not support individual due dates + + if (!Files.exists(repoDownloadClonePath)) { + Files.createDirectories(repoDownloadClonePath); + } + + Path exportDir = fileService.getTemporaryUniqueSubfolderPath(repoDownloadClonePath, 15); + Path zipFile = null; + + if (repositoryType == null) { // Export student repository + var submission = programmingSubmissionRepository.findById(submissionId).orElseThrow(); + // Load participation with eager submissions + var participation = (ProgrammingExerciseStudentParticipation) programmingExerciseStudentParticipationRepository + .findWithSubmissionsById(submission.getParticipation().getId()).get(0); + zipFile = programmingExerciseExportService.createZipForRepositoryWithParticipation(programmingExercise, participation, exportOptions, exportDir, exportDir); + } + else { + List exportErrors = List.of(); + var exportFile = programmingExerciseExportService.exportInstructorRepositoryForExercise(programmingExercise.getId(), repositoryType, exportDir, exportDir, + exportErrors); + if (exportFile.isPresent()) { + zipFile = exportFile.get().toPath(); + } + } + + if (zipFile == null) { + throw new IOException("Failed to export repository"); + } + + return zipFile.toFile(); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionService.java index f960aacd6691..e5526347a1c2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionService.java @@ -3,12 +3,9 @@ import java.util.List; import java.util.Optional; -import javax.validation.constraints.NotNull; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -17,9 +14,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; -import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.exception.NetworkingException; -import de.tum.in.www1.artemis.service.dto.athena.TextExerciseDTO; +import de.tum.in.www1.artemis.service.dto.athena.ExerciseDTO; /** * Service for selecting the "best" submission to assess right now using Athena, e.g. by the highest information gain. @@ -32,21 +29,14 @@ public class AthenaSubmissionSelectionService { private final Logger log = LoggerFactory.getLogger(AthenaSubmissionSelectionService.class); - @Value("${artemis.athena.url}") - private String athenaUrl; - private final AthenaConnector connector; - private static class RequestDTO { - - public TextExerciseDTO exercise; + private final AthenaModuleUrlHelper athenaModuleUrlHelper; - public List submissionIds; // Athena just needs submission IDs => quicker request, because less data is sent + private final AthenaDTOConverter athenaDTOConverter; - RequestDTO(@NotNull TextExercise exercise, @NotNull List submissionIds) { - this.exercise = TextExerciseDTO.of(exercise); - this.submissionIds = submissionIds; - } + private record RequestDTO(ExerciseDTO exercise, List submissionIds// Athena just needs submission IDs => quicker request, because less data is sent + ) { } private record ResponseDTO(@JsonProperty("data") long submissionId // submission ID to choose, or -1 if no submission was explicitly chosen @@ -57,12 +47,15 @@ private record ResponseDTO(@JsonProperty("data") long submissionId // submission * Create a new AthenaSubmissionSelectionService * Responses should be fast, and it's not too bad if it fails. Therefore, we use a very short timeout for requests. */ - public AthenaSubmissionSelectionService(@Qualifier("veryShortTimeoutAthenaRestTemplate") RestTemplate veryShortTimeoutAthenaRestTemplate) { + public AthenaSubmissionSelectionService(@Qualifier("veryShortTimeoutAthenaRestTemplate") RestTemplate veryShortTimeoutAthenaRestTemplate, + AthenaModuleUrlHelper athenaModuleUrlHelper, AthenaDTOConverter athenaDTOConverter) { connector = new AthenaConnector<>(veryShortTimeoutAthenaRestTemplate, ResponseDTO.class); + this.athenaModuleUrlHelper = athenaModuleUrlHelper; + this.athenaDTOConverter = athenaDTOConverter; } /** - * Fetches the proposedTextSubmission for a given exercise from Athena. + * Fetches the proposed submission for a given exercise from Athena. * It is not guaranteed that you get a valid submission ID, so you need to check for existence yourself. * * @param exercise the exercise to get the proposed Submission for @@ -70,23 +63,22 @@ public AthenaSubmissionSelectionService(@Qualifier("veryShortTimeoutAthenaRestTe * @return a submission ID suggested by the Athena submission selector (e.g. chosen by the highest information gain) * @throws IllegalArgumentException if exercise isn't automatically assessable */ - public Optional getProposedSubmissionId(TextExercise exercise, List submissionIds) { - if (!exercise.isFeedbackSuggestionsEnabled()) { + public Optional getProposedSubmissionId(Exercise exercise, List submissionIds) { + if (!exercise.getFeedbackSuggestionsEnabled()) { throw new IllegalArgumentException("The Exercise does not have feedback suggestions enabled."); } if (submissionIds.isEmpty()) { return Optional.empty(); } - log.debug("Start Athena Submission Selection Service for Text Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); + log.debug("Start Athena Submission Selection Service for Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); log.info("Calling Athena to calculate next proposed submissions for {} submissions.", submissionIds.size()); try { - final RequestDTO request = new RequestDTO(exercise, submissionIds); - // TODO: make module selection dynamic (based on exercise) + final RequestDTO request = new RequestDTO(athenaDTOConverter.ofExercise(exercise), submissionIds); // allow no retries because this should be fast and it's not too bad if it fails - ResponseDTO response = connector.invokeWithRetry(athenaUrl + "/modules/text/module_text_cofee/select_submission", request, 0); + ResponseDTO response = connector.invokeWithRetry(athenaModuleUrlHelper.getAthenaModuleUrl(exercise.getExerciseType()) + "/select_submission", request, 0); log.info("Athena to calculate next proposes submissions responded: {}", response.submissionId); if (response.submissionId == -1) { return Optional.empty(); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingService.java index 65e3cd000468..74b73ad28307 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingService.java @@ -5,12 +5,9 @@ import java.util.List; import java.util.Set; -import javax.validation.constraints.NotNull; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -18,13 +15,11 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.exception.NetworkingException; -import de.tum.in.www1.artemis.repository.TextSubmissionRepository; -import de.tum.in.www1.artemis.service.TextSubmissionService; -import de.tum.in.www1.artemis.service.dto.athena.TextExerciseDTO; -import de.tum.in.www1.artemis.service.dto.athena.TextSubmissionDTO; +import de.tum.in.www1.artemis.repository.SubmissionRepository; +import de.tum.in.www1.artemis.service.dto.athena.ExerciseDTO; +import de.tum.in.www1.artemis.service.dto.athena.SubmissionDTO; /** * Service for sending submissions to the Athena service for further processing @@ -38,34 +33,26 @@ public class AthenaSubmissionSendingService { private final Logger log = LoggerFactory.getLogger(AthenaSubmissionSendingService.class); - @Value("${artemis.athena.url}") - private String athenaUrl; - - private final TextSubmissionRepository textSubmissionRepository; + private final SubmissionRepository submissionRepository; private final AthenaConnector connector; + private final AthenaModuleUrlHelper athenaModuleUrlHelper; + + private final AthenaDTOConverter athenaDTOConverter; + /** * Creates a new AthenaSubmissionSendingService. - * - * @param athenaRestTemplate the RestTemplate to use for requests to the Athena service - * @param textSubmissionRepository the repository to use for finding submissions in the database */ - public AthenaSubmissionSendingService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, TextSubmissionRepository textSubmissionRepository) { - this.textSubmissionRepository = textSubmissionRepository; + public AthenaSubmissionSendingService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, SubmissionRepository submissionRepository, + AthenaModuleUrlHelper athenaModuleUrlHelper, AthenaDTOConverter athenaDTOConverter) { + this.submissionRepository = submissionRepository; connector = new AthenaConnector<>(athenaRestTemplate, ResponseDTO.class); + this.athenaModuleUrlHelper = athenaModuleUrlHelper; + this.athenaDTOConverter = athenaDTOConverter; } - private static class RequestDTO { - - public TextExerciseDTO exercise; - - public List submissions; - - RequestDTO(@NotNull TextExercise exercise, @NotNull Set submissions) { - this.exercise = TextExerciseDTO.of(exercise); - this.submissions = submissions.stream().map(submission -> TextSubmissionDTO.of(exercise.getId(), submission)).toList(); - } + private record RequestDTO(ExerciseDTO exercise, List submissions) { } private record ResponseDTO(String data) { @@ -76,31 +63,29 @@ private record ResponseDTO(String data) { * * @param exercise the exercise the automatic assessments should be calculated for */ - public void sendSubmissions(TextExercise exercise) { + public void sendSubmissions(Exercise exercise) { sendSubmissions(exercise, 1); } /** - * Calls the remote Athena service to submit a Job for calculating automatic feedback - * - * @see TextSubmissionService :getTextSubmissionsByExerciseId` for selection of Submissions. + * Calls the remote Athena service to submit a job for calculating automatic feedback * * @param exercise the exercise the automatic assessments should be calculated for * @param maxRetries number of retries before the request will be canceled */ - public void sendSubmissions(TextExercise exercise, int maxRetries) { - if (!exercise.isFeedbackSuggestionsEnabled()) { + public void sendSubmissions(Exercise exercise, int maxRetries) { + if (!exercise.getFeedbackSuggestionsEnabled()) { throw new IllegalArgumentException("The Exercise does not have feedback suggestions enabled."); } - log.debug("Start Athena Submission Sending Service for Text Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); + log.debug("Start Athena Submission Sending Service for Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); - // Find all text submissions for exercise (later we will support others) + // Find all submissions for exercise (later we will support others) Pageable pageRequest = PageRequest.of(0, SUBMISSIONS_PER_REQUEST); while (true) { - Page textSubmissions = textSubmissionRepository.findByParticipation_ExerciseIdAndSubmittedIsTrue(exercise.getId(), pageRequest); - sendSubmissions(exercise, textSubmissions.toSet(), maxRetries); - if (textSubmissions.isLast()) { + Page submissions = submissionRepository.findLatestSubmittedSubmissionsByExerciseId(exercise.getId(), pageRequest); + sendSubmissions(exercise, submissions.toSet(), maxRetries); + if (submissions.isLast()) { break; } pageRequest = pageRequest.next(); @@ -110,30 +95,31 @@ public void sendSubmissions(TextExercise exercise, int maxRetries) { /** * Calls the remote Athena service to submit a Job for calculating automatic feedback * - * @param exercise the exercise the automatic assessments should be calculated for - * @param textSubmissions the submissions to send - * @param maxRetries number of retries before the request will be canceled + * @param exercise the exercise the automatic assessments should be calculated for + * @param submissions the submissions to send + * @param maxRetries number of retries before the request will be canceled */ - public void sendSubmissions(TextExercise exercise, Set textSubmissions, int maxRetries) { - Set filteredTextSubmissions = new HashSet<>(textSubmissions); + public void sendSubmissions(Exercise exercise, Set submissions, int maxRetries) { + Set filteredSubmissions = new HashSet<>(submissions); // filter submissions with an open participation (because of individual due dates) - filteredTextSubmissions.removeIf(submission -> { + filteredSubmissions.removeIf(submission -> { var individualDueDate = submission.getParticipation().getIndividualDueDate(); return individualDueDate != null && individualDueDate.isAfter(ZonedDateTime.now()); }); - if (filteredTextSubmissions.isEmpty()) { - log.info("No text submissions found to send (total: {}, filtered: 0)", textSubmissions.size()); + if (filteredSubmissions.isEmpty()) { + log.info("No submissions found to send (total: {}, filtered: 0)", submissions.size()); return; } - log.info("Calling Athena to calculate automatic feedback for {} submissions.", textSubmissions.size()); + log.info("Calling Athena to calculate automatic feedback for {} submissions (skipped {} because of individual due date filter)", filteredSubmissions.size(), + submissions.size() - filteredSubmissions.size()); try { - final RequestDTO request = new RequestDTO(exercise, filteredTextSubmissions); - // TODO: make module selection dynamic (based on exercise) - ResponseDTO response = connector.invokeWithRetry(athenaUrl + "/modules/text/module_text_cofee/submissions", request, maxRetries); + final RequestDTO request = new RequestDTO(athenaDTOConverter.ofExercise(exercise), + filteredSubmissions.stream().map((submission) -> athenaDTOConverter.ofSubmission(exercise.getId(), submission)).toList()); + ResponseDTO response = connector.invokeWithRetry(athenaModuleUrlHelper.getAthenaModuleUrl(exercise.getExerciseType()) + "/submissions", request, maxRetries); log.info("Athena (calculating automatic feedback) responded: {}", response.data); } catch (NetworkingException error) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/GradingCriterionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/GradingCriterionDTO.java new file mode 100644 index 000000000000..d37bd3bbe591 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/GradingCriterionDTO.java @@ -0,0 +1,25 @@ +package de.tum.in.www1.artemis.service.dto; + +import java.util.Set; +import java.util.stream.Collectors; + +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.GradingCriterion; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record GradingCriterionDTO(long id, String title, Set structuredGradingInstructions) { + + /** + * Convert GradingCriterion to GradingCriterionDTO. Used in the exercise DTOs for Athena. + * + * @param gradingCriterion GradingCriterion to convert + * @return GradingCriterionDTO + */ + public static GradingCriterionDTO of(@NotNull GradingCriterion gradingCriterion) { + return new GradingCriterionDTO(gradingCriterion.getId(), gradingCriterion.getTitle(), + gradingCriterion.getStructuredGradingInstructions().stream().map(GradingInstructionDTO::of).collect(Collectors.toSet())); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/GradingInstructionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/GradingInstructionDTO.java new file mode 100644 index 000000000000..4d68f937f17f --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/GradingInstructionDTO.java @@ -0,0 +1,19 @@ +package de.tum.in.www1.artemis.service.dto; + +import javax.validation.constraints.NotNull; + +import de.tum.in.www1.artemis.domain.GradingInstruction; + +public record GradingInstructionDTO(long id, double credits, String gradingScale, String instructionDescription, String feedback, int usageCount) { + + /** + * Convert GradingInstruction to GradingInstructionDTO + * + * @param gradingInstruction GradingInstruction to convert + * @return GradingInstructionDTO + */ + public static GradingInstructionDTO of(@NotNull GradingInstruction gradingInstruction) { + return new GradingInstructionDTO(gradingInstruction.getId(), gradingInstruction.getCredits(), gradingInstruction.getGradingScale(), + gradingInstruction.getInstructionDescription(), gradingInstruction.getFeedback(), gradingInstruction.getUsageCount()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ExerciseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ExerciseDTO.java new file mode 100644 index 000000000000..2c3d32b95a45 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ExerciseDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +/** + * Interface used to type the ExerciseDTOs for Athena: ProgrammingExerciseDTO and TextExerciseDTO + */ +public interface ExerciseDTO { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/FeedbackDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/FeedbackDTO.java new file mode 100644 index 000000000000..149ad58cadee --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/FeedbackDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +/** + * Interface used to type the FeedbackDTOs for Athena: ProgrammingFeedbackDTO and TextFeedbackDTO + */ +public interface FeedbackDTO { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingExerciseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingExerciseDTO.java new file mode 100644 index 000000000000..2792226f8839 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingExerciseDTO.java @@ -0,0 +1,37 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +import static de.tum.in.www1.artemis.config.Constants.ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH; + +import java.util.List; + +import javax.validation.constraints.NotNull; + +import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.service.dto.GradingCriterionDTO; + +/** + * A DTO representing a ProgrammingExercise, for transferring data to Athena + */ +public record ProgrammingExerciseDTO(long id, String title, double maxPoints, double bonusPoints, String gradingInstructions, List gradingCriteria, + String problemStatement, String programmingLanguage, String solutionRepositoryUrl, String templateRepositoryUrl, String testsRepositoryUrl) implements ExerciseDTO { + + /** + * Create a new TextExerciseDTO from a TextExercise + */ + public static ProgrammingExerciseDTO of(@NotNull ProgrammingExercise exercise, String artemisServerUrl) { + return new ProgrammingExerciseDTO(exercise.getId(), exercise.getTitle(), exercise.getMaxPoints(), exercise.getBonusPoints(), exercise.getGradingInstructions(), + exercise.getGradingCriteria().stream().map(GradingCriterionDTO::of).toList(), exercise.getProblemStatement(), exercise.getProgrammingLanguage().name(), + artemisServerUrl + ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH + exercise.getId() + "/repository/solution", + artemisServerUrl + ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH + exercise.getId() + "/repository/template", + artemisServerUrl + ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH + exercise.getId() + "/repository/tests"); + } + + /** + * The type of the exercise. This is used by Athena to determine whether the correct exercise type was sent. + * + * @return "programming" + */ + public String getType() { + return "programming"; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingFeedbackDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingFeedbackDTO.java new file mode 100644 index 000000000000..6838ee8f5350 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingFeedbackDTO.java @@ -0,0 +1,39 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +import javax.validation.constraints.NotNull; + +import de.tum.in.www1.artemis.domain.Feedback; + +/** + * A DTO representing a Feedback on a ProgrammingExercise, for transferring data to Athena and receiving suggestions from Athena + */ +public record ProgrammingFeedbackDTO(long id, long exerciseId, long submissionId, String title, String description, double credits, Long structuredGradingInstructionId, + String filePath, Integer lineStart, Integer lineEnd) implements FeedbackDTO { + + /** + * Creates a TextFeedbackDTO from a Feedback object + * + * @param exerciseId the id of the exercise the feedback is given for + * @param submissionId the id of the submission the feedback is given for + * @param feedback the feedback object + * @return the TextFeedbackDTO + */ + public static ProgrammingFeedbackDTO of(long exerciseId, long submissionId, @NotNull Feedback feedback) { + // Referenced feedback has a reference looking like this: "file:src/main/java/SomeFile.java_line:42" + String filePath = null; + Integer lineStart = null; + final String referenceStart = "file:"; + if (feedback.hasReference() && feedback.getReference().startsWith(referenceStart)) { + String[] referenceParts = feedback.getReference().split("_line:"); + filePath = referenceParts[0].substring(referenceStart.length()); + lineStart = Integer.parseInt(referenceParts[1]); + } + Long gradingInstructionId = null; + if (feedback.getGradingInstruction() != null) { + gradingInstructionId = feedback.getGradingInstruction().getId(); + } + // There is only one line and Athena supports multiple lines, so we just take the line for both start and end + return new ProgrammingFeedbackDTO(feedback.getId(), exerciseId, submissionId, feedback.getText(), feedback.getDetailText(), feedback.getCredits(), gradingInstructionId, + filePath, lineStart, lineStart); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingSubmissionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingSubmissionDTO.java new file mode 100644 index 000000000000..e5045067f23e --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/ProgrammingSubmissionDTO.java @@ -0,0 +1,25 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +import static de.tum.in.www1.artemis.config.Constants.ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH; + +import javax.validation.constraints.NotNull; + +import de.tum.in.www1.artemis.domain.ProgrammingSubmission; + +/** + * A DTO representing a ProgrammingSubmission, for transferring data to Athena + */ +public record ProgrammingSubmissionDTO(long id, long exerciseId, String repositoryUrl) implements SubmissionDTO { + + /** + * Creates a new ProgrammingSubmissionDTO from a ProgrammingSubmission. The DTO also contains the exerciseId of the exercise the submission belongs to. + * + * @param exerciseId The id of the exercise the submission belongs to + * @param submission The submission to create the DTO from + * @return The created DTO + */ + public static ProgrammingSubmissionDTO of(long exerciseId, @NotNull ProgrammingSubmission submission, String artemisServerUrl) { + return new ProgrammingSubmissionDTO(submission.getId(), exerciseId, + artemisServerUrl + ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH + exerciseId + "/submissions/" + submission.getId() + "/repository"); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/SubmissionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/SubmissionDTO.java new file mode 100644 index 000000000000..9961f7c8e8d1 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/SubmissionDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.service.dto.athena; + +/** + * Interface used to type the SubmissionDTOs for Athena: ProgrammingSubmissionDTO and TextSubmissionDTO + */ +public interface SubmissionDTO { +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextExerciseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextExerciseDTO.java index 366a2052dda2..96a36ea88a3f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextExerciseDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextExerciseDTO.java @@ -1,20 +1,24 @@ package de.tum.in.www1.artemis.service.dto.athena; +import java.util.List; + import javax.validation.constraints.NotNull; import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.service.dto.GradingCriterionDTO; /** * A DTO representing a TextExercise, for transferring data to Athena */ -public record TextExerciseDTO(long id, String title, Double maxPoints, double bonusPoints, String gradingInstructions, String problemStatement) { +public record TextExerciseDTO(long id, String title, double maxPoints, double bonusPoints, String gradingInstructions, List gradingCriteria, + String problemStatement, String exampleSolution) implements ExerciseDTO { /** * Create a new TextExerciseDTO from a TextExercise */ public static TextExerciseDTO of(@NotNull TextExercise exercise) { return new TextExerciseDTO(exercise.getId(), exercise.getTitle(), exercise.getMaxPoints(), exercise.getBonusPoints(), exercise.getGradingInstructions(), - exercise.getProblemStatement()); + exercise.getGradingCriteria().stream().map(GradingCriterionDTO::of).toList(), exercise.getProblemStatement(), exercise.getExampleSolution()); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextFeedbackDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextFeedbackDTO.java index 16570763762b..57f28409f882 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextFeedbackDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextFeedbackDTO.java @@ -3,16 +3,13 @@ import javax.validation.constraints.NotNull; import de.tum.in.www1.artemis.domain.Feedback; -import de.tum.in.www1.artemis.domain.GradingInstruction; import de.tum.in.www1.artemis.domain.TextBlock; -import de.tum.in.www1.artemis.domain.TextBlockRef; -import de.tum.in.www1.artemis.domain.TextSubmission; /** - * A DTO representing a Feedback, for transferring data to Athena + * A DTO representing a Feedback on a TextExercise, for transferring data to Athena and receiving suggestions from Athena */ -public record TextFeedbackDTO(long id, long exerciseId, long submissionId, String title, String description, double credits, Long gradingInstructionId, Integer indexStart, - Integer indexEnd) { +public record TextFeedbackDTO(long id, long exerciseId, long submissionId, String title, String description, double credits, Long structuredGradingInstructionId, + Integer indexStart, Integer indexEnd) implements FeedbackDTO { /** * Creates a TextFeedbackDTO from a Feedback object @@ -26,31 +23,11 @@ public record TextFeedbackDTO(long id, long exerciseId, long submissionId, Strin public static TextFeedbackDTO of(long exerciseId, long submissionId, @NotNull Feedback feedback, TextBlock feedbackBlock) { Integer startIndex = feedbackBlock == null ? null : feedbackBlock.getStartIndex(); Integer endIndex = feedbackBlock == null ? null : feedbackBlock.getEndIndex(); - var gradingInstructionId = feedback.getGradingInstruction() == null ? null : feedback.getGradingInstruction().getId(); + Long gradingInstructionId = null; + if (feedback.getGradingInstruction() != null) { + gradingInstructionId = feedback.getGradingInstruction().getId(); + } return new TextFeedbackDTO(feedback.getId(), exerciseId, submissionId, feedback.getText(), feedback.getDetailText(), feedback.getCredits(), gradingInstructionId, startIndex, endIndex); } - - /** - * Creates a TextBlockRef (feedback + text block combined) from this DTO and a TextSubmission - */ - public TextBlockRef toTextBlockRef(TextSubmission onSubmission, GradingInstruction gradingInstruction) { - Feedback feedback = new Feedback(); - feedback.setId(id()); - feedback.setText(title()); - feedback.setDetailText(description()); - feedback.setCredits(credits()); - // The given grading instruction should match the one of this DTO: - if (gradingInstructionId() != null && !gradingInstructionId().equals(gradingInstruction.getId())) { - throw new IllegalArgumentException("The grading instruction of this DTO does not match the given grading instruction"); - } - feedback.setGradingInstruction(gradingInstruction); - - TextBlock textBlock = new TextBlock(); - textBlock.setStartIndex(indexStart()); - textBlock.setEndIndex(indexEnd()); - textBlock.setText(onSubmission.getText().substring(indexStart(), indexEnd())); - - return new TextBlockRef(textBlock, feedback); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextSubmissionDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextSubmissionDTO.java index 6a457f8c5d15..eebf7ba6e822 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextSubmissionDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/athena/TextSubmissionDTO.java @@ -7,7 +7,7 @@ /** * A DTO representing a TextSubmission, for transferring data to Athena */ -public record TextSubmissionDTO(long id, long exerciseId, String text, String language) { +public record TextSubmissionDTO(long id, long exerciseId, String text, String language) implements SubmissionDTO { /** * Creates a new TextSubmissionDTO from a TextSubmission. The DTO also contains the exerciseId of the exercise the submission belongs to. diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java index 33b301020c26..519bb93f6590 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/export/ProgrammingExerciseExportService.java @@ -641,7 +641,7 @@ private File createZipWithAllRepositories(ProgrammingExercise programmingExercis * @return The checked out and zipped repository * @throws IOException if zip file creation failed */ - private Path createZipForRepositoryWithParticipation(final ProgrammingExercise programmingExercise, final ProgrammingExerciseStudentParticipation participation, + public Path createZipForRepositoryWithParticipation(final ProgrammingExercise programmingExercise, final ProgrammingExerciseStudentParticipation participation, final RepositoryExportOptionsDTO repositoryExportOptions, Path workingDir, Path outputDir) throws IOException, UncheckedIOException { if (participation.getVcsRepositoryUrl() == null) { log.warn("Ignore participation {} for export, because its repository URL is null", participation.getId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java index 63d149d84d33..1e196d33de42 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java @@ -37,8 +37,6 @@ public class InstanceMessageReceiveService { private final UserScheduleService userScheduleService; - private final TextExerciseRepository textExerciseRepository; - private final ExerciseRepository exerciseRepository; private final ProgrammingExerciseRepository programmingExerciseRepository; @@ -48,12 +46,11 @@ public class InstanceMessageReceiveService { private final UserRepository userRepository; public InstanceMessageReceiveService(ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseScheduleService programmingExerciseScheduleService, - ModelingExerciseRepository modelingExerciseRepository, ModelingExerciseScheduleService modelingExerciseScheduleService, TextExerciseRepository textExerciseRepository, - ExerciseRepository exerciseRepository, Optional athenaScheduleService, HazelcastInstance hazelcastInstance, UserRepository userRepository, - UserScheduleService userScheduleService, NotificationScheduleService notificationScheduleService, ParticipantScoreScheduleService participantScoreScheduleService) { + ModelingExerciseRepository modelingExerciseRepository, ModelingExerciseScheduleService modelingExerciseScheduleService, ExerciseRepository exerciseRepository, + Optional athenaScheduleService, HazelcastInstance hazelcastInstance, UserRepository userRepository, UserScheduleService userScheduleService, + NotificationScheduleService notificationScheduleService, ParticipantScoreScheduleService participantScoreScheduleService) { this.programmingExerciseRepository = programmingExerciseRepository; this.programmingExerciseScheduleService = programmingExerciseScheduleService; - this.textExerciseRepository = textExerciseRepository; this.athenaScheduleService = athenaScheduleService; this.modelingExerciseRepository = modelingExerciseRepository; this.modelingExerciseScheduleService = modelingExerciseScheduleService; @@ -66,10 +63,12 @@ public InstanceMessageReceiveService(ProgrammingExerciseRepository programmingEx hazelcastInstance.getTopic(MessageTopic.PROGRAMMING_EXERCISE_SCHEDULE.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); processScheduleProgrammingExercise((message.getMessageObject())); + processSchedulePotentialAthenaExercise((message.getMessageObject())); }); hazelcastInstance.getTopic(MessageTopic.PROGRAMMING_EXERCISE_SCHEDULE_CANCEL.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); processScheduleProgrammingExerciseCancel(message.getMessageObject()); + processPotentialAthenaExerciseScheduleCancel(message.getMessageObject()); }); hazelcastInstance.getTopic(MessageTopic.MODELING_EXERCISE_SCHEDULE.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); @@ -85,11 +84,11 @@ public InstanceMessageReceiveService(ProgrammingExerciseRepository programmingEx }); hazelcastInstance.getTopic(MessageTopic.TEXT_EXERCISE_SCHEDULE.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); - processScheduleTextExercise((message.getMessageObject())); + processSchedulePotentialAthenaExercise(message.getMessageObject()); }); hazelcastInstance.getTopic(MessageTopic.TEXT_EXERCISE_SCHEDULE_CANCEL.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); - processTextExerciseScheduleCancel((message.getMessageObject())); + processPotentialAthenaExerciseScheduleCancel(message.getMessageObject()); }); hazelcastInstance.getTopic(MessageTopic.PROGRAMMING_EXERCISE_UNLOCK_REPOSITORIES.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); @@ -192,14 +191,14 @@ public void processModelingExerciseInstantClustering(Long exerciseId) { modelingExerciseScheduleService.scheduleExerciseForInstant(modelingExercise); } - public void processScheduleTextExercise(Long exerciseId) { - log.info("Received schedule update for text exercise {}", exerciseId); - TextExercise textExercise = textExerciseRepository.findByIdElseThrow(exerciseId); - athenaScheduleService.ifPresent(service -> service.scheduleExerciseForAthenaIfRequired(textExercise)); + public void processSchedulePotentialAthenaExercise(Long exerciseId) { + log.info("Received schedule update for potential Athena exercise {}", exerciseId); + Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); + athenaScheduleService.ifPresent(service -> service.scheduleExerciseForAthenaIfRequired(exercise)); } - public void processTextExerciseScheduleCancel(Long exerciseId) { - log.info("Received schedule cancel for text exercise {}", exerciseId); + public void processPotentialAthenaExerciseScheduleCancel(Long exerciseId) { + log.info("Received schedule cancel for potential Athena exercise {}", exerciseId); athenaScheduleService.ifPresent(service -> service.cancelScheduledAthena(exerciseId)); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java index ebbeaff76bd6..9b10f478f4c2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java @@ -21,11 +21,13 @@ public MainInstanceMessageSendService(InstanceMessageReceiveService instanceMess @Override public void sendProgrammingExerciseSchedule(Long exerciseId) { instanceMessageReceiveService.processScheduleProgrammingExercise(exerciseId); + instanceMessageReceiveService.processSchedulePotentialAthenaExercise(exerciseId); } @Override public void sendProgrammingExerciseScheduleCancel(Long exerciseId) { instanceMessageReceiveService.processScheduleProgrammingExerciseCancel(exerciseId); + instanceMessageReceiveService.processPotentialAthenaExerciseScheduleCancel(exerciseId); } @Override @@ -45,12 +47,12 @@ public void sendModelingExerciseInstantClustering(Long exerciseId) { @Override public void sendTextExerciseSchedule(Long exerciseId) { - instanceMessageReceiveService.processScheduleTextExercise(exerciseId); + instanceMessageReceiveService.processSchedulePotentialAthenaExercise(exerciseId); } @Override public void sendTextExerciseScheduleCancel(Long exerciseId) { - instanceMessageReceiveService.processTextExerciseScheduleCancel(exerciseId); + instanceMessageReceiveService.processPotentialAthenaExerciseScheduleCancel(exerciseId); } @Override diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java index ac8ab7714bf0..b40e19bac440 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java @@ -280,6 +280,11 @@ public ProgrammingExercise importProgrammingExercise(ProgrammingExercise origina newExercise.generateAndSetProjectKey(); programmingExerciseService.checkIfProjectExists(newExercise); + if (newExercise.isExamExercise()) { + // Disable feedback suggestions on exam exercises (currently not supported) + newExercise.setFeedbackSuggestionsEnabled(false); + } + final var importedProgrammingExercise = programmingExerciseImportBasicService.importProgrammingExerciseBasis(originalProgrammingExercise, newExercise); importRepositories(originalProgrammingExercise, importedProgrammingExercise); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java index 0073887d0b6f..742963a52ca3 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java @@ -27,6 +27,7 @@ import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.connectors.GitService; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaSubmissionSelectionService; import de.tum.in.www1.artemis.service.connectors.ci.ContinuousIntegrationTriggerService; import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlService; import de.tum.in.www1.artemis.service.exam.ExamDateService; @@ -79,9 +80,9 @@ public ProgrammingSubmissionService(ProgrammingSubmissionRepository programmingS ExerciseDateService exerciseDateService, CourseRepository courseRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ComplaintRepository complaintRepository, ProgrammingExerciseGitDiffReportService programmingExerciseGitDiffReportService, ParticipationAuthorizationCheckService participationAuthCheckService, - FeedbackService feedbackService, SubmissionPolicyRepository submissionPolicyRepository) { + FeedbackService feedbackService, SubmissionPolicyRepository submissionPolicyRepository, Optional athenaSubmissionSelectionService) { super(submissionRepository, userRepository, authCheckService, resultRepository, studentParticipationRepository, participationService, feedbackRepository, examDateService, - exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService); + exerciseDateService, courseRepository, participationRepository, complaintRepository, feedbackService, athenaSubmissionSelectionService); this.programmingSubmissionRepository = programmingSubmissionRepository; this.programmingExerciseRepository = programmingExerciseRepository; this.programmingMessagingService = programmingMessagingService; @@ -520,17 +521,15 @@ public Optional getNextAssessableSubmission(ProgrammingEx * For exam exercises we should also remove the test run participations as these should not be graded by the tutors. * * @param programmingExercise the exercise for which we want to retrieve a submission without manual result + * @param skipAssessmentQueue flag to determine if the submission should be retrieved from the assessment queue * @param correctionRound - the correction round we want our submission to have results for * @param examMode flag to determine if test runs should be removed. This should be set to true for exam exercises * @return a programmingSubmission without any manual result or an empty Optional if no submission without manual result could be found */ - public Optional getRandomAssessableSubmission(ProgrammingExercise programmingExercise, boolean examMode, int correctionRound) { - var submissionWithoutResult = super.getRandomAssessableSubmission(programmingExercise, examMode, correctionRound); - if (submissionWithoutResult.isPresent()) { - ProgrammingSubmission programmingSubmission = (ProgrammingSubmission) submissionWithoutResult.get(); - return Optional.of(programmingSubmission); - } - return Optional.empty(); + public Optional getRandomAssessableSubmission(ProgrammingExercise programmingExercise, boolean skipAssessmentQueue, boolean examMode, + int correctionRound) { + return super.getRandomAssessableSubmission(programmingExercise, skipAssessmentQueue, examMode, correctionRound, + programmingSubmissionRepository::findWithEagerResultsAndFeedbacksById); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/AthenaScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/AthenaScheduleService.java index 406b6b7c5316..84f05a171f29 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/AthenaScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/AthenaScheduleService.java @@ -12,9 +12,9 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.enumeration.ExerciseLifecycle; -import de.tum.in.www1.artemis.repository.TextExerciseRepository; +import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.ExerciseLifecycleService; import de.tum.in.www1.artemis.service.ProfileService; @@ -28,7 +28,7 @@ public class AthenaScheduleService { private final ExerciseLifecycleService exerciseLifecycleService; - private final TextExerciseRepository textExerciseRepository; + private final ExerciseRepository exerciseRepository; private final ProfileService profileService; @@ -36,16 +36,16 @@ public class AthenaScheduleService { private final AthenaSubmissionSendingService athenaSubmissionSendingService; - public AthenaScheduleService(ExerciseLifecycleService exerciseLifecycleService, TextExerciseRepository textExerciseRepository, ProfileService profileService, + public AthenaScheduleService(ExerciseLifecycleService exerciseLifecycleService, ExerciseRepository exerciseRepository, ProfileService profileService, AthenaSubmissionSendingService athenaSubmissionSendingService) { this.exerciseLifecycleService = exerciseLifecycleService; - this.textExerciseRepository = textExerciseRepository; + this.exerciseRepository = exerciseRepository; this.profileService = profileService; this.athenaSubmissionSendingService = athenaSubmissionSendingService; } /** - * Schedule Athena tasks for all text exercises with future due dates on startup. + * Schedule Athena tasks for all exercises with future due dates on startup. */ @PostConstruct public void scheduleRunningExercisesOnStartup() { @@ -54,18 +54,18 @@ public void scheduleRunningExercisesOnStartup() { // NOTE: if you want to test this locally, please comment it out, but do not commit the changes return; } - final List runningTextExercises = textExerciseRepository.findAllAutomaticAssessmentTextExercisesWithFutureDueDate(); - runningTextExercises.forEach(this::scheduleExerciseForAthenaIfRequired); - log.info("Scheduled Athena for {} text exercises with future due dates.", runningTextExercises.size()); + final Set runningExercises = exerciseRepository.findAllFeedbackSuggestionsEnabledExercisesWithFutureDueDate(); + runningExercises.forEach(this::scheduleExerciseForAthenaIfRequired); + log.info("Scheduled Athena for {} exercises with future due dates.", runningExercises.size()); } /** - * Schedule an Athena task for a text exercise with its due date if automatic assessments are enabled and its due date is in the future. + * Schedule an Athena task for a exercise with its due date if automatic assessments are enabled and its due date is in the future. * * @param exercise exercise to schedule Athena for */ - public void scheduleExerciseForAthenaIfRequired(TextExercise exercise) { - if (!exercise.isFeedbackSuggestionsEnabled()) { + public void scheduleExerciseForAthenaIfRequired(Exercise exercise) { + if (!exercise.getFeedbackSuggestionsEnabled()) { cancelScheduledAthena(exercise.getId()); return; } @@ -77,7 +77,7 @@ public void scheduleExerciseForAthenaIfRequired(TextExercise exercise) { scheduleExerciseForAthena(exercise); } - private void scheduleExerciseForAthena(TextExercise exercise) { + private void scheduleExerciseForAthena(Exercise exercise) { // check if already scheduled for exercise. if so, cancel. // no exercise should be scheduled for Athena more than once. cancelScheduledAthena(exercise.getId()); @@ -85,11 +85,11 @@ private void scheduleExerciseForAthena(TextExercise exercise) { final ScheduledFuture future = exerciseLifecycleService.scheduleTask(exercise, ExerciseLifecycle.DUE, athenaRunnableForExercise(exercise)); scheduledAthenaTasks.put(exercise.getId(), future); - log.debug("Scheduled Athena for Text Exercise '{}' (#{}) for {}.", exercise.getTitle(), exercise.getId(), exercise.getDueDate()); + log.debug("Scheduled Athena for Exercise '{}' (#{}) for {}.", exercise.getTitle(), exercise.getId(), exercise.getDueDate()); } @NotNull - private Runnable athenaRunnableForExercise(TextExercise exercise) { + private Runnable athenaRunnableForExercise(Exercise exercise) { return () -> { SecurityUtils.setAuthorizationObject(); athenaSubmissionSendingService.sendSubmissions(exercise); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AthenaResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AthenaResource.java index 829b3ea0c057..7ccd9f7a3a5a 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/AthenaResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AthenaResource.java @@ -1,81 +1,208 @@ package de.tum.in.www1.artemis.web.rest; +import java.io.IOException; import java.util.List; +import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -import de.tum.in.www1.artemis.domain.TextBlockRef; +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.Submission; +import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; import de.tum.in.www1.artemis.exception.NetworkingException; -import de.tum.in.www1.artemis.repository.TextExerciseRepository; -import de.tum.in.www1.artemis.repository.TextSubmissionRepository; +import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor; +import de.tum.in.www1.artemis.security.annotations.EnforceNothing; +import de.tum.in.www1.artemis.security.annotations.ManualConfig; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.connectors.athena.AthenaFeedbackSuggestionsService; -import de.tum.in.www1.artemis.web.rest.errors.ConflictException; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaRepositoryExportService; +import de.tum.in.www1.artemis.service.dto.athena.ProgrammingFeedbackDTO; +import de.tum.in.www1.artemis.service.dto.athena.TextFeedbackDTO; +import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.util.ResponseUtil; /** * REST controller for Athena feedback suggestions. */ @RestController -@RequestMapping("api/athena/") +@RequestMapping("api/") @Profile("athena") public class AthenaResource { private final Logger log = LoggerFactory.getLogger(AthenaResource.class); + @Value("${artemis.athena.secret}") + private String athenaSecret; + private final TextExerciseRepository textExerciseRepository; private final TextSubmissionRepository textSubmissionRepository; + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final ProgrammingSubmissionRepository programmingSubmissionRepository; + private final AuthorizationCheckService authCheckService; private final AthenaFeedbackSuggestionsService athenaFeedbackSuggestionsService; + private final AthenaRepositoryExportService athenaRepositoryExportService; + /** * The AthenaResource provides an endpoint for the client to fetch feedback suggestions from Athena. */ - public AthenaResource(AthenaFeedbackSuggestionsService athenaFeedbackSuggestionsService, TextExerciseRepository textExerciseRepository, - TextSubmissionRepository textSubmissionRepository, AuthorizationCheckService authCheckService) { - this.athenaFeedbackSuggestionsService = athenaFeedbackSuggestionsService; + public AthenaResource(TextExerciseRepository textExerciseRepository, TextSubmissionRepository textSubmissionRepository, + ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingSubmissionRepository programmingSubmissionRepository, + AuthorizationCheckService authCheckService, AthenaFeedbackSuggestionsService athenaFeedbackSuggestionsService, + AthenaRepositoryExportService athenaRepositoryExportService) { this.textExerciseRepository = textExerciseRepository; this.textSubmissionRepository = textSubmissionRepository; + this.programmingExerciseRepository = programmingExerciseRepository; + this.programmingSubmissionRepository = programmingSubmissionRepository; this.authCheckService = authCheckService; + this.athenaFeedbackSuggestionsService = athenaFeedbackSuggestionsService; + this.athenaRepositoryExportService = athenaRepositoryExportService; + } + + @FunctionalInterface + private interface FeedbackProvider { + + /** + * Method to apply the feedback provider. Examples: AthenaFeedbackSuggestionsService::getTextFeedbackSuggestions, + * AthenaFeedbackSuggestionsService::getProgrammingFeedbackSuggestions + */ + List apply(ExerciseType exercise, SubmissionType submission) throws NetworkingException; + } + + private ResponseEntity> getFeedbackSuggestions(long exerciseId, long submissionId, + Function exerciseFetcher, Function submissionFetcher, FeedbackProvider feedbackProvider) { + + log.debug("REST call to get feedback suggestions for exercise {}, submission {}", exerciseId, submissionId); + + final var exercise = exerciseFetcher.apply(exerciseId); + authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, exercise, null); + + final var submission = submissionFetcher.apply(submissionId); + + try { + return ResponseEntity.ok(feedbackProvider.apply(exercise, submission)); + } + catch (NetworkingException e) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } } /** - * GET athena/exercises/:exerciseId/submissions/:submissionId/feedback-suggestions : Get feedback suggestions from Athena + * GET athena/text-exercises/:exerciseId/submissions/:submissionId/feedback-suggestions : Get feedback suggestions from Athena for a text exercise * * @param exerciseId the id of the exercise the submission belongs to * @param submissionId the id of the submission to get feedback suggestions for * @return 200 Ok if successful with the corresponding result as body */ - @GetMapping("exercises/{exerciseId}/submissions/{submissionId}/feedback-suggestions") + @GetMapping("athena/text-exercises/{exerciseId}/submissions/{submissionId}/feedback-suggestions") @EnforceAtLeastTutor - public ResponseEntity> getFeedbackSuggestions(@PathVariable long exerciseId, @PathVariable long submissionId) { - log.debug("REST call to get feedback suggestions for exercise {}, submission {}", exerciseId, submissionId); + public ResponseEntity> getTextFeedbackSuggestions(@PathVariable long exerciseId, @PathVariable long submissionId) { + return getFeedbackSuggestions(exerciseId, submissionId, textExerciseRepository::findByIdElseThrow, textSubmissionRepository::findByIdElseThrow, + athenaFeedbackSuggestionsService::getTextFeedbackSuggestions); + } - final var exercise = textExerciseRepository.findByIdElseThrow(exerciseId); - authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, exercise, null); - final var submission = textSubmissionRepository.findByIdElseThrow(submissionId); - if (submission.getParticipation().getExercise().getId() != exerciseId) { - log.error("Exercise id {} does not match submission's exercise id {}", exerciseId, submission.getParticipation().getExercise().getId()); - throw new ConflictException("Exercise id does not match submission's exercise id", "Exercise", "exerciseIdDoesNotMatch"); - } - try { - List feedbackSuggestions = athenaFeedbackSuggestionsService.getFeedbackSuggestions(exercise, submission); - return ResponseEntity.ok(feedbackSuggestions); - } - catch (NetworkingException e) { - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + /** + * GET athena/programming-exercises/:exerciseId/submissions/:submissionId/feedback-suggestions : Get feedback suggestions from Athena for a programming exercise + * + * @param exerciseId the id of the exercise the submission belongs to + * @param submissionId the id of the submission to get feedback suggestions for + * @return 200 Ok if successful with the corresponding result as body + */ + @GetMapping("athena/programming-exercises/{exerciseId}/submissions/{submissionId}/feedback-suggestions") + @EnforceAtLeastTutor + public ResponseEntity> getProgrammingFeedbackSuggestions(@PathVariable long exerciseId, @PathVariable long submissionId) { + return getFeedbackSuggestions(exerciseId, submissionId, programmingExerciseRepository::findByIdElseThrow, programmingSubmissionRepository::findByIdElseThrow, + athenaFeedbackSuggestionsService::getProgrammingFeedbackSuggestions); + } + + /** + * Check if the given auth header is valid for Athena, otherwise throw an exception. + * + * @param auth the auth header value to check + */ + private void checkAthenaSecret(String auth) { + if (!auth.equals(athenaSecret)) { + log.error("Athena secret does not match"); + throw new AccessForbiddenException("Athena secret does not match"); } } + + /** + * GET public/athena/programming-exercises/:exerciseId/submissions/:submissionId/repository : Get the repository as a zip file download + * + * @param exerciseId the id of the exercise the submission belongs to + * @param submissionId the id of the submission to get the repository for + * @param auth the auth header value to check + * @return 200 Ok with the zip file as body if successful + */ + @GetMapping("public/athena/programming-exercises/{exerciseId}/submissions/{submissionId}/repository") + @EnforceNothing // We check the Athena secret instead + @ManualConfig + public ResponseEntity getRepository(@PathVariable long exerciseId, @PathVariable long submissionId, @RequestHeader("Authorization") String auth) throws IOException { + log.debug("REST call to get student repository for exercise {}, submission {}", exerciseId, submissionId); + checkAthenaSecret(auth); + return ResponseUtil.ok(athenaRepositoryExportService.exportRepository(exerciseId, submissionId, null)); + } + + /** + * GET public/athena/programming-exercises/:exerciseId/repository/template : Get the template repository as a zip file download + * + * @param exerciseId the id of the exercise + * @param auth the auth header value to check + * @return 200 Ok with the zip file as body if successful + */ + @GetMapping("public/athena/programming-exercises/{exerciseId}/repository/template") + @EnforceNothing // We check the Athena secret instead + @ManualConfig + public ResponseEntity getTemplateRepository(@PathVariable long exerciseId, @RequestHeader("Authorization") String auth) throws IOException { + log.debug("REST call to get template repository for exercise {}", exerciseId); + checkAthenaSecret(auth); + return ResponseUtil.ok(athenaRepositoryExportService.exportRepository(exerciseId, null, RepositoryType.TEMPLATE)); + } + + /** + * GET public/athena/programming-exercises/:exerciseId/repository/solution : Get the solution repository as a zip file download + * + * @param exerciseId the id of the exercise + * @param auth the auth header value to check + * @return 200 Ok with the zip file as body if successful + */ + @GetMapping("public/athena/programming-exercises/{exerciseId}/repository/solution") + @EnforceNothing // We check the Athena secret instead + @ManualConfig + public ResponseEntity getSolutionRepository(@PathVariable long exerciseId, @RequestHeader("Authorization") String auth) throws IOException { + log.debug("REST call to get solution repository for exercise {}", exerciseId); + checkAthenaSecret(auth); + return ResponseUtil.ok(athenaRepositoryExportService.exportRepository(exerciseId, null, RepositoryType.SOLUTION)); + } + + /** + * GET public/athena/programming-exercises/:exerciseId/repository/tests : Get the test repository as a zip file download + * + * @param exerciseId the id of the exercise + * @param auth the auth header value to check + * @return 200 Ok with the zip file as body if successful + */ + @GetMapping("public/athena/programming-exercises/{exerciseId}/repository/tests") + @EnforceNothing // We check the Athena secret instead + @ManualConfig + public ResponseEntity getTestRepository(@PathVariable long exerciseId, @RequestHeader("Authorization") String auth) throws IOException { + log.debug("REST call to get test repository for exercise {}", exerciseId); + checkAthenaSecret(auth); + return ResponseUtil.ok(athenaRepositoryExportService.exportRepository(exerciseId, null, RepositoryType.TESTS)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java index a829005e3cdc..b073b827a208 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java @@ -2,6 +2,7 @@ import java.time.ZonedDateTime; import java.util.Comparator; +import java.util.List; import java.util.Optional; import org.hibernate.Hibernate; @@ -20,6 +21,7 @@ import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ExerciseDateService; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaFeedbackSendingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiNewResultService; import de.tum.in.www1.artemis.service.exam.ExamService; import de.tum.in.www1.artemis.service.notifications.SingleUserNotificationService; @@ -51,11 +53,13 @@ public class ProgrammingAssessmentResource extends AssessmentResource { private final ProgrammingExerciseParticipationService programmingExerciseParticipationService; + private final Optional athenaFeedbackSendingService; + public ProgrammingAssessmentResource(AuthorizationCheckService authCheckService, UserRepository userRepository, ProgrammingAssessmentService programmingAssessmentService, ProgrammingSubmissionRepository programmingSubmissionRepository, ExerciseRepository exerciseRepository, ResultRepository resultRepository, ExamService examService, ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, StudentParticipationRepository studentParticipationRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, SingleUserNotificationService singleUserNotificationService, - ProgrammingExerciseParticipationService programmingExerciseParticipationService) { + ProgrammingExerciseParticipationService programmingExerciseParticipationService, Optional athenaFeedbackSendingService) { super(authCheckService, userRepository, exerciseRepository, programmingAssessmentService, resultRepository, examService, resultWebsocketService, exampleSubmissionRepository, submissionRepository, singleUserNotificationService); this.programmingAssessmentService = programmingAssessmentService; @@ -63,6 +67,7 @@ public ProgrammingAssessmentResource(AuthorizationCheckService authCheckService, this.ltiNewResultService = ltiNewResultService; this.studentParticipationRepository = studentParticipationRepository; this.programmingExerciseParticipationService = programmingExerciseParticipationService; + this.athenaFeedbackSendingService = athenaFeedbackSendingService; } /** @@ -199,6 +204,7 @@ public ResponseEntity saveProgrammingAssessment(@PathVariable Long parti if (submission.getParticipation() instanceof StudentParticipation studentParticipation && studentParticipation.getStudent().isPresent()) { singleUserNotificationService.checkNotificationForAssessmentExerciseSubmission(programmingExercise, studentParticipation.getStudent().get(), newManualResult); } + sendFeedbackToAthena(programmingExercise, submission, newManualResult.getFeedbacks()); } // remove information about the student for tutors to ensure double-blind assessment if (!isAtLeastInstructor) { @@ -240,6 +246,15 @@ public ResponseEntity deleteAssessment(@PathVariable Long participationId, return super.deleteAssessment(participationId, submissionId, resultId); } + /** + * Send feedback to Athena (if enabled for both the Artemis instance and the exercise). + */ + private void sendFeedbackToAthena(final ProgrammingExercise exercise, final ProgrammingSubmission programmingSubmission, final List feedbacks) { + if (athenaFeedbackSendingService.isPresent() && exercise.getFeedbackSuggestionsEnabled()) { + athenaFeedbackSendingService.get().sendFeedback(exercise, programmingSubmission, feedbacks); + } + } + @Override String getEntityName() { return ENTITY_NAME; diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingSubmissionResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingSubmissionResource.java index a855c20a9b6e..681160700105 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingSubmissionResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingSubmissionResource.java @@ -363,7 +363,8 @@ public ResponseEntity getProgrammingSubmissionWithoutAsse submission = programmingSubmissionService.getNextAssessableSubmission(programmingExercise, programmingExercise.isExamExercise(), correctionRound).orElse(null); } else { - submission = programmingSubmissionService.getRandomAssessableSubmission(programmingExercise, programmingExercise.isExamExercise(), correctionRound).orElse(null); + submission = programmingSubmissionService.getRandomAssessableSubmission(programmingExercise, !lockSubmission, programmingExercise.isExamExercise(), correctionRound) + .orElse(null); // Check if tutors can start assessing the students submission programmingSubmissionService.checkIfExerciseDueDateIsReached(programmingExercise); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/TextAssessmentResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/TextAssessmentResource.java index 68ab81f5ede9..b80032035d62 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/TextAssessmentResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/TextAssessmentResource.java @@ -159,7 +159,6 @@ public ResponseEntity saveTextExampleAssessment(@PathVariable long exerc final var textSubmission = textSubmissionService.findOneWithEagerResultFeedbackAndTextBlocks(submission.getId()); final var feedbacksWithIds = response.getBody().getFeedbacks(); saveTextBlocks(textAssessment.getTextBlocks(), textSubmission, exercise, feedbacksWithIds); - sendFeedbackToAthena(exercise, textSubmission, feedbacksWithIds); } return response; } @@ -490,7 +489,7 @@ private void saveTextBlocks(final Set textBlocks, final TextSubmissio * Send feedback to Athena (if enabled for both the Artemis instance and the exercise). */ private void sendFeedbackToAthena(final TextExercise exercise, final TextSubmission textSubmission, final List feedbacks) { - if (athenaFeedbackSendingService.isPresent() && exercise.isFeedbackSuggestionsEnabled()) { + if (athenaFeedbackSendingService.isPresent() && exercise.getFeedbackSuggestionsEnabled()) { athenaFeedbackSendingService.get().sendFeedback(exercise, textSubmission, feedbacks); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/errors/ServiceUnavailableException.java b/src/main/java/de/tum/in/www1/artemis/web/rest/errors/ServiceUnavailableException.java new file mode 100644 index 000000000000..9a10ed026d24 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/errors/ServiceUnavailableException.java @@ -0,0 +1,20 @@ +package de.tum.in.www1.artemis.web.rest.errors; + +import java.io.Serial; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Generic unchecked exception for service unavailable (i.e. 503) errors. + */ +@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) +public class ServiceUnavailableException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public ServiceUnavailableException(String message) { + super(message); + } +} diff --git a/src/main/resources/config/application-artemis.yml b/src/main/resources/config/application-artemis.yml index ab5ff7246080..07c444320bbe 100644 --- a/src/main/resources/config/application-artemis.yml +++ b/src/main/resources/config/application-artemis.yml @@ -93,6 +93,9 @@ artemis: athena: url: http://localhost:5000 secret: abcdef12345 + modules: + text: module_text_cofee + programming: module_programming_themisml apollon: conversion-service-url: http://localhost:8080 diff --git a/src/main/resources/config/liquibase/changelog/20230903000000_changelog.xml b/src/main/resources/config/liquibase/changelog/20230903000000_changelog.xml new file mode 100644 index 000000000000..9ca32418da79 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230903000000_changelog.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + discriminator='T' AND assessment_type='SEMI_AUTOMATIC' + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 34065e21143b..fdd6ef597dfb 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -58,6 +58,7 @@ + diff --git a/src/main/webapp/app/assessment/athena.service.ts b/src/main/webapp/app/assessment/athena.service.ts index c296ca895e01..11c7ff211fd2 100644 --- a/src/main/webapp/app/assessment/athena.service.ts +++ b/src/main/webapp/app/assessment/athena.service.ts @@ -1,9 +1,13 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Observable, of, switchMap } from 'rxjs'; +import { Observable, map, of, switchMap } from 'rxjs'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; -import { Feedback } from 'app/entities/feedback.model'; +import { ProgrammingFeedbackSuggestion, TextFeedbackSuggestion } from 'app/entities/feedback-suggestion.model'; +import { Exercise } from 'app/entities/exercise.model'; +import { FEEDBACK_SUGGESTION_ACCEPTED_IDENTIFIER, FEEDBACK_SUGGESTION_IDENTIFIER, Feedback, FeedbackType } from 'app/entities/feedback.model'; +import { TextBlock } from 'app/entities/text-block.model'; import { TextBlockRef } from 'app/entities/text-block-ref.model'; +import { TextSubmission } from 'app/entities/text-submission.model'; @Injectable({ providedIn: 'root' }) export class AthenaService { @@ -14,42 +18,119 @@ export class AthenaService { private profileService: ProfileService, ) {} - // TODO: The following two functions will be merged in a future PR + public isEnabled(): Observable { + return this.profileService.getProfileInfo().pipe(switchMap((profileInfo) => of(profileInfo.activeProfiles.includes('athena')))); + } /** * Get feedback suggestions for the given submission from Athena - for programming exercises * Currently, this is separate for programming and text exercises (will be changed) * - * @param exerciseId the id of the exercise + * @param exercise * @param submissionId the id of the submission + * @return observable that emits the feedback suggestions */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getFeedbackSuggestionsForProgramming(exerciseId: number, submissionId: number): Observable { - return of([]); // Will be fetched in a future PR + private getFeedbackSuggestions(exercise: Exercise, submissionId: number): Observable { + if (!exercise.feedbackSuggestionsEnabled) { + return of([]); + } + return this.isEnabled().pipe( + switchMap((isAthenaEnabled) => { + if (!isAthenaEnabled) { + return of([] as T[]); + } + return this.http + .get(`${this.resourceUrl}/${exercise.type}-exercises/${exercise.id}/submissions/${submissionId}/feedback-suggestions`, { observe: 'response' }) + .pipe(switchMap((res: HttpResponse) => of(res.body!))); + }), + ); } /** - * Get feedback suggestions for the given submission from Athena - for text exercises - * Currently, this is separate for programming and text exercises (will be changed) + * Find a grading instruction by id in the given exercise + */ + private findGradingInstruction(exercise: Exercise, id: number): any | undefined { + for (const criterium of exercise.gradingCriteria ?? []) { + for (const instruction of criterium.structuredGradingInstructions) { + if (instruction.id == id) { + return instruction; + } + } + } + return undefined; + } + + /** + * Get feedback suggestions for the given text submission from Athena + * + * @param exercise + * @param submission the submission + * @return observable that emits the referenced feedback suggestions as TextBlockRef objects + * with TextBlocks and the unreferenced feedback suggestions as Feedback objects + * with the "FeedbackSuggestion:" prefix + */ + public getTextFeedbackSuggestions(exercise: Exercise, submission: TextSubmission): Observable<(TextBlockRef | Feedback)[]> { + return this.getFeedbackSuggestions(exercise, submission.id!).pipe( + map((suggestions) => { + // Convert referenced feedback suggestions to TextBlockRefs for easier handling in the components + return suggestions.map((suggestion) => { + const feedback = new Feedback(); + feedback.credits = suggestion.credits; + // Text feedback suggestions are automatically accepted, so we can set the text directly: + feedback.text = FEEDBACK_SUGGESTION_ACCEPTED_IDENTIFIER + suggestion.title; + feedback.detailText = suggestion.description; + // Load grading instruction from exercise, if available + if (suggestion.structuredGradingInstructionId != undefined) { + feedback.gradingInstruction = this.findGradingInstruction(exercise, suggestion.structuredGradingInstructionId); + } + if (suggestion.indexStart == null) { + // Unreferenced feedback, return Feedback object + feedback.type = FeedbackType.MANUAL_UNREFERENCED; + return feedback; + } + // Referenced feedback, convert to TextBlockRef + feedback.type = FeedbackType.MANUAL; + const textBlock = new TextBlock(); + textBlock.startIndex = suggestion.indexStart; + textBlock.endIndex = suggestion.indexEnd; + textBlock.setTextFromSubmission(submission); + feedback.reference = textBlock.id; + return new TextBlockRef(textBlock, feedback); + }); + }), + ); + } + + /** + * Get feedback suggestions for the given programming submission from Athena * - * @param exerciseId the id of the exercise + * @param exercise * @param submissionId the id of the submission + * @return observable that emits the feedback suggestions as Feedback objects with the "FeedbackSuggestion:" prefix */ - getFeedbackSuggestionsForText(exerciseId: number, submissionId: number): Observable { - return this.profileService.getProfileInfo().pipe( - switchMap((profileInfo) => { - if (!profileInfo.activeProfiles.includes('athena')) { - return of([] as TextBlockRef[]); - } - return this.http - .get(`${this.resourceUrl}/exercises/${exerciseId}/submissions/${submissionId}/feedback-suggestions`, { observe: 'response' }) - .pipe(switchMap((res: HttpResponse) => of(res.body!))) - .pipe( - switchMap((feedbackSuggestions) => { - // Make real TextBlockRef objects out of the plain objects - return of(feedbackSuggestions.map((feedbackSuggestion) => new TextBlockRef(feedbackSuggestion.block!, feedbackSuggestion.feedback))); - }), - ); + public getProgrammingFeedbackSuggestions(exercise: Exercise, submissionId: number): Observable { + return this.getFeedbackSuggestions(exercise, submissionId).pipe( + map((suggestions) => { + return suggestions.map((suggestion) => { + const feedback = new Feedback(); + feedback.credits = suggestion.credits; + feedback.text = FEEDBACK_SUGGESTION_IDENTIFIER + suggestion.title; + feedback.detailText = suggestion.description; + if (suggestion.filePath != undefined && (suggestion.lineEnd ?? suggestion.lineStart) != undefined) { + // Referenced feedback + feedback.type = FeedbackType.MANUAL; + feedback.reference = `file:${suggestion.filePath}_line:${suggestion.lineEnd ?? suggestion.lineStart}`; // Only use a single line for now because Artemis does not support line ranges + } else { + // Unreferenced feedback + feedback.type = FeedbackType.MANUAL_UNREFERENCED; + feedback.reference = undefined; + } + // Load grading instruction from exercise, if available + if (suggestion.structuredGradingInstructionId != undefined) { + feedback.gradingInstruction = this.findGradingInstruction(exercise, suggestion.structuredGradingInstructionId); + } + return feedback; + }); }), ); } diff --git a/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.html b/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.html index da2d8e9b4c7f..c78dd295850d 100644 --- a/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.html +++ b/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.html @@ -1,6 +1,10 @@
- +
+ +
+ + + +
diff --git a/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.ts b/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.ts index c6e04529ba68..e23ae1d82755 100644 --- a/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.ts @@ -6,6 +6,8 @@ import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { faCogs, faUserCheck, faUserSlash } from '@fortawesome/free-solid-svg-icons'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { IncludedInOverallScore } from 'app/entities/exercise.model'; +import { Observable } from 'rxjs'; +import { AthenaService } from 'app/assessment/athena.service'; @Component({ selector: 'jhi-programming-exercise-lifecycle', @@ -25,9 +27,12 @@ export class ProgrammingExerciseLifecycleComponent implements OnInit, OnChanges faUserCheck = faUserCheck; faUserSlash = faUserSlash; + isAthenaEnabled$: Observable | undefined; + constructor( private translateService: TranslateService, private exerciseService: ExerciseService, + private athenaService: AthenaService, ) {} /** @@ -37,6 +42,7 @@ export class ProgrammingExerciseLifecycleComponent implements OnInit, OnChanges if (!this.exercise.id) { this.exercise.assessmentType = AssessmentType.AUTOMATIC; } + this.isAthenaEnabled$ = this.athenaService.isEnabled(); } ngOnChanges(simpleChanges: SimpleChanges) { @@ -71,6 +77,7 @@ export class ProgrammingExerciseLifecycleComponent implements OnInit, OnChanges this.exercise.assessmentType = AssessmentType.AUTOMATIC; this.exercise.assessmentDueDate = undefined; this.exercise.allowComplaintsForAutomaticAssessments = false; + this.exercise.feedbackSuggestionsEnabled = false; } else { this.exercise.assessmentType = AssessmentType.SEMI_AUTOMATIC; this.exercise.allowComplaintsForAutomaticAssessments = false; diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback-suggestion-badge/feedback-suggestion-badge.component.ts b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestion-badge/feedback-suggestion-badge.component.ts index ae4f37acc1f2..382cf3a045db 100644 --- a/src/main/webapp/app/exercises/shared/feedback/feedback-suggestion-badge/feedback-suggestion-badge.component.ts +++ b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestion-badge/feedback-suggestion-badge.component.ts @@ -12,6 +12,9 @@ export class FeedbackSuggestionBadgeComponent { @Input() feedback: Feedback; + @Input() + useDefaultText = false; + // Icons faLightbulb = faLightbulb; @@ -19,19 +22,27 @@ export class FeedbackSuggestionBadgeComponent { get text(): string { const feedbackSuggestionType = Feedback.getFeedbackSuggestionType(this.feedback); + if (feedbackSuggestionType === FeedbackSuggestionType.ADAPTED) { + // Always mark adapted feedback suggestions as such, even with the default badge in text mode + return 'artemisApp.assessment.suggestion.adapted'; + } + if (this.useDefaultText) { + return 'artemisApp.assessment.suggestion.default'; + } switch (feedbackSuggestionType) { case FeedbackSuggestionType.SUGGESTED: return 'artemisApp.assessment.suggestion.suggested'; case FeedbackSuggestionType.ACCEPTED: return 'artemisApp.assessment.suggestion.accepted'; - case FeedbackSuggestionType.ADAPTED: - return 'artemisApp.assessment.suggestion.adapted'; default: return ''; } } get tooltip(): string { + if (this.useDefaultText) { + return this.translateService.instant('artemisApp.assessment.suggestionTitle.default'); + } const feedbackSuggestionType = Feedback.getFeedbackSuggestionType(this.feedback); switch (feedbackSuggestionType) { case FeedbackSuggestionType.SUGGESTED: diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.html b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.html new file mode 100644 index 000000000000..e0565ae193e2 --- /dev/null +++ b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.html @@ -0,0 +1,21 @@ +
+ + + +
diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.ts b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.ts new file mode 100644 index 000000000000..b0c996f5b6ff --- /dev/null +++ b/src/main/webapp/app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { faBan, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'jhi-feedback-suggestions-pending-confirmation-dialog', + templateUrl: './feedback-suggestions-pending-confirmation-dialog.component.html', +}) +export class FeedbackSuggestionsPendingConfirmationDialogComponent { + // Icons + faBan = faBan; + faTimes = faTimes; + + constructor(private activeModal: NgbActiveModal) {} + + /** + * Close the confirmation dialog + */ + close(confirm: boolean): void { + this.activeModal.close(confirm); + } +} diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback.module.ts b/src/main/webapp/app/exercises/shared/feedback/feedback.module.ts index 83234b612392..82de5aedc16a 100644 --- a/src/main/webapp/app/exercises/shared/feedback/feedback.module.ts +++ b/src/main/webapp/app/exercises/shared/feedback/feedback.module.ts @@ -9,10 +9,19 @@ import { FeedbackComponent } from 'app/exercises/shared/feedback/feedback.compon import { FeedbackTextComponent } from 'app/exercises/shared/feedback/text/feedback-text.component'; import { StandaloneFeedbackComponent } from './standalone-feedback/standalone-feedback.component'; import { FeedbackSuggestionBadgeComponent } from 'app/exercises/shared/feedback/feedback-suggestion-badge/feedback-suggestion-badge.component'; +import { FeedbackSuggestionsPendingConfirmationDialogComponent } from 'app/exercises/shared/feedback/feedback-suggestions-pending-confirmation-dialog/feedback-suggestions-pending-confirmation-dialog.component'; @NgModule({ imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule, ArtemisSharedComponentModule, BarChartModule], - declarations: [FeedbackCollapseComponent, FeedbackNodeComponent, FeedbackComponent, FeedbackTextComponent, StandaloneFeedbackComponent, FeedbackSuggestionBadgeComponent], - exports: [FeedbackComponent, FeedbackSuggestionBadgeComponent], + declarations: [ + FeedbackCollapseComponent, + FeedbackNodeComponent, + FeedbackComponent, + FeedbackTextComponent, + StandaloneFeedbackComponent, + FeedbackSuggestionBadgeComponent, + FeedbackSuggestionsPendingConfirmationDialogComponent, + ], + exports: [FeedbackComponent, FeedbackSuggestionBadgeComponent, FeedbackSuggestionsPendingConfirmationDialogComponent], }) export class ArtemisFeedbackModule {} diff --git a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.html b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.html index c2ded6b20eec..ca2935974eb8 100644 --- a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.html +++ b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.html @@ -17,6 +17,7 @@

Feedback (onFeedbackDelete)="deleteFeedback(feedback)" [readOnly]="readOnly" [highlightDifferences]="highlightDifferences" + [useDefaultFeedbackSuggestionBadgeText]="useDefaultFeedbackSuggestionBadgeText" >
@@ -26,6 +27,7 @@

Feedback [readOnly]="true" (onAcceptSuggestion)="acceptSuggestion($event)" (onDiscardSuggestion)="discardSuggestion($event)" + [useDefaultFeedbackSuggestionBadgeText]="useDefaultFeedbackSuggestionBadgeText" >

diff --git a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts index 7a8e29bbd4b8..e6b8bd398203 100644 --- a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts +++ b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts @@ -15,6 +15,7 @@ export class UnreferencedFeedbackComponent { @Input() readOnly: boolean; @Input() highlightDifferences: boolean; + @Input() useDefaultFeedbackSuggestionBadgeText: boolean = false; /** * In order to make it possible to mark unreferenced feedback based on the correction status, we assign reference ids to the unreferenced feedback diff --git a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.html b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.html index 11548f098779..752d4575f0b8 100644 --- a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.html +++ b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.html @@ -61,6 +61,7 @@ [highlightDifferences]="highlightDifferences" (feedbacksChange)="validateFeedback()" [readOnly]="readOnly" + [useDefaultFeedbackSuggestionBadgeText]="true" > diff --git a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts index da5c98c72e58..2126cdd98004 100644 --- a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts +++ b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts @@ -325,14 +325,21 @@ export class TextSubmissionAssessmentComponent extends TextAssessmentBaseCompone if (this.assessments.length > 0) { return; } - this.feedbackSuggestionsObservable = this.athenaService - .getFeedbackSuggestionsForText(this.exercise!.id!, this.submission!.id!) - .subscribe((feedbackSuggestions: TextBlockRef[]) => { - for (const suggestion of feedbackSuggestions) { + this.feedbackSuggestionsObservable = this.athenaService.getTextFeedbackSuggestions(this.exercise!, this.submission!).subscribe((feedbackSuggestions) => { + feedbackSuggestions.forEach((suggestion) => { + if (suggestion instanceof TextBlockRef) { + // referenced feedback suggestion - add to existing text blocks but avoid conflicts this.addAutomaticTextBlockRef(suggestion); + } else { + // unreferenced feedback suggestion - we can just add it + this.result!.feedbacks ??= []; + this.result!.feedbacks = [...this.result!.feedbacks, suggestion]; + // the unreferencedFeedback variable does not auto-update and needs to be updated manually + this.unreferencedFeedback = [...this.unreferencedFeedback, suggestion]; } - this.validateFeedback(); }); + this.validateFeedback(); + }); } /** diff --git a/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.html b/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.html index ba6a1e0b6556..5e4eb03290ce 100644 --- a/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.html +++ b/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.html @@ -1,5 +1,5 @@
- +
diff --git a/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.ts b/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.ts index 8b7db3fd5d12..f99baf73f449 100644 --- a/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.ts +++ b/src/main/webapp/app/exercises/text/assess/textblock-feedback-editor/textblock-feedback-editor.component.ts @@ -134,11 +134,11 @@ export class TextblockFeedbackEditorComponent implements AfterViewInit { * Hook to indicate changes in the feedback editor */ didChange(): void { - const feedbackTypeBefore = this.feedback.type; + const feedbackTextBefore = this.feedback.text; Feedback.updateFeedbackTypeOnChange(this.feedback); this.feedbackChange.emit(this.feedback); - // send event to analytics if the feedback type changed - if (feedbackTypeBefore !== this.feedback.type) { + // send event to analytics if the feedback was adapted (=> title text changes to have prefix with "adapted" in it) + if (feedbackTextBefore !== this.feedback.text) { this.textAssessmentAnalytics.sendAssessmentEvent(TextAssessmentEventType.EDIT_AUTOMATIC_FEEDBACK, this.feedback.type, this.textBlock.type); } } diff --git a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.html b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.html index 4d6b58fcd6ce..26589566a8b1 100644 --- a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.html +++ b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.html @@ -33,9 +33,9 @@

Exercise Details

-
Automatic Assessment Enabled
+
Enable feedback suggestions from Athena
- {{ (textExercise.assessmentType === AssessmentType.SEMI_AUTOMATIC ? 'global.generic.yes' : 'global.generic.no') | artemisTranslate }} + {{ (textExercise.feedbackSuggestionsEnabled ? 'global.generic.yes' : 'global.generic.no') | artemisTranslate }}
Example Solution
diff --git a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.html b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.html index 73ad6f76a0a1..8067bb405083 100644 --- a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.html +++ b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.html @@ -134,17 +134,18 @@

+
- + +
diff --git a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts index 420de57ac143..6111cf204327 100644 --- a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { TextExercise } from 'app/entities/text-exercise.model'; import { TextExerciseService } from './text-exercise.service'; @@ -23,6 +23,8 @@ import { AlertService } from 'app/core/util/alert.service'; import { EventManager } from 'app/core/util/event-manager.service'; import { faBan, faSave } from '@fortawesome/free-solid-svg-icons'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { AthenaService } from 'app/assessment/athena.service'; +import { Observable } from 'rxjs'; import { scrollToTopOfPage } from 'app/shared/util/utils'; import { loadCourseExerciseCategories } from 'app/exercises/shared/course-exercises/course-utils'; @@ -42,6 +44,7 @@ export class TextExerciseUpdateComponent implements OnInit { goBackAfterSaving = false; EditorMode = EditorMode; AssessmentType = AssessmentType; + isAthenaEnabled$: Observable | undefined; textExercise: TextExercise; backupExercise: TextExercise; @@ -67,8 +70,8 @@ export class TextExerciseUpdateComponent implements OnInit { private courseService: CourseManagementService, private eventManager: EventManager, private activatedRoute: ActivatedRoute, - private router: Router, private navigationUtilService: ArtemisNavigationUtilService, + private athenaService: AthenaService, ) {} get editType(): EditType { @@ -147,6 +150,8 @@ export class TextExerciseUpdateComponent implements OnInit { } }); + this.isAthenaEnabled$ = this.athenaService.isEnabled(); + this.isSaving = false; this.notificationText = undefined; } diff --git a/src/main/webapp/i18n/de/assessment.json b/src/main/webapp/i18n/de/assessment.json index 94c4f64b13de..98731e8398b7 100644 --- a/src/main/webapp/i18n/de/assessment.json +++ b/src/main/webapp/i18n/de/assessment.json @@ -10,14 +10,21 @@ "feedbackCommentPlaceholder": "Du kannst hier ein Feedback abgeben...", "additionalFeedbackCommentPlaceholder": "Du kannst hier zusätzliches Feedback abgeben...", "suggestion": { - "suggested": "Feedback-Vorschlag (nicht akzeptiert)", - "accepted": "Akzeptierter Feedback-Vorschlag", - "adapted": "Angepasster Feedback-Vorschlag" + "default": "Vorschlag", + "suggested": "Offener Vorschlag", + "accepted": "Angenommener Vorschlag", + "adapted": "Angepasster Vorschlag" }, "suggestionTitle": { - "suggested": "Dieser automatisch erstellte Feedback-Vorschlag ist nicht in der Gesamtbewertung enthalten, solange er nicht explizit akzeptiert wird.", - "accepted": "Automatisch erstellter Feedback-Vorschlag", - "adapted": "Automatisch erstellter Feedback-Vorschlag, bearbeitet" + "default": "Dieser Vorschlag wurde automatisch erstellt.", + "suggested": "Dieser automatisch erstellte Vorschlag ist nicht in der Gesamtbewertung enthalten, solange er nicht explizit akzeptiert wird.", + "accepted": "Automatisch erstellter angenommener Vorschlag", + "adapted": "Automatisch erstellter bearbeiteter Vorschlag" + }, + "suggestionPendingDialog": { + "title": "Ausstehende Feedback-Vorschläge", + "description": "Es sind ausstehende Feedback-Vorschläge verfügbar. Diese werden beim Absenden verworfen. Wirklich absenden?", + "discardAndSubmit": "Vorschläge verwerfen & absenden" }, "subsequentFeedback": "Dies ist ein zusätzliches Feedback, das nicht in die Gesamtbewertung eingeht.", "error": { diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index 410728f5d4e9..6b2b0b332a4f 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -96,6 +96,8 @@ "exampleSolutionPublicationDate": "Veröffentlichungsdatum der Beispiellösung", "exampleSolutionPublicationDateError": "Bei benoteten Übungen muss das Veröffentlichungsdatum der Beispiellösung nach dem Veröffentlichungsdatum, dem Startdatum und nach der Einreichungsfrist für Aufgaben sein, falls diese festgelegt wurden. Bitte überprüfe deine Auswahl nochmal.", "exampleSolutionPublicationDateWarning": "Veröffentlichungsdatum der Beispiellösung liegt vor Einreichungsfrist!", + "feedbackSuggestionsEnabled": "Aktiviere Feedback-Vorschläge von Athena", + "feedbackSuggestionsEnabledTooltip": "Wenn diese Option aktiviert ist, werden Tutor:innen Feedback-Vorschläge von Athena angezeigt.", "points": "Punktzahl", "pointsError": "Muss eine Zahl zwischen 1 und 9999 sein.", "bonusPoints": "Bonuspunkte", diff --git a/src/main/webapp/i18n/de/textExercise.json b/src/main/webapp/i18n/de/textExercise.json index 0f9f356b38f8..cb6ea8e26d6b 100644 --- a/src/main/webapp/i18n/de/textExercise.json +++ b/src/main/webapp/i18n/de/textExercise.json @@ -27,7 +27,6 @@ "submission": "Abgabe", "assessmentPending": "Bewertung ausstehend", "createExampleSubmissions": "Beispielabgabe erstellen", - "automaticAssessmentEnabled": "Automatische Bewertungsvorschläge aktiviert", "checkPlagiarism": "Auf Plagiate prüfen", "saveSuccessful": "Dein Antwort wurde erfolgreich gespeichert!", "error": "Ein Fehler ist aufgetreten. Bitte versuche es später noch einmal.", diff --git a/src/main/webapp/i18n/en/assessment.json b/src/main/webapp/i18n/en/assessment.json index 1c35c2845dff..adab83cab273 100644 --- a/src/main/webapp/i18n/en/assessment.json +++ b/src/main/webapp/i18n/en/assessment.json @@ -10,14 +10,21 @@ "feedbackCommentPlaceholder": "You can enter feedback here...", "additionalFeedbackCommentPlaceholder": "You can enter additional feedback here...", "suggestion": { - "suggested": "Suggestion (not accepted)", + "default": "Suggestion", + "suggested": "Open Suggestion", "accepted": "Accepted Suggestion", "adapted": "Adapted Suggestion" }, "suggestionTitle": { + "default": "This is an automatically generated feedback suggestion.", "suggested": "This automatically generated feedback suggestion is not included in the final assessment unless you explicitly accept it.", - "accepted": "Automatically generated feedback", - "adapted": "Automatically generated feedback, manually adapted" + "accepted": "Automatically generated accepted suggestion", + "adapted": "Automatically generated suggestion, manually adapted" + }, + "suggestionPendingDialog": { + "title": "Pending Feedback Suggestions", + "description": "There are pending feedback suggestions available. They will be discarded when submitting. Do you really want to submit?", + "discardAndSubmit": "Discard suggestions & submit" }, "subsequentFeedback": "This is subsequent feedback which is not included in the total score.", "error": { diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index f09fa7cfeebc..9c81702f5c54 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -57,6 +57,8 @@ "emptyExampleSolution": "(No example solution)", "exampleSolutionPublicationDateError": "For graded exercises, Example Solution Publication Date must be after Release, Start and Due Date if set. Please check your selection again", "exampleSolutionPublicationDateWarning": "Example Solution Publication Date is before Due Date!", + "feedbackSuggestionsEnabled": "Enable feedback suggestions from Athena", + "feedbackSuggestionsEnabledTooltip": "If enabled, Athena will suggest feedback to tutors on some submissions.", "assessmentCriterion": "Assessment Criterion", "assessmentInstructions": "Assessment Instructions", "assessmentInstruction": "Assessment Instruction: ", diff --git a/src/main/webapp/i18n/en/textExercise.json b/src/main/webapp/i18n/en/textExercise.json index 0e41002faafa..9cb8ae066a6b 100644 --- a/src/main/webapp/i18n/en/textExercise.json +++ b/src/main/webapp/i18n/en/textExercise.json @@ -27,7 +27,6 @@ "submission": "Submission", "assessmentPending": "Assessment pending", "createExampleSubmissions": "Create example submission", - "automaticAssessmentEnabled": "Automatic assessment suggestions enabled", "checkPlagiarism": "Check Plagiarism", "saveSuccessful": "Your answer was saved successfully!", "error": "An error occurred. Please try again later.", diff --git a/src/test/cypress/e2e/exercises/text/TextExerciseManagement.cy.ts b/src/test/cypress/e2e/exercises/text/TextExerciseManagement.cy.ts index 3b157b66ddcf..e29bb45bc05d 100644 --- a/src/test/cypress/e2e/exercises/text/TextExerciseManagement.cy.ts +++ b/src/test/cypress/e2e/exercises/text/TextExerciseManagement.cy.ts @@ -40,7 +40,6 @@ describe('Text exercise management', () => { textExerciseCreation.setDueDate(dayjs().add(1, 'days')); textExerciseCreation.setAssessmentDueDate(dayjs().add(2, 'days')); textExerciseCreation.typeMaxPoints(10); - textExerciseCreation.checkAutomaticAssessmentSuggestions(); const problemStatement = 'This is a problem statement'; const exampleSolution = 'E = mc^2'; textExerciseCreation.typeProblemStatement(problemStatement); diff --git a/src/test/cypress/support/pageobjects/exercises/text/TextExerciseCreationPage.ts b/src/test/cypress/support/pageobjects/exercises/text/TextExerciseCreationPage.ts index d227ed8ccfe7..b4ecc2a71816 100644 --- a/src/test/cypress/support/pageobjects/exercises/text/TextExerciseCreationPage.ts +++ b/src/test/cypress/support/pageobjects/exercises/text/TextExerciseCreationPage.ts @@ -31,10 +31,6 @@ export class TextExerciseCreationPage { cy.get('#field_points').type(maxPoints.toString()); } - checkAutomaticAssessmentSuggestions() { - cy.get('#automatic_assessment_enabled').check(); - } - typeProblemStatement(statement: string) { this.typeText('#problemStatement', statement); } diff --git a/src/test/java/de/tum/in/www1/artemis/AbstractAthenaTest.java b/src/test/java/de/tum/in/www1/artemis/AbstractAthenaTest.java index 294048400f91..55ec5a0e06b8 100644 --- a/src/test/java/de/tum/in/www1/artemis/AbstractAthenaTest.java +++ b/src/test/java/de/tum/in/www1/artemis/AbstractAthenaTest.java @@ -3,7 +3,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import de.tum.in.www1.artemis.connector.AthenaRequestMockProvider; @@ -12,9 +11,6 @@ */ public abstract class AbstractAthenaTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { - @Value("${artemis.athena.url}") - protected String athenaUrl; - @Autowired protected AthenaRequestMockProvider athenaRequestMockProvider; diff --git a/src/test/java/de/tum/in/www1/artemis/connector/AthenaRequestMockProvider.java b/src/test/java/de/tum/in/www1/artemis/connector/AthenaRequestMockProvider.java index 830c066dfd34..2ac32499d48d 100644 --- a/src/test/java/de/tum/in/www1/artemis/connector/AthenaRequestMockProvider.java +++ b/src/test/java/de/tum/in/www1/artemis/connector/AthenaRequestMockProvider.java @@ -26,8 +26,6 @@ @Profile("athena") public class AthenaRequestMockProvider { - private static final String MODULE_EXAMPLE = "module_example"; - private final RestTemplate restTemplate; private MockRestServiceServer mockServer; @@ -80,40 +78,53 @@ public void reset() throws Exception { } } + /** + * Returns the name of the test module for the given module type + * + * @param moduleType The type of the module: "text" or "programming" + */ + private static String getTestModuleName(String moduleType) { + return "module_" + moduleType + "_test"; + } + /** * Mocks the /submissions API from Athena used to submit all submissions of an exercise. * + * @param moduleType The type of the module: "text" or "programming" * @param expectedContents The expected contents of the request */ - public void mockSendSubmissionsAndExpect(RequestMatcher... expectedContents) { - ResponseActions responseActions = mockServer.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/text/module_text_cofee/submissions")) + public void mockSendSubmissionsAndExpect(String moduleType, RequestMatcher... expectedContents) { + ResponseActions responseActions = mockServer + .expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/" + moduleType + "/" + getTestModuleName(moduleType) + "/submissions")) .andExpect(method(HttpMethod.POST)).andExpect(content().contentType(MediaType.APPLICATION_JSON)); for (RequestMatcher matcher : expectedContents) { responseActions = responseActions.andExpect(matcher); } - // Response: {"status":200,"data":null,"module_name":"module_example"} - final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", MODULE_EXAMPLE).putNull("data"); + // Response: {"status":200,"data":null,"module_name":"module_text_test"} + final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", getTestModuleName(moduleType)).putNull("data"); responseActions.andRespond(withSuccess(node.toString(), MediaType.APPLICATION_JSON)); } /** * Mocks the /select_submission API from Athena used to select a submission for manual assessment * + * @param moduleType The type of the module: "text" or "programming" * @param submissionIdResponse The submission id to return from the endpoint. An ID of -1 means "no selection". * @param expectedContents The expected contents of the request */ - public void mockSelectSubmissionsAndExpect(long submissionIdResponse, RequestMatcher... expectedContents) { - ResponseActions responseActions = mockServerVeryShortTimeout.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/text/module_text_cofee/select_submission")) + public void mockSelectSubmissionsAndExpect(String moduleType, long submissionIdResponse, RequestMatcher... expectedContents) { + ResponseActions responseActions = mockServerVeryShortTimeout + .expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/" + moduleType + "/" + getTestModuleName(moduleType) + "/select_submission")) .andExpect(method(HttpMethod.POST)).andExpect(content().contentType(MediaType.APPLICATION_JSON)); for (RequestMatcher matcher : expectedContents) { responseActions.andExpect(matcher); } - // Response: {"status":200,"data":,"module_name":"module_example"} - final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", MODULE_EXAMPLE).put("data", submissionIdResponse); + // Response: e.g. {"status":200,"data":,"module_name":"module_example"} + final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", getTestModuleName(moduleType)).put("data", submissionIdResponse); responseActions.andRespond(withSuccess(node.toString(), MediaType.APPLICATION_JSON)); } @@ -121,27 +132,31 @@ public void mockSelectSubmissionsAndExpect(long submissionIdResponse, RequestMat /** * Mocks the /select_submission API from Athena used to retrieve the selected submission for manual assessment * with a server error. + * + * @param moduleType The type of the module: "text" or "programming" */ - public void mockGetSelectedSubmissionAndExpectNetworkingException() { - mockServerVeryShortTimeout.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/text/module_text_cofee/select_submission")).andExpect(method(HttpMethod.POST)) - .andRespond(withException(new SocketTimeoutException("Mocked SocketTimeoutException"))); + public void mockGetSelectedSubmissionAndExpectNetworkingException(String moduleType) { + mockServerVeryShortTimeout.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/" + moduleType + "/" + getTestModuleName(moduleType) + "/select_submission")) + .andExpect(method(HttpMethod.POST)).andRespond(withException(new SocketTimeoutException("Mocked SocketTimeoutException"))); } /** * Mocks the /feedbacks API from Athena used to submit feedbacks for a submission * + * @param moduleType The type of the module: "text" or "programming" * @param expectedContents The expected contents of the request */ - public void mockSendFeedbackAndExpect(RequestMatcher... expectedContents) { - ResponseActions responseActions = mockServer.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/text/module_text_cofee/feedbacks")) + public void mockSendFeedbackAndExpect(String moduleType, RequestMatcher... expectedContents) { + ResponseActions responseActions = mockServer + .expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/" + moduleType + "/" + getTestModuleName(moduleType) + "/feedbacks")) .andExpect(method(HttpMethod.POST)).andExpect(content().contentType(MediaType.APPLICATION_JSON)); for (RequestMatcher matcher : expectedContents) { responseActions.andExpect(matcher); } - // Response: {"status":200,"data":null,"module_name":"module_text_cofee"} - final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", "module_text_cofee").putNull("data"); + // Response: e.g. {"status":200,"data":null,"module_name":"module_text_test"} + final ObjectNode node = mapper.createObjectNode().put("status", 200).put("module_name", getTestModuleName(moduleType)).putNull("data"); responseActions.andRespond(withSuccess(node.toString(), MediaType.APPLICATION_JSON)); } @@ -150,21 +165,32 @@ public void mockSendFeedbackAndExpect(RequestMatcher... expectedContents) { * Mocks the /feedback_suggestions API from Athena used to retrieve feedback suggestions for a submission * Makes the endpoint return one example feedback suggestion. * + * @param moduleType The type of the module: "text" or "programming" * @param expectedContents The expected contents of the request */ - public void mockGetFeedbackSuggestionsAndExpect(RequestMatcher... expectedContents) { - ResponseActions responseActions = mockServer.expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/text/module_text_cofee/feedback_suggestions")) + public void mockGetFeedbackSuggestionsAndExpect(String moduleType, RequestMatcher... expectedContents) { + ResponseActions responseActions = mockServer + .expect(ExpectedCount.once(), requestTo(athenaUrl + "/modules/" + moduleType + "/" + getTestModuleName(moduleType) + "/feedback_suggestions")) .andExpect(method(HttpMethod.POST)).andExpect(content().contentType(MediaType.APPLICATION_JSON)); for (RequestMatcher matcher : expectedContents) { responseActions.andExpect(matcher); } - // Response: {"status":200,"data":,"module_name":"module_text_cofee"} - final ObjectNode suggestion = mapper.createObjectNode().put("id", 1L).put("exerciseId", 1L).put("submissionId", 1L).put("title", "Not so good") - .put("description", "This needs to be improved").put("credits", -1.0).put("indexStart", 3).put("indexEnd", 9); + ObjectNode suggestion = mapper.createObjectNode().put("id", 1L).put("exerciseId", 1L).put("submissionId", 1L).put("title", "Not so good") + .put("description", "This needs to be improved").put("credits", -1.0); + if (moduleType.equals("text")) { + suggestion = suggestion.put("indexStart", 3).put("indexEnd", 9); + } + else if (moduleType.equals("programming")) { + suggestion = suggestion.put("lineStart", 3).put("lineEnd", 4); + } + else { + throw new IllegalArgumentException("Unknown module type: " + moduleType); + } - final ObjectNode node = mapper.createObjectNode().put("module_name", "module_text_cofee").put("status", 200).set("data", mapper.createArrayNode().add(suggestion)); + final ObjectNode node = mapper.createObjectNode().put("module_name", getTestModuleName(moduleType)).put("status", 200).set("data", + mapper.createArrayNode().add(suggestion)); responseActions.andRespond(withSuccess(node.toString(), MediaType.APPLICATION_JSON)); } @@ -183,7 +209,7 @@ public void mockHealthStatusSuccess(boolean exampleModuleHealthy) { moduleExampleNode.set("url", mapper.valueToTree("http://localhost:5001")); moduleExampleNode.set("type", mapper.valueToTree("programming")); moduleExampleNode.set("healthy", mapper.valueToTree(exampleModuleHealthy)); - modules.set(MODULE_EXAMPLE, moduleExampleNode); + modules.set("module_example", moduleExampleNode); node.set("modules", modules); final ResponseActions responseActions = mockServerShortTimeout.expect(ExpectedCount.once(), requestTo(athenaUrl + "/health")).andExpect(method(HttpMethod.GET)); @@ -199,9 +225,9 @@ public void mockHealthStatusFailure() { } /** - * Ensures that there is no request to Athena + * Verify the requests made to Athena */ - public void ensureNoRequest() { + public void verify() { mockServer.verify(); } diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/AthenaResourceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/AthenaResourceIntegrationTest.java index 52020df48191..8e532a6ae1e3 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/AthenaResourceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/AthenaResourceIntegrationTest.java @@ -2,57 +2,83 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.ZonedDateTime; import java.util.List; +import java.util.zip.ZipFile; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; import de.tum.in.www1.artemis.AbstractAthenaTest; -import de.tum.in.www1.artemis.domain.Feedback; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; -import de.tum.in.www1.artemis.domain.enumeration.InitializationState; -import de.tum.in.www1.artemis.domain.enumeration.Language; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.enumeration.*; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; import de.tum.in.www1.artemis.participation.ParticipationFactory; -import de.tum.in.www1.artemis.repository.StudentParticipationRepository; -import de.tum.in.www1.artemis.repository.TextSubmissionRepository; +import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.user.UserUtilService; class AthenaResourceIntegrationTest extends AbstractAthenaTest { private static final String TEST_PREFIX = "athenaintegration"; + @Value("${artemis.athena.secret}") + private String athenaSecret; + @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + @Autowired private ExerciseUtilService exerciseUtilService; @Autowired private TextSubmissionRepository textSubmissionRepository; + @Autowired + private ProgrammingSubmissionRepository programmingSubmissionRepository; + @Autowired private StudentParticipationRepository studentParticipationRepository; @Autowired private UserUtilService userUtilService; + @Autowired + private ResultRepository resultRepository; + + @Autowired + private ProgrammingExerciseRepository programmingExerciseRepository; + + @Autowired + private FeedbackRepository feedbackRepository; + private TextExercise textExercise; private TextSubmission textSubmission; + private ProgrammingExercise programmingExercise; + + private ProgrammingSubmission programmingSubmission; + @BeforeEach protected void initTestCase() { super.initTestCase(); - userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); - var course = textExerciseUtilService.addCourseWithOneReleasedTextExercise(); - textExercise = exerciseUtilService.findTextExerciseWithTitle(course.getExercises(), "Text"); + + var textCourse = textExerciseUtilService.addCourseWithOneReleasedTextExercise(); + textExercise = exerciseUtilService.findTextExerciseWithTitle(textCourse.getExercises(), "Text"); textSubmission = ParticipationFactory.generateTextSubmission("This is a test sentence. This is a second test sentence. This is a third test sentence.", Language.ENGLISH, true); var studentParticipation = ParticipationFactory.generateStudentParticipation(InitializationState.FINISHED, textExercise, @@ -61,31 +87,146 @@ protected void initTestCase() { textSubmission.setParticipation(studentParticipation); textSubmissionRepository.save(textSubmission); - athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect(); - } + var programmingCourse = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + programmingExercise = exerciseUtilService.findProgrammingExerciseWithTitle(programmingCourse.getExercises(), "Programming"); + // Allow manual results + programmingExercise.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); + programmingExercise.setBuildAndTestStudentSubmissionsAfterDueDate(ZonedDateTime.now().minusDays(1)); + programmingExerciseRepository.save(programmingExercise); - @AfterEach - void tearDown() throws Exception { - athenaRequestMockProvider.reset(); + programmingSubmission = ParticipationFactory.generateProgrammingSubmission(true); + var programmingParticipation = ParticipationFactory.generateStudentParticipation(InitializationState.FINISHED, programmingExercise, + userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + studentParticipationRepository.save(programmingParticipation); + programmingSubmission.setParticipation(programmingParticipation); + programmingSubmissionRepository.save(programmingSubmission); } @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") - void testGetFeedbackSuggestionsSuccess() throws Exception { - List response = request.getList("/api/athena/exercises/" + textExercise.getId() + "/submissions/" + textSubmission.getId() + "/feedback-suggestions", + void testGetFeedbackSuggestionsSuccessText() throws Exception { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("text"); + List response = request.getList("/api/athena/text-exercises/" + textExercise.getId() + "/submissions/" + textSubmission.getId() + "/feedback-suggestions", HttpStatus.OK, Feedback.class); assertThat(response).as("response is not empty").isNotEmpty(); } @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") - void testGetFeedbackSuggestionsNotFound() throws Exception { - request.get("/api/athena/exercises/9999/submissions/9999/feedback-suggestions", HttpStatus.NOT_FOUND, List.class); + void testGetFeedbackSuggestionsSuccessProgramming() throws Exception { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("programming"); + List response = request.getList( + "/api/athena/programming-exercises/" + programmingExercise.getId() + "/submissions/" + programmingSubmission.getId() + "/feedback-suggestions", HttpStatus.OK, + Feedback.class); + assertThat(response).as("response is not empty").isNotEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetTextFeedbackSuggestionsNotFound() throws Exception { + request.get("/api/athena/text-exercises/9999/submissions/9999/feedback-suggestions", HttpStatus.NOT_FOUND, List.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetProgrammingFeedbackSuggestionsNotFound() throws Exception { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("programming"); + request.get("/api/athena/programming-exercises/9999/submissions/9999/feedback-suggestions", HttpStatus.NOT_FOUND, List.class); } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "STUDENT") - void testGetFeedbackSuggestionsAccessForbidden() throws Exception { - request.get("/api/athena/exercises/" + textExercise.getId() + "/submissions/" + textSubmission.getId() + "/feedback-suggestions", HttpStatus.FORBIDDEN, List.class); + void testGetTextFeedbackSuggestionsAccessForbidden() throws Exception { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("text"); + request.get("/api/athena/text-exercises/" + textExercise.getId() + "/submissions/" + textSubmission.getId() + "/feedback-suggestions", HttpStatus.FORBIDDEN, List.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "STUDENT") + void testGetProgrammingFeedbackSuggestionsAccessForbidden() throws Exception { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("programming"); + request.get("/api/athena/programming-exercises/" + textExercise.getId() + "/submissions/" + programmingSubmission.getId() + "/feedback-suggestions", HttpStatus.FORBIDDEN, + List.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetFeedbackSuggestionsAthenaEnabled() throws Exception { + // Create example participation with submission + var participation = new StudentParticipation(); + participation.setParticipant(userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + participation.setExercise(programmingExercise); + participation = studentParticipationRepository.save(participation); + programmingSubmission.setParticipation(participation); + programmingSubmissionRepository.save(programmingSubmission); + // Create example result + var result = new Result(); + result.setScore(1.0); + result.setSubmission(programmingSubmission); + result.setParticipation(participation); + result.setAssessmentType(AssessmentType.MANUAL); + // Create example feedback so that Athena can process it + var feedback = new Feedback(); + feedback.setCredits(1.0); + feedback.setResult(result); + result.setFeedbacks(List.of(feedback)); + + result = resultRepository.save(result); + feedbackRepository.save(feedback); + + // Enable Athena for the exercise + programmingExercise.setFeedbackSuggestionsEnabled(true); + programmingExerciseRepository.save(programmingExercise); + + Result response = request.putWithResponseBody("/api/participations/" + participation.getId() + "/manual-results?submit=true", result, Result.class, HttpStatus.OK); + + // Check that nothing went wrong, even with Athena enabled + assertThat(response).as("response is not null").isNotNull(); + } + + @ParameterizedTest + @ValueSource(strings = { "repository/template", "repository/solution", "repository/tests" }) + void testRepositoryExportEndpoint(String urlSuffix) throws Exception { + // Enable Athena for the exercise + programmingExercise.setFeedbackSuggestionsEnabled(true); + programmingExerciseRepository.save(programmingExercise); + + // Add Git repo for export + programmingExerciseUtilService.createGitRepository(); + + // Get exports from endpoint + var authHeaders = new HttpHeaders(); + authHeaders.add("Authorization", athenaSecret); + var repoZip = request.getFile("/api/public/athena/programming-exercises/" + programmingExercise.getId() + "/" + urlSuffix, HttpStatus.OK, new LinkedMultiValueMap<>(), + authHeaders, null); + + // Check that ZIP contains file + try (var zipFile = new ZipFile(repoZip)) { + assertThat(zipFile.size()).as("zip file contains files").isGreaterThan(0); + } + } + + @ParameterizedTest + @ValueSource(strings = { "repository/template", "repository/solution", "repository/tests", "submissions/100/repository" }) + void testRepositoryExportEndpointsFailWhenAthenaNotEnabled(String urlSuffix) throws Exception { + var authHeaders = new HttpHeaders(); + authHeaders.add("Authorization", athenaSecret); + + // Expect status 503 because Athena is not enabled for the exercise + request.get("/api/public/athena/programming-exercises/" + programmingExercise.getId() + "/" + urlSuffix, HttpStatus.SERVICE_UNAVAILABLE, Result.class, authHeaders); + } + + @ParameterizedTest + @ValueSource(strings = { "repository/template", "repository/solution", "repository/tests", "submissions/100/repository" }) + void testRepositoryExportEndpointsFailWithWrongAuthentication(String urlSuffix) throws Exception { + var authHeaders = new HttpHeaders(); + authHeaders.add("Authorization", athenaSecret + "-wrong"); + + // Enable Athena for the exercise + programmingExercise.setFeedbackSuggestionsEnabled(true); + programmingExerciseRepository.save(programmingExercise); + + // Expect status 403 because the Authorization header is wrong + request.get("/api/public/athena/programming-exercises/" + programmingExercise.getId() + "/" + urlSuffix, HttpStatus.FORBIDDEN, Result.class, authHeaders); } } diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseUtilService.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseUtilService.java index ac4f88c1c782..354c3fafd47b 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseUtilService.java @@ -1,12 +1,24 @@ package de.tum.in.www1.artemis.exercise.programmingexercise; import static org.assertj.core.api.Assertions.assertThat; - +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.ZonedDateTime; import java.util.*; import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.course.CourseFactory; @@ -23,7 +35,9 @@ import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.hestia.*; +import de.tum.in.www1.artemis.service.connectors.GitService; import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.LocalRepository; import de.tum.in.www1.artemis.util.TestConstants; /** @@ -36,6 +50,9 @@ public class ProgrammingExerciseUtilService { private static final ZonedDateTime futureFutureTimestamp = ZonedDateTime.now().plusDays(2); + @Value("${artemis.version-control.default-branch:main}") + protected String defaultBranch; + @Autowired private TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepo; @@ -105,6 +122,25 @@ public class ProgrammingExerciseUtilService { @Autowired private UserUtilService userUtilService; + @Autowired + private GitService gitService; + + /** + * Create an example programming exercise + * + * @return the created programming exercise + */ + public ProgrammingExercise createSampleProgrammingExercise() { + var programmingExercise = new ProgrammingExercise(); + programmingExercise.setTitle("Title"); + programmingExercise.setShortName("Shortname"); + programmingExercise.setProgrammingLanguage(ProgrammingLanguage.JAVA); + programmingExercise.setMaxPoints(10.0); + programmingExercise.setBonusPoints(0.0); + programmingExercise = programmingExerciseRepository.save(programmingExercise); + return programmingExercise; + } + /** * Adds template participation to the provided programming exercise. * @@ -774,4 +810,27 @@ public void addCodeHintsToProgrammingExercise(ProgrammingExercise programmingExe public ProgrammingExercise loadProgrammingExerciseWithEagerReferences(ProgrammingExercise lazyExercise) { return programmingExerciseTestRepository.findOneWithEagerEverything(lazyExercise.getId()); } + + /** + * Creates an example repository and makes the given GitService return it when asked to check it out. + * + * @throws Exception if creating the repository fails + */ + public void createGitRepository() throws Exception { + // Create repository + var testRepo = new LocalRepository(defaultBranch); + testRepo.configureRepos("testLocalRepo", "testOriginRepo"); + // Add test file to the repository folder + Path filePath = Path.of(testRepo.localRepoFile + "/Test.java"); + var file = Files.createFile(filePath).toFile(); + FileUtils.write(file, "Test", Charset.defaultCharset()); + // Create mock repo that has the file + var mockRepository = mock(Repository.class); + doReturn(true).when(mockRepository).isValidFile(any()); + doReturn(testRepo.localRepoFile.toPath()).when(mockRepository).getLocalPath(); + // Mock Git service operations + doReturn(mockRepository).when(gitService).getOrCheckoutRepository(any(), any(), any(), anyBoolean(), anyString()); + doNothing().when(gitService).resetToOriginHead(any()); + doReturn(Paths.get("repo.zip")).when(gitService).zipRepositoryWithParticipation(any(), anyString(), anyBoolean()); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/textexercise/TextExerciseUtilService.java b/src/test/java/de/tum/in/www1/artemis/exercise/textexercise/TextExerciseUtilService.java index c17720d7cca1..99e74040a6ca 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/textexercise/TextExerciseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/textexercise/TextExerciseUtilService.java @@ -83,15 +83,35 @@ public class TextExerciseUtilService { /** * Creates and saves a TextExercise with feedback suggestions enabled. * + * @param count expected size of TextBlock set + * @return Set of dummy TextBlocks + */ + public Set generateTextBlocks(int count) { + Set textBlocks = new HashSet<>(); + TextBlock textBlock; + for (int i = 0; i < count; i++) { + textBlock = new TextBlock(); + textBlock.setText("TextBlock" + i); + textBlocks.add(textBlock); + } + return textBlocks; + } + + /** + * Create an example text exercise + * + * @param course The course to which the exercise belongs + * @return the created text exercise * @param course The Course to which the exercise belongs * @return The created TextExercise */ public TextExercise createSampleTextExercise(Course course) { - TextExercise textExercise = new TextExercise(); + var textExercise = new TextExercise(); textExercise.setCourse(course); textExercise.setTitle("Title"); textExercise.setShortName("Shortname"); - textExercise.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); + textExercise.setMaxPoints(10.0); + textExercise.setBonusPoints(0.0); textExercise = textExerciseRepository.save(textExercise); return textExercise; } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingServiceTest.java index b7cbbe6053cd..4a6aac4de9df 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSendingServiceTest.java @@ -11,77 +11,183 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.util.ReflectionTestUtils; import de.tum.in.www1.artemis.AbstractAthenaTest; -import de.tum.in.www1.artemis.domain.Feedback; -import de.tum.in.www1.artemis.domain.TextBlock; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.FeedbackType; +import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; +import de.tum.in.www1.artemis.domain.modeling.ModelingSubmission; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; +import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.TextBlockRepository; +import de.tum.in.www1.artemis.repository.TextExerciseRepository; class AthenaFeedbackSendingServiceTest extends AbstractAthenaTest { + @Autowired + private AthenaModuleUrlHelper athenaModuleUrlHelper; + @Mock private TextBlockRepository textBlockRepository; + @Mock + private TextExerciseRepository textExerciseRepository; + + @Mock + private ProgrammingExerciseRepository programmingExerciseRepository; + @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + private AthenaFeedbackSendingService athenaFeedbackSendingService; private TextExercise textExercise; private TextSubmission textSubmission; - private Feedback feedback; + private Feedback textFeedback; private TextBlock textBlock; + private ProgrammingExercise programmingExercise; + + private ProgrammingSubmission programmingSubmission; + + private Feedback programmingFeedback; + @BeforeEach void setUp() { - athenaFeedbackSendingService = new AthenaFeedbackSendingService(athenaRequestMockProvider.getRestTemplate(), textBlockRepository); - ReflectionTestUtils.setField(athenaFeedbackSendingService, "athenaUrl", athenaUrl); + athenaFeedbackSendingService = new AthenaFeedbackSendingService(athenaRequestMockProvider.getRestTemplate(), athenaModuleUrlHelper, + new AthenaDTOConverter(textBlockRepository, textExerciseRepository, programmingExerciseRepository)); athenaRequestMockProvider.enableMockingOfRequests(); textExercise = textExerciseUtilService.createSampleTextExercise(null); + textExercise.setFeedbackSuggestionsEnabled(true); + when(textExerciseRepository.findByIdWithGradingCriteriaElseThrow(textExercise.getId())).thenReturn(textExercise); textSubmission = new TextSubmission(2L).text("Test - This is what the feedback references - Submission"); textBlock = new TextBlock().startIndex(7).endIndex(46).text("This is what the feedback references").submission(textSubmission); textBlock.computeId(); + when(textBlockRepository.findById(textBlock.getId())).thenReturn(Optional.of(textBlock)); - feedback = new Feedback().type(FeedbackType.MANUAL).credits(5.0).reference(textBlock.getId()); - feedback.setId(3L); + textFeedback = new Feedback().type(FeedbackType.MANUAL).credits(5.0).reference(textBlock.getId()); + textFeedback.setId(3L); + var result = new Result(); + textFeedback.setResult(result); + var participation = new StudentParticipation(); + participation.setExercise(textExercise); + result.setParticipation(participation); + + programmingExercise = programmingExerciseUtilService.createSampleProgrammingExercise(); + programmingExercise.setFeedbackSuggestionsEnabled(true); + when(programmingExerciseRepository.findByIdWithGradingCriteriaElseThrow(programmingExercise.getId())).thenReturn(programmingExercise); + + programmingSubmission = new ProgrammingSubmission(); + programmingSubmission.setParticipation(new StudentParticipation()); + programmingSubmission.getParticipation().setExercise(programmingExercise); + programmingSubmission.setId(2L); + + programmingFeedback = new Feedback().type(FeedbackType.MANUAL).credits(5.0).reference("test"); + programmingFeedback.setId(3L); + programmingFeedback.setReference("file:src/Test.java_line:12"); + var programmingResult = new Result(); + programmingFeedback.setResult(programmingResult); + programmingResult.setParticipation(programmingSubmission.getParticipation()); + } - when(textBlockRepository.findById(textBlock.getId())).thenReturn(Optional.of(textBlock)); + @Test + void testFeedbackSendingText() { + athenaRequestMockProvider.mockSendFeedbackAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), + jsonPath("$.submission.id").value(textSubmission.getId()), jsonPath("$.submission.exerciseId").value(textExercise.getId()), + jsonPath("$.feedbacks[0].id").value(textFeedback.getId()), jsonPath("$.feedbacks[0].exerciseId").value(textExercise.getId()), + jsonPath("$.feedbacks[0].title").value(textFeedback.getText()), jsonPath("$.feedbacks[0].description").value(textFeedback.getDetailText()), + jsonPath("$.feedbacks[0].credits").value(textFeedback.getCredits()), jsonPath("$.feedbacks[0].credits").value(textFeedback.getCredits()), + jsonPath("$.feedbacks[0].indexStart").value(textBlock.getStartIndex()), jsonPath("$.feedbacks[0].indexEnd").value(textBlock.getEndIndex())); + + athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of(textFeedback)); + athenaRequestMockProvider.verify(); + } + + private GradingCriterion createExampleGradingCriterion() { + var gradingInstruction = new GradingInstruction(); + gradingInstruction.setId(101L); + gradingInstruction.setCredits(1.0); + gradingInstruction.setGradingScale("good"); + gradingInstruction.setInstructionDescription("Give this feedback if xyz"); + gradingInstruction.setFeedback("Well done!"); + gradingInstruction.setUsageCount(1); + var gradingCriterion = new GradingCriterion(); + gradingCriterion.setId(1L); + gradingCriterion.setTitle("Test"); + gradingCriterion.setExercise(textExercise); + gradingCriterion.setStructuredGradingInstructions(List.of(gradingInstruction)); + return gradingCriterion; + } + + @Test + void testFeedbackSendingTextWithGradingInstruction() { + textExercise.setGradingCriteria(List.of(createExampleGradingCriterion())); + textExerciseRepository.save(textExercise); + + textFeedback.setGradingInstruction(textExercise.getGradingCriteria().get(0).getStructuredGradingInstructions().get(0)); + + athenaRequestMockProvider.mockSendFeedbackAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.exercise.gradingCriteria[0].id").value(1), + jsonPath("$.exercise.gradingCriteria[0].title").value("Test"), jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].id").value(101), + jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].credits").value(1.0), + jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].gradingScale").value("good"), + jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].instructionDescription").value("Give this feedback if xyz"), + jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].feedback").value("Well done!"), + jsonPath("$.exercise.gradingCriteria[0].structuredGradingInstructions[0].usageCount").value(1), jsonPath("$.submission.id").value(textSubmission.getId()), + jsonPath("$.submission.exerciseId").value(textExercise.getId()), jsonPath("$.feedbacks[0].id").value(textFeedback.getId()), + jsonPath("$.feedbacks[0].exerciseId").value(textExercise.getId()), jsonPath("$.feedbacks[0].title").value(textFeedback.getText()), + jsonPath("$.feedbacks[0].description").value(textFeedback.getDetailText()), jsonPath("$.feedbacks[0].credits").value(textFeedback.getCredits()), + jsonPath("$.feedbacks[0].credits").value(textFeedback.getCredits()), jsonPath("$.feedbacks[0].indexStart").value(textBlock.getStartIndex()), + jsonPath("$.feedbacks[0].indexEnd").value(textBlock.getEndIndex()), jsonPath("$.feedbacks[0].structuredGradingInstructionId").value(101)); + + athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of(textFeedback)); + athenaRequestMockProvider.verify(); + } + + @Test + void testFeedbackSendingProgramming() { + athenaRequestMockProvider.mockSendFeedbackAndExpect("programming", jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.submission.id").value(programmingSubmission.getId()), jsonPath("$.submission.exerciseId").value(programmingExercise.getId()), + jsonPath("$.feedbacks[0].id").value(programmingFeedback.getId()), jsonPath("$.feedbacks[0].exerciseId").value(programmingExercise.getId()), + jsonPath("$.feedbacks[0].title").value(programmingFeedback.getText()), jsonPath("$.feedbacks[0].description").value(programmingFeedback.getDetailText()), + jsonPath("$.feedbacks[0].credits").value(programmingFeedback.getCredits()), jsonPath("$.feedbacks[0].credits").value(programmingFeedback.getCredits()), + jsonPath("$.feedbacks[0].lineStart").value(12), jsonPath("$.feedbacks[0].lineEnd").value(12)); + + athenaFeedbackSendingService.sendFeedback(programmingExercise, programmingSubmission, List.of(programmingFeedback)); + athenaRequestMockProvider.verify(); } @Test - void testFeedbackSending() { - athenaRequestMockProvider.mockSendFeedbackAndExpect(jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submission.id").value(textSubmission.getId()), - jsonPath("$.submission.exerciseId").value(textExercise.getId()), jsonPath("$.feedbacks[0].id").value(feedback.getId()), - jsonPath("$.feedbacks[0].exerciseId").value(textExercise.getId()), jsonPath("$.feedbacks[0].title").value(feedback.getText()), - jsonPath("$.feedbacks[0].description").value(feedback.getDetailText()), jsonPath("$.feedbacks[0].credits").value(feedback.getCredits()), - jsonPath("$.feedbacks[0].credits").value(feedback.getCredits()), jsonPath("$.feedbacks[0].indexStart").value(textBlock.getStartIndex()), - jsonPath("$.feedbacks[0].indexEnd").value(textBlock.getEndIndex())); - - athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of(feedback)); + void testFeedbackSendingUnsupportedExerciseType() { + athenaRequestMockProvider.mockSendFeedbackAndExpect("modeling"); + assertThatThrownBy(() -> athenaFeedbackSendingService.sendFeedback(new ModelingExercise(), new ModelingSubmission(), List.of())) + .isInstanceOf(IllegalArgumentException.class); } @Test void testEmptyFeedbackNotSending() { - athenaRequestMockProvider.ensureNoRequest(); athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of()); + athenaFeedbackSendingService.sendFeedback(programmingExercise, programmingSubmission, List.of()); + athenaRequestMockProvider.verify(); // Ensure that there was no request } @Test void testSendFeedbackWithFeedbackSuggestionsDisabled() { - textExercise.setAssessmentType(AssessmentType.MANUAL); // disable feedback suggestions - assertThatThrownBy(() -> athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of(feedback))).isInstanceOf(IllegalArgumentException.class); + textExercise.setFeedbackSuggestionsEnabled(false); + assertThatThrownBy(() -> athenaFeedbackSendingService.sendFeedback(textExercise, textSubmission, List.of(textFeedback))).isInstanceOf(IllegalArgumentException.class); + programmingExercise.setFeedbackSuggestionsEnabled(false); + assertThatThrownBy(() -> athenaFeedbackSendingService.sendFeedback(programmingExercise, programmingSubmission, List.of(programmingFeedback))) + .isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsServiceTest.java index 912f8604da6b..51dea71ae4f5 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaFeedbackSuggestionsServiceTest.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.service.connectors.athena; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; import java.util.List; @@ -8,42 +9,83 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractAthenaTest; -import de.tum.in.www1.artemis.domain.TextBlockRef; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.exception.NetworkingException; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; +import de.tum.in.www1.artemis.service.dto.athena.ProgrammingFeedbackDTO; +import de.tum.in.www1.artemis.service.dto.athena.TextFeedbackDTO; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; class AthenaFeedbackSuggestionsServiceTest extends AbstractAthenaTest { + private static final String TEST_PREFIX = "athenafeedbacksuggestionsservicetest"; + @Autowired private AthenaFeedbackSuggestionsService athenaFeedbackSuggestionsService; @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + private TextExercise textExercise; private TextSubmission textSubmission; + private ProgrammingExercise programmingExercise; + + private ProgrammingSubmission programmingSubmission; + @BeforeEach void setUp() { athenaRequestMockProvider.enableMockingOfRequests(); textExercise = textExerciseUtilService.createSampleTextExercise(null); textSubmission = new TextSubmission(2L).text("This is a text submission"); + textSubmission.setParticipation(new StudentParticipation().exercise(textExercise)); + + programmingExercise = programmingExerciseUtilService.createSampleProgrammingExercise(); + programmingSubmission = new ProgrammingSubmission(); + programmingSubmission.setId(3L); + programmingSubmission.setParticipation(new StudentParticipation().exercise(programmingExercise)); } @Test - void testFeedbackSuggestions() throws NetworkingException { - athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect(jsonPath("$.exercise.id").value(textExercise.getId()), + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testFeedbackSuggestionsText() throws NetworkingException { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.exercise.title").value(textExercise.getTitle()), jsonPath("$.submission.id").value(textSubmission.getId()), jsonPath("$.submission.text").value(textSubmission.getText())); - List suggestions = athenaFeedbackSuggestionsService.getFeedbackSuggestions(textExercise, textSubmission); - assertThat(suggestions.get(0).feedback().getText()).isEqualTo("Not so good"); - assertThat(suggestions.get(0).block().getStartIndex()).isEqualTo(3); - assertThat(suggestions.get(0).feedback().getReference()).isEqualTo(suggestions.get(0).block().getId()); + List suggestions = athenaFeedbackSuggestionsService.getTextFeedbackSuggestions(textExercise, textSubmission); + assertThat(suggestions.get(0).title()).isEqualTo("Not so good"); + assertThat(suggestions.get(0).indexStart()).isEqualTo(3); + athenaRequestMockProvider.verify(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testFeedbackSuggestionsProgramming() throws NetworkingException { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("programming", jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.exercise.title").value(programmingExercise.getTitle()), jsonPath("$.submission.id").value(programmingSubmission.getId()), + jsonPath("$.submission.repositoryUrl") + .value("https://artemislocal.ase.in.tum.de/api/public/athena/programming-exercises/" + programmingExercise.getId() + "/submissions/3/repository")); + List suggestions = athenaFeedbackSuggestionsService.getProgrammingFeedbackSuggestions(programmingExercise, programmingSubmission); + assertThat(suggestions.get(0).title()).isEqualTo("Not so good"); + assertThat(suggestions.get(0).lineStart()).isEqualTo(3); + athenaRequestMockProvider.verify(); + } + + @Test + void testFeedbackSuggestionsIdConflict() { + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("text"); + var otherExercise = new TextExercise(); + textSubmission.setParticipation(new StudentParticipation().exercise(otherExercise)); // Add submission to wrong exercise + assertThatExceptionOfType(ConflictException.class).isThrownBy(() -> athenaFeedbackSuggestionsService.getTextFeedbackSuggestions(textExercise, textSubmission)); } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaHealthIndicatorTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaHealthIndicatorTest.java index bec0c2bc8376..a43b9d210eae 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaHealthIndicatorTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaHealthIndicatorTest.java @@ -26,6 +26,7 @@ void healthUp() { final Health health = athenaHealthIndicator.health(); assertThat(health.getStatus()).isEqualTo(Status.UP); assertThat(health.getDetails().get(MODULE_EXAMPLE).toString()).contains(GREEN_CIRCLE); + athenaRequestMockProvider.verify(); } @Test @@ -34,6 +35,7 @@ void healthUpExampleModuleDown() { final Health health = athenaHealthIndicator.health(); assertThat(health.getStatus()).isEqualTo(Status.UP); assertThat(health.getDetails().get(MODULE_EXAMPLE).toString()).contains(RED_CIRCLE); + athenaRequestMockProvider.verify(); } @Test @@ -41,5 +43,6 @@ void healthDown() { athenaRequestMockProvider.mockHealthStatusFailure(); final Health health = athenaHealthIndicator.health(); assertThat(health.getStatus()).isEqualTo(Status.DOWN); + athenaRequestMockProvider.verify(); } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportServiceTest.java new file mode 100644 index 000000000000..c6e0bcf70550 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaRepositoryExportServiceTest.java @@ -0,0 +1,94 @@ +package de.tum.in.www1.artemis.service.connectors.athena; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.io.File; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.LocalRepository; +import de.tum.in.www1.artemis.web.rest.errors.ServiceUnavailableException; + +class AthenaRepositoryExportServiceTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "athenarepositoryexport"; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private ProgrammingExerciseRepository programmingExerciseRepository; + + @Autowired + private AthenaRepositoryExportService athenaRepositoryExportService; + + private final LocalRepository testRepo = new LocalRepository(defaultBranch); + + @BeforeEach + void initTestCase() throws Exception { + userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 1); + + testRepo.configureRepos("testLocalRepo", "testOriginRepo"); + + // add test file to the repository folder + Path filePath = Path.of(testRepo.localRepoFile + "/Test.java"); + var file = Files.createFile(filePath).toFile(); + // write content to the created file + FileUtils.write(file, "Test", Charset.defaultCharset()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1") + void shouldExportRepository() throws Exception { + Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + var programmingExercise = programmingExerciseRepository.findByCourseIdWithLatestResultForTemplateSolutionParticipations(course.getId()).stream().iterator().next(); + programmingExercise.setFeedbackSuggestionsEnabled(true); + programmingExerciseUtilService.addTemplateParticipationForProgrammingExercise(programmingExercise); + programmingExerciseUtilService.addSolutionParticipationForProgrammingExercise(programmingExercise); + var programmingExerciseWithId = programmingExerciseRepository.save(programmingExercise); + + ProgrammingExerciseStudentParticipation participation = new ProgrammingExerciseStudentParticipation(); + participation.setRepositoryUrl("git://test"); + participation.setProgrammingExercise(programmingExerciseWithId); + ProgrammingSubmission submission = new ProgrammingSubmission(); + submission.setParticipation(participation); + var programmingSubmissionWithId = programmingExerciseUtilService.addProgrammingSubmission(programmingExerciseWithId, submission, TEST_PREFIX + "student1"); + + programmingExerciseUtilService.createGitRepository(); + + File resultStudentRepo = athenaRepositoryExportService.exportRepository(programmingExerciseWithId.getId(), programmingSubmissionWithId.getId(), null); + File resultSolutionRepo = athenaRepositoryExportService.exportRepository(programmingExerciseWithId.getId(), programmingSubmissionWithId.getId(), RepositoryType.SOLUTION); + + assertThat(resultStudentRepo.toPath()).isEqualTo(Paths.get("repo.zip")); // The student repository ZIP is returned + assertThat(resultSolutionRepo).exists(); // The solution repository ZIP can actually be created in the test + } + + @Test + void shouldThrowServiceUnavailableWhenFeedbackSuggestionsNotEnabled() { + var programmingExercise = new ProgrammingExercise(); + programmingExercise.setFeedbackSuggestionsEnabled(false); + var programmingExerciseWithId = programmingExerciseRepository.save(programmingExercise); + + assertThatExceptionOfType(ServiceUnavailableException.class) + .isThrownBy(() -> athenaRepositoryExportService.exportRepository(programmingExerciseWithId.getId(), null, null)); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionServiceTest.java index 76bb1c57e755..f33abbd3775d 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSelectionServiceTest.java @@ -10,74 +10,167 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractAthenaTest; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; +import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; +import de.tum.in.www1.artemis.repository.TextExerciseRepository; class AthenaSubmissionSelectionServiceTest extends AbstractAthenaTest { + private static final String TEST_PREFIX = "athenasubmissionselectionservicetest"; + @Autowired private AthenaSubmissionSelectionService athenaSubmissionSelectionService; @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private TextExerciseRepository textExerciseRepository; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private ProgrammingExerciseRepository programmingExerciseRepository; + private TextExercise textExercise; private TextSubmission textSubmission1; private TextSubmission textSubmission2; + private ProgrammingExercise programmingExercise; + + private ProgrammingSubmission programmingSubmission1; + + private ProgrammingSubmission programmingSubmission2; + @BeforeEach void setUp() { athenaRequestMockProvider.enableMockingOfRequests(); textExercise = textExerciseUtilService.createSampleTextExercise(null); + textExercise.setFeedbackSuggestionsEnabled(true); + textExercise.setGradingCriteria(List.of(new GradingCriterion())); + textExerciseRepository.save(textExercise); textSubmission1 = new TextSubmission(1L); textSubmission2 = new TextSubmission(2L); + + programmingExercise = programmingExerciseUtilService.createSampleProgrammingExercise(); + programmingExercise.setFeedbackSuggestionsEnabled(true); + programmingExercise.setGradingCriteria(List.of(new GradingCriterion())); + programmingExerciseRepository.save(programmingExercise); + programmingSubmission1 = new ProgrammingSubmission(); + programmingSubmission1.setId(3L); + programmingSubmission2 = new ProgrammingSubmission(); + programmingSubmission2.setId(4L); } @Test - void testSubmissionSelectionFromEmpty() { - athenaRequestMockProvider.ensureNoRequest(); + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testTextSubmissionSelectionFromEmpty() { var submission = athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of()); assertThat(submission).isEmpty(); + athenaRequestMockProvider.verify(); // Ensure that there was no request + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testProgrammingSubmissionSelectionFromEmpty() { + var submission = athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of()); + assertThat(submission).isEmpty(); + athenaRequestMockProvider.verify(); // Ensure that there was no request } @Test - void testSubmissionSelectionFromOne() { - athenaRequestMockProvider.mockSelectSubmissionsAndExpect(1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSubmissionSelectionFromOneText() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("text", 1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); var submission = athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of(textSubmission1.getId())); assertThat(submission).contains(textSubmission1.getId()); + athenaRequestMockProvider.verify(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSubmissionSelectionFromOneProgramming() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("programming", 3, jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.submissionIds").isArray()); + var submission = athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of(programmingSubmission1.getId())); + assertThat(submission).contains(programmingSubmission1.getId()); + athenaRequestMockProvider.verify(); } @Test - void testNoSubmissionSelectionFromOne() { - athenaRequestMockProvider.mockSelectSubmissionsAndExpect(-1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testTextNoSubmissionSelectionFromOne() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("text", -1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); var submission = athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of(textSubmission1.getId())); assertThat(submission).isEmpty(); + athenaRequestMockProvider.verify(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testProgrammingNoSubmissionSelectionFromOne() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("programming", -1, jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.submissionIds").isArray()); + var submission = athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of(programmingSubmission1.getId())); + assertThat(submission).isEmpty(); + athenaRequestMockProvider.verify(); } @Test - void testSubmissionSelectionFromTwo() { - athenaRequestMockProvider.mockSelectSubmissionsAndExpect(1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSubmissionSelectionFromTwoText() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("text", 1, jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissionIds").isArray()); var submission = athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of(textSubmission1.getId(), textSubmission2.getId())); assertThat(submission).contains(textSubmission1.getId()); + athenaRequestMockProvider.verify(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSubmissionSelectionFromTwoProgramming() { + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("programming", 4, jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.submissionIds").isArray()); + var submission = athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of(programmingSubmission1.getId(), programmingSubmission2.getId())); + assertThat(submission).contains(programmingSubmission2.getId()); + athenaRequestMockProvider.verify(); } @Test - void testSubmissionSelectionWithFeedbackSuggestionsDisabled() { - textExercise.setAssessmentType(AssessmentType.MANUAL); // disable feedback suggestions + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testTextSubmissionSelectionWithFeedbackSuggestionsDisabled() { + textExercise.setFeedbackSuggestionsEnabled(false); assertThatThrownBy(() -> athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of(textSubmission1.getId()))) .isInstanceOf(IllegalArgumentException.class); } @Test - void testSubmissionSelectionWithException() { - athenaRequestMockProvider.mockGetSelectedSubmissionAndExpectNetworkingException(); + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testProgrammingSubmissionSelectionWithFeedbackSuggestionsDisabled() { + programmingExercise.setFeedbackSuggestionsEnabled(false); + assertThatThrownBy(() -> athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of(programmingSubmission1.getId()))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testTextSubmissionSelectionWithException() { + athenaRequestMockProvider.mockGetSelectedSubmissionAndExpectNetworkingException("text"); assertThatNoException().isThrownBy(() -> athenaSubmissionSelectionService.getProposedSubmissionId(textExercise, List.of(textSubmission1.getId()))); } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testProgrammingSubmissionSelectionWithException() { + athenaRequestMockProvider.mockGetSelectedSubmissionAndExpectNetworkingException("programming"); + assertThatNoException().isThrownBy(() -> athenaSubmissionSelectionService.getProposedSubmissionId(programmingExercise, List.of(programmingSubmission1.getId()))); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingServiceTest.java index 69f3521b19f2..994e5de618ff 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/athena/AthenaSubmissionSendingServiceTest.java @@ -7,18 +7,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractAthenaTest; -import de.tum.in.www1.artemis.domain.TextExercise; -import de.tum.in.www1.artemis.domain.TextSubmission; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.InitializationState; import de.tum.in.www1.artemis.domain.enumeration.Language; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; import de.tum.in.www1.artemis.participation.ParticipationFactory; import de.tum.in.www1.artemis.repository.StudentParticipationRepository; -import de.tum.in.www1.artemis.repository.TextSubmissionRepository; +import de.tum.in.www1.artemis.repository.SubmissionRepository; import de.tum.in.www1.artemis.user.UserUtilService; class AthenaSubmissionSendingServiceTest extends AbstractAthenaTest { @@ -32,11 +31,20 @@ class AthenaSubmissionSendingServiceTest extends AbstractAthenaTest { private static final String DEFAULT_SUBMISSION_TEXT = "This is a test submission."; @Autowired - private TextSubmissionRepository textSubmissionRepository; + private SubmissionRepository submissionRepository; + + @Autowired + private AthenaModuleUrlHelper athenaModuleUrlHelper; + + @Autowired + private AthenaDTOConverter athenaDTOConverter; @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + @Autowired private StudentParticipationRepository studentParticipationRepository; @@ -47,73 +55,116 @@ class AthenaSubmissionSendingServiceTest extends AbstractAthenaTest { private TextExercise textExercise; + private ProgrammingExercise programmingExercise; + @BeforeEach void setUp() { athenaRequestMockProvider.enableMockingOfRequests(); // we need to have one student per participation, otherwise the database constraints cannot be fulfilled userUtilService.addUsers(TEST_PREFIX, MAX_NUMBER_OF_TOTAL_PARTICIPATIONS, 0, 0, 0); - athenaSubmissionSendingService = new AthenaSubmissionSendingService(athenaRequestMockProvider.getRestTemplate(), textSubmissionRepository); - ReflectionTestUtils.setField(athenaSubmissionSendingService, "athenaUrl", athenaUrl); + athenaSubmissionSendingService = new AthenaSubmissionSendingService(athenaRequestMockProvider.getRestTemplate(), submissionRepository, athenaModuleUrlHelper, + athenaDTOConverter); textExercise = textExerciseUtilService.createSampleTextExercise(null); + textExercise.setFeedbackSuggestionsEnabled(true); + + programmingExercise = programmingExerciseUtilService.createSampleProgrammingExercise(); + programmingExercise.setFeedbackSuggestionsEnabled(true); } @AfterEach void tearDown() { - textSubmissionRepository.deleteAll(textSubmissionRepository.findByParticipation_ExerciseIdAndSubmittedIsTrue(textExercise.getId())); + submissionRepository.deleteAll(submissionRepository.findByParticipation_ExerciseIdAndSubmittedIsTrue(textExercise.getId())); studentParticipationRepository.deleteAll(studentParticipationRepository.findByExerciseId(textExercise.getId())); + submissionRepository.deleteAll(submissionRepository.findByParticipation_ExerciseIdAndSubmittedIsTrue(programmingExercise.getId())); + studentParticipationRepository.deleteAll(studentParticipationRepository.findByExerciseId(programmingExercise.getId())); } private void createTextSubmissionsForSubmissionSending(int totalSubmissions) { for (long i = 0; i < totalSubmissions; i++) { - var submission = new TextSubmission(); - submission.setLanguage(DEFAULT_SUBMISSION_LANGUAGE); - submission.setText(DEFAULT_SUBMISSION_TEXT); - submission.setSubmitted(true); var studentParticipation = ParticipationFactory.generateStudentParticipation(InitializationState.FINISHED, textExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student" + (i + 1))); studentParticipation.setExercise(textExercise); studentParticipationRepository.save(studentParticipation); + var submission = new TextSubmission(); + submission.setLanguage(DEFAULT_SUBMISSION_LANGUAGE); + submission.setText(DEFAULT_SUBMISSION_TEXT); + submission.setSubmitted(true); + // Set a submission date so that the submission is found + submission.setSubmissionDate(studentParticipation.getInitializationDate()); submission.setParticipation(studentParticipation); - textSubmissionRepository.save(submission); + submissionRepository.save(submission); } } @Test - void testSendSubmissionsSuccess() { + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSendTextSubmissionsSuccess() { createTextSubmissionsForSubmissionSending(1); - athenaRequestMockProvider.mockSendSubmissionsAndExpect(jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.exercise.title").value(textExercise.getTitle()), - jsonPath("$.exercise.maxPoints").value(textExercise.getMaxPoints()), jsonPath("$.exercise.bonusPoints").value(textExercise.getBonusPoints()), - jsonPath("$.exercise.gradingInstructions").value(textExercise.getGradingInstructions()), + athenaRequestMockProvider.mockSendSubmissionsAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), + jsonPath("$.exercise.title").value(textExercise.getTitle()), jsonPath("$.exercise.maxPoints").value(textExercise.getMaxPoints()), + jsonPath("$.exercise.bonusPoints").value(textExercise.getBonusPoints()), jsonPath("$.exercise.gradingInstructions").value(textExercise.getGradingInstructions()), jsonPath("$.exercise.problemStatement").value(textExercise.getProblemStatement()), jsonPath("$.submissions[0].exerciseId").value(textExercise.getId()), jsonPath("$.submissions[0].text").value(DEFAULT_SUBMISSION_TEXT), jsonPath("$.submissions[0].language").value(DEFAULT_SUBMISSION_LANGUAGE.toString())); athenaSubmissionSendingService.sendSubmissions(textExercise); + athenaRequestMockProvider.verify(); + } + + private void createProgrammingSubmissionForSubmissionSending() { + var studentParticipation = ParticipationFactory.generateStudentParticipation(InitializationState.FINISHED, programmingExercise, + userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + studentParticipation.setExercise(programmingExercise); + studentParticipationRepository.save(studentParticipation); + var submission = ParticipationFactory.generateProgrammingSubmission(true); + submission.setParticipation(studentParticipation); + submissionRepository.save(submission); + athenaRequestMockProvider.verify(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSendSubmissionsSuccessProgramming() { + createProgrammingSubmissionForSubmissionSending(); + athenaRequestMockProvider.mockSendSubmissionsAndExpect("programming", jsonPath("$.exercise.id").value(programmingExercise.getId()), + jsonPath("$.exercise.title").value(programmingExercise.getTitle()), jsonPath("$.exercise.maxPoints").value(programmingExercise.getMaxPoints()), + jsonPath("$.exercise.bonusPoints").value(programmingExercise.getBonusPoints()), + jsonPath("$.exercise.gradingInstructions").value(programmingExercise.getGradingInstructions()), + jsonPath("$.exercise.problemStatement").value(programmingExercise.getProblemStatement()), + jsonPath("$.submissions[0].exerciseId").value(programmingExercise.getId()), jsonPath("$.submissions[0].repositoryUrl").isString()); + + athenaSubmissionSendingService.sendSubmissions(programmingExercise); + athenaRequestMockProvider.verify(); } @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testSendNoSubmissions() { - athenaRequestMockProvider.ensureNoRequest(); athenaSubmissionSendingService.sendSubmissions(textExercise); + athenaSubmissionSendingService.sendSubmissions(programmingExercise); + athenaRequestMockProvider.verify(); // Ensure that there was no request } @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testSendMultipleSubmissionBatches() { createTextSubmissionsForSubmissionSending(MAX_NUMBER_OF_TOTAL_PARTICIPATIONS); // 190 = almost twice the batch size (100) // expect two batches of submissions - athenaRequestMockProvider.mockSendSubmissionsAndExpect(jsonPath("$.exercise.id").value(textExercise.getId()), + athenaRequestMockProvider.mockSendSubmissionsAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), // We cannot check IDs or similar here because the submissions are not ordered jsonPath("$.submissions[0].text").value(DEFAULT_SUBMISSION_TEXT)); - athenaRequestMockProvider.mockSendSubmissionsAndExpect(jsonPath("$.exercise.id").value(textExercise.getId()), + athenaRequestMockProvider.mockSendSubmissionsAndExpect("text", jsonPath("$.exercise.id").value(textExercise.getId()), jsonPath("$.submissions[0].text").value(DEFAULT_SUBMISSION_TEXT)); athenaSubmissionSendingService.sendSubmissions(textExercise); + athenaRequestMockProvider.verify(); } @Test - void testSendSubmissionsWithFeedbackSuggestionsDisabled() { - textExercise.setAssessmentType(AssessmentType.MANUAL); // disable feedback suggestions + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testSendSubmissionsWithFeedbackSuggestionsDisabledText() { + textExercise.setFeedbackSuggestionsEnabled(false); assertThatThrownBy(() -> athenaSubmissionSendingService.sendSubmissions(textExercise)).isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java index f4621821028c..738d174d7714 100644 --- a/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java @@ -63,6 +63,9 @@ class TextAssessmentIntegrationTest extends AbstractSpringIntegrationBambooBitbu @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private TextExerciseRepository textExerciseRepository; + @Autowired private TextSubmissionRepository textSubmissionRepository; @@ -124,7 +127,7 @@ void initTestCase() { exerciseRepo.save(textExercise); // every test indirectly uses the submission selection in Athena, so we mock it here athenaRequestMockProvider.enableMockingOfRequests(); - athenaRequestMockProvider.mockSelectSubmissionsAndExpect(0); // always select the first submission + athenaRequestMockProvider.mockSelectSubmissionsAndExpect("text", 0); // always select the first submission } @AfterEach @@ -936,6 +939,8 @@ private void overrideAssessment(String student, String originalAssessor, HttpSta @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testTextBlocksAreConsistentWhenOpeningSameAssessmentTwiceWithAthenaEnabled() throws Exception { + textExercise.setFeedbackSuggestionsEnabled(true); + textExerciseRepository.save(textExercise); TextSubmission textSubmission = ParticipationFactory.generateTextSubmission("This is Part 1, and this is Part 2. There is also Part 3.", Language.ENGLISH, true); textExerciseUtilService.saveTextSubmission(textExercise, textSubmission, TEST_PREFIX + "student1"); exerciseDueDatePassed(); diff --git a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java index 1b573e21b4e9..3c5ff111795f 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java @@ -567,7 +567,12 @@ public File getFile(String path, HttpStatus expectedStatus, MultiValueMap params, @Nullable Map expectedResponseHeaders) throws Exception { - MvcResult res = mvc.perform(MockMvcRequestBuilders.get(new URI(path)).params(params).headers(new HttpHeaders())).andExpect(status().is(expectedStatus.value())).andReturn(); + return getFile(path, expectedStatus, params, new HttpHeaders(), expectedResponseHeaders); + } + + public File getFile(String path, HttpStatus expectedStatus, MultiValueMap params, HttpHeaders headers, @Nullable Map expectedResponseHeaders) + throws Exception { + MvcResult res = mvc.perform(MockMvcRequestBuilders.get(new URI(path)).params(params).headers(headers)).andExpect(status().is(expectedStatus.value())).andReturn(); restoreSecurityContext(); if (!expectedStatus.is2xxSuccessful()) { assertThat(res.getResponse().containsHeader("location")).as("no location header on failed request").isFalse(); diff --git a/src/test/javascript/spec/component/code-editor/code-editor-ace.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-ace.component.spec.ts index 397958564361..e637912e15b9 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-ace.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-ace.component.spec.ts @@ -17,8 +17,9 @@ import { MockComponent, MockDirective } from 'ng-mocks'; import { CodeEditorTutorAssessmentInlineFeedbackComponent } from 'app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component'; import { TranslatePipeMock } from '../../helpers/mocks/service/mock-translate.service'; import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code-editor/header/code-editor-header.component'; -import { FeedbackType } from 'app/entities/feedback.model'; +import { Feedback, FeedbackType } from 'app/entities/feedback.model'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; +import { CodeEditorTutorAssessmentInlineFeedbackSuggestionComponent } from 'app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback-suggestion.component'; describe('CodeEditorAceComponent', () => { let comp: CodeEditorAceComponent; @@ -35,6 +36,7 @@ describe('CodeEditorAceComponent', () => { CodeEditorAceComponent, TranslatePipeMock, MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), + MockComponent(CodeEditorTutorAssessmentInlineFeedbackSuggestionComponent), MockComponent(CodeEditorHeaderComponent), MockDirective(NgModel), ], @@ -339,9 +341,15 @@ describe('CodeEditorAceComponent', () => { expect(removeLineWidget).toHaveBeenCalledTimes(2); }); + it('should only show referenced feedback that is for the current file', () => { + comp.selectedFile = 'src/Test.java'; + const feedbacks = [{ reference: 'file:src/Test.java_line:16' }, { reference: 'file:src/Another.java_line:16' }, { reference: undefined }]; + expect(comp.filterFeedbackForFile(feedbacks)).toEqual([{ reference: 'file:src/Test.java_line:16' }]); + }); + it('should convert an accepted feedback suggestion to a marked manual feedback', async () => { await comp.initEditor(); - const suggestion = { text: 'FeedbackSuggestion:', detailText: 'test', reference: 'file:src/Test.java_line:16', type: FeedbackType.AUTOMATIC }; + const suggestion = { text: 'FeedbackSuggestion:', detailText: 'test', reference: 'file:src/Test.java_line:16', type: FeedbackType.MANUAL }; comp.feedbackSuggestions = [suggestion]; await comp.acceptSuggestion(suggestion); expect(comp.feedbackSuggestions).toBeEmpty(); @@ -350,9 +358,34 @@ describe('CodeEditorAceComponent', () => { it('should remove discarded suggestions', async () => { await comp.initEditor(); - const suggestion = { text: 'FeedbackSuggestion:', detailText: 'test', reference: 'file:src/Test.java_line:16', type: FeedbackType.AUTOMATIC }; + const suggestion = { text: 'FeedbackSuggestion:', detailText: 'test', reference: 'file:src/Test.java_line:16', type: FeedbackType.MANUAL }; comp.feedbackSuggestions = [suggestion]; comp.discardSuggestion(suggestion); expect(comp.feedbackSuggestions).toBeEmpty(); }); + + it('should update the line widget heights when feedbacks or suggestions change', async () => { + const updateLineWidgetHeightSpy = jest.spyOn(comp, 'updateLineWidgets'); + + await comp.initEditor(); + + // Change of feedbacks from the outside + await comp.ngOnChanges({ feedbacks: { previousValue: [], currentValue: [new Feedback()], firstChange: true, isFirstChange: () => true } }); + expect(updateLineWidgetHeightSpy).toHaveBeenCalled(); + + // Change of feedback suggestions from the outside + await comp.ngOnChanges({ feedbackSuggestions: { previousValue: [], currentValue: [new Feedback()], firstChange: true, isFirstChange: () => true } }); + expect(updateLineWidgetHeightSpy).toHaveBeenCalled(); + }); + + it('renders line widgets for feedback suggestions', async () => { + await comp.initEditor(); + const addLineWidgetWithFeedbackSpy = jest.spyOn(comp, 'addLineWidgetWithFeedback'); + + comp.feedbackSuggestions = [{ text: 'FeedbackSuggestion:', detailText: 'test', reference: 'file:src/Test.java_line:16', type: FeedbackType.MANUAL }]; + comp.selectedFile = 'src/Test.java'; + await comp.updateLineWidgets(); + + expect(addLineWidgetWithFeedbackSpy).toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/exercises/shared/feedback/feedback-suggestion-badge.component.spec.ts b/src/test/javascript/spec/component/exercises/shared/feedback/feedback-suggestion-badge.component.spec.ts index 17b263ffd254..6494255f4a94 100644 --- a/src/test/javascript/spec/component/exercises/shared/feedback/feedback-suggestion-badge.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/feedback/feedback-suggestion-badge.component.spec.ts @@ -67,4 +67,21 @@ describe('FeedbackSuggestionBadgeComponent', () => { expect(component.text).toBe(''); expect(component.tooltip).toBe(''); }); + + it('should respect the useDefaultText setting', () => { + component.useDefaultText = true; + + expect(component.text).toBe('artemisApp.assessment.suggestion.default'); + expect(component.tooltip).toBe('artemisApp.assessment.suggestionTitle.default'); + }); + + it('should ignore the useDefaultText setting for ADAPTED feedback', () => { + component.useDefaultText = true; + component.feedback = new Feedback(); + jest.spyOn(Feedback, 'getFeedbackSuggestionType').mockReturnValue(FeedbackSuggestionType.ADAPTED); + jest.spyOn(translateService, 'instant').mockReturnValue('Mocked Tooltip'); + + expect(component.text).toBe('artemisApp.assessment.suggestion.adapted'); + expect(component.tooltip).toBe('Mocked Tooltip'); + }); }); diff --git a/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts b/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts index a826bc783b9b..c4ff1e74abf8 100644 --- a/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts +++ b/src/test/javascript/spec/component/programming-assessment/code-editor-tutor-assessment-container.component.spec.ts @@ -1,6 +1,6 @@ import * as ace from 'brace'; import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { DebugElement } from '@angular/core'; import { of, throwError } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; @@ -252,17 +252,17 @@ describe('CodeEditorTutorAssessmentContainerComponent', () => { it('should not show feedback suggestions where there are already existing manual feedbacks', async () => { comp.unreferencedFeedback = [{ text: 'unreferenced test', detailText: 'some detail', reference: undefined }]; comp.referencedFeedback = [{ text: 'referenced test', detailText: 'some detail', reference: 'file:src/Test.java_line:1' }]; - const feedbackSuggestionsStub = jest.spyOn(comp['athenaService'], 'getFeedbackSuggestionsForProgramming'); + const feedbackSuggestionsStub = jest.spyOn(comp['athenaService'], 'getProgrammingFeedbackSuggestions'); feedbackSuggestionsStub.mockReturnValue( of([ - { text: 'unreferenced test', detailText: 'some detail', reference: undefined }, - { text: 'referenced test', detailText: 'some detail', reference: 'file:src/Test.java_line:1' }, - { text: 'suggestion to pass', detailText: 'some detail', reference: 'file:src/Test.java_line:2' }, - ]), + { text: 'FeedbackSuggestion:unreferenced test', detailText: 'some detail' }, + { text: 'FeedbackSuggestion:referenced test', detailText: 'some detail', reference: 'file:src/Test.java_line:1' }, + { text: 'FeedbackSuggestion:suggestion to pass', detailText: 'some detail', reference: 'file:src/Test.java_line:2' }, + ] as Feedback[]), ); comp['submission'] = { id: undefined }; // Needed for loadFeedbackSuggestions await comp['loadFeedbackSuggestions'](); - expect(comp.feedbackSuggestions).toStrictEqual([{ text: 'suggestion to pass', detailText: 'some detail', reference: 'file:src/Test.java_line:2' }]); + expect(comp.feedbackSuggestions).toStrictEqual([{ text: 'FeedbackSuggestion:suggestion to pass', detailText: 'some detail', reference: 'file:src/Test.java_line:2' }]); }); it('should show complaint for result with complaint and check assessor', fakeAsync(() => { @@ -665,4 +665,29 @@ describe('CodeEditorTutorAssessmentContainerComponent', () => { comp.removeSuggestion(feedbackSuggestion2); expect(comp.feedbackSuggestions).toEqual([feedbackSuggestion1, feedbackSuggestion3]); }); + + it('should show a confirmation dialog if there are pending feedback suggestions', async () => { + const modalOpenStub = jest.spyOn(comp['modalService'], 'open').mockReturnValue({ closed: of(true) } as NgbModalRef); // Confirm dismissal + comp.feedbackSuggestions = [{ id: 1, credits: 1 }]; + await comp.discardPendingSubmissionsWithConfirmation(); + expect(modalOpenStub).toHaveBeenCalled(); + // Dismissal should clear all feedback suggestions + expect(comp.feedbackSuggestions).toBeEmpty(); + }); + + it('should keep feedback suggestions if the confirmation dialog is cancelled', async () => { + const modalOpenStub = jest.spyOn(comp['modalService'], 'open').mockReturnValue({ closed: of(false) } as NgbModalRef); // Cancel suggestion dismissal + comp.feedbackSuggestions = [{ id: 1, credits: 1 }]; + await comp.discardPendingSubmissionsWithConfirmation(); + expect(modalOpenStub).toHaveBeenCalled(); + // Cancelling should keep everything intact + expect(comp.feedbackSuggestions).not.toBeEmpty(); + }); + + it('should not show a confirmation dialog if there are no feedback suggestions left', async () => { + const modalOpenStub = jest.spyOn(comp['modalService'], 'open'); + comp.feedbackSuggestions = []; + await comp.discardPendingSubmissionsWithConfirmation(); + expect(modalOpenStub).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-lifecycle.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-lifecycle.component.spec.ts index 3c1a6b002f16..bf3f28627104 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-lifecycle.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-lifecycle.component.spec.ts @@ -152,6 +152,15 @@ describe('ProgrammingExerciseLifecycleComponent', () => { expect(comp.exercise.assessmentDueDate).toBeUndefined(); }); + it('should disable feedback suggestions when changing the assessment type to automatic', () => { + comp.exercise = exercise; + comp.exercise.assessmentType = AssessmentType.SEMI_AUTOMATIC; + comp.exercise.feedbackSuggestionsEnabled = true; + comp.toggleAssessmentType(); // toggle to AUTOMATIC + + expect(comp.exercise.feedbackSuggestionsEnabled).toBeFalse(); + }); + it('should change publication of tests for programming exercise with published solution', () => { comp.exercise = { ...exercise, exampleSolutionPublicationDate: dayjs() }; expect(comp.exercise.releaseTestsWithExampleSolution).toBeFalsy(); diff --git a/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts b/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts index 9b36b144b0e8..45495f24ca74 100644 --- a/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts +++ b/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts @@ -486,7 +486,7 @@ describe('TextSubmissionAssessmentComponent', () => { component.unreferencedFeedback = []; const feedbackSuggestionTextBlockRef = createTextBlockRefWithFeedbackFromTo(0, 10); feedbackSuggestionTextBlockRef.feedback!.text = "I'm a feedback suggestion"; - const athenaServiceFeedbackSuggestionsStub = jest.spyOn(athenaService, 'getFeedbackSuggestionsForText').mockReturnValue(of([feedbackSuggestionTextBlockRef])); + const athenaServiceFeedbackSuggestionsStub = jest.spyOn(athenaService, 'getTextFeedbackSuggestions').mockReturnValue(of([feedbackSuggestionTextBlockRef])); component.loadFeedbackSuggestions(); tick(); expect(athenaServiceFeedbackSuggestionsStub).toHaveBeenCalled(); @@ -605,7 +605,7 @@ describe('TextSubmissionAssessmentComponent', () => { // Set up initial state with an existing text block that doesn't overlap const feedbackSuggestions = input.map(([start, end]) => createTextBlockRefWithFeedbackFromTo(start, end)); - jest.spyOn(athenaService, 'getFeedbackSuggestionsForText').mockReturnValue(of(feedbackSuggestions)); + jest.spyOn(athenaService, 'getTextFeedbackSuggestions').mockReturnValue(of(feedbackSuggestions)); component.loadFeedbackSuggestions(); @@ -627,7 +627,7 @@ describe('TextSubmissionAssessmentComponent', () => { it('should not load feedback suggestions if there already are assessments', fakeAsync(() => { // preparation already added an assessment - const athenaServiceFeedbackSuggestionsSpy = jest.spyOn(athenaService, 'getFeedbackSuggestionsForText'); + const athenaServiceFeedbackSuggestionsSpy = jest.spyOn(athenaService, 'getTextFeedbackSuggestions'); component.loadFeedbackSuggestions(); tick(); expect(athenaServiceFeedbackSuggestionsSpy).not.toHaveBeenCalled(); diff --git a/src/test/javascript/spec/component/text-submission-assessment/textblock-feedback-editor.component.spec.ts b/src/test/javascript/spec/component/text-submission-assessment/textblock-feedback-editor.component.spec.ts index c9b7431c5f85..6c08b06ec59a 100644 --- a/src/test/javascript/spec/component/text-submission-assessment/textblock-feedback-editor.component.spec.ts +++ b/src/test/javascript/spec/component/text-submission-assessment/textblock-feedback-editor.component.spec.ts @@ -179,7 +179,7 @@ describe('TextblockFeedbackEditorComponent', () => { }); it('should send assessment event if feedback type changed', () => { - component.feedback.type = FeedbackType.AUTOMATIC; + component.feedback.text = 'FeedbackSuggestion:accepted:Test'; const typeSpy = jest.spyOn(component.textAssessmentAnalytics, 'sendAssessmentEvent'); component.didChange(); expect(typeSpy).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-athena-service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-athena-service.ts index 904f3af9560a..0c37be2b35f4 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-athena-service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-athena-service.ts @@ -1,13 +1,12 @@ import { Observable, of } from 'rxjs'; -import { TextBlockRef } from 'app/entities/text-block-ref.model'; -import { Feedback } from 'app/entities/feedback.model'; +import { ProgrammingFeedbackSuggestion, TextFeedbackSuggestion } from 'app/entities/feedback-suggestion.model'; export class MockAthenaService { - getFeedbackSuggestionsForProgramming(exerciseId: number, submissionId: number): Observable { - return of([] as Feedback[]); + getTextFeedbackSuggestions(exerciseId: number, submissionId: number): Observable { + return of([] as TextFeedbackSuggestion[]); } - getFeedbackSuggestionsForText(exerciseId: number, submissionId: number): Observable { - return of([] as TextBlockRef[]); + getProgrammingFeedbackSuggestions(exerciseId: number, submissionId: number): Observable { + return of([] as ProgrammingFeedbackSuggestion[]); } } diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-athena.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-athena.service.ts index 830e3396dd80..29f436c70082 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-athena.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-athena.service.ts @@ -1,12 +1,11 @@ -import { Feedback } from 'app/entities/feedback.model'; import { Observable, of } from 'rxjs'; -import { TextBlockRef } from 'app/entities/text-block-ref.model'; +import { ProgrammingFeedbackSuggestion, TextFeedbackSuggestion } from 'app/entities/feedback-suggestion.model'; export class MockAthenaService { - getFeedbackSuggestionsForProgramming(exerciseId: number, submissionId: number): Observable { + getProgrammingFeedbackSuggestions(exerciseId: number, submissionId: number): Observable { return of([]); } - getFeedbackSuggestionsForText(exerciseId: number, submissionId: number): Observable { + getTextFeedbackSuggestions(exerciseId: number, submissionId: number): Observable { return of([]); } } diff --git a/src/test/javascript/spec/service/athena.service.spec.ts b/src/test/javascript/spec/service/athena.service.spec.ts index 309099cfdefa..faf1ccde9319 100644 --- a/src/test/javascript/spec/service/athena.service.spec.ts +++ b/src/test/javascript/spec/service/athena.service.spec.ts @@ -1,17 +1,47 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { AthenaService } from 'app/assessment/athena.service'; -import { TextBlockRef } from 'app/entities/text-block-ref.model'; import { ArtemisTestModule } from '../test.module'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; import { of } from 'rxjs'; import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { Exercise } from 'app/entities/exercise.model'; +import { ProgrammingFeedbackSuggestion, TextFeedbackSuggestion } from 'app/entities/feedback-suggestion.model'; +import { TextSubmission } from 'app/entities/text-submission.model'; +import { TextBlockRef } from 'app/entities/text-block-ref.model'; +import { Feedback, FeedbackType } from 'app/entities/feedback.model'; describe('AthenaService', () => { let athenaService: AthenaService; let profileService: ProfileService; let httpTestingController: HttpTestingController; + const gradingCriteria = [ + { + id: 1, + title: 'Test Criteria', + structuredGradingInstructions: [ + { + id: 4321, + credits: 1.0, + instructionDescription: 'Test Instruction', + usageCount: 0, + }, + ], + }, + ]; + const textExercise = { + id: 1, + type: 'text', + feedbackSuggestionsEnabled: true, + gradingCriteria, + } as Exercise; + const programmingExercise = { + id: 2, + type: 'programming', + feedbackSuggestionsEnabled: true, + gradingCriteria, + } as Exercise; beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, HttpClientTestingModule], @@ -26,32 +56,70 @@ describe('AthenaService', () => { }); it('should get feedback suggestions when athena is enabled', fakeAsync(() => { - const feedbackSuggestions: TextBlockRef[] = []; + const textFeedbackSuggestions = [new TextFeedbackSuggestion(0, 1, 2, 'Test Text', 'Test Text Description', 0.0, 4321, 5, 10)]; + const programmingFeedbackSuggestions: ProgrammingFeedbackSuggestion[] = [ + new ProgrammingFeedbackSuggestion(0, 2, 2, 'Test Programming', 'Test Programming Description', -1.0, 4321, 'src/Test.java', 4, undefined), + ]; + let textResponse: TextBlockRef[] | null = null; + let programmingResponse: Feedback[] | null = null; + + const mockProfileInfo = { activeProfiles: ['athena'] } as ProfileInfo; + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(mockProfileInfo)); + + athenaService.getTextFeedbackSuggestions(textExercise, { id: 2, text: 'Hello world, this is a test' } as TextSubmission).subscribe((suggestions: TextBlockRef[]) => { + textResponse = suggestions; + }); + const requestWrapperText = httpTestingController.expectOne({ url: 'api/athena/text-exercises/1/submissions/2/feedback-suggestions' }); + requestWrapperText.flush(textFeedbackSuggestions); + + tick(); + + athenaService.getProgrammingFeedbackSuggestions(programmingExercise, 2).subscribe((suggestions: Feedback[]) => { + programmingResponse = suggestions; + }); + const requestWrapperProgramming = httpTestingController.expectOne({ url: 'api/athena/programming-exercises/2/submissions/2/feedback-suggestions' }); + requestWrapperProgramming.flush(programmingFeedbackSuggestions); + + tick(); + + expect(requestWrapperText.request.method).toBe('GET'); + expect(textResponse![0].feedback!.type).toEqual(FeedbackType.MANUAL); + expect(textResponse![0].feedback!.text).toBe('FeedbackSuggestion:accepted:Test Text'); + expect(textResponse![0].feedback!.detailText).toBe('Test Text Description'); + expect(textResponse![0].block!.startIndex).toBe(5); + expect(textResponse![0].block!.id).toEqual(textResponse![0].feedback!.reference); + expect(requestWrapperProgramming.request.method).toBe('GET'); + expect(programmingResponse![0].type).toEqual(FeedbackType.MANUAL); + expect(programmingResponse![0].text).toBe('FeedbackSuggestion:Test Programming'); + expect(programmingResponse![0].detailText).toBe('Test Programming Description'); + expect(programmingResponse![0].credits).toBe(-1.0); + expect(programmingResponse![0].reference).toBe('file:src/Test.java_line:4'); + })); + + it('should return no feedback suggestions when feedback suggestions are disabled on the exercise', fakeAsync(() => { let response: TextBlockRef[] | null = null; const mockProfileInfo = { activeProfiles: ['athena'] } as ProfileInfo; jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(mockProfileInfo)); - athenaService.getFeedbackSuggestionsForText(1, 2).subscribe((suggestions: TextBlockRef[]) => { + const exerciseWithoutFeedbackSuggestions = { ...textExercise, feedbackSuggestionsEnabled: false } as Exercise; + + athenaService.getTextFeedbackSuggestions(exerciseWithoutFeedbackSuggestions, { id: 2, text: '' } as TextSubmission).subscribe((suggestions: TextBlockRef[]) => { response = suggestions; }); - const requestWrapper = httpTestingController.expectOne({ url: 'api/athena/exercises/1/submissions/2/feedback-suggestions' }); - requestWrapper.flush(feedbackSuggestions); - tick(); - expect(requestWrapper.request.method).toBe('GET'); - expect(response).toEqual(feedbackSuggestions); + expect(response).toEqual([]); })); - it('should return no feedback suggestions when athena is disabled', fakeAsync(() => { + it('should return no feedback suggestions when athena is disabled on the server', fakeAsync(() => { let response: TextBlockRef[] | null = null; const mockProfileInfo = { activeProfiles: ['something'] } as ProfileInfo; jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(mockProfileInfo)); - athenaService.getFeedbackSuggestionsForText(1, 2).subscribe((suggestions: TextBlockRef[]) => { + athenaService.getTextFeedbackSuggestions(textExercise, { id: 2, text: '' } as TextSubmission).subscribe((suggestions: TextBlockRef[]) => { response = suggestions; }); diff --git a/src/test/resources/config/application-artemis.yml b/src/test/resources/config/application-artemis.yml index e4bc7bc1b1bc..e4cb80f5e20a 100644 --- a/src/test/resources/config/application-artemis.yml +++ b/src/test/resources/config/application-artemis.yml @@ -56,6 +56,9 @@ artemis: athena: url: http://localhost:5000 secret: abcdef12345 + modules: + text: module_text_test + programming: module_programming_test apollon: conversion-service-url: http://localhost:8080 plagiarism-checks: diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 95c72bbcbb57..75cc0ccaf80c 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -66,8 +66,6 @@ artemis: default: "~~invalid~~" ocaml: default: "~~invalid~~" - athena: - secret: abcdef12345 spring: application: