diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java index 9481d09119bc..19b2bf459b87 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java @@ -347,6 +347,7 @@ AND EXISTS ( FROM Exercise e LEFT JOIN FETCH e.posts LEFT JOIN FETCH e.categories + LEFT JOIN FETCH e.submissionPolicy WHERE e.id = :exerciseId """) Optional findByIdWithDetailsForStudent(@Param("exerciseId") Long exerciseId); diff --git a/src/main/java/de/tum/in/www1/artemis/service/hestia/ExerciseHintService.java b/src/main/java/de/tum/in/www1/artemis/service/hestia/ExerciseHintService.java index f59c3376207b..ed07b788d891 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/hestia/ExerciseHintService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/hestia/ExerciseHintService.java @@ -157,6 +157,11 @@ public Set getActivatedExerciseHints(ProgrammingExercise exercise, * @return All available exercise hints */ public Set getAvailableExerciseHints(ProgrammingExercise exercise, User user) { + var exerciseHints = exerciseHintRepository.findByExerciseId(exercise.getId()); + if (exerciseHints.isEmpty()) { + return new HashSet<>(); + } + var submissions = getSubmissionsForStudent(exercise, user); if (submissions.isEmpty()) { @@ -169,8 +174,6 @@ public Set getAvailableExerciseHints(ProgrammingExercise exercise, if (latestResult == null || latestResult.getFeedbacks().isEmpty()) { return new HashSet<>(); } - - var exerciseHints = exerciseHintRepository.findByExerciseId(exercise.getId()); var tasks = programmingExerciseTaskService.getSortedTasks(exercise); var subsequentNumberOfUnsuccessfulSubmissionsByTask = tasks.stream() diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/settings/IrisSettingsService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/settings/IrisSettingsService.java index 077e545da823..562f5c2327d3 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/settings/IrisSettingsService.java @@ -17,6 +17,7 @@ import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.iris.IrisTemplate; import de.tum.in.www1.artemis.domain.iris.settings.IrisChatSubSettings; import de.tum.in.www1.artemis.domain.iris.settings.IrisCompetencyGenerationSubSettings; @@ -28,6 +29,7 @@ import de.tum.in.www1.artemis.domain.iris.settings.IrisSubSettings; import de.tum.in.www1.artemis.domain.iris.settings.IrisSubSettingsType; import de.tum.in.www1.artemis.repository.iris.IrisSettingsRepository; +import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.iris.IrisDefaultTemplateService; import de.tum.in.www1.artemis.service.iris.dto.IrisCombinedSettingsDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenAlertException; @@ -51,11 +53,14 @@ public class IrisSettingsService { private final IrisDefaultTemplateService irisDefaultTemplateService; - public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, - IrisDefaultTemplateService irisDefaultTemplateService) { + private final AuthorizationCheckService authCheckService; + + public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, IrisDefaultTemplateService irisDefaultTemplateService, + AuthorizationCheckService authCheckService) { this.irisSettingsRepository = irisSettingsRepository; this.irisSubSettingsService = irisSubSettingsService; this.irisDefaultTemplateService = irisDefaultTemplateService; + this.authCheckService = authCheckService; } private Optional loadGlobalTemplateVersion() { @@ -402,6 +407,17 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Exercise exercise, boo irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal)); } + /** + * Check if we have to show minimal settings for an exercise. Editors can see the full settings, students only the reduced settings. + * + * @param exercise The exercise to check + * @param user The user to check + * @return Whether we have to show the user the minimal settings + */ + public boolean shouldShowMinimalSettings(Exercise exercise, User user) { + return !authCheckService.isAtLeastEditorForExercise(exercise, user); + } + /** * Get the default Iris settings for a course. * The default settings are used if no Iris settings for the course exist. diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismCaseService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismCaseService.java index 4126cf1f95cc..2cae05c668da 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismCaseService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismCaseService.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -22,6 +23,7 @@ import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismComparisonRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismSubmissionRepository; import de.tum.in.www1.artemis.service.notifications.SingleUserNotificationService; +import de.tum.in.www1.artemis.web.rest.dto.plagiarism.PlagiarismCaseInfoDTO; import de.tum.in.www1.artemis.web.rest.dto.plagiarism.PlagiarismVerdictDTO; @Profile(PROFILE_CORE) @@ -115,6 +117,25 @@ public void createOrAddToPlagiarismCasesForComparison(long plagiarismComparisonI createOrAddToPlagiarismCaseForStudent(plagiarismComparison, plagiarismComparison.getSubmissionB(), false); } + /** + * Get the plagiarism case for a student and exercise. + * + * @param exerciseId the ID of the exercise + * @param userId the ID of the student + * @return the plagiarism case for the student and exercise if it exists + */ + public Optional getPlagiarismCaseInfoForExerciseAndUser(long exerciseId, long userId) { + return plagiarismCaseRepository.findByStudentIdAndExerciseIdWithPost(userId, exerciseId) + // the student was notified if the plagiarism case is available (due to the nature of the query above) + // the following line is already checked in the SQL statement, but we want to ensure it 100% + .filter((plagiarismCase) -> plagiarismCase.getPost() != null).map((plagiarismCase) -> { + // Note: we only return the ID and verdict to tell the client there is a confirmed plagiarism case with student notification (post) + // and to support navigating to the detail page + // all other information might be irrelevant or sensitive and could lead to longer loading times + return new PlagiarismCaseInfoDTO(plagiarismCase.getId(), plagiarismCase.getVerdict(), plagiarismCase.isCreatedByContinuousPlagiarismControl()); + }); + } + /** * Create or add to a plagiarism case for a student defined via the submission involved in a plagiarism comparison. * The following logic applies: diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseResource.java index b3011cbb4ae8..fdc424207a00 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseResource.java @@ -6,6 +6,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -29,10 +30,10 @@ import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.enumeration.TutorParticipationStatus; import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.hestia.ExerciseHint; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.participation.TutorParticipation; import de.tum.in.www1.artemis.domain.quiz.QuizExercise; -import de.tum.in.www1.artemis.domain.submissionpolicy.SubmissionPolicy; import de.tum.in.www1.artemis.repository.ExampleSubmissionRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.repository.GradingCriterionRepository; @@ -50,8 +51,14 @@ import de.tum.in.www1.artemis.service.TutorParticipationService; import de.tum.in.www1.artemis.service.exam.ExamAccessService; import de.tum.in.www1.artemis.service.exam.ExamDateService; +import de.tum.in.www1.artemis.service.hestia.ExerciseHintService; +import de.tum.in.www1.artemis.service.iris.dto.IrisCombinedSettingsDTO; +import de.tum.in.www1.artemis.service.iris.settings.IrisSettingsService; +import de.tum.in.www1.artemis.service.plagiarism.PlagiarismCaseService; import de.tum.in.www1.artemis.service.quiz.QuizBatchService; +import de.tum.in.www1.artemis.web.rest.dto.ExerciseDetailsDTO; import de.tum.in.www1.artemis.web.rest.dto.StatsForDashboardDTO; +import de.tum.in.www1.artemis.web.rest.dto.plagiarism.PlagiarismCaseInfoDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @@ -93,11 +100,18 @@ public class ExerciseResource { private final ExamAccessService examAccessService; + private final Optional irisSettingsService; + + private final PlagiarismCaseService plagiarismCaseService; + + private final ExerciseHintService exerciseHintService; + public ExerciseResource(ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, ParticipationService participationService, UserRepository userRepository, ExamDateService examDateService, AuthorizationCheckService authCheckService, TutorParticipationService tutorParticipationService, ExampleSubmissionRepository exampleSubmissionRepository, ProgrammingExerciseRepository programmingExerciseRepository, GradingCriterionRepository gradingCriterionRepository, ExerciseRepository exerciseRepository, QuizBatchService quizBatchService, - ParticipationRepository participationRepository, ExamAccessService examAccessService) { + ParticipationRepository participationRepository, ExamAccessService examAccessService, Optional irisSettingsService, + PlagiarismCaseService plagiarismCaseService, ExerciseHintService exerciseHintService) { this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; this.participationService = participationService; @@ -112,6 +126,9 @@ public ExerciseResource(ExerciseService exerciseService, ExerciseDeletionService this.quizBatchService = quizBatchService; this.participationRepository = participationRepository; this.examAccessService = examAccessService; + this.irisSettingsService = irisSettingsService; + this.plagiarismCaseService = plagiarismCaseService; + this.exerciseHintService = exerciseHintService; } /** @@ -289,7 +306,7 @@ public ResponseEntity reset(@PathVariable Long exerciseId) { */ @GetMapping("exercises/{exerciseId}/details") @EnforceAtLeastStudent - public ResponseEntity getExerciseDetails(@PathVariable Long exerciseId) { + public ResponseEntity getExerciseDetails(@PathVariable Long exerciseId) { User user = userRepository.getUserWithGroupsAndAuthorities(); Exercise exercise = exerciseService.findOneWithDetailsForStudents(exerciseId, user); @@ -321,11 +338,6 @@ public ResponseEntity getExerciseDetails(@PathVariable Long exerciseId quizExercise.setQuizBatches(null); quizExercise.setQuizBatches(quizBatchService.getQuizBatchForStudentByLogin(quizExercise, user.getLogin()).stream().collect(Collectors.toSet())); } - if (exercise instanceof ProgrammingExercise programmingExercise) { - // TODO: instead fetch the policy without programming exercise, should be faster - SubmissionPolicy policy = programmingExerciseRepository.findByIdWithSubmissionPolicyElseThrow(programmingExercise.getId()).getSubmissionPolicy(); - programmingExercise.setSubmissionPolicy(policy); - } // TODO: we should also check that the submissions do not contain sensitive data // remove sensitive information for students @@ -333,7 +345,17 @@ public ResponseEntity getExerciseDetails(@PathVariable Long exerciseId exercise.filterSensitiveInformation(); } - return ResponseEntity.ok(exercise); + IrisCombinedSettingsDTO irisSettings = irisSettingsService.map(service -> service.getCombinedIrisSettingsFor(exercise, service.shouldShowMinimalSettings(exercise, user))) + .orElse(null); + PlagiarismCaseInfoDTO plagiarismCaseInfo = plagiarismCaseService.getPlagiarismCaseInfoForExerciseAndUser(exercise.getId(), user.getId()).orElse(null); + + if (exercise instanceof ProgrammingExercise programmingExercise) { + Set activatedExerciseHints = exerciseHintService.getActivatedExerciseHints(programmingExercise, user); + Set availableExerciseHints = exerciseHintService.getAvailableExerciseHints(programmingExercise, user); + return ResponseEntity.ok(new ExerciseDetailsDTO(exercise, irisSettings, plagiarismCaseInfo, availableExerciseHints, activatedExerciseHints)); + } + + return ResponseEntity.ok(new ExerciseDetailsDTO(exercise, irisSettings, plagiarismCaseInfo, null, null)); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseDetailsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseDetailsDTO.java new file mode 100644 index 000000000000..e2b6b5a5c0e2 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseDetailsDTO.java @@ -0,0 +1,15 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.hestia.ExerciseHint; +import de.tum.in.www1.artemis.service.iris.dto.IrisCombinedSettingsDTO; +import de.tum.in.www1.artemis.web.rest.dto.plagiarism.PlagiarismCaseInfoDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ExerciseDetailsDTO(Exercise exercise, IrisCombinedSettingsDTO irisSettings, PlagiarismCaseInfoDTO plagiarismCaseInfo, Set availableExerciseHints, + Set activatedExerciseHints) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSettingsResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSettingsResource.java index d718718e7fb6..2eb992fde508 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSettingsResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSettingsResource.java @@ -126,9 +126,7 @@ public ResponseEntity getProgrammingExerciseSettings(@P var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, exercise, user); - // Editors can see the full settings, students only the reduced settings - var getReduced = !authCheckService.isAtLeastEditorForExercise(exercise, user); - var combinedIrisSettings = irisSettingsService.getCombinedIrisSettingsFor(exercise, getReduced); + var combinedIrisSettings = irisSettingsService.getCombinedIrisSettingsFor(exercise, irisSettingsService.shouldShowMinimalSettings(exercise, user)); return ResponseEntity.ok(combinedIrisSettings); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java index 671cbff7f456..bf3270b9b92d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java @@ -193,21 +193,7 @@ public ResponseEntity getPlagiarismCaseForExerciseForStud var user = userRepository.getUserWithGroupsAndAuthorities(); authenticationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user); - var plagiarismCaseOptional = plagiarismCaseRepository.findByStudentIdAndExerciseIdWithPost(user.getId(), exerciseId); - if (plagiarismCaseOptional.isPresent()) { - // the student was notified if the plagiarism case is available (due to the nature of the query above) - var plagiarismCase = plagiarismCaseOptional.get(); - // the following line is already checked in the SQL statement, but we want to ensure it 100% - if (plagiarismCase.getPost() != null) { - // Note: we only return the ID and verdict to tell the client there is a confirmed plagiarism case with student notification (post) and to support navigating to the - // detail page - // all other information might be irrelevant or sensitive and could lead to longer loading times - var plagiarismCaseInfoDTO = new PlagiarismCaseInfoDTO(plagiarismCase.getId(), plagiarismCase.getVerdict(), plagiarismCase.isCreatedByContinuousPlagiarismControl()); - return ResponseEntity.ok(plagiarismCaseInfoDTO); - } - } - // in all other cases the response is empty - return ResponseEntity.ok(null); + return ResponseEntity.ok(plagiarismCaseService.getPlagiarismCaseInfoForExerciseAndUser(exerciseId, user.getId()).orElse(null)); } /** diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts index 1e19e1174927..a4d1084b55c6 100644 --- a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts @@ -144,7 +144,7 @@ export class LearningPathContainerComponent implements OnInit { loadExercise() { this.exerciseService.getExerciseDetails(this.learningObjectId!).subscribe({ next: (exerciseResponse) => { - this.exercise = exerciseResponse.body!; + this.exercise = exerciseResponse.body!.exercise; }, error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), }); diff --git a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts index 8a6c0b578a09..cc0e3071a266 100644 --- a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts +++ b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts @@ -19,6 +19,9 @@ import { TextExercise } from 'app/entities/text-exercise.model'; import { FileUploadExercise } from 'app/entities/file-upload-exercise.model'; import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { SafeHtml } from '@angular/platform-browser'; +import { PlagiarismCaseInfo } from 'app/exercises/shared/plagiarism/types/PlagiarismCaseInfo'; +import { ExerciseHint } from 'app/entities/hestia/exercise-hint.model'; +import { IrisExerciseSettings } from 'app/entities/iris/settings/iris-settings.model'; export type EntityResponseType = HttpResponse; export type EntityArrayResponseType = HttpResponse; @@ -30,6 +33,15 @@ export type ExampleSolutionInfo = { exampleSolutionPublished: boolean; }; +export type EntityDetailsResponseType = HttpResponse; +export type ExerciseDetailsType = { + exercise: Exercise; + irisSettings?: IrisExerciseSettings; + plagiarismCaseInfo?: PlagiarismCaseInfo; + availableExerciseHints?: ExerciseHint[]; + activatedExerciseHints?: ExerciseHint[]; +}; + export interface ExerciseServicable { create(exercise: T): Observable>; @@ -146,15 +158,18 @@ export class ExerciseService { * Get exercise details including all results for the currently logged-in user * @param { number } exerciseId - Id of the exercise to get the repos from */ - getExerciseDetails(exerciseId: number): Observable { - return this.http.get(`${this.resourceUrl}/${exerciseId}/details`, { observe: 'response' }).pipe( - map((res: EntityResponseType) => { - this.processExerciseEntityResponse(res); - + getExerciseDetails(exerciseId: number): Observable { + return this.http.get(`${this.resourceUrl}/${exerciseId}/details`, { observe: 'response' }).pipe( + map((res: EntityDetailsResponseType) => { if (res.body) { + res.body.exercise = ExerciseService.convertExerciseDatesFromServer(res.body.exercise)!; + ExerciseService.parseExerciseCategories(res.body.exercise); // insert an empty list to avoid additional calls in case the list is empty on the server (because then it would be undefined in the client) - if (res.body.posts === undefined) { - res.body.posts = []; + if (res.body.exercise.posts === undefined) { + res.body.exercise.posts = []; + } + for (const hint of res.body.activatedExerciseHints ?? []) { + this.entityTitleService.setTitle(EntityType.HINT, [hint?.id, exerciseId], hint?.title); } } return res; diff --git a/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.ts b/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.ts index b988e5d13e28..566bb17362cb 100644 --- a/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.ts +++ b/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, Optional } from '@angular/core'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { Result } from 'app/entities/result.model'; import dayjs from 'dayjs/esm'; -import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { ActivatedRoute } from '@angular/router'; import { HttpResponse } from '@angular/common/http'; import { ExerciseCacheService } from 'app/exercises/shared/exercise/exercise-cache.service'; @@ -35,8 +35,8 @@ export class StandaloneFeedbackComponent implements OnInit { const participationId = parseInt(params['participationId'], 10); const resultId = parseInt(params['resultId'], 10); - this.exerciseService.getExerciseDetails(exerciseId).subscribe((exerciseResponse: HttpResponse) => { - this.exercise = exerciseResponse.body!; + this.exerciseService.getExerciseDetails(exerciseId).subscribe((exerciseResponse: HttpResponse) => { + this.exercise = exerciseResponse.body!.exercise; const participation = this.exercise?.studentParticipations?.find((participation) => participation.id === participationId); if (participation) { participation.exercise = this.exercise; @@ -50,7 +50,7 @@ export class StandaloneFeedbackComponent implements OnInit { this.result = relevantResult; // We set isBuilding here to false. It is the mobile applications responsibility to make the user aware if a participation is being built - const templateStatus = evaluateTemplateStatus(exerciseResponse.body!, participation, relevantResult, false); + const templateStatus = evaluateTemplateStatus(this.exercise, participation, relevantResult, false); if (templateStatus == ResultTemplateStatus.MISSING) { this.messageKey = 'artemisApp.result.notLatestSubmission'; } else { diff --git a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts index 2bedca558360..5f50652ea20a 100644 --- a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts +++ b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts @@ -3,7 +3,7 @@ import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ActivatedRoute } from '@angular/router'; import { Subscription, combineLatest } from 'rxjs'; -import { filter, switchMap } from 'rxjs/operators'; +import { filter, skip } from 'rxjs/operators'; import { Result } from 'app/entities/result.model'; import dayjs from 'dayjs/esm'; import { User } from 'app/core/user/user.model'; @@ -16,7 +16,7 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { Participation } from 'app/entities/participation/participation.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; -import { ExampleSolutionInfo, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExampleSolutionInfo, ExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { hasExerciseDueDatePassed } from 'app/exercises/shared/exercise/exercise.utils'; import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; @@ -203,23 +203,21 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp this.studentParticipations = this.participationWebsocketService.getParticipationsForExercise(this.exerciseId); this.updateStudentParticipations(); this.resultWithComplaint = getFirstResultWithComplaintFromResults(this.gradedStudentParticipation?.results); - this.exerciseService.getExerciseDetails(this.exerciseId).subscribe((exerciseResponse: HttpResponse) => { + this.exerciseService.getExerciseDetails(this.exerciseId).subscribe((exerciseResponse: HttpResponse) => { this.handleNewExercise(exerciseResponse.body!); this.loadComplaintAndLatestRatedResult(); }); - this.plagiarismCaseService.getPlagiarismCaseInfoForStudent(this.courseId, this.exerciseId).subscribe((res: HttpResponse) => { - this.plagiarismCaseInfo = res.body ?? undefined; - }); } - handleNewExercise(newExercise: Exercise) { - this.exercise = newExercise; + handleNewExercise(newExerciseDetails: ExerciseDetailsType) { + this.exercise = newExerciseDetails.exercise; this.filterUnfinishedResults(this.exercise.studentParticipations); this.mergeResultsAndSubmissionsForParticipations(); this.isAfterAssessmentDueDate = !this.exercise.assessmentDueDate || dayjs().isAfter(this.exercise.assessmentDueDate); this.exerciseCategories = this.exercise.categories ?? []; this.allowComplaintsForAutomaticAssessments = false; + this.plagiarismCaseInfo = newExerciseDetails.plagiarismCaseInfo; if (this.exercise.type === ExerciseType.PROGRAMMING) { const programmingExercise = this.exercise as ProgrammingExercise; @@ -229,22 +227,18 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp (!programmingExercise.buildAndTestStudentSubmissionsAfterDueDate || dayjs().isAfter(programmingExercise.buildAndTestStudentSubmissionsAfterDueDate))); this.allowComplaintsForAutomaticAssessments = !!programmingExercise.allowComplaintsForAutomaticAssessments && isAfterDateForComplaint; - this.programmingExerciseSubmissionPolicyService.getSubmissionPolicyOfProgrammingExercise(this.exerciseId).subscribe((submissionPolicy) => { - this.submissionPolicy = submissionPolicy; - }); + this.submissionPolicy = programmingExercise.submissionPolicy; - this.profileService - .getProfileInfo() - .pipe( - filter((profileInfo) => profileInfo?.activeProfiles?.includes(PROFILE_IRIS)), - switchMap(() => this.irisSettingsService.getCombinedProgrammingExerciseSettings(this.exercise!.id!)), - ) - .subscribe((settings) => { - this.irisSettings = settings; - }); + this.profileService.getProfileInfo().subscribe((profileInfo) => { + if (profileInfo?.activeProfiles?.includes(PROFILE_IRIS)) { + this.irisSettings = newExerciseDetails.irisSettings; + } + }); + this.availableExerciseHints = newExerciseDetails.availableExerciseHints || []; + this.activatedExerciseHints = newExerciseDetails.activatedExerciseHints || []; } - this.showIfExampleSolutionPresent(newExercise); + this.showIfExampleSolutionPresent(newExerciseDetails.exercise); this.subscribeForNewResults(); this.subscribeToTeamAssignmentUpdates(); @@ -321,48 +315,52 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp } this.participationUpdateListener?.unsubscribe(); - this.participationUpdateListener = this.participationWebsocketService.subscribeForParticipationChanges().subscribe((changedParticipation: StudentParticipation) => { - if (changedParticipation && this.exercise && changedParticipation.exercise?.id === this.exercise.id) { - // Notify student about late submission result - if ( - changedParticipation.exercise?.dueDate && - hasExerciseDueDatePassed(changedParticipation.exercise, changedParticipation) && - changedParticipation.id === this.gradedStudentParticipation?.id && - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - changedParticipation.results?.length! > this.gradedStudentParticipation?.results?.length! - ) { - this.alertService.success('artemisApp.exercise.lateSubmissionResultReceived'); - } - if (this.studentParticipations?.some((participation) => participation.id === changedParticipation.id)) { - this.exercise.studentParticipations = this.studentParticipations.map((participation) => - participation.id === changedParticipation.id ? changedParticipation : participation, - ); - } else { - this.exercise.studentParticipations = [...this.studentParticipations, changedParticipation]; - } - this.updateStudentParticipations(); - this.mergeResultsAndSubmissionsForParticipations(); - - if (ExerciseType.PROGRAMMING === this.exercise?.type) { - this.exerciseHintService.getActivatedExerciseHints(this.exerciseId).subscribe((activatedRes?: HttpResponse) => { - this.activatedExerciseHints = activatedRes!.body!; - - this.exerciseHintService.getAvailableExerciseHints(this.exerciseId).subscribe((availableRes?: HttpResponse) => { - // filter out the activated hints from the available hints - this.availableExerciseHints = availableRes!.body!.filter( - (availableHint) => !this.activatedExerciseHints.some((activatedHint) => availableHint.id === activatedHint.id), - ); - const filteredAvailableExerciseHints = this.availableExerciseHints.filter((hint) => hint.displayThreshold !== 0); - if (filteredAvailableExerciseHints.length) { - this.alertService.info('artemisApp.exerciseHint.availableHintsAlertMessage', { - taskName: filteredAvailableExerciseHints.first()?.programmingExerciseTask?.taskName, - }); - } + this.participationUpdateListener = this.participationWebsocketService + .subscribeForParticipationChanges() + // Skip the first event, as it is the initial state. All data should already be loaded. + .pipe(skip(1)) + .subscribe((changedParticipation: StudentParticipation) => { + if (changedParticipation && this.exercise && changedParticipation.exercise?.id === this.exercise.id) { + // Notify student about late submission result + if ( + changedParticipation.exercise?.dueDate && + hasExerciseDueDatePassed(changedParticipation.exercise, changedParticipation) && + changedParticipation.id === this.gradedStudentParticipation?.id && + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + changedParticipation.results?.length! > this.gradedStudentParticipation?.results?.length! + ) { + this.alertService.success('artemisApp.exercise.lateSubmissionResultReceived'); + } + if (this.studentParticipations?.some((participation) => participation.id === changedParticipation.id)) { + this.exercise.studentParticipations = this.studentParticipations.map((participation) => + participation.id === changedParticipation.id ? changedParticipation : participation, + ); + } else { + this.exercise.studentParticipations = [...this.studentParticipations, changedParticipation]; + } + this.updateStudentParticipations(); + this.mergeResultsAndSubmissionsForParticipations(); + + if (ExerciseType.PROGRAMMING === this.exercise?.type) { + this.exerciseHintService.getActivatedExerciseHints(this.exerciseId).subscribe((activatedRes?: HttpResponse) => { + this.activatedExerciseHints = activatedRes!.body!; + + this.exerciseHintService.getAvailableExerciseHints(this.exerciseId).subscribe((availableRes?: HttpResponse) => { + // filter out the activated hints from the available hints + this.availableExerciseHints = availableRes!.body!.filter( + (availableHint) => !this.activatedExerciseHints.some((activatedHint) => availableHint.id === activatedHint.id), + ); + const filteredAvailableExerciseHints = this.availableExerciseHints.filter((hint) => hint.displayThreshold !== 0); + if (filteredAvailableExerciseHints.length) { + this.alertService.info('artemisApp.exerciseHint.availableHintsAlertMessage', { + taskName: filteredAvailableExerciseHints.first()?.programmingExerciseTask?.taskName, + }); + } + }); }); - }); + } } - } - }); + }); } private updateStudentParticipations() { diff --git a/src/main/webapp/app/overview/exercise-details/problem-statement/problem-statement.component.ts b/src/main/webapp/app/overview/exercise-details/problem-statement/problem-statement.component.ts index 58bcd688cb78..3b70909f554e 100644 --- a/src/main/webapp/app/overview/exercise-details/problem-statement/problem-statement.component.ts +++ b/src/main/webapp/app/overview/exercise-details/problem-statement/problem-statement.component.ts @@ -3,7 +3,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; -import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; @Component({ @@ -33,8 +33,8 @@ export class ProblemStatementComponent implements OnInit { } if (!this.exercise) { - this.exerciseService.getExerciseDetails(exerciseId).subscribe((exerciseResponse: HttpResponse) => { - this.exercise = exerciseResponse.body!; + this.exerciseService.getExerciseDetails(exerciseId).subscribe((exerciseResponse: HttpResponse) => { + this.exercise = exerciseResponse.body!.exercise; }); } if (!this.participation && participationId) { diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/ExerciseIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/ExerciseIntegrationTest.java index 999b4f35c321..d5ac0f835ba3 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/ExerciseIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/ExerciseIntegrationTest.java @@ -59,6 +59,7 @@ import de.tum.in.www1.artemis.service.ExerciseService; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.util.TestResourceUtils; +import de.tum.in.www1.artemis.web.rest.dto.ExerciseDetailsDTO; import de.tum.in.www1.artemis.web.rest.dto.StatsForDashboardDTO; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -373,7 +374,8 @@ void testGetExerciseDetails() throws Exception { List courses = courseUtilService.createCoursesWithExercisesAndLectures(TEST_PREFIX, true, NUMBER_OF_TUTORS); for (Course course : courses) { for (Exercise exercise : course.getExercises()) { - Exercise exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, Exercise.class); + ExerciseDetailsDTO exerciseWithDetailsWrapper = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); + Exercise exerciseWithDetails = exerciseWithDetailsWrapper.exercise(); if (exerciseWithDetails instanceof FileUploadExercise fileUploadExercise) { assertFileUploadExercise(fileUploadExercise, "png", null); @@ -459,8 +461,8 @@ void testGetExerciseDetails_assessmentDueDate_notPassed() throws Exception { participationUtilService.addResultToParticipation(AssessmentType.SEMI_AUTOMATIC, ZonedDateTime.now().minusHours(1L), exercise.getStudentParticipations().iterator().next()); } - Exercise exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, Exercise.class); - for (StudentParticipation participation : exerciseWithDetails.getStudentParticipations()) { + ExerciseDetailsDTO exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); + for (StudentParticipation participation : exerciseWithDetails.exercise().getStudentParticipations()) { // Programming exercises should only have one automatic result if (exercise instanceof ProgrammingExercise) { assertThat(participation.getResults()).hasSize(1); @@ -484,8 +486,8 @@ void testGetExerciseDetails_assessmentDueDate_passed() throws Exception { participationUtilService.addResultToParticipation(AssessmentType.SEMI_AUTOMATIC, ZonedDateTime.now().minusHours(1L), exercise.getStudentParticipations().iterator().next()); } - Exercise exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, Exercise.class); - for (StudentParticipation participation : exerciseWithDetails.getStudentParticipations()) { + ExerciseDetailsDTO exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); + for (StudentParticipation participation : exerciseWithDetails.exercise().getStudentParticipations()) { // Programming exercises should now how two results and the latest one is the manual result. if (exercise instanceof ProgrammingExercise) { assertThat(participation.getResults()).hasSize(2); @@ -511,7 +513,7 @@ void testGetExerciseDetails_withExamExercise_asStudent() throws Exception { @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testGetExerciseDetails_withExamExercise_asTutor() throws Exception { Exercise exercise = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExercise(); - request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, Exercise.class); + request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); } @Test diff --git a/src/test/java/de/tum/in/www1/artemis/team/TeamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/team/TeamIntegrationTest.java index b5522fa94dd3..1cd000de5bde 100644 --- a/src/test/java/de/tum/in/www1/artemis/team/TeamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/team/TeamIntegrationTest.java @@ -42,6 +42,7 @@ import de.tum.in.www1.artemis.service.dto.TeamSearchUserDTO; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.web.rest.dto.CoursesForDashboardDTO; +import de.tum.in.www1.artemis.web.rest.dto.ExerciseDetailsDTO; class TeamIntegrationTest extends AbstractSpringIntegrationIndependentTest { @@ -499,8 +500,8 @@ void testAssignedTeamIdOnExerciseForCurrentUser() throws Exception { assertThat(serverExercise.isStudentAssignedTeamIdComputed()).as("Assigned team id on exercise was computed.").isTrue(); // Check for endpoint: @GetMapping("exercises/{exerciseId}/details") - Exercise exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, Exercise.class); - assertThat(exerciseWithDetails.getStudentAssignedTeamId()).as("Assigned team id on exercise from details is correct for student.").isEqualTo(team.getId()); + ExerciseDetailsDTO exerciseWithDetails = request.get("/api/exercises/" + exercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); + assertThat(exerciseWithDetails.exercise().getStudentAssignedTeamId()).as("Assigned team id on exercise from details is correct for student.").isEqualTo(team.getId()); assertThat(serverExercise.isStudentAssignedTeamIdComputed()).as("Assigned team id on exercise was computed.").isTrue(); } diff --git a/src/test/java/de/tum/in/www1/artemis/text/TextSubmissionIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/text/TextSubmissionIntegrationTest.java index e4779c7569ff..b816cfb02a04 100644 --- a/src/test/java/de/tum/in/www1/artemis/text/TextSubmissionIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/text/TextSubmissionIntegrationTest.java @@ -21,7 +21,6 @@ import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest; import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.SubmissionVersion; import de.tum.in.www1.artemis.domain.Team; import de.tum.in.www1.artemis.domain.TextExercise; @@ -53,6 +52,7 @@ import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismComparisonRepository; import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.web.rest.dto.ExerciseDetailsDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -341,9 +341,9 @@ void getResultsForCurrentStudent_assessorHiddenForStudent() throws Exception { textSubmission = ParticipationFactory.generateTextSubmission("Some text", Language.ENGLISH, true); textExerciseUtilService.saveTextSubmissionWithResultAndAssessor(finishedTextExercise, textSubmission, TEST_PREFIX + "student1", TEST_PREFIX + "tutor1"); - Exercise returnedExercise = request.get("/api/exercises/" + finishedTextExercise.getId() + "/details", HttpStatus.OK, Exercise.class); + ExerciseDetailsDTO returnedExerciseDetails = request.get("/api/exercises/" + finishedTextExercise.getId() + "/details", HttpStatus.OK, ExerciseDetailsDTO.class); - assertThat(returnedExercise.getStudentParticipations().iterator().next().getResults().iterator().next().getAssessor()).as("assessor is null").isNull(); + assertThat(returnedExerciseDetails.exercise().getStudentParticipations().iterator().next().getResults().iterator().next().getAssessor()).as("assessor is null").isNull(); } @Test diff --git a/src/test/javascript/spec/component/exercises/problem-statement.component.spec.ts b/src/test/javascript/spec/component/exercises/problem-statement.component.spec.ts index f21b3f2f64ae..dfe3302f9d69 100644 --- a/src/test/javascript/spec/component/exercises/problem-statement.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/problem-statement.component.spec.ts @@ -49,7 +49,7 @@ describe('ProblemStatementComponent', () => { getExerciseDetailsMock = jest.spyOn(exerciseService, 'getExerciseDetails'); exercise.problemStatement = 'Test problem statement'; course.exercises = [exercise]; - getExerciseDetailsMock.mockReturnValue(of({ body: exercise })); + getExerciseDetailsMock.mockReturnValue(of({ body: { exercise: exercise } })); // mock participationService participationService = fixture.debugElement.injector.get(ParticipationService); diff --git a/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts b/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts index 0e2d45ad8d4c..3c0cc20079f0 100644 --- a/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts @@ -80,7 +80,7 @@ describe('LearningPathContainerComponent', () => { exercise = new TextExercise(undefined, undefined); exercise.id = exerciseId; exerciseService = TestBed.inject(ExerciseService); - getExerciseDetailsStub = jest.spyOn(exerciseService, 'getExerciseDetails').mockReturnValue(of(new HttpResponse({ body: exercise }))); + getExerciseDetailsStub = jest.spyOn(exerciseService, 'getExerciseDetails').mockReturnValue(of(new HttpResponse({ body: { exercise: exercise } }))); historyService = TestBed.inject(LearningPathStorageService); hasNextRecommendationStub = jest.spyOn(historyService, 'hasNextRecommendation'); diff --git a/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts b/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts index 18e74390430e..957b1b6738ce 100644 --- a/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts +++ b/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts @@ -57,9 +57,7 @@ import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { SubmissionPolicyService } from 'app/exercises/programming/manage/services/submission-policy.service'; import { LockRepositoryPolicy } from 'app/entities/submission-policy.model'; import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagiarism-cases.service'; -import { PlagiarismCaseInfo } from 'app/exercises/shared/plagiarism/types/PlagiarismCaseInfo'; import { PlagiarismVerdict } from 'app/exercises/shared/plagiarism/types/PlagiarismVerdict'; -import { HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; import { ExerciseHintButtonOverlayComponent } from 'app/exercises/shared/exercise-hint/participate/exercise-hint-button-overlay.component'; import { ProgrammingExerciseExampleSolutionRepoDownloadComponent } from 'app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component'; @@ -72,23 +70,25 @@ import { ScienceService } from 'app/shared/science/science.service'; import { MockScienceService } from '../../../helpers/mocks/service/mock-science-service'; import { ScienceEventType } from 'app/shared/science/science.model'; import { PROFILE_IRIS } from 'app/app.constants'; +import { ExerciseHintService } from 'app/exercises/shared/exercise-hint/shared/exercise-hint.service'; describe('CourseExerciseDetailsComponent', () => { let comp: CourseExerciseDetailsComponent; let fixture: ComponentFixture; let profileService: ProfileService; let exerciseService: ExerciseService; + let exerciseHintService: ExerciseHintService; let teamService: TeamService; let participationService: ParticipationService; let participationWebsocketService: ParticipationWebsocketService; let complaintService: ComplaintService; - let plagiarismCaseService: PlagiarismCasesService; - let getProfileInfoMock: jest.SpyInstance; let getExerciseDetailsMock: jest.SpyInstance; + let getActivatedExerciseHintsMock: jest.SpyInstance; + let getAvailableExerciseHintsMock: jest.SpyInstance; let mergeStudentParticipationMock: jest.SpyInstance; let subscribeForParticipationChangesMock: jest.SpyInstance; - let plagiarismCaseServiceMock: jest.SpyInstance; + let participationWebsockerBehaviourSubject: BehaviorSubject; let scienceService: ScienceService; let logEventStub: jest.SpyInstance; @@ -103,6 +103,20 @@ describe('CourseExerciseDetailsComponent', () => { const plagiarismCaseInfo = { id: 20, verdict: PlagiarismVerdict.WARNING }; + const submissionPolicy = new LockRepositoryPolicy(); + + const programmingExercise = { + id: exercise.id, + type: ExerciseType.PROGRAMMING, + studentParticipations: [], + course: { id: 2 }, + allowComplaintsForAutomaticAssessments: true, + secondCorrectionEnabled: false, + studentAssignedTeamIdComputed: true, + numberOfAssessmentsOfCorrectionRounds: [], + submissionPolicy: submissionPolicy, + } as ProgrammingExercise; + const parentParams = { courseId: 1 }; const parentRoute = { parent: { parent: { params: of(parentParams) } } } as any as ActivatedRoute; const route = { params: of({ exerciseId: exercise.id }), parent: parentRoute, queryParams: of({ welcome: '' }) } as any as ActivatedRoute; @@ -157,6 +171,7 @@ describe('CourseExerciseDetailsComponent', () => { MockProvider(PlagiarismCasesService), MockProvider(AlertService), MockProvider(IrisSettingsService), + MockProvider(ExerciseHintService), ], }) .compileComponents() @@ -176,7 +191,7 @@ describe('CourseExerciseDetailsComponent', () => { // mock exerciseService exerciseService = fixture.debugElement.injector.get(ExerciseService); getExerciseDetailsMock = jest.spyOn(exerciseService, 'getExerciseDetails'); - getExerciseDetailsMock.mockReturnValue(of({ body: exercise })); + getExerciseDetailsMock.mockReturnValue(of({ body: { exercise: exercise } })); // mock teamService, needed for team assignment teamService = fixture.debugElement.injector.get(TeamService); @@ -184,20 +199,22 @@ describe('CourseExerciseDetailsComponent', () => { jest.spyOn(teamService, 'teamAssignmentUpdates', 'get').mockReturnValue(Promise.resolve(of(teamAssignmentPayload))); // mock participationService, needed for team assignment + participationWebsockerBehaviourSubject = new BehaviorSubject(undefined); participationWebsocketService = fixture.debugElement.injector.get(ParticipationWebsocketService); subscribeForParticipationChangesMock = jest.spyOn(participationWebsocketService, 'subscribeForParticipationChanges'); - subscribeForParticipationChangesMock.mockReturnValue(new BehaviorSubject(undefined)); + subscribeForParticipationChangesMock.mockReturnValue(participationWebsockerBehaviourSubject); complaintService = fixture.debugElement.injector.get(ComplaintService); - // mock plagiarismCaseService used when loading exercises - plagiarismCaseService = fixture.debugElement.injector.get(PlagiarismCasesService); - plagiarismCaseServiceMock = jest - .spyOn(plagiarismCaseService, 'getPlagiarismCaseInfoForStudent') - .mockReturnValue(of({ body: plagiarismCaseInfo } as HttpResponse)); - scienceService = TestBed.inject(ScienceService); logEventStub = jest.spyOn(scienceService, 'logEvent'); + + exerciseHintService = TestBed.inject(ExerciseHintService); + getActivatedExerciseHintsMock = jest.spyOn(exerciseHintService, 'getActivatedExerciseHints'); + getAvailableExerciseHintsMock = jest.spyOn(exerciseHintService, 'getAvailableExerciseHints'); + + participationService = TestBed.inject(ParticipationService); + mergeStudentParticipationMock = jest.spyOn(participationService, 'mergeStudentParticipations'); }); }); @@ -228,7 +245,7 @@ describe('CourseExerciseDetailsComponent', () => { studentParticipation.results = [result]; studentParticipation.exercise = exercise; - const exerciseDetail = { ...exercise, studentParticipations: [studentParticipation] }; + const exerciseDetail = { exercise: { ...exercise, studentParticipations: [studentParticipation] }, plagiarismCaseInfo: plagiarismCaseInfo }; const exerciseDetailResponse = of({ body: exerciseDetail }); // return initial participation for websocketService @@ -236,8 +253,6 @@ describe('CourseExerciseDetailsComponent', () => { jest.spyOn(complaintService, 'findBySubmissionId').mockReturnValue(of({} as EntityResponseType)); // mock participationService, needed for team assignment - participationService = TestBed.inject(ParticipationService); - mergeStudentParticipationMock = jest.spyOn(participationService, 'mergeStudentParticipations'); mergeStudentParticipationMock.mockReturnValue([studentParticipation]); const changedParticipation = cloneDeep(studentParticipation); const changedResult = { ...result, id: 2 }; @@ -253,14 +268,12 @@ describe('CourseExerciseDetailsComponent', () => { comp.loadExercise(); fixture.detectChanges(); expect(comp.courseId).toBe(1); - expect(comp.studentParticipations?.[0].exercise?.id).toBe(exerciseDetail.id); + expect(comp.studentParticipations?.[0].exercise?.id).toBe(exercise.id); expect(comp.exercise!.id).toBe(exercise.id); expect(comp.exercise!.studentParticipations![0].results![0]).toStrictEqual(changedResult); expect(comp.plagiarismCaseInfo).toEqual(plagiarismCaseInfo); expect(comp.hasMoreResults).toBeFalse(); expect(comp.exerciseRatedBadge(result)).toBe('bg-info'); - expect(plagiarismCaseServiceMock).toHaveBeenCalledTimes(2); - expect(plagiarismCaseServiceMock).toHaveBeenCalledWith(1, exercise.id); })); it('should not be a quiz exercise', () => { @@ -319,21 +332,6 @@ describe('CourseExerciseDetailsComponent', () => { }); it('should handle new programming exercise', () => { - const submissionPolicyService = fixture.debugElement.injector.get(SubmissionPolicyService); - const submissionPolicy = new LockRepositoryPolicy(); - const submissionPolicyServiceSpy = jest.spyOn(submissionPolicyService, 'getSubmissionPolicyOfProgrammingExercise').mockReturnValue(of(submissionPolicy)); - - const programmingExercise = { - id: exercise.id, - type: ExerciseType.PROGRAMMING, - studentParticipations: [], - course: { id: 2 }, - allowComplaintsForAutomaticAssessments: true, - secondCorrectionEnabled: false, - studentAssignedTeamIdComputed: true, - numberOfAssessmentsOfCorrectionRounds: [], - } as ProgrammingExercise; - const childComponent = {} as DiscussionSectionComponent; comp.onChildActivate(childComponent); @@ -341,10 +339,9 @@ describe('CourseExerciseDetailsComponent', () => { comp.courseId = courseId; - comp.handleNewExercise(programmingExercise); + comp.handleNewExercise({ exercise: programmingExercise }); expect(comp.baseResource).toBe(`/course-management/${courseId}/${programmingExercise.type}-exercises/${programmingExercise.id}/`); expect(comp.allowComplaintsForAutomaticAssessments).toBeTrue(); - expect(submissionPolicyServiceSpy).toHaveBeenCalledOnce(); expect(comp.submissionPolicy).toEqual(submissionPolicy); expect(childComponent.exercise).toEqual(programmingExercise); }); @@ -370,28 +367,49 @@ describe('CourseExerciseDetailsComponent', () => { expect(alertServiceSpy).toHaveBeenCalledWith(error.message); })); + it('should handle participation update', fakeAsync(() => { + const submissionId = 55; + const submission = { id: submissionId }; + const participation = { submissions: [submission] }; + comp.gradedStudentParticipation = participation; + comp.sortedHistoryResults = [{ id: 2 }]; + comp.exercise = { ...programmingExercise }; + + comp.courseId = programmingExercise.course!.id!; + + comp.handleNewExercise({ exercise: programmingExercise }); + tick(); + + const newParticipation = { ...participation, submissions: [submission, { id: submissionId + 1 }] }; + + getActivatedExerciseHintsMock.mockReturnValue(of({ body: [] })); + getAvailableExerciseHintsMock.mockReturnValue(of({ body: [] })); + mergeStudentParticipationMock.mockReturnValue([newParticipation]); + + participationWebsockerBehaviourSubject.next({ ...newParticipation, exercise: programmingExercise, results: [] }); + + tick(); + + expect(getActivatedExerciseHintsMock).toHaveBeenCalledOnce(); + expect(getAvailableExerciseHintsMock).toHaveBeenCalledOnce(); + })); + it.each<[string[]]>([[[]], [[PROFILE_IRIS]]])( 'should load iris settings only if profile iris is active', fakeAsync((activeProfiles: string[]) => { // Setup + const submissionPolicy = new LockRepositoryPolicy(); const programmingExercise = { id: 42, type: ExerciseType.PROGRAMMING, studentParticipations: [], course: {}, + submissionPolicy: submissionPolicy, } as unknown as ProgrammingExercise; const fakeSettings = {} as any as IrisSettings; - const irisSettingsService = TestBed.inject(IrisSettingsService); - const getCombinedProgrammingExerciseSettingsMock = jest.spyOn(irisSettingsService, 'getCombinedProgrammingExerciseSettings'); - getCombinedProgrammingExerciseSettingsMock.mockReturnValue(of(fakeSettings)); - - const submissionPolicyService = TestBed.inject(SubmissionPolicyService); - const submissionPolicy = new LockRepositoryPolicy(); - jest.spyOn(submissionPolicyService, 'getSubmissionPolicyOfProgrammingExercise').mockReturnValue(of(submissionPolicy)); - - getExerciseDetailsMock.mockReturnValue(of({ body: programmingExercise })); + getExerciseDetailsMock.mockReturnValue(of({ body: { exercise: programmingExercise, irisSettings: fakeSettings } })); const profileService = TestBed.inject(ProfileService); jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of({ activeProfiles } as any as ProfileInfo)); @@ -402,11 +420,9 @@ describe('CourseExerciseDetailsComponent', () => { if (activeProfiles.includes(PROFILE_IRIS)) { // Should have called getCombinedProgrammingExerciseSettings if 'iris' is active - expect(getCombinedProgrammingExerciseSettingsMock).toHaveBeenCalled(); expect(comp.irisSettings).toBe(fakeSettings); } else { // Should not have called getCombinedProgrammingExerciseSettings if 'iris' is not active - expect(getCombinedProgrammingExerciseSettingsMock).not.toHaveBeenCalled(); expect(comp.irisSettings).toBeUndefined(); } }), diff --git a/src/test/javascript/spec/component/shared/feedback/standalone-feedback.component.spec.ts b/src/test/javascript/spec/component/shared/feedback/standalone-feedback.component.spec.ts index 3663a91c9616..aa2aa73b8ccb 100644 --- a/src/test/javascript/spec/component/shared/feedback/standalone-feedback.component.spec.ts +++ b/src/test/javascript/spec/component/shared/feedback/standalone-feedback.component.spec.ts @@ -46,7 +46,7 @@ describe('StandaloneFeedbackComponent', () => { participation.results = [result]; exercise.studentParticipations = [participation]; course.exercises = [exercise]; - getExerciseDetailsMock.mockReturnValue(of({ body: exercise })); + getExerciseDetailsMock.mockReturnValue(of({ body: { exercise: exercise } })); // mock exerciseCacheService exerciseCacheService = fixture.debugElement.injector.get(ExerciseCacheService); diff --git a/src/test/javascript/spec/service/exercise.service.spec.ts b/src/test/javascript/spec/service/exercise.service.spec.ts index e62aac226c3a..8e58498bef7a 100644 --- a/src/test/javascript/spec/service/exercise.service.spec.ts +++ b/src/test/javascript/spec/service/exercise.service.spec.ts @@ -6,7 +6,8 @@ import { Exercise, ExerciseType, IncludedInOverallScore } from 'app/entities/exe import { InitializationState } from 'app/entities/participation/participation.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { TextExercise } from 'app/entities/text-exercise.model'; -import { EntityResponseType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import type { EntityResponseType, ExerciseDetailsType } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import dayjs from 'dayjs/esm'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MockRouter } from '../helpers/mocks/mock-router'; @@ -444,31 +445,30 @@ describe('Exercise Service', () => { }); it('should get exercise details', () => { - const serviceSpy = jest.spyOn(service, 'processExerciseEntityResponse'); - const exerciseId = 123; - const expectedReturnedExercise = { - id: exerciseId, - posts: undefined, - } as Exercise; + const expectedReturnedExerciseDetails = { + exercise: { + id: exerciseId, + posts: undefined, + } as ProgrammingExercise, + activatedExerciseHints: [{ id: 42, title: 'testHint' }], + } as ExerciseDetailsType; const result = service.getExerciseDetails(exerciseId); - let actualReturnedExercise: Exercise | undefined = undefined; - result.subscribe((exerciseResponse) => (actualReturnedExercise = exerciseResponse.body!)); + let actualReturnedExerciseDetails: ExerciseDetailsType | undefined = undefined; + result.subscribe((exerciseResponse) => (actualReturnedExerciseDetails = exerciseResponse.body!)); const testRequest = httpMock.expectOne({ url: `api/exercises/${exerciseId}/details`, method: 'GET', }); - testRequest.flush(expectedReturnedExercise); + testRequest.flush(expectedReturnedExerciseDetails); - expect(serviceSpy).toHaveBeenCalledOnce(); - expect(serviceSpy).toHaveBeenCalledWith(expect.objectContaining({ body: expectedReturnedExercise })); - expect(actualReturnedExercise).toEqual(expectedReturnedExercise); - expect(actualReturnedExercise!.posts).toEqual([]); + expect(actualReturnedExerciseDetails).toEqual(expectedReturnedExerciseDetails); + expect(expectedReturnedExerciseDetails.exercise!.posts).toEqual([]); }); it('should get exercise for example solution', () => { diff --git a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts index 56161de7baaf..453566ba70dc 100644 --- a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts +++ b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts @@ -302,7 +302,7 @@ export async function prepareExam(course: Course, end: dayjs.Dayjs, exerciseType const courseList = new CoursesPage(page); const courseOverview = new CourseOverviewPage(page); const modelingExerciseEditor = new ModelingEditor(page); - const programmingExerciseEditor = new OnlineEditorPage(page, courseList, courseOverview); + const programmingExerciseEditor = new OnlineEditorPage(page); const quizExerciseMultipleChoice = new MultipleChoiceQuiz(page); const textExerciseEditor = new TextEditorPage(page); const examNavigation = new ExamNavigationBar(page);