Skip to content

Commit

Permalink
Exam mode: Send problem statement notifications only after the start …
Browse files Browse the repository at this point in the history
…of an exam (ls1intum#9136)
  • Loading branch information
edkaya authored Aug 3, 2024
1 parent bb5f431 commit 305aa74
Show file tree
Hide file tree
Showing 19 changed files with 105 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package de.tum.in.www1.artemis.service;

import static de.tum.in.www1.artemis.config.Constants.EXAM_START_WAIT_TIME_MINUTES;
import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE;
import static de.tum.in.www1.artemis.service.util.RoundingUtil.roundScoreSpecifiedByCourseSettings;
import static java.time.ZonedDateTime.now;

import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
Expand Down Expand Up @@ -542,7 +543,7 @@ private void setAssignedTeamIdForExerciseAndUser(Exercise exercise, User user) {
*/
public List<CourseManagementOverviewExerciseStatisticsDTO> getStatisticsForCourseManagementOverview(Long courseId, Integer amountOfStudentsInCourse) {
// We only display the latest five past exercises in the client, only calculate statistics for those
List<Exercise> pastExercises = exerciseRepository.getPastExercisesForCourseManagementOverview(courseId, ZonedDateTime.now());
List<Exercise> pastExercises = exerciseRepository.getPastExercisesForCourseManagementOverview(courseId, now());

Comparator<Exercise> exerciseDateComparator = Comparator.comparing(
exercise -> exercise.getAssessmentDueDate() != null ? exercise.getAssessmentDueDate() : exercise.getDueDate(), Comparator.nullsLast(Comparator.naturalOrder()));
Expand All @@ -558,7 +559,7 @@ public List<CourseManagementOverviewExerciseStatisticsDTO> getStatisticsForCours
}

// Fill statistics for all exercises potentially displayed on the client
var exercisesForManagementOverview = exerciseRepository.getActiveExercisesForCourseManagementOverview(courseId, ZonedDateTime.now());
var exercisesForManagementOverview = exerciseRepository.getActiveExercisesForCourseManagementOverview(courseId, now());
exercisesForManagementOverview.addAll(lastFivePastExercises);
return generateCourseManagementDTOs(exercisesForManagementOverview, amountOfStudentsInCourse, averageScoreById);
}
Expand Down Expand Up @@ -775,7 +776,9 @@ public void notifyAboutExerciseChanges(Exercise originalExercise, Exercise updat
if (originalExercise.isCourseExercise()) {
groupNotificationScheduleService.checkAndCreateAppropriateNotificationsWhenUpdatingExercise(originalExercise, updatedExercise, notificationText);
}
else if (originalExercise.isExamExercise() && !StringUtils.equals(originalExercise.getProblemStatement(), updatedExercise.getProblemStatement())) {
// start sending problem statement updates within the last 5 minutes before the exam starts
else if (now().plusMinutes(EXAM_START_WAIT_TIME_MINUTES).isAfter(originalExercise.getExam().getStartDate()) && originalExercise.isExamExercise()
&& !StringUtils.equals(originalExercise.getProblemStatement(), updatedExercise.getProblemStatement())) {
User instructor = userRepository.getUser();
this.examLiveEventsService.createAndSendProblemStatementUpdateEvent(updatedExercise, notificationText, instructor);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public void createAndSendProblemStatementUpdateEvent(StudentExam studentExam, Ex
event.setTextContent(message);
event.setProblemStatement(exercise.getProblemStatement());
event.setExerciseId(exercise.getId());
event.setExerciseName(exercise.getTitle());
event.setExerciseName(exercise.getExerciseGroup().getTitle());

this.storeAndDistributeLiveExamEvent(event);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { faBullhorn } from '@fortawesome/free-solid-svg-icons';
import { AlertService } from 'app/core/util/alert.service';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { Subscription, from } from 'rxjs';
import { ExamLiveEvent, ExamLiveEventType, ExamParticipationLiveEventsService } from 'app/exam/participate/exam-participation-live-events.service';
import { ExamLiveEventsOverlayComponent } from 'app/exam/participate/events/exam-live-events-overlay.component';
import dayjs from 'dayjs/esm';

export const USER_DISPLAY_RELEVANT_EVENTS = [
ExamLiveEventType.EXAM_WIDE_ANNOUNCEMENT,
Expand All @@ -25,6 +26,7 @@ export class ExamLiveEventsButtonComponent implements OnInit, OnDestroy {
private liveEventsSubscription?: Subscription;
private allEventsSubscription?: Subscription;
eventCount = 0;
@Input() examStartDate: dayjs.Dayjs;

// Icons
faBullhorn = faBullhorn;
Expand All @@ -37,10 +39,12 @@ export class ExamLiveEventsButtonComponent implements OnInit, OnDestroy {

ngOnInit(): void {
this.allEventsSubscription = this.liveEventsService.observeAllEvents(USER_DISPLAY_RELEVANT_EVENTS_REOPEN).subscribe((events: ExamLiveEvent[]) => {
this.eventCount = events.length;
// do not count the problem statements events that are made before the start of the exam
const filteredEvents = events.filter((event) => !(event.eventType === ExamLiveEventType.PROBLEM_STATEMENT_UPDATE && event.createdDate.isBefore(this.examStartDate)));
this.eventCount = filteredEvents.length;
});

this.liveEventsSubscription = this.liveEventsService.observeNewEventsAsUser(USER_DISPLAY_RELEVANT_EVENTS).subscribe(() => {
this.liveEventsSubscription = this.liveEventsService.observeNewEventsAsUser(USER_DISPLAY_RELEVANT_EVENTS, this.examStartDate).subscribe(() => {
// If any unacknowledged event comes in, open the dialog to display it
if (!this.modalRef) {
this.openDialog();
Expand All @@ -65,6 +69,8 @@ export class ExamLiveEventsButtonComponent implements OnInit, OnDestroy {
windowClass: 'live-events-modal-window',
});

this.modalRef.componentInstance.examStartDate = this.examStartDate;

from(this.modalRef.result).subscribe(() => (this.modalRef = undefined));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { Subscription } from 'rxjs';
import { ExamLiveEvent, ExamLiveEventType, ExamParticipationLiveEventsService, ProblemStatementUpdateEvent } from 'app/exam/participate/exam-participation-live-events.service';
import { USER_DISPLAY_RELEVANT_EVENTS, USER_DISPLAY_RELEVANT_EVENTS_REOPEN } from 'app/exam/participate/events/exam-live-events-button.component';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ExamExerciseUpdateService } from 'app/exam/manage/exam-exercise-update.service';
import dayjs from 'dayjs/esm';

@Component({
selector: 'jhi-exam-live-events-overlay',
Expand All @@ -19,6 +20,7 @@ export class ExamLiveEventsOverlayComponent implements OnInit, OnDestroy {
eventsToDisplay?: ExamLiveEvent[];
events: ExamLiveEvent[] = [];

@Input() examStartDate: dayjs.Dayjs;
// Icons
faCheck = faCheck;

Expand All @@ -37,13 +39,14 @@ export class ExamLiveEventsOverlayComponent implements OnInit, OnDestroy {

ngOnInit(): void {
this.allLiveEventsSubscription = this.liveEventsService.observeAllEvents(USER_DISPLAY_RELEVANT_EVENTS_REOPEN).subscribe((events: ExamLiveEvent[]) => {
this.events = events;
// display the problem statements events only after the start of the exam
this.events = events.filter((event) => !(event.eventType === ExamLiveEventType.PROBLEM_STATEMENT_UPDATE && event.createdDate.isBefore(this.examStartDate)));
if (!this.eventsToDisplay) {
this.updateEventsToDisplay();
}
});

this.newLiveEventsSubscription = this.liveEventsService.observeNewEventsAsUser(USER_DISPLAY_RELEVANT_EVENTS).subscribe((event: ExamLiveEvent) => {
this.newLiveEventsSubscription = this.liveEventsService.observeNewEventsAsUser(USER_DISPLAY_RELEVANT_EVENTS, this.examStartDate).subscribe((event: ExamLiveEvent) => {
this.unacknowledgedEvents.unshift(event);
this.updateEventsToDisplay();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ <h3 class="align-self-center mb-0 me-3">
@if (!examTimeLineView) {
<div class="d-flex justify-content-between align-items-center">
<jhi-exam-timer class="me-3" [criticalTime]="criticalTime" [endDate]="endDate" (timerAboutToEnd)="triggerExamAboutToEnd()" />
<jhi-exam-live-events-button />
<jhi-exam-live-events-button [examStartDate]="examStartDate" />
<button id="hand-in-early" class="btn btn-danger ms-2" aria-label="Hand In Early" (click)="handInEarly()">
<div class="d-flex justify-content-between">
<span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ExamBarComponent implements AfterViewInit {
@Input() endDate: dayjs.Dayjs;
@Input() exerciseIndex = 0;
@Input() exercises: Exercise[] = [];
@Input() examStartDate: dayjs.Dayjs;

readonly faDoorClosed = faDoorClosed;
criticalTime = dayjs.duration(5, 'minutes');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@if (!startView && !studentFailedToSubmit) {
<div class="w-100 d-flex">
<div class="col-md-3">
<jhi-exam-live-events-button />
<jhi-exam-live-events-button [examStartDate]="exam.startDate!" />
</div>
<h2 class="col-md-6" style="text-align: center; font-weight: normal" id="exam-finished-title">
<span
Expand Down Expand Up @@ -39,7 +39,7 @@ <h3>{{ exam.title }}</h3>
<div class="d-flex justify-content-between">
<h3 class="mt-3">{{ exam.title }}</h3>
<div class="mt-3">
<jhi-exam-live-events-button />
<jhi-exam-live-events-button [examStartDate]="exam.startDate!" />
</div>
</div>
<hr />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,13 @@ export class ExamParticipationLiveEventsService {
return observable;
}

public observeNewEventsAsUser(eventTypes: ExamLiveEventType[] = []): Observable<ExamLiveEvent> {
public observeNewEventsAsUser(eventTypes: ExamLiveEventType[] = [], examStartDate: dayjs.Dayjs): Observable<ExamLiveEvent> {
const observable = this.newUserEventSubject.asObservable().pipe(
filter(
(event: ExamLiveEvent) =>
!this.lastAcknowledgedEventStatus?.acknowledgedEvents[String(event.id)]?.user && (eventTypes.length === 0 || eventTypes.includes(event.eventType)),
!this.lastAcknowledgedEventStatus?.acknowledgedEvents[String(event.id)]?.user &&
(eventTypes.length === 0 || eventTypes.includes(event.eventType)) &&
!(event.eventType === ExamLiveEventType.PROBLEM_STATEMENT_UPDATE && event.createdDate.isBefore(examStartDate)),
),
tap((event: ExamLiveEvent) => this.setEventAcknowledgeTimestamps(event)),
distinct((event) => event.id),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
[endDate]="individualStudentEndDate"
[exerciseIndex]="exerciseIndex"
[exercises]="studentExam.exercises!"
[examStartDate]="exam.startDate!"
(examAboutToEnd)="examEnded()"
(onExamHandInEarly)="toggleHandInEarly()"
(heightChange)="updateHeight($event)"
Expand Down
42 changes: 42 additions & 0 deletions src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.tum.in.www1.artemis.exam;

import static java.time.ZonedDateTime.now;
import static org.assertj.core.api.Assertions.assertThat;

import java.net.URI;
Expand Down Expand Up @@ -910,6 +911,47 @@ public ExerciseGroup addExerciseGroupWithExamAndCourse(boolean mandatory) {
return exerciseGroup;
}

/**
* Creates and saves an Exam with an ExerciseGroup for a newly created, active course with default group names.
*
* @param mandatory True, if the ExerciseGroup should be mandatory
* @param startDateBeforeCurrentTime True, if the start date of the created Exam should be before the current time, needed for examLiveEvent tests for already started exams
* @return The newly created ExerciseGroup
*/
public ExerciseGroup addExerciseGroupWithExamAndCourse(boolean mandatory, boolean startDateBeforeCurrentTime) {
Course course = CourseFactory.generateCourse(null, PAST_TIMESTAMP, FUTURE_FUTURE_TIMESTAMP, new HashSet<>(), "tumuser", "tutor", "editor", "instructor");
Exam exam;
if (startDateBeforeCurrentTime) {
// Create an exam that is already started
ZonedDateTime currentTime = now();
exam = ExamFactory.generateExam(course, currentTime.minusMinutes(10), currentTime.minusMinutes(5), currentTime.plusMinutes(60), false);
}
else {
exam = ExamFactory.generateExam(course);
}
ExerciseGroup exerciseGroup = ExamFactory.generateExerciseGroup(mandatory, exam);

course = courseRepo.save(course);
exam = examRepository.save(exam);

Optional<Course> optionalCourse = courseRepo.findById(course.getId());
assertThat(optionalCourse).as("course can be retrieved").isPresent();
Course courseDB = optionalCourse.orElseThrow();

Optional<Exam> optionalExam = examRepository.findById(exam.getId());
assertThat(optionalCourse).as("exam can be retrieved").isPresent();
Exam examDB = optionalExam.orElseThrow();

Optional<ExerciseGroup> optionalExerciseGroup = exerciseGroupRepository.findById(exerciseGroup.getId());
assertThat(optionalExerciseGroup).as("exerciseGroup can be retrieved").isPresent();
ExerciseGroup exerciseGroupDB = optionalExerciseGroup.orElseThrow();

assertThat(examDB.getCourse().getId()).as("exam and course are linked correctly").isEqualTo(courseDB.getId());
assertThat(exerciseGroupDB.getExam().getId()).as("exerciseGroup and exam are linked correctly").isEqualTo(examDB.getId());

return exerciseGroup;
}

/**
* Creates and saves an Exam with an ExerciseGroup for a newly created, active course. The exam has a review date [now; now + 60min].
*
Expand Down
Loading

0 comments on commit 305aa74

Please sign in to comment.