Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Learning analytics: Add competency details to student course dashboard #8570

Merged
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
4598492
Add DB migration for dashboard feature
kaancayli Apr 30, 2024
9c620a6
Implement initial dashboard UI
kaancayli Apr 2, 2024
a948526
Implement initial dashboard UI
kaancayli Apr 2, 2024
c383325
Add translations and bugfixes
kaancayli Apr 3, 2024
25e8aca
Add feature toggle for dashboard
kaancayli Apr 30, 2024
e85e8b3
Add toggle switch for dashboard on course page
kaancayli Apr 30, 2024
f296f38
Clean up the dashboard page for the new UI
kaancayli Apr 30, 2024
ab7e629
Adjust tests for dashboardEnabled
kaancayli Apr 30, 2024
2396566
Fix dashboard visibility
kaancayli Apr 30, 2024
ffb9ece
Add empty css file
kaancayli Apr 30, 2024
a662753
Fix import errors
kaancayli Apr 30, 2024
12e0625
Add work in progress dislaimer to the page. Reorder sidebar elements
kaancayli Apr 30, 2024
c1469ae
Update german translations
kaancayli Apr 30, 2024
71d623a
Delete unnecessary files and code.
kaancayli Apr 30, 2024
ceb248d
Rename Dashboard to StudentCourseAnalyticsDashboard
kaancayli Apr 30, 2024
54aaa22
Add tests
kaancayli Apr 30, 2024
b939136
Bugfix and translation updates
kaancayli Apr 30, 2024
0d812aa
Fix broken FeatureToggleServiceTest.java
kaancayli Apr 30, 2024
10f7780
Fix broken client feature toggle tests.
kaancayli Apr 30, 2024
8386e6b
Remove public modifier from StudentLearningAnalyticsIntegrationTest c…
kaancayli Apr 30, 2024
813198a
Add client tests
kaancayli May 1, 2024
88315b1
Remove duplicate translation key
kaancayli May 2, 2024
0ea83aa
Add one small test
kaancayli May 2, 2024
e42eac9
Merge develop
kaancayli May 10, 2024
fcd0552
feat: competency accordion
kaancayli May 4, 2024
0f86937
feat: competency accordion
kaancayli May 6, 2024
1805c00
Add server side implementation
kaancayli May 7, 2024
40b9ed4
Adjust client side implementation
kaancayli May 8, 2024
29709b6
Sort competencies according to soft due date
kaancayli May 8, 2024
da32a1a
Add show learning path button
kaancayli May 8, 2024
88dc2ae
Scroll active competency to view
kaancayli May 10, 2024
be78d72
Add tests
kaancayli May 10, 2024
0c85650
Solve naming conflicts and import errors due to rebase
kaancayli May 10, 2024
fc1e9ec
Add translations
kaancayli May 10, 2024
adf7e40
Fix border radius
kaancayli May 10, 2024
10bdfb9
Add tooltips
kaancayli May 10, 2024
1412887
Adjust tests
kaancayli May 10, 2024
82fa6d8
Fix exercise completion
kaancayli May 14, 2024
201a25a
Merge hd3-develop into
kaancayli May 15, 2024
30a5647
Address feedback
kaancayli May 15, 2024
268874e
Merge branch 'hd3-develop' of github.com:ls1intum/Artemis into featur…
kaancayli May 16, 2024
fc9fe1e
Merge branch 'hd3-develop' of github.com:ls1intum/Artemis into featur…
kaancayli May 16, 2024
427d51c
Fetch competency details from metrics endpoint
kaancayli May 16, 2024
30eaaac
FE - delete old code
kaancayli May 16, 2024
dc784ab
BE - delete old code
kaancayli May 16, 2024
74f72aa
chore: small refactoring
kaancayli May 16, 2024
655d6de
Restore accidental deletion
kaancayli May 16, 2024
63cc2b7
fix accordion animation and border radius
FelixTJDietrich May 16, 2024
f7c9157
remove duplicate transition property
FelixTJDietrich May 16, 2024
1ae1a06
refactor with some minor improvements
FelixTJDietrich May 16, 2024
47c6dc8
Revert "Restore accidental deletion"
kaancayli May 16, 2024
f812ac3
Merge branch 'feature/learning-analytics/competency-accordion' of git…
kaancayli May 16, 2024
b3306b0
Merge branch 'refs/heads/hd3-develop' into feature/learning-analytics…
FelixTJDietrich May 17, 2024
5bf8cf4
Revert "Restore accidental deletion"
FelixTJDietrich May 17, 2024
fbdd1d8
Merge remote-tracking branch 'origin/feature/learning-analytics/compe…
FelixTJDietrich May 17, 2024
2fe1cff
Revert "Revert "Restore accidental deletion""
FelixTJDietrich May 17, 2024
dddbbdc
fix indentation of idea file
FelixTJDietrich May 17, 2024
3078cdd
fix rounding in course dashboard
FelixTJDietrich May 17, 2024
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
11 changes: 11 additions & 0 deletions src/main/java/de/tum/in/www1/artemis/domain/Course.java
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ public class Course extends DomainObject {
@Column(name = "learning_paths_enabled", nullable = false)
private boolean learningPathsEnabled = false;

@Column(name = "student_course_analytics_dashboard_enabled", nullable = false)
private boolean studentCourseAnalyticsDashboardEnabled = false;

@OneToMany(mappedBy = "course", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
@JsonIgnoreProperties("course")
private Set<LearningPath> learningPaths = new HashSet<>();
Expand Down Expand Up @@ -774,6 +777,14 @@ public void setLearningPathsEnabled(boolean learningPathsEnabled) {
this.learningPathsEnabled = learningPathsEnabled;
}

public boolean getStudentCourseAnalyticsDashboardEnabled() {
return studentCourseAnalyticsDashboardEnabled;
}

public void setStudentCourseAnalyticsDashboardEnabled(boolean studentCourseAnalyticsDashboardEnabled) {
this.studentCourseAnalyticsDashboardEnabled = studentCourseAnalyticsDashboardEnabled;
}

public Set<LearningPath> getLearningPaths() {
return learningPaths;
}
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/de/tum/in/www1/artemis/domain/Exercise.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonIncludeProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonView;
Expand Down Expand Up @@ -236,6 +237,22 @@ public abstract class Exercise extends BaseExercise implements LearningObject {
@Transient
private String channelNameTransient;

/**
* Used for receiving marking if the user completed an exercise.
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
*/
@Transient
private boolean completed;

@JsonIgnore(false)
@JsonProperty("completed")
public boolean isCompleted() {
return completed;
}

public void setCompleted(boolean completed) {
this.completed = completed;
}

@Override
public boolean isCompletedFor(User user) {
return this.getStudentParticipations().stream().anyMatch((participation) -> participation.getStudents().contains(user));
Expand Down
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ public interface CompetencyRepository extends JpaRepository<Competency, Long>, J
""")
Set<Competency> findPrerequisitesByCourseId(@Param("courseId") long courseId);

@Query("""
SELECT c
FROM Competency c
LEFT JOIN FETCH c.lectureUnits lu
LEFT JOIN FETCH c.exercises
WHERE c.course.id = :courseId
ORDER BY c.softDueDate ASC, c.id ASC
""")
List<Competency> findAllWithLectureUnitsAndExercisesByCourseId(@Param("courseId") Long courseId);

@Query("""
SELECT COUNT(*)
FROM Competency pr
Expand Down
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,17 @@ AND EXISTS (
""")
Set<Long> findAllIdsByCourseId(@Param("courseId") Long courseId);

@Query("""
SELECT DISTINCT e
FROM Exercise e
WHERE e IN (
SELECT ex
FROM Exercise ex JOIN ex.competencies c
WHERE c.id IN :competencyIds
)
""")
Set<Exercise> findExercisesByCompetencyIds(@Param("competencyIds") Set<Long> competencyIds);

@EntityGraph(type = LOAD, attributePaths = { "studentParticipations", "studentParticipations.student", "studentParticipations.submissions" })
Optional<Exercise> findWithEagerStudentParticipationsStudentAndSubmissionsById(Long exerciseId);

Expand Down
10 changes: 10 additions & 0 deletions src/main/java/de/tum/in/www1/artemis/service/ExerciseService.java
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ else if (authCheckService.isOnlyStudentInCourse(course, user)) {
return exercisesUserIsAllowedToSee;
}

/**
* Gets the exercise that belong to any of the competency in the given set
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
*
* @param competencyIds the set of competency ids
* @return the exercises that belong to any of the competencies in the given set
*/
public Set<Exercise> findExercisesByCompetencyIds(Set<Long> competencyIds) {
return exerciseRepository.findExercisesByCompetencyIds(competencyIds);
}

/**
* Given an exercise exerciseId, it creates an object node with numberOfSubmissions, totalNumberOfAssessments, numberOfComplaints and numberOfMoreFeedbackRequests, that are
* used by both stats for assessment dashboard and for instructor dashboard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,45 @@ public List<Competency> findCompetenciesWithProgressForUserByCourseId(Long cours
return competencies;
}

/**
* Finds competencies within a course and fetch and their lecture units, exercises and progress for the provided user. It also fetches the lecture unit and exercise progress
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
* for the same user.
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
* <p>
* As Spring Boot 3 doesn't support conditional JOIN FETCH statements, we have to retrieve the data manually.
*
* @param courseId The id of the course for which to fetch the competencies
* @param userId The id of the user for which to fetch the progress
* @return The found competencies
*/
public List<Competency> findCompetenciesWithExercisesAndLectureUnitsAndProgressForUserByCourseId(Long courseId, Long userId) {
List<Competency> competencies = competencyRepository.findAllWithLectureUnitsAndExercisesByCourseId(courseId);
var progress = competencyProgressRepository.findByCompetenciesAndUser(competencies, userId).stream()
.collect(Collectors.toMap(completion -> completion.getCompetency().getId(), completion -> completion));
// collect to map lecture unit id -> this
var completions = lectureUnitCompletionRepository
.findByLectureUnitsAndUserId(competencies.stream().flatMap(competency -> competency.getLectureUnits().stream()).collect(Collectors.toSet()), userId).stream()
.collect(Collectors.toMap(completion -> completion.getLectureUnit().getId(), completion -> completion));

competencies.forEach(competency -> {
if (progress.containsKey(competency.getId())) {
competency.setUserProgress(Set.of(progress.get(competency.getId())));
}
else {
competency.setUserProgress(Collections.emptySet());
}
competency.getLectureUnits().forEach(lectureUnit -> {
if (completions.containsKey(lectureUnit.getId())) {
lectureUnit.setCompletedUsers(Set.of(completions.get(lectureUnit.getId())));
}
else {
lectureUnit.setCompletedUsers(Collections.emptySet());
}
});
});

return competencies;
}

/**
* Gets a new competency from an existing one (without relations).
* <p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package de.tum.in.www1.artemis.service.feature;

public enum Feature {
ProgrammingExercises, PlagiarismChecks, Exports, TutorialGroups, LearningPaths, Science, StandardizedCompetencies
ProgrammingExercises, PlagiarismChecks, Exports, TutorialGroups, LearningPaths, Science, StandardizedCompetencies, StudentCourseAnalyticsDashboard
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public void init() {
// This ensures that all features (except the Science API) are enabled once the system starts up
// Standardized Competencies are also disabled by default until the feature is ready
for (Feature feature : Feature.values()) {
if (!features.containsKey(feature) && feature != Feature.Science && feature != Feature.StandardizedCompetencies) {
if (!features.containsKey(feature) && feature != Feature.Science && feature != Feature.StandardizedCompetencies && feature != Feature.StudentCourseAnalyticsDashboard) {
features.put(feature, true);
}
}
Expand All @@ -58,6 +58,9 @@ public void init() {
if (!features.containsKey(Feature.StandardizedCompetencies)) {
features.put(Feature.StandardizedCompetencies, false);
}
if (!features.containsKey(Feature.StudentCourseAnalyticsDashboard)) {
features.put(Feature.StudentCourseAnalyticsDashboard, false);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse;
import de.tum.in.www1.artemis.service.AuthorizationCheckService;
import de.tum.in.www1.artemis.service.ExerciseService;
import de.tum.in.www1.artemis.service.LearningObjectService;
import de.tum.in.www1.artemis.service.LectureUnitService;
import de.tum.in.www1.artemis.service.competency.CompetencyProgressService;
import de.tum.in.www1.artemis.service.competency.CompetencyRelationService;
Expand Down Expand Up @@ -103,11 +104,13 @@ public class CompetencyResource {

private final CompetencyRelationService competencyRelationService;

private final LearningObjectService learningObjectService;

public CompetencyResource(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository,
CompetencyRepository competencyRepository, CompetencyRelationRepository competencyRelationRepository, CompetencyService competencyService,
CompetencyProgressRepository competencyProgressRepository, CompetencyProgressService competencyProgressService, ExerciseService exerciseService,
LectureUnitService lectureUnitService, CompetencyRelationService competencyRelationService,
Optional<IrisCompetencyGenerationSessionService> irisCompetencyGenerationSessionService) {
Optional<IrisCompetencyGenerationSessionService> irisCompetencyGenerationSessionService, LearningObjectService learningObjectService) {
this.courseRepository = courseRepository;
this.competencyRelationRepository = competencyRelationRepository;
this.authorizationCheckService = authorizationCheckService;
Expand All @@ -120,6 +123,7 @@ public CompetencyResource(CourseRepository courseRepository, AuthorizationCheckS
this.lectureUnitService = lectureUnitService;
this.competencyRelationService = competencyRelationService;
this.irisCompetencyGenerationSessionService = irisCompetencyGenerationSessionService;
this.learningObjectService = learningObjectService;
}

/**
Expand Down Expand Up @@ -179,6 +183,37 @@ public ResponseEntity<List<Competency>> getCompetenciesWithProgress(@PathVariabl
return ResponseEntity.ok(competencies);
}

/**
* GET /courses/:courseId/competencies/student-analytics-dashboard : gets all the competencies of
* a course with relevant info for the student dashboard
*
* @param courseId the id of the course for which the competencies should be fetched
* @return the ResponseEntity with status 200 (OK) and with body the found competencies with exercises and lecture units
*/
@GetMapping("courses/{courseId}/competencies/student-analytics-dashboard")
@EnforceAtLeastStudent
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
public ResponseEntity<List<Competency>> getCompetenciesWithDetailsForDashboard(@PathVariable long courseId) {
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
log.debug("REST request to get competencies for course student dashboard with id: {}", courseId);
Course course = courseRepository.findByIdElseThrow(courseId);
User user = userRepository.getUserWithGroupsAndAuthorities();
authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user);
final var competencies = competencyService.findCompetenciesWithExercisesAndLectureUnitsAndProgressForUserByCourseId(courseId, user.getId());

for (Competency competency : competencies) {
// Filter lecture units
competency.setLectureUnits(competency.getLectureUnits().stream().filter(lectureUnit -> authorizationCheckService.isAllowedToSeeLectureUnit(lectureUnit, user))
.peek(lectureUnit -> lectureUnit.setCompleted(lectureUnit.isCompletedFor(user))).collect(Collectors.toSet()));

// Load exercises with information needed for the dashboard
Set<Exercise> exercisesUserIsAllowedToSee = exerciseService.filterOutExercisesThatUserShouldNotSee(competency.getExercises(), user);
Set<Exercise> exercisesWithAllInformationNeeded = exerciseService.loadExercisesWithInformationForDashboard(exercisesUserIsAllowedToSee.stream()
.peek(exercise -> exercise.setCompleted(learningObjectService.isCompletedByUser(exercise, user))).map(Exercise::getId).collect(Collectors.toSet()), user);
competency.setExercises(exercisesWithAllInformationNeeded);
}

return ResponseEntity.ok(competencies);
}

/**
* GET /courses/:courseId/competencies/:competencyId : gets the competency with the specified id including its related exercises and lecture units
* This method also calculates the user progress
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ public ResponseEntity<Course> updateCourse(@PathVariable Long courseId, @Request
throw new BadRequestAlertException("You are not allowed to change the access to restricted Athena modules of a course", Course.ENTITY_NAME,
"restrictedAthenaModulesAccessCannotChange", true);
}
// instructors are not allowed to change the dashboard settings
if (existingCourse.getStudentCourseAnalyticsDashboardEnabled() != courseUpdate.getStudentCourseAnalyticsDashboardEnabled()) {
throw new BadRequestAlertException("You are not allowed to change the dashboard settings of a course", Course.ENTITY_NAME, "dashboardSettingsCannotChange", true);
}
}

if (courseUpdate.getPresentationScore() != null && courseUpdate.getPresentationScore() != 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>

<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

<!--
Change Set for adding following changes to the database schema:
- Dashboard Enabled (dashboard_enabled) for enabling or disabling the dashboard
-->
<changeSet id="20240428210130" author="kaancayli">
<addColumn tableName="course">
<column defaultValueBoolean="false" name="student_course_analytics_dashboard_enabled" type="boolean">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
1 change: 1 addition & 0 deletions src/main/resources/config/liquibase/master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<include file="classpath:config/liquibase/changelog/20230628215302_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20240412170323_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20240418204415_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20240428210130_changelog.xml" relativeToChangelogFile="false"/>
<!-- NOTE: please use the format "YYYYMMDDhhmmss_changelog.xml", i.e. year month day hour minutes seconds and not something else! -->
<!-- we should also stay in a chronological order! -->
<!-- you can use the command 'date '+%Y%m%d%H%M%S'' to get the current date and time in the correct format -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<div>
<button
[id]="'competency-accordion-' + competency.id"
class="competency-accordion-header row align-items-center justify-content-between mt-2 position-relative flex-wrap"
(click)="toggle()"
>
<div class="col-auto d-md-block my-2">
<h3 class="fw-medium">
<fa-icon
[icon]="getIcon(competency.taxonomy)"
[fixedWidth]="true"
[ngbTooltip]="'artemisApp.competency.taxonomies.' + (competency.taxonomy ?? 'none') | artemisTranslate"
container="body"
/>
</h3>
</div>
<div class="col-sm col py-2">
<h3>{{ competency.title }}</h3>
</div>
@if (!open) {
<div class="col-sm col py-2" style="max-width: 200px" [class.header-tooltip-open]="open">
<div class="row mb-2">
<div class="col-auto d-md-block">
<fa-icon [icon]="faList" [ngbTooltip]="'artemisApp.studentAnalyticsDashboard.competencyAccordion.exercises' | artemisTranslate" />
</div>
<div class="col">
<jhi-student-analytics-dashboard-progress-bar [size]="'small'" [maxValue]="100" [currentValue]="exercisesProgress" [inline]="true" />
</div>
</div>
<div class="row align-items-center justify-content-center">
<div class="col-auto d-md-block">
<fa-icon [icon]="faPdf" [ngbTooltip]="'artemisApp.studentAnalyticsDashboard.competencyAccordion.lectures' | artemisTranslate" />
</div>
<div class="col">
<jhi-student-analytics-dashboard-progress-bar [size]="'small'" [maxValue]="100" [currentValue]="lectureUnitsProgress" [inline]="true" />
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
</div>
</div>
</div>
<div class="col-sm col text-end py-1 px-2 justify-content-center align-items-center" style="max-width: 75px">
<jhi-competency-rings [confidence]="this.confidence" [progress]="this.progress" [mastery]="this.mastery" [hideTooltip]="false" [playAnimation]="false" />
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
</div>
kaancayli marked this conversation as resolved.
Show resolved Hide resolved
} @else {
<div class="col-sm col py-2" style="max-width: 275px" [class.header-tooltip-closed]="!open">
<button
(click)="navigateToCompetencyDetailPage($event)"
class="btn btn-primary w-100 my-2"
jhiTranslate="artemisApp.studentAnalyticsDashboard.button.viewLecturesAndExercises"
></button>
</div>
}
</button>
<div [class.competency-accordion-body-open]="open" [class.competency-accordion-body-closed]="!open" class="competency-accordion-body">
<div class="row align-items-center justify-content-center">
<div class="col-sm">
<jhi-student-analytics-dashboard-progress-bar
[maxValue]="100"
[currentValue]="exercisesProgress"
[icon]="faList"
[title]="'artemisApp.studentAnalyticsDashboard.competencyAccordion.exercises' | artemisTranslate"
/>
</div>
<div class="col-sm py-2">
<jhi-student-analytics-dashboard-progress-bar
[maxValue]="100"
[currentValue]="lectureUnitsProgress"
[icon]="faPdf"
[title]="'artemisApp.studentAnalyticsDashboard.competencyAccordion.lectures' | artemisTranslate"
/>
</div>
</div>
@if (nextExercise) {
<div class="row align-items-center justify-content-center mt-2">
<div class="col">
<h5><b jhiTranslate="artemisApp.studentAnalyticsDashboard.competencyAccordion.nextExercise.title"></b></h5>
<jhi-course-exercise-row [exercise]="nextExercise" [course]="competency!.course!" />
</div>
</div>
}
</div>
</div>
Loading
Loading