Skip to content

Commit

Permalink
Exam mode: Improve behavior of exam cards for non participating stude…
Browse files Browse the repository at this point in the history
…nts and tutors (#6286)
  • Loading branch information
JohannesStoehr authored Oct 14, 2023
1 parent 6ff0ac3 commit 41b845c
Show file tree
Hide file tree
Showing 19 changed files with 342 additions and 108 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) {
Exam examWithExerciseGroupsAndExercises = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId);

if (!examWithExerciseGroupsAndExercises.isTestExam()) {
// We skip the alert since this can happen when a tutor sees the exam card or the user did not participate yet is registered for the exam
throw new BadRequestAlertException("The requested Exam is no test exam and thus no student exam can be created", ENTITY_NAME,
"StudentExamGenerationOnlyForTestExams");
"StudentExamGenerationOnlyForTestExams", true);
}
studentExam = studentExamService.generateTestExam(examWithExerciseGroupsAndExercises, currentUser);
// For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@ public ResponseEntity<Void> removeAllStudentsFromExam(@PathVariable Long courseI
}

/**
* GET /courses/{courseId}/exams/{examId}/start : Get an exam for the exam start.
* GET /courses/{courseId}/exams/{examId}/own-student-exam: Get the own student exam for the exam
* Real Exams: StudentExam needs to be generated by an instructor
* Test Exam: StudentExam can be self-created by the user
* Note: The Access control is performed in the {@link ExamAccessService#getExamInCourseElseThrow(Long, Long)} to limit the DB-calls
Expand All @@ -1123,9 +1123,9 @@ public ResponseEntity<Void> removeAllStudentsFromExam(@PathVariable Long courseI
* @param examId the id of the exam
* @return the ResponseEntity with status 200 (OK) and with the found student exam (without exercises) as body
*/
@GetMapping("courses/{courseId}/exams/{examId}/start")
@GetMapping("courses/{courseId}/exams/{examId}/own-student-exam")
@EnforceAtLeastStudent
public ResponseEntity<StudentExam> getStudentExamForStart(@PathVariable Long courseId, @PathVariable Long examId) {
public ResponseEntity<StudentExam> getOwnStudentExam(@PathVariable Long courseId, @PathVariable Long examId) {
log.debug("REST request to get exam {} for conduction", examId);
StudentExam exam = examAccessService.getExamInCourseElseThrow(courseId, examId);
return ResponseEntity.ok(exam);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,16 @@ <h2 jhiTranslate="artemisApp.examParticipation.submissionSuccessful.title"></h2>
<jhi-exam-participation-summary *ngIf="showExamSummary" [studentExam]="studentExam"></jhi-exam-participation-summary>
</ng-container>
</ng-container>
<div class="alert alert-danger" *ngIf="!loadingExam && !exam">
<h6 *ngIf="!testExam" jhiTranslate="artemisApp.examParticipation.noStudentExam"></h6>
<h6 *ngIf="testExam" jhiTranslate="artemisApp.examParticipation.noFurtherAttempts"></h6>
</div>
<ng-container *ngIf="!loadingExam && !exam">
<div class="alert alert-warning" *ngIf="isAtLeastTutor && !testRunId; else noTutor">
<h6 jhiTranslate="artemisApp.examParticipation.atLeastTutorStudentExam"></h6>
<a [routerLink]="['/course-management', courseId, 'exams', examId]" class="btn btn-primary">
<fa-icon [icon]="faGraduationCap" [fixedWidth]="true"></fa-icon>&nbsp;{{ 'artemisApp.examParticipation.goToExamManagement' | artemisTranslate }}
</a>
</div>
<ng-template #noTutor>
<div class="alert alert-danger">
<h6 [jhiTranslate]="'artemisApp.examParticipation.' + (testExam ? 'noFurtherAttempts' : 'noStudentExam')"></h6>
</div>
</ng-template>
</ng-container>
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import { ExamPage } from 'app/entities/exam-page.model';
import { ExamPageComponent } from 'app/exam/participate/exercises/exam-page.component';
import { AUTOSAVE_CHECK_INTERVAL, AUTOSAVE_EXERCISE_INTERVAL } from 'app/shared/constants/exercise-exam-constants';
import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service';
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle, faGraduationCap } from '@fortawesome/free-solid-svg-icons';
import { CourseManagementService } from 'app/course/manage/course-management.service';
import { CourseStorageService } from 'app/course/manage/course-storage.service';
import { ExamLiveEventType, ExamParticipationLiveEventsService, WorkingTimeUpdateEvent } from 'app/exam/participate/exam-participation-live-events.service';

type GenerateParticipationStatus = 'generating' | 'failed' | 'success';
Expand Down Expand Up @@ -114,9 +116,13 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC
private programmingSubmissionSubscriptions: Subscription[] = [];

loadingExam: boolean;
isAtLeastTutor?: boolean;

generateParticipationStatus: BehaviorSubject<GenerateParticipationStatus> = new BehaviorSubject('success');

// Icons
faGraduationCap = faGraduationCap;

constructor(
private websocketService: JhiWebsocketService,
private route: ActivatedRoute,
Expand All @@ -130,6 +136,8 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC
private alertService: AlertService,
private courseExerciseService: CourseExerciseService,
private liveEventsService: ExamParticipationLiveEventsService,
private courseService: CourseManagementService,
private courseStorageService: CourseStorageService,
) {
// show only one synchronization error every 5s
this.errorSubscription = this.synchronizationAlert.pipe(throttleTime(5000)).subscribe(() => {
Expand Down Expand Up @@ -167,37 +175,13 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC
error: () => (this.loadingExam = false),
});
} else {
this.examParticipationService.loadStudentExam(this.courseId, this.examId).subscribe({
this.examParticipationService.getOwnStudentExam(this.courseId, this.examId).subscribe({
next: (studentExam) => {
this.studentExam = studentExam;
this.exam = studentExam.exam!;
this.testExam = this.exam.testExam!;
if (!this.exam.testExam) {
this.initIndividualEndDates(this.exam.startDate!);
}

// only show the summary if the student was able to submit on time.
if (this.isOver() && this.studentExam.submitted) {
this.loadAndDisplaySummary();
} else {
// Directly start the exam when we continue from a failed save
if (this.examParticipationService.lastSaveFailed(this.courseId, this.examId)) {
this.examParticipationService
.loadStudentExamWithExercisesForConductionFromLocalStorage(this.courseId, this.examId)
.subscribe((localExam: StudentExam) => {
// Keep the working time from the server
localExam.workingTime = this.studentExam.workingTime ?? localExam.workingTime;

this.studentExam = localExam;
this.loadingExam = false;
this.examStarted(this.studentExam);
});
} else {
this.loadingExam = false;
}
}
this.handleStudentExam(studentExam);
},
error: () => {
this.handleNoStudentExam();
},
error: () => (this.loadingExam = false),
});
}
});
Expand Down Expand Up @@ -405,7 +389,7 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC
},
});
} else {
this.examParticipationService.loadStudentExam(this.courseId, this.examId).subscribe({
this.examParticipationService.getOwnStudentExam(this.courseId, this.examId).subscribe({
next: (existingExam: StudentExam) => {
this.studentExam = existingExam;
},
Expand Down Expand Up @@ -518,6 +502,50 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC
window.clearInterval(this.autoSaveInterval);
}

handleStudentExam(studentExam: StudentExam) {
this.studentExam = studentExam;
this.exam = studentExam.exam!;
this.testExam = this.exam.testExam!;
if (!this.exam.testExam) {
this.initIndividualEndDates(this.exam.startDate!);
}

// only show the summary if the student was able to submit on time.
if (this.isOver() && this.studentExam.submitted) {
this.loadAndDisplaySummary();
} else {
// Directly start the exam when we continue from a failed save
if (this.examParticipationService.lastSaveFailed(this.courseId, this.examId)) {
this.examParticipationService.loadStudentExamWithExercisesForConductionFromLocalStorage(this.courseId, this.examId).subscribe((localExam: StudentExam) => {
// Keep the working time from the server
localExam.workingTime = this.studentExam.workingTime ?? localExam.workingTime;

this.studentExam = localExam;
this.loadingExam = false;
this.examStarted(this.studentExam);
});
} else {
this.loadingExam = false;
}
}
}

/**
* Handles the case when there is no student exam. Here we have to check if the user is at least tutor to show the redirect to the exam management page.
* This check is not done in the normal case due to performance reasons of 2000 students sending additional requests
*/
handleNoStudentExam() {
const course = this.courseStorageService.getCourse(this.courseId);
if (!course) {
this.courseService.find(this.courseId).subscribe((courseResponse) => {
this.isAtLeastTutor = courseResponse.body?.isAtLeastTutor;
});
} else {
this.isAtLeastTutor = course.isAtLeastTutor;
}
this.loadingExam = false;
}

/**
* Initializes the individual end dates and sets up a subscription for potential changes during the conduction
* @param startDate the start date of the exam
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export class ExamParticipationService {
* @param courseId the id of the course the exam is created in
* @param examId the id of the exam
*/
public loadStudentExam(courseId: number, examId: number): Observable<StudentExam> {
const url = this.getResourceURL(courseId, examId) + '/start';
public getOwnStudentExam(courseId: number, examId: number): Observable<StudentExam> {
const url = this.getResourceURL(courseId, examId) + '/own-student-exam';
return this.httpClient.get<StudentExam>(url).pipe(
map((studentExam: StudentExam) => {
const convertedStudentExam = ExamParticipationService.convertStudentExamDateFromServer(studentExam);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,21 @@ <h5 class="text-center">
</div>
<div *ngSwitchCase="'CONDUCTING'">
<h5 class="text-center">{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'conducting' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'conductingExplanation' | artemisTranslate }}</div>
</div>
<div *ngSwitchCase="'TIMEEXTENSION'">
<h5 class="text-center">{{ 'artemisApp.exam.overview.timeExtension' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.timeExtensionExplanation' | artemisTranslate }}</div>
</div>
<div *ngSwitchCase="'CLOSED'">
<h5 class="text-center">{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'closed' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'closedExplanation' | artemisTranslate }}</div>
</div>
<div *ngSwitchCase="'STUDENTREVIEW'">
<h5 class="text-center">{{ 'artemisApp.exam.overview.review' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.reviewExplanation' | artemisTranslate }} {{ exam.examStudentReviewEnd | artemisDate }}</div>
</div>
<div *ngSwitchCase="'NO_MORE_ATTEMPTS'">
<h5 class="text-center">{{ 'artemisApp.exam.overview.noMoreAttempts' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.noMoreAttemptsExplanation' | artemisTranslate }}</div>
<h5 class="text-center">{{ 'artemisApp.exam.overview.testExam.noMoreAttempts' | artemisTranslate }}</h5>
<div class="text-center">{{ 'artemisApp.exam.overview.testExam.noMoreAttemptsExplanation' | artemisTranslate }}</div>
</div>
</div>
</ng-template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Course } from 'app/entities/course.model';
import dayjs from 'dayjs/esm';
import { faBook, faCalendarDay, faCirclePlay, faCircleStop, faMagnifyingGlass, faPenAlt, faPlay, faUserClock } from '@fortawesome/free-solid-svg-icons';
import { Subscription, interval } from 'rxjs';
import { StudentExam } from 'app/entities/student-exam.model';
import { ExamParticipationService } from 'app/exam/participate/exam-participation.service';

// Enum to dynamically change the template-content
export const enum ExamState {
Expand All @@ -22,7 +24,7 @@ export const enum ExamState {
STUDENTREVIEW = 'STUDENTREVIEW',
// Case 7: Fallback
UNDEFINED = 'UNDEFINED',
// Case 99: No more attempts
// Case 8: No more attempts
NO_MORE_ATTEMPTS = 'NO_MORE_ATTEMPTS',
}

Expand All @@ -40,6 +42,8 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy {
examStateSubscription: Subscription;
timeLeftToStart: number;

studentExam?: StudentExam;

// Icons
faPenAlt = faPenAlt;
faCirclePlay = faCirclePlay;
Expand All @@ -50,7 +54,10 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy {
faBook = faBook;
faCircleStop = faCircleStop;

constructor(private router: Router) {}
constructor(
private router: Router,
private examParticipationService: ExamParticipationService,
) {}

ngOnInit() {
// A subscription is used here to limit the number of calls
Expand Down Expand Up @@ -93,32 +100,57 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy {
this.cancelExamStateSubscription();
return;
}
if (dayjs(this.exam.startDate).isAfter(dayjs())) {
if (dayjs(this.exam.startDate).diff(dayjs(), 's') < 600) {
if (this.exam.startDate && dayjs().isBefore(this.exam.startDate)) {
if (dayjs(this.exam.startDate).diff(dayjs(), 'seconds') < 600) {
this.examState = ExamState.IMMINENT;
} else {
this.examState = ExamState.UPCOMING;
}
this.timeLeftToStartInSeconds();
return;
}
if (
this.exam.examStudentReviewStart &&
this.exam.examStudentReviewEnd &&
dayjs().isBetween(dayjs(this.exam.examStudentReviewStart), dayjs(this.exam.examStudentReviewEnd))
) {
this.examState = ExamState.STUDENTREVIEW;
if (this.exam.endDate && dayjs().isBefore(this.exam.endDate)) {
this.examState = ExamState.CONDUCTING;
return;
}
if (dayjs(this.exam.endDate).isAfter(dayjs())) {
this.examState = ExamState.CONDUCTING;

this.updateExamStateWithStudentExamOrTestExam();
}

updateExamStateWithStudentExamOrTestExam() {
if (!this.studentExam && !this.exam.testExam && this.course?.id && this.exam?.id) {
this.examParticipationService
.getOwnStudentExam(this.course.id, this.exam.id)
.subscribe({
next: (studentExam) => {
this.studentExam = studentExam;
},
})
.add(() => this.updateExamStateWithLoadedStudentExamOrTestExam());
} else {
this.updateExamStateWithLoadedStudentExamOrTestExam();
}
}

updateExamStateWithLoadedStudentExamOrTestExam() {
const potentialLaterEndDate = this.studentExam?.workingTime ? dayjs(this.exam.startDate).add(this.studentExam.workingTime, 'seconds') : undefined;
const noOrOverPotentialLaterEndDate = !potentialLaterEndDate || dayjs().isAfter(potentialLaterEndDate);
if ((!this.studentExam || (!this.studentExam.submitted && noOrOverPotentialLaterEndDate)) && !this.exam.testExam) {
// Normal exam is over and student did not participate (no student exam was loaded nor submitted). We can cancel the subscription and the exam is closed
this.examState = ExamState.CLOSED;
this.cancelExamStateSubscription();
return;
}

if (this.exam.examStudentReviewStart && this.exam.examStudentReviewEnd && dayjs().isBetween(this.exam.examStudentReviewStart, this.exam.examStudentReviewEnd)) {
this.examState = ExamState.STUDENTREVIEW;
return;
}
if (dayjs(this.exam.endDate).isBefore(dayjs())) {
if (dayjs().isAfter(this.exam.endDate)) {
if (!this.exam.testExam) {
// The longest individual working time is stored on the server side, but should not be extra loaded. Therefore, a sufficiently large time extension is selected.
const endDateWithTimeExtension = dayjs(this.exam.endDate).add(this.exam.workingTime! * 3, 'seconds');
if (endDateWithTimeExtension.isAfter(dayjs())) {
if (dayjs().isBefore(endDateWithTimeExtension)) {
this.examState = ExamState.TIMEEXTENSION;
return;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ <h2 class="my-2" jhiTranslate="artemisApp.exam.overview.realExamsHeading">Exams<
<jhi-course-exam-detail
[exam]="exam"
[course]="course!"
class="card clickable col-12 col-md-4 col-lg-4 col-xl-3 m-2 border-success hover-effect"
class="card col-12 col-md-4 col-lg-4 col-xl-3 m-2 border-success hover-effect"
id="exam-{{ exam.id }}"
></jhi-course-exam-detail>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
pointer-events: none;
}

.hover-effect:hover {
.hover-effect:has(.clickable):hover {
transition: transform 0.2s linear;
transform: scale(1.012);
background-color: var(--hover-slightly-darker-body-bg);
Expand Down
Loading

0 comments on commit 41b845c

Please sign in to comment.