Skip to content

Commit

Permalink
Learning paths: Hide unreleased learning objects in learning path view (
Browse files Browse the repository at this point in the history
  • Loading branch information
JohannesStoehr authored and JohannesWt committed Sep 23, 2024
1 parent a387bd5 commit 33a8f3b
Show file tree
Hide file tree
Showing 15 changed files with 142 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ public interface LearningObject {
Long getId();

Set<CourseCompetency> getCompetencies();

boolean isVisibleToStudents();
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,18 @@ public void setCompletedUsers(Set<LectureUnitCompletion> completedUsers) {
this.completedUsers = completedUsers;
}

/**
* Checks if the lecture unit is visible to the students.
* A lecture unit is visible to the students if the lecture is visible to the students and the release date is null or in the past.
*
* @return true if the lecture unit is visible to the students, false otherwise
*/
@JsonProperty("visibleToStudents")
public boolean isVisibleToStudents() {
if (lecture == null || !lecture.isVisibleToStudents()) {
return false;
}

if (releaseDate == null) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,27 +369,32 @@ public NgxLearningPathDTO generateNgxPathRepresentation(@NotNull LearningPath le
* @return the navigation overview
*/
public LearningPathNavigationOverviewDTO getLearningPathNavigationOverview(long learningPathId) {
var learningPath = findWithCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
if (!userRepository.getUser().equals(learningPath.getUser())) {
throw new AccessForbiddenException("You are not allowed to access this learning path");
}
return learningPathNavigationService.getNavigationOverview(learningPath);
}

/**
* Finds a learning path by its id and eagerly fetches the competencies, linked lecture units and exercises, and the corresponding domain objects storing the progress of the
* connected user.
* Finds a learning path by its id and eagerly fetches the competencies, linked and released lecture units and exercises, and the corresponding domain objects storing the
* progress of the connected user.
* <p>
* As Spring Boot 3 doesn't support conditional JOIN FETCH statements, we have to retrieve the data manually.
*
* @param learningPathId the id of the learning path to fetch
* @return the learning path with fetched data
*/
public LearningPath findWithCompetenciesAndLearningObjectsAndCompletedUsersById(long learningPathId) {
public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(long learningPathId) {
LearningPath learningPath = learningPathRepository.findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(learningPathId);
// Remove exercise units, since they are already retrieved as exercises
learningPath.getCompetencies().stream().forEach(competency -> competency
.setLectureUnits(competency.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)).collect(Collectors.toSet())));

// Remove exercises that are not visible to students
learningPath.getCompetencies()
.forEach(competency -> competency.setExercises(competency.getExercises().stream().filter(Exercise::isVisibleToStudents).collect(Collectors.toSet())));
// Remove unreleased lecture units as well as exercise units, since they are already retrieved as exercises
learningPath.getCompetencies().forEach(competency -> competency.setLectureUnits(competency.getLectureUnits().stream()
.filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit) && lectureUnit.isVisibleToStudents()).collect(Collectors.toSet())));

if (learningPath.getUser() == null) {
learningPath.getCompetencies().forEach(competency -> {
competency.setUserProgress(Collections.emptySet());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ public ResponseEntity<LearningPathNavigationDTO> getRelativeLearningPathNavigati
@RequestParam LearningObjectType learningObjectType, @RequestParam long competencyId) {
log.debug("REST request to get navigation for learning path with id: {} relative to learning object with id: {} and type: {} in competency with id: {}", learningPathId,
learningObjectId, learningObjectType, competencyId);
var learningPath = learningPathService.findWithCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
checkLearningPathAccessElseThrow(Optional.empty(), learningPath, Optional.empty());
return ResponseEntity.ok(learningPathNavigationService.getNavigationRelativeToLearningObject(learningPath, learningObjectId, learningObjectType, competencyId));
}
Expand All @@ -282,7 +282,7 @@ public ResponseEntity<LearningPathNavigationDTO> getRelativeLearningPathNavigati
@EnforceAtLeastStudent
public ResponseEntity<LearningPathNavigationDTO> getLearningPathNavigation(@PathVariable long learningPathId) {
log.debug("REST request to get navigation for learning path with id: {}", learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
checkLearningPathAccessElseThrow(Optional.empty(), learningPath, Optional.empty());
return ResponseEntity.ok(learningPathNavigationService.getNavigation(learningPath));
}
Expand All @@ -302,7 +302,7 @@ public ResponseEntity<LearningPathNavigationOverviewDTO> getLearningPathNavigati
}

private ResponseEntity<NgxLearningPathDTO> getLearningPathNgx(@PathVariable long learningPathId, NgxRequestType type) {
LearningPath learningPath = learningPathService.findWithCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId);
LearningPath learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
Course course = courseRepository.findByIdElseThrow(learningPath.getCourse().getId());
courseService.checkLearningPathsEnabledElseThrow(course);

Expand Down Expand Up @@ -387,7 +387,7 @@ public ResponseEntity<Set<CompetencyProgressForLearningPathDTO>> getCompetencyPr
@EnforceAtLeastStudent
public ResponseEntity<List<CompetencyNameDTO>> getCompetencyOrderForLearningPath(@PathVariable long learningPathId) {
log.debug("REST request to get competency order for learning path: {}", learningPathId);
final var learningPath = learningPathService.findWithCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId);
final var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);

checkLearningPathAccessElseThrow(Optional.of(learningPath.getCourse()), learningPath, Optional.empty());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @param type the type of the learning object
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record LearningPathNavigationObjectDTO(long id, boolean completed, String name, long competencyId, LearningObjectType type) {
public record LearningPathNavigationObjectDTO(long id, boolean completed, String name, long competencyId, LearningObjectType type, boolean unreleased) {

/**
* Create a navigation object DTO from a learning object.
Expand All @@ -25,11 +25,26 @@ public record LearningPathNavigationObjectDTO(long id, boolean completed, String
* @return the navigation object DTO
*/
public static LearningPathNavigationObjectDTO of(LearningObject learningObject, boolean completed, long competencyId) {
return switch (learningObject) {
case LectureUnit lectureUnit -> new LearningPathNavigationObjectDTO(lectureUnit.getId(), completed, lectureUnit.getName(), competencyId, LearningObjectType.LECTURE);
case Exercise exercise -> new LearningPathNavigationObjectDTO(learningObject.getId(), completed, exercise.getTitle(), competencyId, LearningObjectType.EXERCISE);
long id = learningObject.getId();
String name;
LearningObjectType type;
boolean unreleased = !learningObject.isVisibleToStudents();

switch (learningObject) {
case LectureUnit lectureUnit -> {
name = lectureUnit.getName();
type = LearningObjectType.LECTURE;
}
case Exercise exercise -> {
name = exercise.getTitle();
type = LearningObjectType.EXERCISE;
}
default -> throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise");
};
}

name = unreleased ? "" : name;

return new LearningPathNavigationObjectDTO(id, completed, name, competencyId, type, unreleased);
}

public enum LearningObjectType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
@for (learningObject of learningObjects(); let last = $last; track learningObject) {
<div
(click)="selectLearningObject(learningObject)"
class="row p-3 m-0 clickable align-items-center"
[ngClass]="{ 'selected-learning-object': currentLearningObject()?.id === learningObject.id && currentLearningObject()?.type === learningObject.type }"
class="row p-3 m-0 align-items-center"
[ngClass]="{
'selected-learning-object': currentLearningObject()?.id === learningObject.id && currentLearningObject()?.type === learningObject.type,
clickable: !learningObject.unreleased,
}"
>
<span class="col-md-auto p-0">{{ learningObject.name }}</span>
@if (learningObject.unreleased) {
<span class="col-md-auto p-0 text-muted" jhiTranslate="artemisApp.learningPath.navigation.overview.unreleasedLearningObjectLabel"></span>
<fa-icon [icon]="faLock" class="col-md-auto text-muted" />
} @else {
<span class="col-md-auto p-0">{{ learningObject.name }}</span>
}
@if (learningObject.completed) {
<fa-icon [icon]="faCheckCircle" class="col-md-auto text-success" />
} @else if (nextLearningObjectOnPath()?.id === learningObject.id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AlertService } from 'app/core/util/alert.service';
import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service';
import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service';
import { LearningPathNavigationObjectDTO } from 'app/entities/competency/learning-path.model';
import { IconDefinition, faCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { IconDefinition, faCheckCircle, faLock } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';

Expand All @@ -17,6 +17,7 @@ import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
})
export class LearningPathNavOverviewLearningObjectsComponent implements OnInit {
protected readonly faCheckCircle: IconDefinition = faCheckCircle;
protected readonly faLock: IconDefinition = faLock;

private readonly alertService: AlertService = inject(AlertService);
private readonly learningPathApiService: LearningPathApiService = inject(LearningPathApiService);
Expand Down Expand Up @@ -57,7 +58,9 @@ export class LearningPathNavOverviewLearningObjectsComponent implements OnInit {
}

selectLearningObject(learningObject: LearningPathNavigationObjectDTO): void {
this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), learningObject);
this.onLearningObjectSelected.emit();
if (!learningObject.unreleased) {
this.learningPathNavigationService.loadRelativeLearningPathNavigation(this.learningPathId(), learningObject);
this.onLearningObjectSelected.emit();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export interface LearningPathCompetencyDTO {
export interface LearningPathNavigationObjectDTO {
id: number;
completed: boolean;
name: string;
name?: string;
competencyId: number;
type: LearningObjectType;
unreleased: boolean;
}

export interface LearningPathNavigationDTO {
Expand Down
5 changes: 3 additions & 2 deletions src/main/webapp/i18n/de/learningPath.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"overview": {
"title": "Lernpfad Kompetenzen",
"showCompetenciesGraphButton": "Kompetenzgraph",
"emptyLearningObjectsLabel": "Diese Kompetenz enthält bisher noch keine Lerneinheiten!",
"nextLearningObjectOnPathLabel": "Nächste"
"emptyLearningObjectsLabel": "Diese Kompetenz enthält bisher noch keine Lernobjekte!",
"nextLearningObjectOnPathLabel": "Nächste",
"unreleasedLearningObjectLabel": "Unveröffentlichte Lerneinheit"
},
"recapLabel": "Lerneinheiten wiederholen"
},
Expand Down
5 changes: 3 additions & 2 deletions src/main/webapp/i18n/en/learningPath.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"overview": {
"title": "Learning path competencies",
"showCompetenciesGraphButton": "Competency graph",
"emptyLearningObjectsLabel": "This competency doesn't contain any learning units yet!",
"nextLearningObjectOnPathLabel": "Next"
"emptyLearningObjectsLabel": "This competency doesn't contain any learning objects yet!",
"nextLearningObjectOnPathLabel": "Next",
"unreleasedLearningObjectLabel": "Unreleased learning unit"
},
"recapLabel": "Recap learning units"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ public Competency[] createCompetencies(Course course, int numberOfCompetencies)
* @param competency The Competency to add to the LectureUnit
* @param lectureUnit The LectureUnit to update
*/
public void linkLectureUnitToCompetency(Competency competency, LectureUnit lectureUnit) {
public LectureUnit linkLectureUnitToCompetency(Competency competency, LectureUnit lectureUnit) {
lectureUnit.getCompetencies().add(competency);
lectureUnitRepository.save(lectureUnit);
return lectureUnitRepository.save(lectureUnit);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import de.tum.in.www1.artemis.repository.GradingCriterionRepository;
import de.tum.in.www1.artemis.repository.LearningPathRepository;
import de.tum.in.www1.artemis.repository.LectureRepository;
import de.tum.in.www1.artemis.repository.LectureUnitRepository;
import de.tum.in.www1.artemis.service.LectureUnitService;
import de.tum.in.www1.artemis.service.competency.CompetencyProgressService;
import de.tum.in.www1.artemis.util.PageableSearchUtilService;
Expand Down Expand Up @@ -107,6 +108,9 @@ class LearningPathIntegrationTest extends AbstractSpringIntegrationIndependentTe
@Autowired
private StudentScoreUtilService studentScoreUtilService;

@Autowired
private LectureUnitRepository lectureUnitRepository;

private Course course;

private Competency[] competencies;
Expand Down Expand Up @@ -725,6 +729,29 @@ void testGetLearningPathNavigationEmptyCompetencies() throws Exception {
verifyNavigationResult(result, thirdTextUnit, null, null);
}

@Test
@WithMockUser(username = STUDENT_OF_COURSE, roles = "USER")
void testGetLearningPathNavigationDoesNotLeakUnreleasedLearningObjects() throws Exception {
course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course);
final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow();
final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId());

textExercise.setCompetencies(Set.of());
textExercise = exerciseRepository.save(textExercise);

TextUnit secondTextUnit = createAndLinkTextUnit(student, competencies[1], false);
secondTextUnit.setReleaseDate(ZonedDateTime.now().plusDays(1));
lectureUnitRepository.save(secondTextUnit);
TextUnit thirdTextUnit = createAndLinkTextUnit(student, competencies[2], false);
TextUnit fourthTextUnit = createAndLinkTextUnit(student, competencies[3], false);
fourthTextUnit.setReleaseDate(ZonedDateTime.now().plusDays(1));
lectureUnitRepository.save(fourthTextUnit);
TextUnit fifthTextUnit = createAndLinkTextUnit(student, competencies[4], false);

var result = request.get("/api/learning-path/" + learningPath.getId() + "/navigation", HttpStatus.OK, LearningPathNavigationDTO.class);
verifyNavigationResult(result, textUnit, thirdTextUnit, fifthTextUnit);
}

private LearningPathNavigationObjectDTO.LearningObjectType getLearningObjectType(LearningObject learningObject) {
return switch (learningObject) {
case LectureUnit ignored -> LearningPathNavigationObjectDTO.LearningObjectType.LECTURE;
Expand Down Expand Up @@ -880,7 +907,7 @@ private TextExercise createAndLinkTextExercise(Competency competency, boolean wi
private TextUnit createAndLinkTextUnit(User student, Competency competency, boolean completed) {
TextUnit textUnit = lectureUtilService.createTextUnit();
lectureUtilService.addLectureUnitsToLecture(lecture, List.of(textUnit));
competencyUtilService.linkLectureUnitToCompetency(competency, textUnit);
textUnit = (TextUnit) competencyUtilService.linkLectureUnitToCompetency(competency, textUnit);

if (completed) {
lectureUnitService.setLectureUnitCompletion(textUnit, student, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ public Long getId() {
public Set<CourseCompetency> getCompetencies() {
return Set.of();
}

@Override
public boolean isVisibleToStudents() {
return false;
}
};
assertThatThrownBy(() -> learningObjectService.isCompletedByUser(unexpectedSubclass, student)).isInstanceOf(IllegalArgumentException.class);
}
Expand Down
Loading

0 comments on commit 33a8f3b

Please sign in to comment.