Skip to content

Commit a0e13a0

Browse files
committed
Merge remote-tracking branch 'origin/develop' into fix/exam-recovery-cdp-fetch-interception
2 parents df7e71d + b1451d6 commit a0e13a0

98 files changed

Lines changed: 1741 additions & 417 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ public class Result extends DomainObject implements Comparable<Result> {
113113
@Column(name = "assessment_type")
114114
private AssessmentType assessmentType;
115115

116+
/**
117+
* Which correction round this result belongs to: 0 for the first correction, 1 for the second, and so on. Null for
118+
* automatic and Athena results, which are not correction rounds.
119+
* <p>
120+
* This used to be the position of the result inside its submission's result list, which is why that list carried an
121+
* order column. Keeping the round here means a result can be written without loading and re-saving the whole
122+
* submission just so Hibernate can renumber the list.
123+
*/
124+
@Column(name = "correction_round")
125+
private Integer correctionRound;
126+
116127
@Column(name = "has_complaint")
117128
private Boolean hasComplaint;
118129

@@ -455,6 +466,15 @@ public AssessmentType getAssessmentType() {
455466
return assessmentType;
456467
}
457468

469+
@Nullable
470+
public Integer getCorrectionRound() {
471+
return correctionRound;
472+
}
473+
474+
public void setCorrectionRound(@Nullable Integer correctionRound) {
475+
this.correctionRound = correctionRound;
476+
}
477+
458478
public Result assessmentType(AssessmentType assessmentType) {
459479
this.assessmentType = assessmentType;
460480
return this;

src/main/java/de/tum/cit/aet/artemis/assessment/dto/ResultDTO.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
*/
2323
@JsonInclude(JsonInclude.Include.NON_EMPTY)
2424
public record ResultDTO(Long id, ZonedDateTime completionDate, Boolean successful, Double score, Boolean rated, SubmissionDTO submission, ParticipationDTO participation,
25-
List<FeedbackDTO> feedbacks, AssessmentType assessmentType, Boolean hasComplaint, Boolean exampleResult, UserNameDTO assessor, AssessmentNoteDTO assessmentNote,
26-
Long exerciseId) implements Serializable {
25+
List<FeedbackDTO> feedbacks, AssessmentType assessmentType, Integer correctionRound, Boolean hasComplaint, Boolean exampleResult, UserNameDTO assessor,
26+
AssessmentNoteDTO assessmentNote, Long exerciseId) implements Serializable {
2727

2828
/**
2929
* Converts a Result into a ResultDTO.
@@ -57,6 +57,6 @@ public static ResultDTO of(Result result) {
5757
// results), where the cascade merge writes the column back - without it on the wire the echo merges
5858
// exercise_id = 0 and dies on the foreign-key constraint.
5959
return new ResultDTO(result.getId(), result.getCompletionDate(), result.isSuccessful(), result.getScore(), result.isRated(), submissionDTO, participationDTO, feedbackDTOs,
60-
result.getAssessmentType(), result.hasComplaint(), result.isExampleResult(), assessorDTO, assessmentNoteDTO, result.getExerciseId());
60+
result.getAssessmentType(), result.getCorrectionRound(), result.hasComplaint(), result.isExampleResult(), assessorDTO, assessmentNoteDTO, result.getExerciseId());
6161
}
6262
}

src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository;
3535
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
3636
import de.tum.cit.aet.artemis.exercise.domain.Submission;
37+
import de.tum.cit.aet.artemis.exercise.dto.CorrectionRoundResultDTO;
3738
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
3839

3940
/**
@@ -500,6 +501,30 @@ SELECT COUNT(DISTINCT p)
500501
*/
501502
boolean existsBySubmissionId(long submissionId);
502503

504+
/**
505+
* Returns the manual results of the given submissions together with the correction round each belongs to.
506+
* <p>
507+
* The scores overview renders assessment actions per correction round, so it needs one entry per round rather than
508+
* only the newest result. Automatic and Athena results are left out because they are not correction rounds, which
509+
* matches {@link de.tum.cit.aet.artemis.exercise.domain.Submission#getManualResults()} and keeps a result whose
510+
* assessment type is not set yet.
511+
*
512+
* @param submissionIds the submissions whose manual results should be read
513+
* @return the manual results of those submissions, in no particular order
514+
*/
515+
@Query("""
516+
SELECT new de.tum.cit.aet.artemis.exercise.dto.CorrectionRoundResultDTO(
517+
result.submission.id, result.id, result.correctionRound, result.assessmentType, result.completionDate, result.hasComplaint)
518+
FROM Result result
519+
WHERE result.submission.id IN :submissionIds
520+
AND (result.assessmentType IS NULL
521+
OR result.assessmentType NOT IN (
522+
de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC,
523+
de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC_ATHENA
524+
))
525+
""")
526+
List<CorrectionRoundResultDTO> findCorrectionRoundResultsBySubmissionIds(@Param("submissionIds") Set<Long> submissionIds);
527+
503528
/**
504529
* Returns true if there is at least one result for the given exercise.
505530
* Uses the denormalized result.exerciseId for efficient filtering.

src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,11 @@ private static void setResultIfNecessary(StudentExam studentExam, StudentPartici
285285
if (latestResult != null) {
286286
latestResult.setSubmission(lastSubmission);
287287
latestResult.filterSensitiveInformation();
288-
lastSubmission.setResults(List.of(latestResult));
288+
lastSubmission.setResults(Set.of(latestResult));
289289
}
290290
}
291291
else {
292-
lastSubmission.setResults(List.of());
292+
lastSubmission.setResults(Set.of());
293293
}
294294
}
295295
}
@@ -434,15 +434,15 @@ private void attachResultsToSubmissions(Set<ProgrammingExercise> programmingExer
434434
ex.getSolutionParticipation().getSubmissions().forEach(sub -> {
435435
Result result = latestSolutionResults.get(sub.getId());
436436
if (result != null) {
437-
sub.setResults(List.of(result));
437+
sub.setResults(Set.of(result));
438438
}
439439
});
440440
}
441441
if (ex.getTemplateParticipation() != null && ex.getTemplateParticipation().getSubmissions() != null) {
442442
ex.getTemplateParticipation().getSubmissions().forEach(sub -> {
443443
Result result = latestTemplateResults.get(sub.getId());
444444
if (result != null) {
445-
sub.setResults(List.of(result));
445+
sub.setResults(Set.of(result));
446446
}
447447
});
448448
}

src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ public void filterResultsForStudents(Participation participation) {
523523
boolean isAssessmentOver = getAssessmentDueDate() == null || getAssessmentDueDate().isBefore(ZonedDateTime.now());
524524

525525
participation.getSubmissions().forEach(submission -> {
526-
List<Result> results = submission.getResults();
526+
Set<Result> results = submission.getResults();
527527
if (results != null && !results.isEmpty()) {
528528
if (!isAssessmentOver) {
529529
// For assessment that's not over yet

src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java

Lines changed: 78 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22

33
import java.time.Duration;
44
import java.time.ZonedDateTime;
5-
import java.util.ArrayList;
65
import java.util.Comparator;
76
import java.util.HashSet;
8-
import java.util.List;
7+
import java.util.LinkedHashSet;
98
import java.util.Objects;
109
import java.util.Optional;
1110
import java.util.Set;
@@ -24,11 +23,10 @@
2423
import jakarta.persistence.InheritanceType;
2524
import jakarta.persistence.ManyToOne;
2625
import jakarta.persistence.OneToMany;
27-
import jakarta.persistence.OrderColumn;
26+
import jakarta.persistence.OrderBy;
2827
import jakarta.persistence.Table;
2928

3029
import org.hibernate.annotations.ConcreteProxy;
31-
import org.jspecify.annotations.NonNull;
3230
import org.jspecify.annotations.Nullable;
3331

3432
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -87,13 +85,26 @@ public abstract class Submission extends DomainObject implements Comparable<Subm
8785
@OneToMany(mappedBy = "submission", cascade = CascadeType.REMOVE)
8886
private Set<SubmissionVersion> versions = new HashSet<>();
8987

88+
/**
89+
* Orders results by their id, putting a result that was not saved yet at the end: it has just been created, so it
90+
* is the newest one. Without the null handling every lookup here would throw for an in-memory result.
91+
*/
92+
private static final Comparator<Result> BY_ID = Comparator.comparing(Result::getId, Comparator.nullsLast(Comparator.naturalOrder()));
93+
9094
/**
9195
* A submission can have multiple results, therefore, results are persisted and removed with a submission.
96+
* <p>
97+
* A set, not a list: this used to be an ordered list whose position carried the correction round, which meant every
98+
* write in this area had to load and re-save the whole submission so that Hibernate could renumber the order
99+
* column. The correction round now lives on {@link Result#getCorrectionRound()}, so nothing needs the position.
100+
* <p>
101+
* Ordered by id all the same. Nothing on the server depends on it, but the collection is serialized to the client,
102+
* and a hash set would hand it the results in an order that can change between two requests for the same data.
92103
*/
93104
@OneToMany(mappedBy = "submission", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
94-
@OrderColumn(name = "results_order")
105+
@OrderBy("id")
95106
@JsonIgnoreProperties({ "submission", "participation" })
96-
private List<Result> results = new ArrayList<>();
107+
private Set<Result> results = new LinkedHashSet<>();
97108

98109
@Column(name = "submission_date")
99110
private ZonedDateTime submissionDate;
@@ -127,7 +138,7 @@ public Long getDurationInMinutes() {
127138
@Nullable
128139
@JsonIgnore
129140
public Result getLatestResult() {
130-
Result latestResult = Optional.ofNullable(results).orElse(List.of()).stream().filter(Objects::nonNull).max(Comparator.comparing(Result::getId)).orElse(null);
141+
Result latestResult = Optional.ofNullable(results).orElse(Set.of()).stream().filter(Objects::nonNull).max(BY_ID).orElse(null);
131142

132143
if (latestResult != null) {
133144
latestResult.setSubmission(this);
@@ -146,8 +157,8 @@ public Result getLatestResult() {
146157
@Nullable
147158
@JsonIgnore
148159
public Result getLatestCompletedResult() {
149-
Result latestResult = Optional.ofNullable(results).orElse(List.of()).stream().filter(result -> result != null && result.getCompletionDate() != null)
150-
.max(Comparator.comparing(Result::getCompletionDate)).orElse(null);
160+
Result latestResult = Optional.ofNullable(results).orElse(Set.of()).stream().filter(result -> result != null && result.getCompletionDate() != null)
161+
.max(Comparator.comparing(Result::getCompletionDate).thenComparing(BY_ID)).orElse(null);
151162

152163
if (latestResult != null) {
153164
latestResult.setSubmission(this);
@@ -166,19 +177,12 @@ public Result getLatestCompletedResult() {
166177
@Nullable
167178
@JsonIgnore
168179
public Result getResultForCorrectionRound(int correctionRound) {
169-
List<Result> filteredResults = filterNonAutomaticResults();
170-
if (correctionRound >= 0 && filteredResults.size() > correctionRound) {
171-
return filteredResults.get(correctionRound);
180+
if (correctionRound < 0) {
181+
return null;
172182
}
173-
return null;
174-
}
175-
176-
/**
177-
* @return an unmodifiable list or all non-automatic results
178-
*/
179-
@NonNull
180-
private List<Result> filterNonAutomaticResults() {
181-
return results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).toList();
183+
// The lowest id among the matches, so that the answer does not depend on the iteration order of an unordered
184+
// set. There should only ever be one result per round, and picking the earliest is what the ordered list did.
185+
return getManualResults().stream().filter(result -> Objects.equals(result.getCorrectionRound(), correctionRound)).min(BY_ID).orElse(null);
182186
}
183187

184188
/**
@@ -191,11 +195,7 @@ private List<Result> filterNonAutomaticResults() {
191195
*/
192196
@JsonIgnore
193197
public boolean hasResultForCorrectionRound(int correctionRound) {
194-
List<Result> withoutAutomaticResults = filterNonAutomaticResults();
195-
if (withoutAutomaticResults.size() > correctionRound) {
196-
return withoutAutomaticResults.get(correctionRound) != null;
197-
}
198-
return false;
198+
return getResultForCorrectionRound(correctionRound) != null;
199199
}
200200

201201
/**
@@ -204,7 +204,8 @@ public boolean hasResultForCorrectionRound(int correctionRound) {
204204
*/
205205
@JsonIgnore
206206
public void removeAutomaticResults() {
207-
this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).collect(Collectors.toCollection(ArrayList::new));
207+
this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased()))
208+
.collect(Collectors.toCollection(LinkedHashSet::new));
208209
}
209210

210211
/**
@@ -219,22 +220,22 @@ public void removeAutomaticResults() {
219220
*/
220221
@JsonIgnore
221222
public void removeNullResults() {
222-
this.results = this.results.stream().filter(Objects::nonNull).collect(Collectors.toCollection(ArrayList::new));
223+
this.results = this.results.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
223224
}
224225

225226
@JsonProperty(value = "results", access = JsonProperty.Access.READ_ONLY)
226-
public List<Result> getResults() {
227+
public Set<Result> getResults() {
227228
return results;
228229
}
229230

230231
@JsonIgnore
231-
public List<Result> getAutomaticResults() {
232-
return results.stream().filter(result -> result != null && (result.isAutomatic() || result.isAthenaBased())).collect(Collectors.toCollection(ArrayList::new));
232+
public Set<Result> getAutomaticResults() {
233+
return results.stream().filter(result -> result != null && (result.isAutomatic() || result.isAthenaBased())).collect(Collectors.toCollection(LinkedHashSet::new));
233234
}
234235

235236
@JsonIgnore
236-
public List<Result> getManualResults() {
237-
return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new));
237+
public Set<Result> getManualResults() {
238+
return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaBased()).collect(Collectors.toCollection(LinkedHashSet::new));
238239
}
239240

240241
/**
@@ -243,8 +244,8 @@ public List<Result> getManualResults() {
243244
* @return non athena automatic results excluding null results
244245
*/
245246
@JsonIgnore
246-
public List<Result> getNonAthenaResults() {
247-
return results.stream().filter(result -> result != null && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new));
247+
public Set<Result> getNonAthenaResults() {
248+
return results.stream().filter(result -> result != null && !result.isAthenaBased()).collect(Collectors.toCollection(LinkedHashSet::new));
248249
}
249250

250251
/**
@@ -267,10 +268,10 @@ public Result getManualResultsById(long resultId) {
267268
@Nullable
268269
@JsonIgnore
269270
public Result getFirstResult() {
270-
if (results != null && !results.isEmpty()) {
271-
return results.getFirst();
271+
if (results == null || results.isEmpty()) {
272+
return null;
272273
}
273-
return null;
274+
return results.stream().filter(Objects::nonNull).min(BY_ID).orElse(null);
274275
}
275276

276277
/**
@@ -281,10 +282,12 @@ public Result getFirstResult() {
281282
@Nullable
282283
@JsonIgnore
283284
public Result getFirstManualResult() {
284-
// Guard on the manual results, not on all results: a submission can carry only automatic or Athena results, and
285-
// getFirst() on the then empty manual list would throw instead of returning null as declared.
286-
List<Result> manualResults = results == null ? List.of() : getManualResults();
287-
return manualResults.isEmpty() ? null : manualResults.getFirst();
285+
// The earliest manual result, which is the one of the first correction round. Guard on the manual results, not
286+
// on all results: a submission can carry only automatic or Athena results and then there is none.
287+
if (results == null) {
288+
return null;
289+
}
290+
return getManualResults().stream().min(BY_ID).orElse(null);
288291
}
289292

290293
/**
@@ -299,29 +302,49 @@ public Result getFirstManualResult() {
299302
@Nullable
300303
@JsonIgnore
301304
public Result getLatestManualResult() {
302-
List<Result> manualResults = results == null ? List.of() : getManualResults();
303-
return manualResults.isEmpty() ? null : manualResults.getLast();
305+
// The most recent manual result, which is the one of the highest correction round.
306+
if (results == null) {
307+
return null;
308+
}
309+
return getManualResults().stream().max(BY_ID).orElse(null);
304310
}
305311

306312
/**
307-
* Add a result to the list.
308-
* NOTE: You must make sure to correctly persist the result in the database!
313+
* Adds a result to this submission and, if it is a correction-round result that does not have a round yet, assigns
314+
* the next one.
315+
* <p>
316+
* This is where the round used to come from implicitly: the results were an ordered list and the position carried
317+
* the round, so adding a result to the list decided which round it belonged to. The round now lives on the result,
318+
* and this is the same moment, so the behaviour is unchanged for every caller that does not set it itself.
319+
* {@code SubmissionService.lockSubmission} does set it, from the round the tutor asked for, and that takes
320+
* precedence. Automatic and Athena results are not correction rounds and keep no round.
309321
*
310-
* @param result the {@link Result} which should be added
322+
* @param result the result to add
311323
*/
312324
public void addResult(Result result) {
325+
if (result != null && result.getCorrectionRound() == null && !result.isAutomatic() && !result.isAthenaBased()) {
326+
result.setCorrectionRound(countCorrectionRoundResults(result));
327+
}
313328
this.results.add(result);
314329
}
315330

331+
/**
332+
* @param resultToAdd the result that is about to be added, which must not count itself
333+
* @return how many correction-round results this submission already holds
334+
*/
335+
private int countCorrectionRoundResults(Result resultToAdd) {
336+
return (int) results.stream().filter(other -> other != null && other != resultToAdd && !other.isAutomatic() && !other.isAthenaBased()).count();
337+
}
338+
316339
/**
317340
* Set the results list to the specified list.
318341
* NOTE: You must correctly persist this change in the database manually!
319342
*
320343
* @param results The list of {@link Result} which should replace the existing results of the submission
321344
*/
322345
@JsonProperty(value = "results", access = JsonProperty.Access.WRITE_ONLY)
323-
public void setResults(List<Result> results) {
324-
this.results = results;
346+
public void setResults(Set<Result> results) {
347+
this.results = results != null ? results : new LinkedHashSet<>();
325348
}
326349

327350
public Participation getParticipation() {
@@ -398,9 +421,12 @@ public void setExampleSubmission(Boolean exampleSubmission) {
398421
*/
399422
public void removeNotNeededResults(int correctionRound, Long resultId) {
400423
if (correctionRound == 0 && resultId == null && getResults().size() >= 2) {
401-
var resultList = new ArrayList<Result>();
402-
resultList.add(getFirstManualResult());
403-
setResults(resultList);
424+
var remainingResults = new HashSet<Result>();
425+
var firstManualResult = getFirstManualResult();
426+
if (firstManualResult != null) {
427+
remainingResults.add(firstManualResult);
428+
}
429+
setResults(remainingResults);
404430
}
405431
}
406432

0 commit comments

Comments
 (0)