diff --git a/src/main/webapp/app/overview/course-dashboard/course-dashboard-guard.service.ts b/src/main/webapp/app/overview/course-dashboard/course-dashboard-guard.service.ts deleted file mode 100644 index fde78c535280..000000000000 --- a/src/main/webapp/app/overview/course-dashboard/course-dashboard-guard.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; - -import { Observable, first, lastValueFrom, map, of, switchMap } from 'rxjs'; -import { FeatureToggle, FeatureToggleService } from 'app/shared/feature-toggle/feature-toggle.service'; -import { CourseStorageService } from 'app/course/manage/course-storage.service'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; - -@Injectable({ - providedIn: 'root', -}) -export class CourseDashboardGuard implements CanActivate { - constructor( - private featureToggleService: FeatureToggleService, - private courseStorageService: CourseStorageService, - private courseManagementService: CourseManagementService, - private router: Router, - ) {} - - /** - * Check if the client can activate a route. - * @return true if CourseD is enabled for this instance, false otherwise - */ - canActivate(route: ActivatedRouteSnapshot): Promise { - return lastValueFrom( - this.featureToggleService.getFeatureToggleActive(FeatureToggle.StudentCourseAnalyticsDashboard).pipe( - first(), - switchMap((isActive): Observable => { - const courseId = route.parent?.paramMap.get('courseId'); - if (!courseId) { - return of(false); - } - - const handleReturn = (value: boolean) => { - if (!value) { - this.router.navigate([`/courses/${courseId}/exercises`]); - } - return value; - }; - - if (!isActive) { - return of(handleReturn(false)); - } - - // Check if course has dashboard enabled - const course = this.courseStorageService.getCourse(parseInt(courseId, 10)); - if (course) { - return of(handleReturn(course?.studentCourseAnalyticsDashboardEnabled ?? false)); - } - - // If course is not in storage, fetch it from the server - return this.courseManagementService.find(parseInt(courseId, 10)).pipe( - map((res) => { - if (res.body) { - // Store course in cache - this.courseStorageService.updateCourse(res.body); - } - return handleReturn(res.body?.studentCourseAnalyticsDashboardEnabled ?? false); - }), - ); - }), - ), - ); - } -} diff --git a/src/main/webapp/app/overview/course-overview-guard.ts b/src/main/webapp/app/overview/course-overview-guard.ts new file mode 100644 index 000000000000..abcacff26b31 --- /dev/null +++ b/src/main/webapp/app/overview/course-overview-guard.ts @@ -0,0 +1,99 @@ +import { Injectable, inject } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; +import { Observable, of, switchMap } from 'rxjs'; +import { CourseStorageService } from 'app/course/manage/course-storage.service'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { Course, isCommunicationEnabled } from 'app/entities/course.model'; +import dayjs from 'dayjs/esm'; +import { ArtemisServerDateService } from 'app/shared/server-date.service'; +import { CourseOverviewRoutePath } from 'app/overview/courses-routing.module'; + +@Injectable({ + providedIn: 'root', +}) +export class CourseOverviewGuard implements CanActivate { + private courseStorageService = inject(CourseStorageService); + private courseManagementService = inject(CourseManagementService); + private router = inject(Router); + private serverDateService = inject(ArtemisServerDateService); + + /** + * Check if the client can activate a course overview route. + * @return true if the client is allowed to access the route, false otherwise + */ + canActivate(route: ActivatedRouteSnapshot): Observable { + const courseIdString = route.parent?.paramMap.get('courseId'); + if (!courseIdString) { + return of(false); + } + const courseIdNumber = parseInt(courseIdString, 10); + + const path = route.routeConfig?.path; + if (!path) { + return of(false); + } + //we need to load the course from the server to check if the user has access to the requested route. The course in the cache might not be sufficient (e.g. misses exams or lectures) + return this.courseManagementService.findOneForDashboard(courseIdNumber).pipe( + switchMap((res) => { + if (res.body) { + // Store course in cache + this.courseStorageService.updateCourse(res.body); + } + // Flatten the result to return Observable directly + return this.handleReturn(this.courseStorageService.getCourse(courseIdNumber), path); + }), + ); + } + + handleReturn = (course?: Course, type?: string): Observable => { + let hasAccess: boolean; + switch (type) { + // Should always be accessible + case CourseOverviewRoutePath.EXERCISES: + hasAccess = true; + break; + case CourseOverviewRoutePath.LECTURES: + hasAccess = !!course?.lectures; + break; + case CourseOverviewRoutePath.EXAMS: + hasAccess = this.hasVisibleExams(course); + break; + case CourseOverviewRoutePath.COMPETENCIES: + hasAccess = !!(course?.numberOfCompetencies || course?.numberOfPrerequisites); + break; + case CourseOverviewRoutePath.TUTORIAL_GROUPS: + hasAccess = !!course?.numberOfTutorialGroups; + break; + case CourseOverviewRoutePath.DASHBOARD: + hasAccess = course?.studentCourseAnalyticsDashboardEnabled ?? false; + break; + case CourseOverviewRoutePath.FAQ: + hasAccess = course?.faqEnabled ?? false; + break; + case CourseOverviewRoutePath.LEARNING_PATH: + hasAccess = course?.learningPathsEnabled ?? false; + break; + case CourseOverviewRoutePath.COMMUNICATION: + hasAccess = isCommunicationEnabled(course); + break; + default: + hasAccess = false; + } + if (!hasAccess) { + // Default route, redirect to exercises if the user does not have access to the requested route + this.router.navigate([`/courses/${course?.id}/exercises`]); + } + return of(hasAccess); + }; + + hasVisibleExams(course?: Course): boolean { + if (course?.exams) { + for (const exam of course.exams) { + if (exam.visibleDate && dayjs(exam.visibleDate).isBefore(this.serverDateService.now())) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/webapp/app/overview/courses-routing.module.ts b/src/main/webapp/app/overview/courses-routing.module.ts index 3e835cc3d3d8..68e90e184205 100644 --- a/src/main/webapp/app/overview/courses-routing.module.ts +++ b/src/main/webapp/app/overview/courses-routing.module.ts @@ -11,8 +11,23 @@ import { CourseTutorialGroupsComponent } from './course-tutorial-groups/course-t import { CourseTutorialGroupDetailComponent } from './tutorial-group-details/course-tutorial-group-detail/course-tutorial-group-detail.component'; import { ExamParticipationComponent } from 'app/exam/participate/exam-participation.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; -import { CourseDashboardGuard } from 'app/overview/course-dashboard/course-dashboard-guard.service'; import { CourseArchiveComponent } from './course-archive/course-archive.component'; +import { CourseOverviewGuard } from 'app/overview/course-overview-guard'; + +export enum CourseOverviewRoutePath { + DASHBOARD = 'dashboard', + EXERCISES = 'exercises', + EXAMS = 'exams', + COMPETENCIES = 'competencies', + TUTORIAL_GROUPS = 'tutorial-groups', + FAQ = 'faq', + LEARNING_PATH = 'learning-path', + LECTURES = 'lectures', + ENROLL = 'enroll', + ARCHIVE = 'archive', + STATISTICS = 'statistics', + COMMUNICATION = 'communication', +} const routes: Routes = [ { @@ -25,11 +40,11 @@ const routes: Routes = [ canActivate: [UserRouteAccessService], }, { - path: 'enroll', + path: CourseOverviewRoutePath.ENROLL, loadChildren: () => import('./course-registration/course-registration.module').then((m) => m.CourseRegistrationModule), }, { - path: 'archive', + path: CourseOverviewRoutePath.ARCHIVE, component: CourseArchiveComponent, data: { authorities: [Authority.USER], @@ -54,7 +69,7 @@ const routes: Routes = [ canActivate: [UserRouteAccessService], children: [ { - path: 'exercises', + path: CourseOverviewRoutePath.EXERCISES, component: CourseExercisesComponent, data: { authorities: [Authority.USER], @@ -128,7 +143,7 @@ const routes: Routes = [ }, { - path: 'lectures', + path: CourseOverviewRoutePath.LECTURES, component: CourseLecturesComponent, data: { authorities: [Authority.USER], @@ -136,7 +151,7 @@ const routes: Routes = [ hasSidebar: true, showRefreshButton: true, }, - canActivate: [UserRouteAccessService], + canActivate: [UserRouteAccessService, CourseOverviewGuard], children: [ { path: ':lectureId', @@ -152,7 +167,7 @@ const routes: Routes = [ ], }, { - path: 'statistics', + path: CourseOverviewRoutePath.STATISTICS, loadChildren: () => import('./course-statistics/course-statistics.module').then((m) => m.CourseStatisticsModule), data: { authorities: [Authority.USER], @@ -161,12 +176,13 @@ const routes: Routes = [ }, }, { - path: 'competencies', + path: CourseOverviewRoutePath.COMPETENCIES, data: { authorities: [Authority.USER], pageTitle: 'overview.competencies', showRefreshButton: true, }, + canActivate: [CourseOverviewGuard], children: [ { path: '', @@ -179,16 +195,16 @@ const routes: Routes = [ ], }, { - path: 'dashboard', + path: CourseOverviewRoutePath.DASHBOARD, loadChildren: () => import('./course-dashboard/course-dashboard.module').then((m) => m.CourseDashboardModule), data: { authorities: [Authority.USER], pageTitle: 'overview.dashboard', }, - canActivate: [UserRouteAccessService, CourseDashboardGuard], + canActivate: [UserRouteAccessService, CourseOverviewGuard], }, { - path: 'learning-path', + path: CourseOverviewRoutePath.LEARNING_PATH, loadComponent: () => import('app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component').then((c) => c.LearningPathStudentPageComponent), data: { @@ -196,9 +212,10 @@ const routes: Routes = [ pageTitle: 'overview.learningPath', showRefreshButton: true, }, + canActivate: [CourseOverviewGuard], }, { - path: 'communication', + path: CourseOverviewRoutePath.COMMUNICATION, loadChildren: () => import('./course-conversations/course-conversations.module').then((m) => m.CourseConversationsModule), data: { authorities: [Authority.USER], @@ -208,7 +225,7 @@ const routes: Routes = [ }, }, { - path: 'tutorial-groups', + path: CourseOverviewRoutePath.TUTORIAL_GROUPS, component: CourseTutorialGroupsComponent, data: { authorities: [Authority.USER], @@ -216,7 +233,7 @@ const routes: Routes = [ hasSidebar: true, showRefreshButton: true, }, - canActivate: [UserRouteAccessService], + canActivate: [UserRouteAccessService, CourseOverviewGuard], children: [ { path: ':tutorialGroupId', @@ -233,7 +250,7 @@ const routes: Routes = [ ], }, { - path: 'exams', + path: CourseOverviewRoutePath.EXAMS, component: CourseExamsComponent, data: { authorities: [Authority.USER], @@ -241,7 +258,7 @@ const routes: Routes = [ hasSidebar: true, showRefreshButton: true, }, - canActivate: [UserRouteAccessService], + canActivate: [UserRouteAccessService, CourseOverviewGuard], children: [ { path: ':examId', @@ -267,7 +284,7 @@ const routes: Routes = [ }, }, { - path: 'faq', + path: CourseOverviewRoutePath.FAQ, loadComponent: () => import('../overview/course-faq/course-faq.component').then((m) => m.CourseFaqComponent), data: { authorities: [Authority.USER], @@ -275,10 +292,11 @@ const routes: Routes = [ hasSidebar: false, showRefreshButton: true, }, + canActivate: [CourseOverviewGuard], }, { path: '', - redirectTo: 'dashboard', // dashboard will redirect to exercises if not enabled + redirectTo: CourseOverviewRoutePath.DASHBOARD, // dashboard will redirect to exercises if not enabled pathMatch: 'full', }, ], diff --git a/src/test/javascript/spec/component/overview/course-overview-guard.spec.ts b/src/test/javascript/spec/component/overview/course-overview-guard.spec.ts new file mode 100644 index 000000000000..69f2b958de55 --- /dev/null +++ b/src/test/javascript/spec/component/overview/course-overview-guard.spec.ts @@ -0,0 +1,170 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { ArtemisTestModule } from '../../test.module'; +import { of } from 'rxjs'; +import { CourseStorageService } from 'app/course/manage/course-storage.service'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import dayjs from 'dayjs/esm'; +import { Course } from 'app/entities/course.model'; +import { HttpResponse } from '@angular/common/http'; +import { CourseOverviewGuard } from 'app/overview/course-overview-guard'; +import { Exam } from 'app/entities/exam/exam.model'; +import { Lecture } from 'app/entities/lecture.model'; +import { CourseOverviewRoutePath } from 'app/overview/courses-routing.module'; + +describe('CourseOverviewGuard', () => { + let guard: CourseOverviewGuard; + let courseStorageService: CourseStorageService; + let courseManagementService: CourseManagementService; + let router: Router; + + const visibleRealExam = { + id: 1, + visibleDate: dayjs().subtract(1, 'days'), + startDate: dayjs().subtract(30, 'minutes'), + testExam: false, + } as Exam; + + const lecture = new Lecture(); + + const mockCourse: Course = { id: 1, lectures: [lecture], exams: [visibleRealExam], faqEnabled: true } as Course; + + const responseFakeCourse = { body: mockCourse } as HttpResponse; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + }); + guard = TestBed.inject(CourseOverviewGuard); + courseStorageService = TestBed.inject(CourseStorageService); + courseManagementService = TestBed.inject(CourseManagementService); + router = TestBed.inject(Router); + }); + + describe('canActivate', () => { + it('should return false if courseId is not present', () => { + const route = { parent: { paramMap: { get: () => undefined } }, routeConfig: { path: CourseOverviewRoutePath.EXERCISES } } as unknown as ActivatedRouteSnapshot; + let resultValue = true; + guard.canActivate(route).subscribe((result) => { + resultValue = result; + }); + expect(resultValue).toBeFalse(); + }); + + it('should return true if course is fetched from server', () => { + const route = { parent: { paramMap: { get: () => '1' } }, routeConfig: { path: CourseOverviewRoutePath.EXERCISES } } as unknown as ActivatedRouteSnapshot; + let resultValue = false; + jest.spyOn(courseStorageService, 'getCourse').mockReturnValue(undefined); + jest.spyOn(courseManagementService, 'findOneForDashboard').mockReturnValue(of(responseFakeCourse)); + guard.canActivate(route).subscribe((result) => { + resultValue = result; + }); + expect(resultValue).toBeTrue(); + }); + }); + + describe('handleReturn', () => { + it('should return true if type is lectures and course has lectures', () => { + let resultValue = true; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.LECTURES); + result.subscribe((value) => { + resultValue = value; + }); + + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is exams and course has visible exams', () => { + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.EXAMS); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return false if type is exams and course has no visible exams', () => { + mockCourse.exams = []; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.EXAMS); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeFalse(); + }); + + it('should return true if type is competencies and course has competencies', () => { + mockCourse.numberOfCompetencies = 1; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.COMPETENCIES); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is competencies and course has prerequisits', () => { + mockCourse.numberOfPrerequisites = 1; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.COMPETENCIES); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is tutorial-groups and course has tutorial groups', () => { + mockCourse.numberOfTutorialGroups = 1; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.TUTORIAL_GROUPS); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is dashboard and course has studentCourseAnalyticsDashboardEnabled', () => { + mockCourse.studentCourseAnalyticsDashboardEnabled = true; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.DASHBOARD); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is faq and course has faqEnabled', () => { + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.FAQ); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return true if type is learning-path and course has learningPathsEnabled', () => { + mockCourse.learningPathsEnabled = true; + const result = guard.handleReturn(mockCourse, CourseOverviewRoutePath.LEARNING_PATH); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeTrue(); + }); + + it('should return false if type is unknown', () => { + const result = guard.handleReturn(mockCourse, 'unknown'); + let resultValue = true; + result.subscribe((value) => { + resultValue = value; + }); + expect(resultValue).toBeFalse(); + }); + + it('should navigate to exercises if type is unknown', () => { + const navigateSpy = jest.spyOn(router, 'navigate'); + guard.handleReturn(mockCourse, 'unknown'); + expect(navigateSpy).toHaveBeenCalledWith(['/courses/1/exercises']); + }); + }); +});