Skip to content

Commit c261609

Browse files
kruschem4rl0ne
authored andcommitted
Lectures: Fix edge cases that can lead to invalid lecture unit configurations (ls1intum#11648)
1 parent 6afd71d commit c261609

File tree

101 files changed

+1549
-758
lines changed

Some content is hidden

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

101 files changed

+1549
-758
lines changed

docs/dev/setup/server.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ You only need to modify them if your specific work or production environments re
4747
user-management:
4848
use-external: true
4949
password-reset:
50-
credential-provider: <provider> # Example: TUMonline
5150
links:
5251
en: '<link>'
5352
de: '<link>'

src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyProgressApi.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ public void updateProgressForUpdatedLearningObjectAsync(LearningObject originalL
4545
competencyProgressService.updateProgressForUpdatedLearningObjectAsync(originalLearningObject, updatedLearningObject);
4646
}
4747

48+
public void updateProgressForUpdatedLearningObjectAsyncWithOriginalCompetencyIds(Set<Long> originalCompetencyIds, LearningObject updatedLearningObject) {
49+
competencyProgressService.updateProgressForUpdatedLearningObjectAsyncWithOriginalCompetencyIds(originalCompetencyIds, updatedLearningObject);
50+
}
51+
4852
public void updateProgressByLearningObjectSync(LearningObject learningObject, Set<User> users) {
4953
competencyProgressService.updateProgressByLearningObjectSync(learningObject, users);
5054
}

src/main/java/de/tum/cit/aet/artemis/atlas/api/CompetencyRepositoryApi.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ public CompetencyRepositoryApi(CompetencyRepository competencyRepository) {
2424
public Set<Competency> findAllByCourseId(long courseId) {
2525
return competencyRepository.findAllByCourseId(courseId);
2626
}
27+
28+
public Competency findByIdElseThrow(long competencyId) {
29+
return competencyRepository.findByIdElseThrow(competencyId);
30+
}
2731
}

src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLectureUnitLink.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.hibernate.annotations.CacheConcurrencyStrategy;
1717

1818
import com.fasterxml.jackson.annotation.JsonIgnore;
19+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
1920

2021
import de.tum.cit.aet.artemis.lecture.domain.LectureUnit;
2122

@@ -28,6 +29,7 @@ public class CompetencyLectureUnitLink extends CompetencyLearningObjectLink {
2829
@JsonIgnore
2930
protected CompetencyLectureUnitId id = new CompetencyLectureUnitId();
3031

32+
@JsonIgnoreProperties("competencyLinks")
3133
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)
3234
@MapsId("lectureUnitId")
3335
private LectureUnit lectureUnit;

src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,16 +338,15 @@ private void importOrLoadLectureUnit(CompetencyLectureUnitLink sourceLectureUnit
338338

339339
LectureUnit sourceLectureUnit = sourceLectureUnitLink.getLectureUnit();
340340
Lecture sourceLecture = sourceLectureUnit.getLecture();
341-
Lecture importedLecture = importOrLoadLecture(sourceLecture, courseToImportInto, titleToImportedLectures);
341+
Lecture newLecture = importOrLoadLecture(sourceLecture, courseToImportInto, titleToImportedLectures);
342342

343343
Optional<LectureUnit> foundLectureUnit = repositoryApi.findByNameAndLectureTitleAndCourseIdWithCompetencies(sourceLectureUnit.getName(), sourceLecture.getTitle(),
344344
courseToImportInto.getId());
345345
LectureUnit importedLectureUnit;
346346
if (foundLectureUnit.isEmpty()) {
347-
importedLectureUnit = api.importLectureUnit(sourceLectureUnit);
347+
importedLectureUnit = api.importLectureUnit(sourceLectureUnit, newLecture);
348348

349-
importedLecture.getLectureUnits().add(importedLectureUnit);
350-
importedLectureUnit.setLecture(importedLecture);
349+
newLecture.addLectureUnit(importedLectureUnit);
351350
}
352351
else {
353352
importedLectureUnit = foundLectureUnit.get();
@@ -370,10 +369,10 @@ private Lecture importOrLoadLecture(Lecture sourceLecture, Course courseToImport
370369
if (foundLecture.isEmpty()) {
371370
foundLecture = repositoryApi.findUniqueByTitleAndCourseIdWithLectureUnitsElseThrow(sourceLecture.getTitle(), courseToImportInto.getId());
372371
}
373-
Lecture importedLecture = foundLecture.orElseGet(() -> importApi.importLecture(sourceLecture, courseToImportInto, false));
374-
titleToImportedLectures.put(importedLecture.getTitle(), importedLecture);
372+
Lecture newLecture = foundLecture.orElseGet(() -> importApi.importLecture(sourceLecture, courseToImportInto, false));
373+
titleToImportedLectures.put(newLecture.getTitle(), newLecture);
375374

376-
return importedLecture;
375+
return newLecture;
377376
}
378377

379378
private void setAllDates(Set<Exercise> importedExercises, Set<Lecture> importedLectures, Set<LectureUnit> importedLectureUnits,

src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyProgressService.java

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.util.stream.DoubleStream;
1212

1313
import org.jspecify.annotations.NonNull;
14+
import org.jspecify.annotations.Nullable;
1415
import org.slf4j.Logger;
1516
import org.slf4j.LoggerFactory;
1617
import org.springframework.context.annotation.Conditional;
@@ -137,20 +138,33 @@ public void updateProgressByCompetencyAsync(CourseCompetency competency) {
137138
*/
138139
@Async
139140
public void updateProgressForUpdatedLearningObjectAsync(LearningObject originalLearningObject, Optional<LearningObject> updatedLearningObject) {
140-
SecurityUtils.setAuthorizationObject(); // Required for async
141-
142141
Set<Long> originalCompetencyIds = originalLearningObject.getCompetencyLinks().stream().map(CompetencyLearningObjectLink::getCompetency).map(CourseCompetency::getId)
143142
.collect(Collectors.toSet());
144-
Set<CourseCompetency> updatedCompetencies = updatedLearningObject
145-
.map(learningObject -> learningObject.getCompetencyLinks().stream().map(CompetencyLearningObjectLink::getCompetency).collect(Collectors.toSet())).orElse(Set.of());
143+
updateProgressForUpdatedLearningObjectAsyncWithOriginalCompetencyIds(originalCompetencyIds, updatedLearningObject.orElse(null));
144+
}
145+
146+
/**
147+
* Asynchronously update the existing progress for all changed competencies linked to the given learning object
148+
* If new competencies are added, the progress is updated for all users in the course, otherwise only the existing progresses are updated.
149+
*
150+
* @param originalCompetencyIds The original competency ids before the update
151+
* @param updatedLearningObject The updated learning object after the update
152+
*/
153+
@Async
154+
public void updateProgressForUpdatedLearningObjectAsyncWithOriginalCompetencyIds(Set<Long> originalCompetencyIds, @Nullable LearningObject updatedLearningObject) {
155+
SecurityUtils.setAuthorizationObject(); // Required for async
156+
157+
Set<CourseCompetency> updatedCompetencies = updatedLearningObject != null
158+
? updatedLearningObject.getCompetencyLinks().stream().map(CompetencyLearningObjectLink::getCompetency).collect(Collectors.toSet())
159+
: Set.of();
146160
Set<Long> updatedCompetencyIds = updatedCompetencies.stream().map(CourseCompetency::getId).collect(Collectors.toSet());
147161

148162
Set<Long> removedCompetencyIds = originalCompetencyIds.stream().filter(id -> !updatedCompetencyIds.contains(id)).collect(Collectors.toSet());
149163
Set<Long> addedCompetencyIds = updatedCompetencyIds.stream().filter(id -> !originalCompetencyIds.contains(id)).collect(Collectors.toSet());
150164

151165
updateProgressByCompetencyIds(removedCompetencyIds);
152166
if (!addedCompetencyIds.isEmpty()) {
153-
updateProgressByCompetencyIdsAndLearningObject(addedCompetencyIds, originalLearningObject);
167+
updateProgressByCompetencyIdsAndLearningObject(addedCompetencyIds, updatedLearningObject);
154168
}
155169
}
156170

@@ -174,6 +188,8 @@ private void updateProgressByCompetencyIdsAndLearningObject(Set<Long> competency
174188
};
175189
existingCompetencyUsers.addAll(existingLearningObjectUsers);
176190
log.debug("Updating competency progress for {} users.", existingCompetencyUsers.size());
191+
// TODO: this could be a very expensive operation if many users and competencies are involved, we MUST optimize this in the future
192+
// Minimal changes: load all competencies only once outside the loop and reuse them inside
177193
existingCompetencyUsers.forEach(user -> updateCompetencyProgress(competencyId, user));
178194
}
179195
}

src/main/java/de/tum/cit/aet/artemis/core/service/FileService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public byte[] getFileForPath(Path path) throws IOException {
6464
*/
6565
@CacheEvict(value = "files", key = "#path")
6666
public void evictCacheForPath(Path path) {
67-
log.info("Invalidate files cache for {}", path);
67+
log.debug("Invalidate files cache for {}", path);
6868
// Intentionally blank
6969
}
7070

src/main/java/de/tum/cit/aet/artemis/core/web/course/CourseOverviewResource.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ public ResponseEntity<Set<Course>> getCoursesForNotifications() {
243243
* @param courseId the id of the course to retrieve
244244
* @return the ResponseEntity with status 200 (OK) and with body the course, or with status 404 (Not Found)
245245
*/
246+
// TODO: this method is invoked quite often as part of course management resolve. However, it might not be necessary to fetch tutorial group configuration and online course
247+
// configuration in such cases.
246248
@GetMapping("courses/{courseId}")
247249
@EnforceAtLeastStudent
248250
public ResponseEntity<Course> getCourse(@PathVariable Long courseId) {

src/main/java/de/tum/cit/aet/artemis/lecture/api/LectureUnitApi.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import de.tum.cit.aet.artemis.core.domain.User;
1313
import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit;
14+
import de.tum.cit.aet.artemis.lecture.domain.Lecture;
1415
import de.tum.cit.aet.artemis.lecture.domain.LectureUnit;
1516
import de.tum.cit.aet.artemis.lecture.repository.ExerciseUnitRepository;
1617
import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository;
@@ -41,7 +42,7 @@ public LectureUnitApi(LectureUnitService lectureUnitService, LectureUnitReposito
4142
this.exerciseUnitRepository = exerciseUnitRepository;
4243
}
4344

44-
public void setCompletedForAllLectureUnits(List<? extends LectureUnit> lectureUnits, @NonNull User user, boolean completed) {
45+
public <T extends LectureUnit> void setCompletedForAllLectureUnits(List<T> lectureUnits, @NonNull User user, boolean completed) {
4546
lectureUnitService.setCompletedForAllLectureUnits(lectureUnits, user, completed);
4647
}
4748

@@ -56,7 +57,7 @@ public void removeLectureUnitFromExercise(long exerciseId) {
5657
}
5758
}
5859

59-
public LectureUnit importLectureUnit(LectureUnit sourceLectureUnit) {
60-
return lectureUnitImportService.importLectureUnit(sourceLectureUnit);
60+
public LectureUnit importLectureUnit(LectureUnit sourceLectureUnit, Lecture newLecture) {
61+
return lectureUnitImportService.importLectureUnit(sourceLectureUnit, newLecture);
6162
}
6263
}

src/main/java/de/tum/cit/aet/artemis/lecture/domain/Lecture.java

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package de.tum.cit.aet.artemis.lecture.domain;
22

33
import java.time.ZonedDateTime;
4-
import java.util.ArrayList;
4+
import java.util.Comparator;
55
import java.util.HashSet;
6+
import java.util.LinkedHashSet;
67
import java.util.List;
78
import java.util.Set;
89

@@ -11,12 +12,17 @@
1112
import jakarta.persistence.Entity;
1213
import jakarta.persistence.ManyToOne;
1314
import jakarta.persistence.OneToMany;
14-
import jakarta.persistence.OrderColumn;
15+
import jakarta.persistence.OrderBy;
16+
import jakarta.persistence.PrePersist;
17+
import jakarta.persistence.PreUpdate;
1518
import jakarta.persistence.Table;
1619
import jakarta.persistence.Transient;
20+
import jakarta.validation.constraints.NotNull;
1721

22+
import org.hibernate.Hibernate;
1823
import org.hibernate.annotations.Cache;
1924
import org.hibernate.annotations.CacheConcurrencyStrategy;
25+
import org.jspecify.annotations.Nullable;
2026

2127
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
2228
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -59,11 +65,26 @@ public class Lecture extends DomainObject {
5965
@JsonIgnoreProperties(value = "lecture", allowSetters = true)
6066
private Set<Attachment> attachments = new HashSet<>();
6167

68+
/**
69+
* The lecture units of this lecture.
70+
* <p>
71+
* Note: We use a Set here to avoid issues with Hibernate and JPA when managing the collection.
72+
* A list without @OrderColumn is treated as Bag and would lead to issues when fetching data like lecture.lectureUnits.competencyLinks
73+
* A Set prevents this, a LinkedHashSet is used to maintain insertion order.
74+
* <p>
75+
* Note: We cannot use @OrderColumn here because this leads to issues when saving the lecture (there were ugly workarounds needed in the past) and could potentially lead to
76+
* null values in the order column which leads to unexpected behavior and unresolvable bugs in the user interface. This has happened in the past, therefore, we decided to
77+
* NOT rely on @OrderColumn anymore.
78+
* <p>
79+
* Instead, we manage the order manually via the lectureUnitOrder field in LectureUnit, other developers who use lecture and lectureUnit do not need to worry about it as
80+
* long as they use the provided methods to add/remove/reorder lecture units.
81+
*
82+
*/
6283
@OneToMany(mappedBy = "lecture", cascade = CascadeType.ALL, orphanRemoval = true)
63-
@OrderColumn(name = "lecture_unit_order")
84+
@OrderBy("lectureUnitOrder ASC") // DB → Java: always ordered by that column
6485
@JsonIgnoreProperties("lecture")
6586
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
66-
private List<LectureUnit> lectureUnits = new ArrayList<>();
87+
private Set<LectureUnit> lectureUnits = new LinkedHashSet<>();
6788

6889
@ManyToOne
6990
@JsonIgnoreProperties(value = { "lectures", "exercises", "posts" }, allowSetters = true)
@@ -136,17 +157,119 @@ public void setAttachments(Set<Attachment> attachments) {
136157
this.attachments = attachments;
137158
}
138159

160+
/**
161+
* Get an unmodifiable list of lecture units when the objects are initialized by Hibernate.
162+
* This is important so that external code does not modify the list without updating the back-references and order.
163+
* <p>
164+
* Use {@link #addLectureUnit}, {@link #removeLectureUnit}, {@link #setLectureUnits}, or {@link #reorderLectureUnits} to modify the lecture units.
165+
*
166+
* @return the lecture units
167+
*/
139168
public List<LectureUnit> getLectureUnits() {
140-
return lectureUnits;
169+
if (Hibernate.isInitialized(lectureUnits)) {
170+
return List.copyOf(lectureUnits);
171+
}
172+
// when not initialized, return an empty list to avoid LazyInitializationExceptions
173+
return List.of();
174+
}
175+
176+
/**
177+
* Reorder the lecture units based on the given list of ordered IDs.
178+
* Makes sure to update the order after reordering.
179+
*
180+
* @param orderedIds the list of lecture unit IDs in the desired order
181+
*/
182+
public void reorderLectureUnits(@NotNull List<Long> orderedIds) {
183+
List<LectureUnit> sorted = lectureUnits.stream().sorted(Comparator.comparing(unit -> orderedIds.indexOf(unit.getId()))).toList();
184+
lectureUnits.clear();
185+
lectureUnits.addAll(sorted);
186+
updateLectureUnitOrder();
141187
}
142188

143-
public void setLectureUnits(List<LectureUnit> lectureUnits) {
144-
this.lectureUnits = lectureUnits;
189+
/**
190+
* Set the lecture units, replacing any existing ones.
191+
* Makes sure to update back-references and order.
192+
*
193+
* @param lectureUnits the new list of lecture units
194+
*/
195+
public void setLectureUnits(@Nullable List<LectureUnit> lectureUnits) {
196+
// this if statement is important to avoid issues when setting lazy loaded lectureUnits to null or empty which would not work in the else statement
197+
if (lectureUnits == null || lectureUnits.isEmpty()) {
198+
this.lectureUnits = new LinkedHashSet<>();
199+
}
200+
else {
201+
this.lectureUnits.clear();
202+
for (LectureUnit lectureUnit : lectureUnits) {
203+
addLectureUnit(lectureUnit); // ensures back-reference and collection management
204+
}
205+
updateLectureUnitOrder();
206+
}
145207
}
146208

147-
public void addLectureUnit(LectureUnit lectureUnit) {
148-
this.lectureUnits.add(lectureUnit);
209+
/**
210+
* Add a lecture unit to the end of the list.
211+
* Makes sure to update back-references and order.
212+
*
213+
* @param lectureUnit the lecture unit to add
214+
*/
215+
public void addLectureUnit(@Nullable LectureUnit lectureUnit) {
216+
if (lectureUnit == null) {
217+
return;
218+
}
219+
lectureUnits.add(lectureUnit); // order is implicit by position
149220
lectureUnit.setLecture(this);
221+
updateLectureUnitOrder();
222+
}
223+
224+
/**
225+
* Remove a lecture unit from the list.
226+
* Makes sure to update back-references and order.
227+
*
228+
* @param lectureUnit the lecture unit to remove
229+
*/
230+
public void removeLectureUnit(@Nullable LectureUnit lectureUnit) {
231+
if (lectureUnit == null) {
232+
return;
233+
}
234+
lectureUnits.remove(lectureUnit);
235+
lectureUnit.setLecture(null);
236+
updateLectureUnitOrder();
237+
}
238+
239+
/**
240+
* Remove a lecture unit by its ID.
241+
* Makes sure to update back-references and order.
242+
*
243+
* @param lectureUnitId the ID of the lecture unit to remove
244+
*/
245+
public void removeLectureUnitById(@Nullable Long lectureUnitId) {
246+
if (lectureUnitId == null) {
247+
return;
248+
}
249+
250+
// find the unit to remove
251+
LectureUnit toRemove = lectureUnits.stream().filter(unit -> unit != null && lectureUnitId.equals(unit.getId())).findFirst().orElse(null);
252+
253+
removeLectureUnit(toRemove);
254+
}
255+
256+
/**
257+
* Update the lectureUnitOrder field of all lecture units based on their current position in the set.
258+
* This method is called before persisting or updating the Lecture entity to ensure consistency.
259+
* It can also be called manually after reordering the lecture units or adding/removing some.
260+
*/
261+
@PrePersist
262+
@PreUpdate
263+
public void updateLectureUnitOrder() {
264+
if (Hibernate.isInitialized(lectureUnits)) {
265+
int order = 0;
266+
for (LectureUnit unit : lectureUnits) {
267+
if (unit == null) {
268+
continue;
269+
}
270+
unit.setLectureUnitOrder(order++);
271+
}
272+
}
150273
}
151274

152275
public Course getCourse() {

0 commit comments

Comments
 (0)