Skip to content

Commit

Permalink
General: Add course archive for old courses from previous semesters (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
edkaya authored Oct 24, 2024
1 parent dd96df5 commit cf918b3
Show file tree
Hide file tree
Showing 33 changed files with 904 additions and 90 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.tum.cit.aet.artemis.core.dto;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
* DTO for representing archived courses from previous semesters.
*
* @param id The id of the course
* @param title The title of the course
* @param semester The semester in which the course was offered
* @param color The background color of the course
* @param icon The icon of the course
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record CourseForArchiveDTO(long id, String title, String semester, String color, String icon) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -542,4 +542,30 @@ SELECT COUNT(c) > 0
""")
boolean hasLearningPathsEnabled(@Param("courseId") long courseId);

/**
* Retrieves all courses that the user has access to based on their role
* or if they are an admin. Filters out any courses that do not belong to
* a specific semester (i.e., have a null semester).
*
* @param userId The id of the user whose courses are being retrieved
* @param isAdmin A boolean flag indicating whether the user is an admin
* @param now The current time to check if the course is still active
* @return A set of courses that the user has access to and belong to a specific semester
*/
@Query("""
SELECT DISTINCT c
FROM Course c
LEFT JOIN UserGroup ug ON ug.group IN (
c.studentGroupName,
c.teachingAssistantGroupName,
c.editorGroupName,
c.instructorGroupName
)
WHERE (:isAdmin = TRUE OR ug.userId = :userId)
AND c.semester IS NOT NULL
AND c.endDate IS NOT NULL
AND c.endDate < :now
""")
Set<Course> findInactiveCoursesForUserRolesWithNonNullSemester(@Param("userId") long userId, @Param("isAdmin") boolean isAdmin, @Param("now") ZonedDateTime now);

}
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,18 @@ public List<Course> getAllCoursesForManagementOverview(boolean onlyActive) {
return courseRepository.findAllCoursesByManagementGroupNames(userGroups);
}

/**
* Retrieves all inactive courses from non-null semesters that the current user is enrolled in
* for the course archive.
*
* @return A list of courses for the course archive.
*/
public Set<Course> getAllCoursesForCourseArchive() {
var user = userRepository.getUserWithGroupsAndAuthorities();
boolean isAdmin = authCheckService.isAdmin(user);
return courseRepository.findInactiveCoursesForUserRolesWithNonNullSemester(user.getId(), isAdmin, ZonedDateTime.now());
}

/**
* Get the active students for these particular exercise ids
*
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
import de.tum.cit.aet.artemis.core.config.Constants;
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO;
import de.tum.cit.aet.artemis.core.dto.CourseForDashboardDTO;
import de.tum.cit.aet.artemis.core.dto.CourseForImportDTO;
import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO;
Expand Down Expand Up @@ -555,6 +556,29 @@ public ResponseEntity<List<Course>> getCoursesForManagementOverview(@RequestPara
return ResponseEntity.ok(courseService.getAllCoursesForManagementOverview(onlyActive));
}

/**
* GET /courses/for-archive : get all courses for course archive
*
* @return the ResponseEntity with status 200 (OK) and with body containing
* a set of DTOs, which contain the courses with id, title, semester, color, icon
*/
@GetMapping("courses/for-archive")
@EnforceAtLeastStudent
public ResponseEntity<Set<CourseForArchiveDTO>> getCoursesForArchive() {
long start = System.nanoTime();
User user = userRepository.getUserWithGroupsAndAuthorities();
log.debug("REST request to get all inactive courses from previous semesters user {} has access to", user.getLogin());
Set<Course> courses = courseService.getAllCoursesForCourseArchive();
log.debug("courseService.getAllCoursesForCourseArchive done");

final Set<CourseForArchiveDTO> dto = courses.stream()
.map(course -> new CourseForArchiveDTO(course.getId(), course.getTitle(), course.getSemester(), course.getColor(), course.getCourseIcon()))
.collect(Collectors.toSet());

log.debug("GET /courses/for-archive took {} for {} courses for user {}", TimeLogUtil.formatDurationFrom(start), courses.size(), user.getLogin());
return ResponseEntity.ok(dto);
}

/**
* GET /courses/{courseId}/for-enrollment : get a course by id if the course allows enrollment and is currently active.
*
Expand Down
7 changes: 7 additions & 0 deletions src/main/webapp/app/course/manage/course-for-archive-dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class CourseForArchiveDTO {
id: number;
title: string;
semester: string;
color: string;
icon: string;
}
17 changes: 17 additions & 0 deletions src/main/webapp/app/course/manage/course-management.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ScoresStorageService } from 'app/course/course-scores/scores-storage.se
import { CourseStorageService } from 'app/course/manage/course-storage.service';
import { ExerciseType, ScoresPerExerciseType } from 'app/entities/exercise.model';
import { OnlineCourseDtoModel } from 'app/lti/online-course-dto.model';
import { CourseForArchiveDTO } from './course-for-archive-dto';

export type EntityResponseType = HttpResponse<Course>;
export type EntityArrayResponseType = HttpResponse<Course[]>;
Expand Down Expand Up @@ -343,6 +344,13 @@ export class CourseManagementService {
);
}

/**
* Find all courses for the archive using a GET request
*/
getCoursesForArchive(): Observable<HttpResponse<CourseForArchiveDTO[]>> {
return this.http.get<CourseForArchiveDTO[]>(`${this.resourceUrl}/for-archive`, { observe: 'response' });
}

/**
* returns the exercise details of the courses for the courses' management dashboard
* @param onlyActive - if true, only active courses will be considered in the result
Expand Down Expand Up @@ -703,4 +711,13 @@ export class CourseManagementService {
disableCourseOverviewBackground() {
this.courseOverviewSubject.next(false);
}

getSemesterCollapseStateFromStorage(storageId: string): boolean {
const storedCollapseState: string | null = localStorage.getItem('semester.collapseState.' + storageId);
return storedCollapseState ? JSON.parse(storedCollapseState) : false;
}

setSemesterCollapseState(storageId: string, isCollapsed: boolean) {
localStorage.setItem('semester.collapseState.' + storageId, JSON.stringify(isCollapsed));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
@if (courses) {
<div class="module-bg p-3 rounded-3 mb-3">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-start gap-1">
<h3 class="fw-medium mb-0" jhiTranslate="artemisApp.course.archive.title"></h3>
<fa-icon
[icon]="faQuestionCircle"
[size]="iconSize"
class="text-secondary align-self-center"
ngbTooltip="{{ 'artemisApp.course.archive.tip' | artemisTranslate }}"
/>
</div>
@if (courses.length) {
<div class="d-flex justify-content-between gap-3 align-items-center ms-1">
<div class="text-primary d-inline-flex">
<a id="sort-test" (click)="onSort()" class="d-inline-flex align-items-center">
<fa-icon id="icon-test-down" [icon]="isSortAscending ? faArrowDown19 : faArrowUp19" />
<span class="ms-1" jhiTranslate="artemisApp.course.archive.sort"></span>
</a>
</div>
<jhi-search-filter (newSearchEvent)="setSearchValue($event)" class="my-0" />
</div>
}
</div>
</div>
<div class="module-bg py-3 rounded-3">
@if (courses.length) {
<div class="mb-0">
@for (semester of semesters; track semester; let last = $last; let i = $index) {
<div
class="d-flex justify-content-between align-items-center px-3"
(click)="isCourseFoundInSemester(semester) && toggleCollapseState(semester)"
tabindex="0"
role="button"
id="semester-group-{{ i }}"
[ngClass]="{ 'text-secondary': !(coursesBySemester[semester] | searchFilter: ['title'] : searchCourseText).length }"
>
<span class="fw-bold" jhiTranslate="{{ fullFormOfSemesterStrings[semester] }}" [translateValues]="{ param: semester.slice(2) }"></span>
<fa-icon [icon]="semesterCollapsed[semester] ? faAngleDown : faAngleUp" />
</div>
@if (!semesterCollapsed[semester]) {
<div class="container-fluid mt-2">
<div class="course-grid justify-content-center align-items-center">
@for (course of coursesBySemester[semester] | searchFilter: ['title'] : searchCourseText; track course) {
<div class="course-card-wrapper p-0">
<jhi-course-card-header
class="col-2"
[courseId]="course.id"
[courseTitle]="course.title"
[courseIcon]="course.icon"
[courseColor]="course.color"
[archiveMode]="true"
/>
</div>
}
</div>
</div>
}
@if (!last) {
<hr class="mx-3" />
}
}
</div>
} @else {
<div class="d-flex justify-content-center">
<h4 class="text-secondary text-center mb-0" jhiTranslate="artemisApp.course.archive.noCoursesPreviousSemester"></h4>
</div>
}
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.course-grid {
display: grid;
// cards can shrink to 325px
grid-template-columns: repeat(auto-fill, minmax(325px, 1fr));
grid-gap: 1rem;
justify-items: center;
}

.course-card-wrapper {
width: 100%;
max-width: 400px;
}

.container-fluid {
// ensure that horizontal spacing in container is consistent
--bs-gutter-x: 2rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Course } from 'app/entities/course.model';
import { CourseManagementService } from '../../course/manage/course-management.service';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { AlertService } from 'app/core/util/alert.service';
import { onError } from 'app/shared/util/global.utils';
import { Subscription } from 'rxjs';
import { faAngleDown, faAngleUp, faArrowDown19, faArrowUp19, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
import { SizeProp } from '@fortawesome/fontawesome-svg-core';
import { ArtemisSharedModule } from 'app/shared/shared.module';
import { CourseCardHeaderComponent } from '../course-card-header/course-card-header.component';
import { CourseForArchiveDTO } from 'app/course/manage/course-for-archive-dto';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

@Component({
selector: 'jhi-course-archive',
templateUrl: './course-archive.component.html',
styleUrls: ['./course-archive.component.scss'],
standalone: true,
imports: [ArtemisSharedModule, CourseCardHeaderComponent, SearchFilterComponent],
})
export class CourseArchiveComponent implements OnInit, OnDestroy {
private archiveCourseSubscription: Subscription;
private courseService = inject(CourseManagementService);
private alertService = inject(AlertService);

courses: CourseForArchiveDTO[] = [];
semesters: string[];
fullFormOfSemesterStrings: { [key: string]: string } = {};
semesterCollapsed: { [key: string]: boolean } = {};
coursesBySemester: { [key: string]: Course[] } = {};
searchCourseText = '';
isSortAscending = true;
iconSize: SizeProp = 'lg';

//Icons
readonly faAngleDown = faAngleDown;
readonly faAngleUp = faAngleUp;
readonly faArrowDown19 = faArrowDown19;
readonly faArrowUp19 = faArrowUp19;
readonly faQuestionCircle = faQuestionCircle;

ngOnInit(): void {
this.loadArchivedCourses();
this.courseService.enableCourseOverviewBackground();
}

/**
* Loads all courses that the student has been enrolled in from previous semesters
*/
loadArchivedCourses(): void {
this.archiveCourseSubscription = this.courseService.getCoursesForArchive().subscribe({
next: (res: HttpResponse<CourseForArchiveDTO[]>) => {
if (res.body) {
this.courses = res.body || [];
this.courses = this.sortCoursesByTitle(this.courses);
this.semesters = this.getUniqueSemesterNamesSorted(this.courses);
this.mapCoursesIntoSemesters();
}
},
error: (error: HttpErrorResponse) => onError(this.alertService, error),
});
}

/**
* maps existing courses to each semester
*/
mapCoursesIntoSemesters(): void {
this.semesters.forEach((semester) => {
this.semesterCollapsed[semester] = false;
this.courseService.setSemesterCollapseState(semester, false);
this.coursesBySemester[semester] = this.courses.filter((course) => course.semester === semester);
this.fullFormOfSemesterStrings[semester] = semester.startsWith('WS') ? 'artemisApp.course.archive.winterSemester' : 'artemisApp.course.archive.summerSemester';
});
}

ngOnDestroy(): void {
this.archiveCourseSubscription.unsubscribe();
this.courseService.disableCourseOverviewBackground();
}

setSearchValue(searchValue: string): void {
this.searchCourseText = searchValue;
if (searchValue !== '') {
this.expandOrCollapseBasedOnSearchValue();
} else {
this.getCollapseStateForSemesters();
}
}

onSort(): void {
if (this.semesters) {
this.semesters.reverse();
this.isSortAscending = !this.isSortAscending;
}
}
/**
* if the searched text is matched with a course title, expand the accordion, otherwise collapse
*/
expandOrCollapseBasedOnSearchValue(): void {
for (const semester of this.semesters) {
const hasMatchingCourse = this.coursesBySemester[semester].some((course) => course.title?.toLowerCase().includes(this.searchCourseText.toLowerCase()));
this.semesterCollapsed[semester] = !hasMatchingCourse;
}
}

getCollapseStateForSemesters(): void {
for (const semester of this.semesters) {
this.semesterCollapsed[semester] = this.courseService.getSemesterCollapseStateFromStorage(semester);
}
}

toggleCollapseState(semester: string): void {
this.semesterCollapsed[semester] = !this.semesterCollapsed[semester];
this.courseService.setSemesterCollapseState(semester, this.semesterCollapsed[semester]);
}

isCourseFoundInSemester(semester: string): boolean {
return this.coursesBySemester[semester].some((course) => course.title?.toLowerCase().includes(this.searchCourseText.toLowerCase()));
}

sortCoursesByTitle(courses: CourseForArchiveDTO[]): CourseForArchiveDTO[] {
return courses.sort((courseA, courseB) => (courseA.title ?? '').localeCompare(courseB.title ?? ''));
}

getUniqueSemesterNamesSorted(courses: CourseForArchiveDTO[]): string[] {
return (
courses
.map((course) => course.semester ?? '')
// filter down to unique values
.filter((course, index, courses) => courses.indexOf(course) === index)
.sort((semesterA, semesterB) => {
// Parse years in base 10 by extracting the two digits after the WS or SS prefix
const yearsCompared = parseInt(semesterB.slice(2, 4), 10) - parseInt(semesterA.slice(2, 4), 10);
if (yearsCompared !== 0) {
return yearsCompared;
}

// If years are the same, sort WS over SS
const prefixA = semesterA.slice(0, 2);
const prefixB = semesterB.slice(0, 2);

if (prefixA === prefixB) {
return 0; // Both semesters are the same (either both WS or both SS)
}

return prefixA === 'WS' ? -1 : 1; // WS should be placed above SS
})
);
}
}
Loading

0 comments on commit cf918b3

Please sign in to comment.