Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c06832d
converted athena endpoints to return filemaps
ekayandan Sep 4, 2025
2e196a4
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 4, 2025
9d00023
Merge remote-tracking branch 'origin' into chore/convert-athena-endpo…
ekayandan Sep 5, 2025
73c8a7d
Merge branch 'chore/convert-athena-endpoints-to-filemap' of github.co…
ekayandan Sep 5, 2025
707637e
improvements on performance and tests
ekayandan Sep 5, 2025
0356671
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 5, 2025
6a0d3f2
added missing docstring
ekayandan Sep 5, 2025
0767f61
Merge branch 'chore/convert-athena-endpoints-to-filemap' of github.co…
ekayandan Sep 5, 2025
f5f5a87
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 6, 2025
d2dc5f1
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 6, 2025
e218bd7
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 6, 2025
b5beb91
review comments
ekayandan Sep 8, 2025
51c8cca
Adapt tests and lint
ekayandan Sep 8, 2025
42dfaa4
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 10, 2025
0eecf38
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 15, 2025
1da53b5
Enhancement: Add feedback suggestion module validation in Athena serv…
ekayandan Sep 15, 2025
3eb6dde
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 15, 2025
f12077f
Merge remote-tracking branch 'origin' into chore/convert-athena-endpo…
ekayandan Sep 15, 2025
6fa9b85
Merge branch 'chore/convert-athena-endpoints-to-filemap' of github.co…
ekayandan Sep 15, 2025
c1b326f
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 25, 2025
c366f1e
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 29, 2025
7e33edc
review comments
ekayandan Sep 29, 2025
1de24ff
fixed server test
ekayandan Sep 29, 2025
d5ba771
Merge branch 'chore/convert-athena-endpoints-to-filemap' of github.co…
ekayandan Sep 29, 2025
4127676
converted var to type
ekayandan Sep 30, 2025
ae9ee78
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Sep 30, 2025
457508b
fixed tests & applied some of the comments
ekayandan Oct 1, 2025
9bbfc4b
Merge branch 'chore/convert-athena-endpoints-to-filemap' of github.co…
ekayandan Oct 1, 2025
2e13784
remaining review comments
ekayandan Oct 1, 2025
ded36b3
changed naming to avoid confusions
ekayandan Oct 1, 2025
99efbf8
Update src/main/webapp/i18n/de/error.json
ekayandan Oct 3, 2025
52b19d2
Merge branch 'develop' into chore/convert-athena-endpoints-to-filemap
ekayandan Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ private LearnerProfile extractLearnerProfile(Submission submission) {
public List<TextFeedbackDTO> getTextFeedbackSuggestions(TextExercise exercise, TextSubmission submission, boolean isGraded) throws NetworkingException {
log.debug("Start Athena '{}' Feedback Suggestions Service for Exercise '{}' (#{}).", isGraded ? "Graded" : "Non Graded", exercise.getTitle(), exercise.getId());

if (exercise.getFeedbackSuggestionModule() == null) {
log.warn("Exercise '{}' (#{}) does not have a feedback suggestion module configured. Returning empty list.", exercise.getTitle(), exercise.getId());
return List.of();
}

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(),
Expand Down Expand Up @@ -202,6 +207,12 @@ public List<TextFeedbackDTO> getTextFeedbackSuggestions(TextExercise exercise, T
public List<ProgrammingFeedbackDTO> getProgrammingFeedbackSuggestions(ProgrammingExercise exercise, ProgrammingSubmission submission, boolean isGraded)
throws NetworkingException {
log.debug("Start Athena '{}' Feedback Suggestions Service for Exercise '{}' (#{}).", isGraded ? "Graded" : "Non Graded", exercise.getTitle(), exercise.getId());

if (exercise.getFeedbackSuggestionModule() == null) {
log.warn("Exercise '{}' (#{}) does not have a feedback suggestion module configured. Returning empty list.", exercise.getTitle(), exercise.getId());
return List.of();
}

final RequestDTO request = new RequestDTO(athenaDTOConverterService.ofExercise(exercise), athenaDTOConverterService.ofSubmission(exercise.getId(), submission), null,
isGraded, null);
ResponseDTOProgramming response = programmingAthenaConnector.invokeWithRetry(athenaModuleService.getAthenaModuleUrl(exercise) + "/feedback_suggestions", request, 0);
Expand All @@ -221,6 +232,11 @@ public List<ProgrammingFeedbackDTO> getProgrammingFeedbackSuggestions(Programmin
public List<ModelingFeedbackDTO> getModelingFeedbackSuggestions(ModelingExercise exercise, ModelingSubmission submission, boolean isGraded) throws NetworkingException {
log.debug("Start Athena '{}' Feedback Suggestions Service for Modeling Exercise '{}' (#{}).", isGraded ? "Graded" : "Non Graded", exercise.getTitle(), exercise.getId());

if (exercise.getFeedbackSuggestionModule() == null) {
log.warn("Exercise '{}' (#{}) does not have a feedback suggestion module configured. Returning empty list.", exercise.getTitle(), exercise.getId());
return List.of();
}

if (!Objects.equals(submission.getParticipation().getExercise().getId(), exercise.getId())) {
throw new ConflictException("Exercise id " + exercise.getId() + " does not match submission's exercise id " + submission.getParticipation().getExercise().getId(),
"Exercise", "exerciseIdDoesNotMatch");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,13 @@ public List<String> getAthenaModulesForCourse(Course course, ExerciseType exerci
*
* @param exercise The exercise for which the URL to Athena should be returned
* @return The URL prefix to access the Athena module. Example: <a href="http://athena.example.com/modules/text/module_text_cofee"></a>
* @throws IllegalArgumentException if the exercise has no feedback suggestion module configured
*/
public String getAthenaModuleUrl(Exercise exercise) {
if (exercise.getFeedbackSuggestionModule() == null) {
throw new IllegalArgumentException("Exercise does not have a feedback suggestion module configured");
}

switch (exercise.getExerciseType()) {
case TEXT -> {
return athenaUrl + "/modules/text/" + exercise.getFeedbackSuggestionModule();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,25 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_ATHENA;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import de.tum.cit.aet.artemis.core.dto.RepositoryExportOptionsDTO;
import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.exception.ServiceUnavailableException;
import de.tum.cit.aet.artemis.core.service.FileService;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.programming.domain.RepositoryType;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingSubmissionRepository;
import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseExportService;
import de.tum.cit.aet.artemis.programming.service.RepositoryService;

/**
* Service for exporting programming exercise repositories for Athena.
Expand All @@ -35,27 +33,23 @@ public class AthenaRepositoryExportService {

private static 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;
/**
* Set of valid instructor repository types that can be accessed by Athena (excludes AUXILIARY).
*/
private static final Set<RepositoryType> ATHENA_INSTRUCTOR_REPOSITORY_TYPES = Set.of(RepositoryType.TEMPLATE, RepositoryType.SOLUTION, RepositoryType.TESTS);

private final ProgrammingExerciseRepository programmingExerciseRepository;

private final ProgrammingExerciseExportService programmingExerciseExportService;

private final FileService fileService;
private final RepositoryService repositoryService;

private final ProgrammingSubmissionRepository programmingSubmissionRepository;

private final ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository;

public AthenaRepositoryExportService(ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseExportService programmingExerciseExportService,
FileService fileService, ProgrammingSubmissionRepository programmingSubmissionRepository,
ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository) {
public AthenaRepositoryExportService(ProgrammingExerciseRepository programmingExerciseRepository, RepositoryService repositoryService,
ProgrammingSubmissionRepository programmingSubmissionRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository) {
this.programmingExerciseRepository = programmingExerciseRepository;
this.programmingExerciseExportService = programmingExerciseExportService;
this.fileService = fileService;
this.repositoryService = repositoryService;
this.programmingSubmissionRepository = programmingSubmissionRepository;
this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository;
}
Expand All @@ -74,51 +68,67 @@ private void checkFeedbackSuggestionsOrAutomaticFeedbackEnabledElseThrow(Exercis
}

/**
* Export the repository for the given exercise and participation to a zip file.
* The ZIP file will be deleted automatically after 15 minutes.
* Returns a mapping of file paths to contents for a student repository.
* Binary files are omitted.
*
* @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 path to the zip file containing the exported repository
* @throws IOException if the export fails
* @param exerciseId the id of the exercise to retrieve the repository for
* @param submissionId the id of the submission
* @return Map of file paths to their textual contents
* @throws IOException if reading from the repository fails
* @throws BadRequestAlertException if the repository URI is null
* @throws AccessForbiddenException if the feedback suggestions are not enabled for the given exercise
*/
public Path exportRepository(long exerciseId, Long submissionId, RepositoryType repositoryType) throws IOException {
log.debug("Exporting repository for exercise {}, submission {}", exerciseId, submissionId);
public Map<String, String> getStudentRepositoryFilesContent(long exerciseId, Long submissionId) throws IOException {
log.debug("Retrieving student repository file contents for exercise {}, submission {}", exerciseId, submissionId);

var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId);
checkFeedbackSuggestionsOrAutomaticFeedbackEnabledElseThrow(programmingExercise);

// Athena currently does not support individual due dates
var exportOptions = new RepositoryExportOptionsDTO(false, true, false, programmingExercise.getDueDate(), false, false, false, true, false);

if (!Files.exists(repoDownloadClonePath)) {
Files.createDirectories(repoDownloadClonePath);
var submission = programmingSubmissionRepository.findById(submissionId).orElseThrow();
var participation = programmingExerciseStudentParticipationRepository.findByIdElseThrow(submission.getParticipation().getId());
var repoUri = participation.getVcsRepositoryUri();
if (repoUri == null) {
throw new BadRequestAlertException(
"Repository URI is null for student participation " + participation.getId() + ". This may indicate that the student repository has not been set up yet.",
"error", "invalid.student.repository.url");
}

Path exportDir = fileService.getTemporaryUniqueSubfolderPath(repoDownloadClonePath, 15);
Path zipFilePath = null;

if (repositoryType == null) { // Export student repository
var submission = programmingSubmissionRepository.findById(submissionId).orElseThrow();
// Load participation with eager submissions
var participation = programmingExerciseStudentParticipationRepository.findWithSubmissionsById(submission.getParticipation().getId()).getFirst();
zipFilePath = programmingExerciseExportService.getRepositoryWithParticipation(programmingExercise, participation, exportOptions, exportDir, exportDir, true);
ZonedDateTime deadline = programmingExercise.getDueDate();
if (deadline != null) {
return repositoryService.getFilesContentFromBareRepositoryForLastCommitBeforeOrAt(repoUri, deadline);
}
else {
List<String> exportErrors = List.of();
var exportFile = programmingExerciseExportService.exportInstructorRepositoryForExercise(programmingExercise.getId(), repositoryType, exportDir, exportDir,
exportErrors);
if (exportFile.isPresent()) {
zipFilePath = exportFile.get().toPath();
}
return repositoryService.getFilesContentFromBareRepositoryForLastCommit(repoUri);
}
}

if (zipFilePath == null) {
throw new IOException("Failed to export repository");
/**
* Retrieves the files content of an instructor repository.
*
* @param exerciseId the id of the exercise to retrieve the repository for
* @param repositoryType the type of repository to retrieve (must be an Athena instructor repository type)
* @return Map of file paths to their textual contents
* @throws IOException if reading from the repository fails
* @throws BadRequestAlertException if the repository URI is null
* @throws AccessForbiddenException if the feedback suggestions are not enabled for the given exercise
* @throws IllegalArgumentException if the repository type is not an Athena instructor repository type
*/
public Map<String, String> getInstructorRepositoryFilesContent(long exerciseId, RepositoryType repositoryType) throws IOException {
log.debug("Retrieving instructor repository file contents for exercise {}, repository type {}", exerciseId, repositoryType);

if (!ATHENA_INSTRUCTOR_REPOSITORY_TYPES.contains(repositoryType)) {
throw new BadRequestAlertException("Invalid instructor repository type", "error", "invalid.instructor.repository.type",
Map.of("repositoryType", repositoryType, "validTypes", ATHENA_INSTRUCTOR_REPOSITORY_TYPES));
}

return zipFilePath;
var programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId);

checkFeedbackSuggestionsOrAutomaticFeedbackEnabledElseThrow(programmingExercise);

var repoUri = programmingExercise.getRepositoryURI(repositoryType);
if (repoUri == null) {
String errorKey = "invalid." + repositoryType.name().toLowerCase() + ".repository.url";
throw new BadRequestAlertException("Repository URI is null for exercise " + exerciseId + " and repository type " + repositoryType + ". This may indicate that the "
+ repositoryType.name().toLowerCase() + " repository has not been set up yet.", "error", errorKey);
}
return repositoryService.getFilesContentFromBareRepositoryForLastCommit(repoUri);
}
}
Loading
Loading