diff --git a/.dockerignore b/.dockerignore index 3c59143e27a0..a9994625f8f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,6 +27,10 @@ node_modules # exclude build binaries except a pre-built .war file build/* !build/libs/*.war +# exlude the .cache of angular +.cache +# exclude the .gradle cache +.gradle ######################### # exclude Artemis configs @@ -38,6 +42,13 @@ build/* # exclude the docker files and the /docker/.docker-data folders docker/ +###################### +# Artemis resources +###################### +/src/main/resources/config/application-local*.yml +/src/main/resources/id_* +/src/main/resources/known_hosts + # files inside of the root directory not needed CITATION.cff CODE_OF_CONDUCT.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e0eb0a988fd..88a0060022dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -137,6 +137,8 @@ jobs: context: . tags: ghcr.io/ls1intum/artemis:${{ steps.compute-tag.outputs.result }} push: true + cache-from: type=gha + cache-to: type=gha,mode=min # TODO: Push to Docker Hub (develop + tag) diff --git a/build.gradle b/build.gradle index 38ef660f1364..cf76b55cd46c 100644 --- a/build.gradle +++ b/build.gradle @@ -347,6 +347,7 @@ dependencies { implementation "commons-fileupload:commons-fileupload:1.5" implementation "net.lingala.zip4j:zip4j:2.11.5" implementation "org.jgrapht:jgrapht-core:1.5.2" + implementation 'com.github.seancfoley:ipaddress:5.4.0' annotationProcessor "org.hibernate:hibernate-jpamodelgen:${hibernate_version}" @@ -431,6 +432,11 @@ node { npmVersion = "${npm_version}" } +// Set the npm cache (used in the Dockerfile) +task npmSetCacheDockerfile(type: NpmTask) { + args = ['set', 'cache', '/opt/artemis/.npm'] +} + // Command to execute the JavaDoc checkstyle verification ./gradlew checkstyleMain checkstyle { toolVersion "${checkstyle_version}" diff --git a/docker/artemis/Dockerfile b/docker/artemis/Dockerfile index a437f1bc0cd7..055216235890 100644 --- a/docker/artemis/Dockerfile +++ b/docker/artemis/Dockerfile @@ -24,18 +24,44 @@ ARG WAR_FILE_STAGE="builder" #----------------------------------------------------------------------------------------------------------------------- FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:17-jdk as builder -WORKDIR /opt/artemis - # some Apple M1 (arm64) builds need python3 and build-essential(make+gcc) for node-gyp to not fail RUN echo "Installing build dependencies" \ && apt-get update && apt-get install -y --no-install-recommends python3 build-essential \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/artemis +# copy gradle related files +COPY gradlew gradlew.bat ./ +COPY build.gradle gradle.properties settings.gradle ./ +COPY gradle gradle/ +# copy npm related files and install node modules +# (from https://stackoverflow.com/questions/63961934/how-to-use-docker-build-cache-when-version-bumping-a-react-app) +COPY package.json package-lock.json ./ +RUN \ + # Mount global cache for Gradle (project cache in /opt/artemis/.gradle doesn't seem to be populated) + --mount=type=cache,target=/root/.gradle/caches \ + # Mount cache for npm + --mount=type=cache,target=/opt/artemis/.npm \ + # Create .npm directory if not yet available + mkdir -p /opt/artemis/.npm \ + # Set .npm directory as npm cache + && ./gradlew -i --stacktrace --no-daemon -Pprod -Pwar npmSetCacheDockerfile \ + # Pre-populate the npm and gradle caches if related files change (see COPY statements above) + && ./gradlew -i --stacktrace --no-daemon -Pprod -Pwar npm_ci + # so far just using the .dockerignore to define what isn't necessary here COPY . . -RUN ./gradlew -i --stacktrace --no-daemon -Pprod -Pwar clean bootWar +RUN \ + # Mount global cache for Gradle (project cache in /opt/artemis/.gradle doesn't seem to be populated) + --mount=type=cache,target=/root/.gradle/caches \ + # Mount cache for npm + --mount=type=cache,target=/opt/artemis/.npm \ + # Mount cache for the Angular CLI + --mount=type=cache,target=/opt/artemis/.cache \ + # Build the .war file + ./gradlew -i --stacktrace --no-daemon -Pprod -Pwar clean bootWar #----------------------------------------------------------------------------------------------------------------------- # external build stage @@ -73,7 +99,8 @@ RUN echo "Installing needed dependencies" \ && rm -rf /var/lib/apt/lists/* # See https://github.com/ls1intum/Artemis/issues/4439 -RUN echo "Fixing locales" \ +RUN \ + echo "Fixing locales" \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ && locale-gen ENV LC_ALL en_US.UTF-8 @@ -81,7 +108,8 @@ ENV LANG en_US.UTF-8 ENV LANGUAGE en_US.UTF-8 # Create directories for volumes, create artemis user and set right owners -RUN mkdir -p /opt/artemis/config /opt/artemis/data /opt/artemis/public/content \ +RUN \ + mkdir -p /opt/artemis/config /opt/artemis/data /opt/artemis/public/content \ && groupadd --gid ${GID} artemis \ && useradd -m --gid ${GID} --uid ${UID} --shell /bin/bash artemis \ && chown -R artemis:artemis /opt/artemis diff --git a/docs/dev/setup.rst b/docs/dev/setup.rst index 568762fe24df..61ef3cd621f8 100644 --- a/docs/dev/setup.rst +++ b/docs/dev/setup.rst @@ -931,7 +931,7 @@ Other useful commands ``docker compose start ``) - Restart a service: ``docker compose restart `` - Remove all local Docker containers: ``docker container rm $(docker ps -a -q)`` -- Remove all local Artemis Docker images: ``docker rmi $(docker images -q ghcr.io/ls1intum/artemis)`` +- Remove all local Artemis Docker images: ``docker rmi $(docker images --filter=reference="ghcr.io/ls1intum/artemis:*" -q)`` ------------------------------------------------------------------------------------------------------------------------ diff --git a/jest.config.js b/jest.config.js index 26ab84c96cca..83b872ba835c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,53 @@ -const esModules = ['lodash-es', 'franc-min', 'trigram-utils', 'n-gram', 'collapse-white-space', '@angular/animations', '@angular/common', '@ls1intum/apollon', - '@angular/compiler', '@angular/core', '@angular/forms', '@angular/localize', '@angular/platform-browser', '@angular/platform-browser-dynamic', '@angular/router', - '@ngx-translate/core', '@ngx-translate/http-loader', '@fortawesome/angular-fontawesome', '@angular/cdk', '@angular/material', '@angular/cdk', 'dayjs/esm', - 'rxjs/operators', '@ng-bootstrap/ng-bootstrap', 'ngx-webstorage', '@ctrl/ngx-emoji-mart', 'ngx-device-detector', '@swimlane/ngx-charts', - '@angular/service-worker', '@danielmoncada/angular-datetime-picker', '@flaviosantoro92/ngx-datatable', 'd3-color', 'd3-interpolate', 'd3-transition', 'd3-brush', - 'd3-drag', 'd3-selection', 'd3-scale', 'd3-array', 'd3-format', 'd3-shape', 'd3-path', 'd3-ease', 'd3-time', 'd3-hierarchy', 'ngx-infinite-scroll', 'internmap', - '@swimlane/ngx-graph'].join('|'); +const esModules = [ + 'lodash-es', + 'franc-min', + 'trigram-utils', + 'n-gram', + 'collapse-white-space', + '@angular/animations', + '@angular/common', + '@ls1intum/apollon', + '@angular/compiler', + '@angular/core', + '@angular/forms', + '@angular/localize', + '@angular/platform-browser', + '@angular/platform-browser-dynamic', + '@angular/router', + '@ngx-translate/core', + '@ngx-translate/http-loader', + '@fortawesome/angular-fontawesome', + '@angular/cdk', + '@angular/material', + '@angular/cdk', + 'dayjs/esm', + 'rxjs/operators', + '@ng-bootstrap/ng-bootstrap', + 'ngx-webstorage', + '@ctrl/ngx-emoji-mart', + 'ngx-device-detector', + '@swimlane/ngx-charts', + '@angular/service-worker', + '@danielmoncada/angular-datetime-picker', + '@flaviosantoro92/ngx-datatable', + 'd3-color', + 'd3-interpolate', + 'd3-transition', + 'd3-brush', + 'd3-drag', + 'd3-selection', + 'd3-scale', + 'd3-array', + 'd3-format', + 'd3-shape', + 'd3-path', + 'd3-ease', + 'd3-time', + 'd3-hierarchy', + 'ngx-infinite-scroll', + 'internmap', + '@swimlane/ngx-graph', +].join('|'); const { compilerOptions: { baseUrl = './' }, @@ -12,25 +55,24 @@ const { module.exports = { globalSetup: 'jest-preset-angular/global-setup', - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.html$', - isolatedModules: true, - diagnostics: { - ignoreCodes: [151001], - }, - }, - }, testEnvironmentOptions: { - url: 'https://artemis.fake/test' + url: 'https://artemis.fake/test', }, roots: ['', `/${baseUrl}`], modulePaths: [`/${baseUrl}`], setupFiles: ['jest-date-mock'], cacheDirectory: '/build/jest-cache', coverageDirectory: '/build/test-results/', - reporters: ['default', ['jest-junit', { outputDirectory: '/build/test-results/', outputName: 'TESTS-results-jest.xml' }]], + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: '/build/test-results/', + outputName: 'TESTS-results-jest.xml', + }, + ], + ], collectCoverageFrom: ['src/main/webapp/**/*.{js,jsx,ts,tsx}', '!src/main/webapp/**/*.module.{js,jsx,ts,tsx}'], coveragePathIgnorePatterns: [ '/node_modules/', @@ -53,24 +95,34 @@ module.exports = { 'src/main/webapp/app/exercises/modeling/manage/modeling-exercise.route.ts', 'src/main/webapp/app/exam/manage/exam-management.route.ts', 'src/main/webapp/app/exercises/shared/exercise-hint/manage/exercise-hint.route.ts', - 'src/main/webapp/app/core/config/prod.config.ts' + 'src/main/webapp/app/core/config/prod.config.ts', ], coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 85.6, - branches: 72.8, - functions: 79.4, - lines: 85.8, + statements: 85.9, + branches: 73.2, + functions: 79.7, + lines: 86.1, }, }, - coverageReporters: ["clover", "json", "lcov", "text-summary"], + coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], setupFilesAfterEnv: ['/src/test/javascript/spec/jest-test-setup.ts', 'jest-extended/all'], moduleFileExtensions: ['ts', 'html', 'js', 'json', 'mjs'], resolver: '/jest.resolver.js', transformIgnorePatterns: [`/node_modules/(?!${esModules})`], transform: { - '^.+\\.(ts|js|mjs|html|svg)$': 'jest-preset-angular', + '^.+\\.(ts|js|mjs|html|svg)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.html$', + isolatedModules: true, + diagnostics: { + ignoreCodes: [151001], + }, + }, + ], }, modulePathIgnorePatterns: [], testTimeout: 3000, @@ -84,7 +136,7 @@ module.exports = { '/src/test/javascript/spec/util/**/*.spec.ts', '/src/test/javascript/spec/interceptor/**/*.spec.ts', '/src/test/javascript/spec/config/**/*.spec.ts', - '/src/test/javascript/spec/core/**/*.spec.ts' + '/src/test/javascript/spec/core/**/*.spec.ts', ], moduleNameMapper: { '^app/(.*)': '/src/main/webapp/app/$1', @@ -94,6 +146,6 @@ module.exports = { '@env': '/src/main/webapp/environments/environment', '@src/(.*)': '/src/src/$1', '@state/(.*)': '/src/app/state/$1', - "^lodash-es$": "lodash" + '^lodash-es$': 'lodash', }, }; diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java index cd23985ea56f..d473fcf14b4f 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java @@ -4,7 +4,7 @@ /** * A set of related exam sessions that are suspicious. - * An exam session is suspicious if it shares the same browser fingerprint hash or user agent or IP address with another + * An exam session is suspicious if it fulfills one of the criteria defined in {@link SuspiciousSessionReason}. * exam session that attempts a different student exam. * * @param examSessions the set of exam sessions that are suspicious diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java index be87111202ce..2ebe5c9449ab 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java @@ -4,5 +4,6 @@ * Enum representing reasons why a session is considered suspicious. */ public enum SuspiciousSessionReason { - SAME_IP_ADDRESS, SAME_BROWSER_FINGERPRINT + DIFFERENT_STUDENT_EXAMS_SAME_IP_ADDRESS, DIFFERENT_STUDENT_EXAMS_SAME_BROWSER_FINGERPRINT, SAME_STUDENT_EXAM_DIFFERENT_IP_ADDRESSES, + SAME_STUDENT_EXAM_DIFFERENT_BROWSER_FINGERPRINTS, IP_ADDRESS_OUTSIDE_OF_RANGE } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionsAnalysisOptions.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionsAnalysisOptions.java new file mode 100644 index 000000000000..8ab8a718d940 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionsAnalysisOptions.java @@ -0,0 +1,15 @@ +package de.tum.in.www1.artemis.domain.exam; + +/** + * Options for the analysis of suspicious sessions. + * The options define which criteria are used to determine whether a session is suspicious. + * + * @param sameBrowserFingerprintDifferentStudentExams whether sessions should be analyzed for the same browser fingerprint but different student exams + * @param sameIpAddressDifferentStudentExams whether sessions should be analyzed for the same IP address but different student exams + * @param differentIpAddressesSameStudentExam whether sessions should be analyzed for different IP addresses but the same student exam + * @param differentBrowserFingerprintsSameStudentExam whether sessions should be analyzed for different browser fingerprints but the same student exam + * @param ipAddressOutsideOfRange whether sessions should be analyzed for IP addresses outside the specified IP range + */ +public record SuspiciousSessionsAnalysisOptions(boolean sameIpAddressDifferentStudentExams, boolean sameBrowserFingerprintDifferentStudentExams, + boolean differentIpAddressesSameStudentExam, boolean differentBrowserFingerprintsSameStudentExam, boolean ipAddressOutsideOfRange) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/iris/settings/IrisSubSettings.java b/src/main/java/de/tum/in/www1/artemis/domain/iris/settings/IrisSubSettings.java index 1d603967c8e3..d3ad1282b64a 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/iris/settings/IrisSubSettings.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/iris/settings/IrisSubSettings.java @@ -32,6 +32,14 @@ public class IrisSubSettings extends DomainObject { @Column(name = "preferredModel") private String preferredModel; + @Nullable + @Column(name = "rateLimit") + private Integer rateLimit; + + @Nullable + @Column(name = "rateLimitTimeframeHours") + private Integer rateLimitTimeframeHours; + public boolean isEnabled() { return enabled; } @@ -57,4 +65,22 @@ public String getPreferredModel() { public void setPreferredModel(@Nullable String preferredModel) { this.preferredModel = preferredModel; } + + @Nullable + public Integer getRateLimit() { + return rateLimit; + } + + public void setRateLimit(@Nullable Integer rateLimit) { + this.rateLimit = rateLimit; + } + + @Nullable + public Integer getRateLimitTimeframeHours() { + return rateLimitTimeframeHours; + } + + public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours) { + this.rateLimitTimeframeHours = rateLimitTimeframeHours; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ParticipationRepository.java index 797c71e83bca..ae1c0a02b16d 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ParticipationRepository.java @@ -18,18 +18,20 @@ public interface ParticipationRepository extends JpaRepository { @Query(""" - SELECT DISTINCT p FROM Participation p - LEFT JOIN FETCH p.results - LEFT JOIN FETCH p.submissions s - LEFT JOIN FETCH s.results + SELECT DISTINCT p + FROM Participation p + LEFT JOIN FETCH p.results + LEFT JOIN FETCH p.submissions s + LEFT JOIN FETCH s.results WHERE p.id = :#{#participationId} """) Optional findByIdWithResultsAndSubmissionsResults(@Param("participationId") Long participationId); @Query(""" - SELECT p FROM Participation p - LEFT JOIN FETCH p.submissions s - LEFT JOIN FETCH s.results r + SELECT p + FROM Participation p + LEFT JOIN FETCH p.submissions s + LEFT JOIN FETCH s.results r WHERE p.id = :participationId AND (s.id = (SELECT max(s2.id) FROM p.submissions s2) OR s.id = NULL) """) @@ -49,9 +51,10 @@ default Participation findByIdWithLatestSubmissionElseThrow(Long participationId } @Query(""" - SELECT p FROM Participation p - LEFT JOIN FETCH p.submissions s - WHERE p.id = :#{#participationId} + SELECT p + FROM Participation p + LEFT JOIN FETCH p.submissions s + WHERE p.id = :participationId AND (s.type <> 'ILLEGAL' OR s.type IS NULL) """) Optional findWithEagerLegalSubmissionsById(@Param("participationId") Long participationId); @@ -61,6 +64,19 @@ default Participation findByIdWithLegalSubmissionsElseThrow(long participationId return findWithEagerLegalSubmissionsById(participationId).orElseThrow(() -> new EntityNotFoundException("Participation", participationId)); } + @Query(""" + SELECT p + FROM Participation p + LEFT JOIN FETCH p.submissions s + WHERE p.id = :participationId + """) + Optional findWithEagerSubmissionsById(@Param("participationId") Long participationId); + + @NotNull + default Participation findByIdWithSubmissionsElseThrow(long participationId) { + return findWithEagerSubmissionsById(participationId).orElseThrow(() -> new EntityNotFoundException("Participation", participationId)); + } + @NotNull default Participation findByIdElseThrow(long participationId) { return findById(participationId).orElseThrow(() -> new EntityNotFoundException("Participation", participationId)); @@ -69,7 +85,7 @@ default Participation findByIdElseThrow(long participationId) { @Query(""" SELECT max(p.individualDueDate) FROM Participation p - WHERE p.exercise.id = :#{#exerciseId} + WHERE p.exercise.id = :exerciseId AND p.individualDueDate IS NOT null """) Optional findLatestIndividualDueDate(@Param("exerciseId") Long exerciseId); @@ -77,7 +93,7 @@ SELECT max(p.individualDueDate) @Query(""" SELECT min(p.individualDueDate) FROM Participation p - WHERE p.exercise.id = :#{#exerciseId} + WHERE p.exercise.id = :exerciseId AND p.individualDueDate IS NOT null """) Optional findEarliestIndividualDueDate(@Param("exerciseId") Long exerciseId); diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index b28f79788423..4cdf3ef5505d 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -320,11 +320,11 @@ default Page searchAllUsersByLoginOrNameInGroupAndConvertToDTO(Pageable */ @EntityGraph(type = LOAD, attributePaths = { "groups", "authorities" }) @Query(""" - SELECT user + SELECT DISTINCT user FROM User user WHERE user.isDeleted = false AND user.login IN :#{#logins} """) - List findAllByLogins(@Param("logins") Set logins); + Set findAllByLogins(@Param("logins") Set logins); /** * Searches for users by their login or full name. diff --git a/src/main/java/de/tum/in/www1/artemis/security/OAuth2JWKSService.java b/src/main/java/de/tum/in/www1/artemis/security/OAuth2JWKSService.java index aa3145aa9c59..06ee441cd64a 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/OAuth2JWKSService.java +++ b/src/main/java/de/tum/in/www1/artemis/security/OAuth2JWKSService.java @@ -9,6 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; import org.springframework.security.crypto.keygen.StringKeyGenerator; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -27,6 +28,7 @@ * On initialisation, each ClientRegistration gets assigned a fresh generated RSAKey. */ @Component +@Profile("lti") public class OAuth2JWKSService { private final OnlineCourseConfigurationService onlineCourseConfigurationService; @@ -83,7 +85,7 @@ public JWKSet getJwkSet() { private List generateOAuth2ClientKeys() { List keys = new LinkedList<>(); - onlineCourseConfigurationService.getAllClientRegistrations().forEach(c -> generateAndAddKey(c, keys)); + onlineCourseConfigurationService.getAllClientRegistrations().forEach(clientRegistration -> generateAndAddKey(clientRegistration, keys)); return keys; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/OnlineCourseConfigurationService.java b/src/main/java/de/tum/in/www1/artemis/service/OnlineCourseConfigurationService.java index 9a4f476f71bb..7950fd919db7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/OnlineCourseConfigurationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/OnlineCourseConfigurationService.java @@ -12,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -27,6 +28,7 @@ * Service Implementation for OnlineCourseConfiguration. */ @Service +@Profile("lti") public class OnlineCourseConfigurationService implements ClientRegistrationRepository { private final Logger log = LoggerFactory.getLogger(OnlineCourseConfigurationService.class); @@ -112,8 +114,8 @@ public ClientRegistration getClientRegistration(OnlineCourseConfiguration online catch (IllegalArgumentException e) { // Log a warning for rare scenarios i.e. ClientId is empty. This can occur when online courses lack an external LMS connection or use LTI v1.0. log.warn("Could not build Client Registration from onlineCourseConfiguration for course with ID: {} and title: {}. Reason: {}", - Optional.ofNullable(onlineCourseConfiguration).map(OnlineCourseConfiguration::getCourse).map(Course::getId).orElse(null), - Optional.ofNullable(onlineCourseConfiguration).map(OnlineCourseConfiguration::getCourse).map(Course::getTitle).orElse(""), e.getMessage()); + Optional.of(onlineCourseConfiguration).map(OnlineCourseConfiguration::getCourse).map(Course::getId).orElse(null), + Optional.of(onlineCourseConfiguration).map(OnlineCourseConfiguration::getCourse).map(Course::getTitle).orElse(""), e.getMessage()); return null; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java index a2a160e4e2aa..dec7453e8f85 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java @@ -43,8 +43,8 @@ /** * This service contains the logic to execute a build job for a programming exercise participation in the local CI system. - * The {@link #runBuildJob(ProgrammingExerciseParticipation, String, String)} method is wrapped into a Callable by the {@link LocalCIBuildJobManagementService} and submitted to the - * executor service. + * The {@link #runBuildJob(ProgrammingExerciseParticipation, String, String, String)} method is wrapped into a Callable by the {@link LocalCIBuildJobManagementService} and + * submitted to the executor service. */ @Service @Profile("localci") @@ -68,8 +68,8 @@ public class LocalCIBuildJobExecutionService { @Value("${artemis.version-control.url}") private URL localVCBaseUrl; - @Value("${artemis.version-control.local-vcs-repo-path}") - private String localVCBasePath; + @Value("${artemis.repo-clone-path}") + private String repoClonePath; public LocalCIBuildJobExecutionService(LocalCIBuildPlanService localCIBuildPlanService, Optional versionControlService, LocalCIContainerService localCIContainerService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, XMLInputFactory localCIXMLInputFactory) { @@ -104,10 +104,11 @@ public String toString() { * @param commitHash The commit hash of the commit that should be built. If it is null, the latest commit of the default branch will be built. * @param containerName The name of the Docker container that will be used to run the build job. * It needs to be prepared beforehand to stop and remove the container if something goes wrong here. + * @param dockerImage The Docker image that will be used to run the build job. * @return The build result. * @throws LocalCIException If some error occurs while preparing or running the build job. */ - public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participation, String commitHash, String containerName) { + public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participation, String commitHash, String containerName, String dockerImage) { // Update the build plan status to "BUILDING". localCIBuildPlanService.updateBuildPlanStatus(participation, ContinuousIntegrationService.BuildStatus.BUILDING); @@ -144,7 +145,7 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa for (int i = 0; i < auxiliaryRepositories.size(); i++) { auxiliaryRepositoriesUrls[i] = new LocalVCRepositoryUrl(auxiliaryRepositories.get(i).getRepositoryUrl(), localVCBaseUrl); - auxiliaryRepositoriesPaths[i] = auxiliaryRepositoriesUrls[i].getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); + auxiliaryRepositoriesPaths[i] = auxiliaryRepositoriesUrls[i].getRepoClonePath(repoClonePath).toAbsolutePath(); auxiliaryRepositoryNames[i] = auxiliaryRepositories.get(i).getName(); } } @@ -157,8 +158,8 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa throw new LocalCIException("Error while creating LocalVCRepositoryUrl", e); } - Path assignmentRepositoryPath = assignmentRepositoryUrl.getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); - Path testsRepositoryPath = testsRepositoryUrl.getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); + Path assignmentRepositoryPath = assignmentRepositoryUrl.getRepoClonePath(repoClonePath).toAbsolutePath(); + Path testsRepositoryPath = testsRepositoryUrl.getRepoClonePath(repoClonePath).toAbsolutePath(); String branch; try { @@ -171,7 +172,7 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa // Create the container from the "ls1tum/artemis-maven-template" image with the local paths to the Git repositories and the shell script bound to it. Also give the // container information about the branch and commit hash to be used. // This does not start the container yet. - CreateContainerResponse container = localCIContainerService.configureContainer(containerName, branch, commitHash); + CreateContainerResponse container = localCIContainerService.configureContainer(containerName, branch, commitHash, dockerImage); return runScriptAndParseResults(participation, containerName, container.getId(), branch, commitHash, assignmentRepositoryPath, testsRepositoryPath, auxiliaryRepositoriesPaths, auxiliaryRepositoryNames, buildScriptPath); @@ -227,7 +228,7 @@ private LocalCIBuildResult runScriptAndParseResults(ProgrammingExerciseParticipa return constructFailedBuildResult(branch, assignmentRepoCommitHash, testRepoCommitHash, buildCompletedDate); } - List testResultsPaths = getTestResultPath(participation.getProgrammingExercise()); + List testResultsPaths = getTestResultPaths(participation.getProgrammingExercise()); // Get an input stream of the test result files. List testResultsTarInputStreams = new ArrayList<>(); @@ -269,34 +270,47 @@ private LocalCIBuildResult runScriptAndParseResults(ProgrammingExerciseParticipa // --- Helper methods ---- - private List getTestResultPath(ProgrammingExercise programmingExercise) { - List testResultPaths = new ArrayList<>(); + private List getTestResultPaths(ProgrammingExercise programmingExercise) { switch (programmingExercise.getProgrammingLanguage()) { case JAVA, KOTLIN -> { - if (ProjectType.isMavenProject(programmingExercise.getProjectType())) { - if (programmingExercise.hasSequentialTestRuns()) { - testResultPaths.add("/repositories/test-repository/structural/target/surefire-reports"); - testResultPaths.add("/repositories/test-repository/behavior/target/surefire-reports"); - } - else { - testResultPaths.add("/repositories/test-repository/target/surefire-reports"); - } - } - else { - if (programmingExercise.hasSequentialTestRuns()) { - testResultPaths.add("/repositories/test-repository/build/test-results/behaviorTests"); - testResultPaths.add("/repositories/test-repository/build/test-results/structuralTests"); - } - else { - testResultPaths.add("/repositories/test-repository/build/test-results/test"); - } - } - return testResultPaths; + return getJavaKotlinTestResultPaths(programmingExercise); + } + case PYTHON -> { + return getPythonTestResultPaths(); } default -> throw new IllegalArgumentException("Programming language " + programmingExercise.getProgrammingLanguage() + " is not supported"); } } + private List getJavaKotlinTestResultPaths(ProgrammingExercise programmingExercise) { + List testResultPaths = new ArrayList<>(); + if (ProjectType.isMavenProject(programmingExercise.getProjectType())) { + if (programmingExercise.hasSequentialTestRuns()) { + testResultPaths.add("/repositories/test-repository/structural/target/surefire-reports"); + testResultPaths.add("/repositories/test-repository/behavior/target/surefire-reports"); + } + else { + testResultPaths.add("/repositories/test-repository/target/surefire-reports"); + } + } + else { + if (programmingExercise.hasSequentialTestRuns()) { + testResultPaths.add("/repositories/test-repository/build/test-results/behaviorTests"); + testResultPaths.add("/repositories/test-repository/build/test-results/structuralTests"); + } + else { + testResultPaths.add("/repositories/test-repository/build/test-results/test"); + } + } + return testResultPaths; + } + + private List getPythonTestResultPaths() { + List testResultPaths = new ArrayList<>(); + testResultPaths.add("/repositories/test-repository/test-reports"); + return testResultPaths; + } + private LocalCIBuildResult parseTestResults(List testResultsTarInputStreams, String assignmentRepoBranchName, String assignmentRepoCommitHash, String testsRepoCommitHash, ZonedDateTime buildCompletedDate) throws IOException, XMLStreamException { @@ -330,7 +344,8 @@ private boolean isValidTestResultFile(TarArchiveEntry tarArchiveEntry) { int lastIndexOfSlash = name.lastIndexOf('/'); String result = (lastIndexOfSlash != -1 && lastIndexOfSlash + 1 < name.length()) ? name.substring(lastIndexOfSlash + 1) : name; - return !tarArchiveEntry.isDirectory() && result.endsWith(".xml") && result.startsWith("TEST-"); + // Java test result files are named "TEST-*.xml", Python test result files are named "*results.xml". + return !tarArchiveEntry.isDirectory() && ((result.endsWith(".xml") && result.startsWith("TEST-")) || result.endsWith("results.xml")); } private String readTarEntryContent(TarArchiveInputStream tarArchiveInputStream) throws IOException { @@ -356,6 +371,10 @@ private void processTestResultFile(String testResultFileString, List addBuildJobToQueue(ProgrammingExerc ProgrammingExercise programmingExercise = participation.getProgrammingExercise(); - List supportedProjectTypes = localCIProgrammingLanguageFeatureService.getProgrammingLanguageFeatures(programmingExercise.getProgrammingLanguage()) - .projectTypes(); + ProgrammingLanguage programmingLanguage = programmingExercise.getProgrammingLanguage(); + + ProjectType projectType = programmingExercise.getProjectType(); + + String dockerImage = programmingLanguageConfiguration.getImage(programmingLanguage, Optional.ofNullable(projectType)); - var projectType = programmingExercise.getProjectType(); + List supportedProjectTypes = localCIProgrammingLanguageFeatureService.getProgrammingLanguageFeatures(programmingLanguage).projectTypes(); - if (projectType == null || !supportedProjectTypes.contains(programmingExercise.getProjectType())) { + if (projectType != null && !supportedProjectTypes.contains(programmingExercise.getProjectType())) { throw new LocalCIException("The project type " + programmingExercise.getProjectType() + " is not supported by the local CI."); } @@ -95,7 +102,7 @@ public CompletableFuture addBuildJobToQueue(ProgrammingExerc String containerName = "artemis-local-ci-" + participation.getId() + "-" + ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); // Prepare a Callable that will later be called. It contains the actual steps needed to execute the build job. - Callable buildJob = () -> localCIBuildJobExecutionService.runBuildJob(participation, commitHash, containerName); + Callable buildJob = () -> localCIBuildJobExecutionService.runBuildJob(participation, commitHash, containerName, dockerImage); // Wrap the buildJob Callable in a BuildJobTimeoutCallable, so that the build job is cancelled if it takes too long. BuildJobTimeoutCallable timedBuildJob = new BuildJobTimeoutCallable<>(buildJob, timeoutSeconds); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java index 0732e58357ec..e83d5387126d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java @@ -66,10 +66,11 @@ public LocalCIContainerService(DockerClient dockerClient) { * @param containerName the name of the container to be created * @param branch the branch to checkout * @param commitHash the commit hash to checkout. If it is null, the latest commit of the branch will be checked out. + * @param image the Docker image to use for the container * @return {@link CreateContainerResponse} that can be used to start the container */ - public CreateContainerResponse configureContainer(String containerName, String branch, String commitHash) { - return dockerClient.createContainerCmd(dockerImage).withName(containerName).withHostConfig(HostConfig.newHostConfig().withAutoRemove(true)) + public CreateContainerResponse configureContainer(String containerName, String branch, String commitHash, String image) { + return dockerClient.createContainerCmd(image).withName(containerName).withHostConfig(HostConfig.newHostConfig().withAutoRemove(true)) .withEnv("ARTEMIS_BUILD_TOOL=gradle", "ARTEMIS_DEFAULT_BRANCH=" + branch, "ARTEMIS_ASSIGNMENT_REPOSITORY_COMMIT_HASH=" + (commitHash != null ? commitHash : "")) // Command to run when the container starts. This is the command that will be executed in the container's main process, which runs in the foreground and blocks the // container from exiting until it finishes. @@ -307,45 +308,64 @@ public Path createBuildScript(ProgrammingExercise programmingExercise, List scriptForJavaKotlin(programmingExercise, buildScript, hasSequentialTestRuns); + case PYTHON -> scriptForPython(buildScript); default -> throw new IllegalArgumentException("No build stage setup for programming language " + programmingExercise.getProgrammingLanguage()); } @@ -359,6 +379,23 @@ public Path createBuildScript(ProgrammingExercise programmingExercise, List auxiliaryRepositories) { + StringBuilder buildScript = new StringBuilder(); + for (AuxiliaryRepository auxiliaryRepository : auxiliaryRepositories) { + buildScript.append(" git clone --depth 1 --branch $ARTEMIS_DEFAULT_BRANCH file:///").append(auxiliaryRepository.getName()).append("-repository\n"); + } + return buildScript; + } + + private StringBuilder copyAuxiliaryRepositories(List auxiliaryRepositories, String source) { + StringBuilder buildScript = new StringBuilder(); + for (AuxiliaryRepository auxiliaryRepository : auxiliaryRepositories) { + buildScript.append(" cp -a ").append(source).append(auxiliaryRepository.getName()).append("-repository/. /repositories/test-repository/") + .append(auxiliaryRepository.getCheckoutDirectory()).append("/\n"); + } + return buildScript; + } + private void scriptForJavaKotlin(ProgrammingExercise programmingExercise, StringBuilder buildScript, boolean hasSequentialTestRuns) { boolean isMaven = ProjectType.isMavenProject(programmingExercise.getProjectType()); @@ -398,6 +435,18 @@ private void scriptForJavaKotlin(ProgrammingExercise programmingExercise, String } } + private void scriptForPython(StringBuilder buildScript) { + buildScript.append(""" + python3 -m compileall . -q || error=true + if [ ! $error ] + then + pytest --junitxml=test-reports/results.xml + else + exit 1 + fi + """); + } + /** * Deletes the build script for a given programming exercise. * The build script is stored in a file in the local-ci-scripts directory. diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java index 3be1c6fca9c8..30603c6ff307 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java @@ -23,7 +23,7 @@ public LocalCIProgrammingLanguageFeatureService() { // TODO LOCALVC_CI: Local CI is not supporting EMPTY at the moment. programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, false, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN), false, false, true)); - // TODO LOCALVC_CI: Local CI is not supporting Python at the moment. + programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, false, true)); // TODO LOCALVC_CI: Local CI is not supporting C at the moment. // TODO LOCALVC_CI: Local CI is not supporting Haskell at the moment. // TODO LOCALVC_CI: Local CI is not supporting Kotlin at the moment. diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java index e8306d789aa7..ee4f0e261052 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java @@ -5,12 +5,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import de.tum.in.www1.artemis.config.ProgrammingLanguageConfiguration; import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.VcsRepositoryUrl; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; @@ -38,20 +38,22 @@ public class LocalCIService extends AbstractContinuousIntegrationService { private final LocalCIDockerService localCIDockerService; - @Value("${artemis.continuous-integration.build.images.java.default}") - String dockerImage; + private final ProgrammingLanguageConfiguration programmingLanguageConfiguration; public LocalCIService(ProgrammingSubmissionRepository programmingSubmissionRepository, FeedbackRepository feedbackRepository, BuildLogEntryService buildLogService, - BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, TestwiseCoverageService testwiseCoverageService, LocalCIDockerService localCIDockerService) { + BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, TestwiseCoverageService testwiseCoverageService, LocalCIDockerService localCIDockerService, + ProgrammingLanguageConfiguration programmingLanguageConfiguration) { super(programmingSubmissionRepository, feedbackRepository, buildLogService, buildLogStatisticsEntryRepository, testwiseCoverageService); this.localCIDockerService = localCIDockerService; + this.programmingLanguageConfiguration = programmingLanguageConfiguration; } @Override public void createBuildPlanForExercise(ProgrammingExercise programmingExercise, String planKey, VcsRepositoryUrl sourceCodeRepositoryURL, VcsRepositoryUrl testRepositoryURL, VcsRepositoryUrl solutionRepositoryURL) { // Only check whether the docker image needed for the build plan exists. - localCIDockerService.pullDockerImage(dockerImage); + localCIDockerService.pullDockerImage( + programmingLanguageConfiguration.getImage(programmingExercise.getProgrammingLanguage(), Optional.ofNullable(programmingExercise.getProjectType()))); } @Override diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java index d9a3067f46c6..68cacd5648a0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java @@ -151,4 +151,14 @@ public boolean isPracticeRepository() { public Path getLocalRepositoryPath(String localVCBasePath) { return Paths.get(localVCBasePath, projectKey, repositorySlug + ".git"); } + + /** + * Get the path to the cloned repository + * + * @param baseRepoClonePath the base path of the cloned repositories + * @return the path to the cloned repository + */ + public Path getRepoClonePath(String baseRepoClonePath) { + return Paths.get(baseRepoClonePath, "git", projectKey, repositorySlug); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java index 76b184ee0b02..b548978b4a1f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java @@ -8,6 +8,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.web.util.matcher.IpAddressMatcher; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.exam.*; @@ -15,6 +16,7 @@ import de.tum.in.www1.artemis.repository.StudentExamRepository; import de.tum.in.www1.artemis.web.rest.dto.*; import inet.ipaddr.IPAddress; +import inet.ipaddr.IPAddressString; /** * Service Implementation for managing ExamSession. @@ -80,45 +82,223 @@ public boolean checkExamSessionIsInitial(Long studentExamId) { /** * Retrieves all suspicious exam sessions for given exam id - * An exam session is suspicious if it has the same browser fingerprint or ip address and belongs to a different student exam + * For a detailed description of the criteria, see {@link SuspiciousSessionsAnalysisOptions} * - * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @param analysisOptions options for the analysis of suspicious sessions + * @param ipSubnet subnet for the analysis of suspicious sessions * @return set of suspicious exam sessions */ - public Set retrieveAllSuspiciousExamSessionsByExamId(long examId) { + public Set retrieveAllSuspiciousExamSessionsByExamId(long examId, SuspiciousSessionsAnalysisOptions analysisOptions, Optional ipSubnet) { Set suspiciousExamSessions = new HashSet<>(); Set examSessions = examSessionRepository.findAllExamSessionsByExamId(examId); - examSessions = filterEqualExamSessionsForSameStudentExam(examSessions); - // first step find all sessions that have matching browser fingerprint and ip address - findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameIpAddressAndBrowserFingerprintByExamIdAndExamSession, - suspiciousExamSessions); - // second step find all sessions that have only matching browser fingerprint - findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameBrowserFingerprintByExamIdAndExamSession, - suspiciousExamSessions); - // third step find all sessions that have only matching ip address - findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameIpAddressByExamIdAndExamSession, suspiciousExamSessions); - + Set filteredSessions = filterEqualExamSessionsForSameStudentExam(examSessions); + Set studentExams = new HashSet<>(); + boolean studentExamsFetched = false; // flag to avoid fetching student exams twice + analyzeSessionsOfDifferentStudentExams(examId, analysisOptions, suspiciousExamSessions, filteredSessions); + analyzeSessionsOfTheSameStudentExam(examId, analysisOptions, suspiciousExamSessions, studentExams, studentExamsFetched); + analyzeIpAddressesOutsideOfRange(analysisOptions, ipSubnet, suspiciousExamSessions, examSessions); return convertSuspiciousSessionsToDTO(suspiciousExamSessions); } /** - * Finds suspicious exam sessions according to the criteria given and adds them to the set of suspicious exam sessions + * Finds all exam sessions that have a IP address outside a given subnet + * + * @param analysisOptions options for the analysis of suspicious sessions + * @param ipSubnet subnet for the analysis of suspicious sessions + * @param suspiciousExamSessions set of suspicious exam sessions + * @param examSessions set of exam sessions to analyze + */ + private void analyzeIpAddressesOutsideOfRange(SuspiciousSessionsAnalysisOptions analysisOptions, Optional ipSubnet, Set suspiciousExamSessions, + Set examSessions) { + Set filteredSessions; + if (analysisOptions.ipAddressOutsideOfRange()) { + // seventh step find all sessions that have ip address outside of range + filteredSessions = filterEqualExamSessionsForSameStudentExam(examSessions); + findSessionsWithIPAddressOutsideOfRange(filteredSessions, ipSubnet.orElseThrow(), suspiciousExamSessions); + } + } + + /** + * Finds all suspicious exam sessions that belong to the same student exam + * + * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @param analysisOptions options for the analysis of suspicious sessions + * @param suspiciousExamSessions set of suspicious exam sessions + * @param studentExams set of student exams + * @param studentExamsFetched flag to avoid fetching student exams twice + */ + private void analyzeSessionsOfTheSameStudentExam(long examId, SuspiciousSessionsAnalysisOptions analysisOptions, Set suspiciousExamSessions, + Set studentExams, boolean studentExamsFetched) { + if (analysisOptions.differentIpAddressesSameStudentExam() && analysisOptions.differentBrowserFingerprintsSameStudentExam()) { + studentExams = studentExamRepository.findByExamIdWithSessions(examId); + studentExamsFetched = true; + // fourth step find all sessions that belong to the same student exam but have different browser fingerprints and ip addresses + analyzeSessionOfStudentExamsForGivenCriteria(studentExams, true, true, suspiciousExamSessions); + } + if (analysisOptions.differentBrowserFingerprintsSameStudentExam()) { + if (!studentExamsFetched) { + studentExams = studentExamRepository.findByExamIdWithSessions(examId); + studentExamsFetched = true; + } + // fifth step find all sessions that belong to the same student exam but have different browser fingerprints + analyzeSessionOfStudentExamsForGivenCriteria(studentExams, false, true, suspiciousExamSessions); + } + if (analysisOptions.differentIpAddressesSameStudentExam()) { + if (!studentExamsFetched) { + studentExams = studentExamRepository.findByExamIdWithSessions(examId); + } + // sixth step find all sessions that belong to the same student exam but have different ip addresses + analyzeSessionOfStudentExamsForGivenCriteria(studentExams, true, false, suspiciousExamSessions); + } + } + + /** + * Finds all suspicious exam sessions that belong to different student exams * - * @param examSessions set of exam sessions to be processed * @param examId id of the exam for which suspicious exam sessions shall be retrieved - * @param criteriaFilter function that returns a set of exam sessions that match the given criteria - * @param suspiciousExamSessions set of suspicious exam sessions to which the found suspicious exam sessions shall be added + * @param analysisOptions options for the analysis of suspicious sessions + * @param suspiciousExamSessions set of suspicious exam sessions + * @param filteredSessions set of exam sessions to analyze + */ + private void analyzeSessionsOfDifferentStudentExams(long examId, SuspiciousSessionsAnalysisOptions analysisOptions, Set suspiciousExamSessions, + Set filteredSessions) { + if (analysisOptions.sameIpAddressDifferentStudentExams() && analysisOptions.sameBrowserFingerprintDifferentStudentExams()) { + // first step find all sessions that have matching browser fingerprint and ip address + findSuspiciousSessionsForGivenCriteria(filteredSessions, examId, + examSessionRepository::findAllExamSessionsWithTheSameIpAddressAndBrowserFingerprintByExamIdAndExamSession, suspiciousExamSessions, true, true); + } + if (analysisOptions.sameBrowserFingerprintDifferentStudentExams()) { + // second step find all sessions that have only matching browser fingerprint + findSuspiciousSessionsForGivenCriteria(filteredSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameBrowserFingerprintByExamIdAndExamSession, + suspiciousExamSessions, false, true); + } + if (analysisOptions.sameIpAddressDifferentStudentExams()) { + // third step find all sessions that have only matching ip address + findSuspiciousSessionsForGivenCriteria(filteredSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameIpAddressByExamIdAndExamSession, + suspiciousExamSessions, true, false); + } + } + + /** + * Finds all suspicious exam sessions that belong to the same student exam + * + * @param studentExams set of student exams + * @param analyzeDifferentIp true if the ip addresses shall be compared, otherwise false + * @param analyzeDifferentFingerprint true if the browser fingerprints shall be compared, otherwise false + * @param suspiciousExamSessions set of suspicious exam sessions */ - private void findSuspiciousSessionsForGivenCriteria(Set examSessions, long examId, BiFunction> criteriaFilter, + private static void analyzeSessionOfStudentExamsForGivenCriteria(Set studentExams, boolean analyzeDifferentIp, boolean analyzeDifferentFingerprint, Set suspiciousExamSessions) { + for (var studentExam : studentExams) { + analyzeSessionsOfStudentExam(analyzeDifferentIp, analyzeDifferentFingerprint, suspiciousExamSessions, studentExam); + } + } + /** + * Finds all suspicious exam sessions that belong to the same student exam + * + * @param analyzeDifferentIp true if the ip addresses shall be compared, otherwise false + * @param analyzeDifferentFingerprint true if the browser fingerprints shall be compared, otherwise false + * @param suspiciousExamSessions set of suspicious exam sessions + * @param studentExam student exam for which the exam sessions shall be analyzed + */ + private static void analyzeSessionsOfStudentExam(boolean analyzeDifferentIp, boolean analyzeDifferentFingerprint, Set suspiciousExamSessions, + StudentExam studentExam) { + var relatedSuspiciousExamSessions = new HashSet(); + Set examSessions = studentExam.getExamSessions(); + Set filteredSessions = filterEqualExamSessionsForSameStudentExam(examSessions); + for (var examSession : filteredSessions) { + for (var examSession2 : filteredSessions) { + compareStudentExamSessions(analyzeDifferentIp, analyzeDifferentFingerprint, relatedSuspiciousExamSessions, examSession, examSession2); + } + } + if (!relatedSuspiciousExamSessions.isEmpty() && !isSubsetOfFoundSuspiciousSessions(relatedSuspiciousExamSessions, suspiciousExamSessions)) { + suspiciousExamSessions.add(new SuspiciousExamSessions(relatedSuspiciousExamSessions)); + } + } + + /** + * Compares two exam sessions of the same student exam and adds the suspicious reasons to the exam sessions if they are suspicious + * + * @param analyzeDifferentIp true if the ip addresses shall be compared, otherwise false + * @param analyzeDifferentFingerprint true if the browser fingerprints shall be compared, otherwise false + * @param relatedSuspiciousExamSessions set of related suspicious exam sessions + * @param examSession first exam session to compare + * @param examSession2 second exam session to compare + */ + private static void compareStudentExamSessions(boolean analyzeDifferentIp, boolean analyzeDifferentFingerprint, HashSet relatedSuspiciousExamSessions, + ExamSession examSession, ExamSession examSession2) { + if (Objects.equals(examSession.getId(), examSession2.getId())) { + return; + } + if (analyzeDifferentIp && !Objects.equals(examSession.getIpAddress(), examSession2.getIpAddress())) { + examSession.addSuspiciousReason(SuspiciousSessionReason.SAME_STUDENT_EXAM_DIFFERENT_IP_ADDRESSES); + examSession2.addSuspiciousReason(SuspiciousSessionReason.SAME_STUDENT_EXAM_DIFFERENT_IP_ADDRESSES); + relatedSuspiciousExamSessions.add(examSession); + relatedSuspiciousExamSessions.add(examSession2); + + } + if (analyzeDifferentFingerprint && !Objects.equals(examSession.getBrowserFingerprintHash(), examSession2.getBrowserFingerprintHash())) { + examSession.addSuspiciousReason(SuspiciousSessionReason.SAME_STUDENT_EXAM_DIFFERENT_BROWSER_FINGERPRINTS); + examSession2.addSuspiciousReason(SuspiciousSessionReason.SAME_STUDENT_EXAM_DIFFERENT_BROWSER_FINGERPRINTS); + relatedSuspiciousExamSessions.add(examSession); + relatedSuspiciousExamSessions.add(examSession2); + } + } + + private void findSessionsWithIPAddressOutsideOfRange(Set examSessions, String ipSubnet, Set suspiciousExamSessions) { + var examSessionsWithIPAddressOutsideOfRange = new HashSet(); + for (var examSession : examSessions) { + if (!checkIPIsInGivenRange(ipSubnet, examSession.getIpAddress())) { + examSession.setSuspiciousReasons(new HashSet<>()); + examSession.addSuspiciousReason(SuspiciousSessionReason.IP_ADDRESS_OUTSIDE_OF_RANGE); + examSessionsWithIPAddressOutsideOfRange.add(examSession); + } + } + if (!examSessionsWithIPAddressOutsideOfRange.isEmpty()) { + suspiciousExamSessions.add(new SuspiciousExamSessions(examSessionsWithIPAddressOutsideOfRange)); + } + } + + private boolean checkIPIsInGivenRange(String ipSubnet, String ipAddress) { + IpAddressMatcher ipAddressMatcher = new IpAddressMatcher(ipSubnet); + // if they are not both IPv4 or IPv6, we cannot check if the address is in the subnet and return true + if (!checkIfSubnetAndAddressHaveTheSameVersion(ipSubnet, ipAddress, IPAddress.IPVersion.IPV4) + && !checkIfSubnetAndAddressHaveTheSameVersion(ipSubnet, ipAddress, IPAddress.IPVersion.IPV6)) { + log.debug("IP address {} and subnet {} have different versions", ipAddress, ipSubnet); + return true; + } + return ipAddressMatcher.matches(ipAddress); + + } + + private static boolean checkIfSubnetAndAddressHaveTheSameVersion(String ipSubnet, String ipAddress, IPAddress.IPVersion version) { + var address = new IPAddressString(ipAddress).getAddress(version); + var ipSubnetAddress = new IPAddressString(ipSubnet).getAddress(version); + // return false if one of the addresses is null, not the expected version (IPv4 or IPv6) + return address != null && ipSubnetAddress != null; + } + + /** + * Finds suspicious exam sessions according to the criteria given and adds them to the set of suspicious exam sessions + * + * @param examSessions set of exam sessions to be processed + * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @param criteriaFilter function that returns a set of exam sessions that match the given criteria + * @param suspiciousExamSessions set of suspicious exam sessions to which the found suspicious exam sessions shall be added + * @param analyzeDifferentStudentExamsSameIp true if the ip addresses shall be compared, otherwise false + * @param analyzeDifferentStudentExamsSameBrowserFingerprint true if the browser fingerprints shall be compared, otherwise false + */ + private static void findSuspiciousSessionsForGivenCriteria(Set examSessions, long examId, BiFunction> criteriaFilter, + Set suspiciousExamSessions, boolean analyzeDifferentStudentExamsSameIp, boolean analyzeDifferentStudentExamsSameBrowserFingerprint) { for (var examSession : examSessions) { Set relatedExamSessions = criteriaFilter.apply(examId, examSession); relatedExamSessions = filterEqualRelatedExamSessionsOfSameStudentExam(relatedExamSessions); if (!relatedExamSessions.isEmpty() && !isSubsetOfFoundSuspiciousSessions(relatedExamSessions, suspiciousExamSessions)) { - addSuspiciousReasons(examSession, relatedExamSessions); - relatedExamSessions.add(examSession); + var session = addSuspiciousReasons(examSession, relatedExamSessions, analyzeDifferentStudentExamsSameIp, analyzeDifferentStudentExamsSameBrowserFingerprint); + relatedExamSessions.add(session); suspiciousExamSessions.add(new SuspiciousExamSessions(relatedExamSessions)); } } @@ -134,7 +314,7 @@ private void findSuspiciousSessionsForGivenCriteria(Set examSession * @param suspiciousExamSessions a set of suspicious exam sessions that have already been found * @return true if the given set of exam sessions is a subset of suspicious exam sessions that have already been found, otherwise false. */ - private boolean isSubsetOfFoundSuspiciousSessions(Set relatedExamSessions, Set suspiciousExamSessions) { + private static boolean isSubsetOfFoundSuspiciousSessions(Set relatedExamSessions, Set suspiciousExamSessions) { for (var suspiciousExamSession : suspiciousExamSessions) { if (suspiciousExamSession.examSessions().containsAll(relatedExamSessions)) { return true; @@ -151,7 +331,7 @@ private boolean isSubsetOfFoundSuspiciousSessions(Set relatedExamSe * @param examSessions exam sessions to filter * @return filtered exam sessions */ - private Set filterEqualExamSessionsForSameStudentExam(Set examSessions) { + private static Set filterEqualExamSessionsForSameStudentExam(Set examSessions) { Set filteredSessions = new HashSet<>(); Set processedSessionKeys = new HashSet<>(); @@ -174,7 +354,7 @@ private Set filterEqualExamSessionsForSameStudentExam(Set filterEqualRelatedExamSessionsOfSameStudentExam(Set examSessions) { + private static Set filterEqualRelatedExamSessionsOfSameStudentExam(Set examSessions) { Set filteredSessions = new HashSet<>(); Set processedSessionsStudentExamIds = new HashSet<>(); @@ -191,7 +371,7 @@ private Set filterEqualRelatedExamSessionsOfSameStudentExam(Set convertSuspiciousSessionsToDTO(Set suspiciousExamSessions) { + private static Set convertSuspiciousSessionsToDTO(Set suspiciousExamSessions) { Set suspiciousExamSessionsDTO = new HashSet<>(); for (var suspiciousExamSession : suspiciousExamSessions) { Set examSessionDTOs = new HashSet<>(); @@ -212,21 +392,39 @@ private Set convertSuspiciousSessionsToDTO(Set relatedExamSessions) { + private static ExamSession addSuspiciousReasons(ExamSession session, Set relatedExamSessions, boolean analyzeDifferentStudentExamsSameIp, + boolean analyzeDifferentStudentExamsSameBrowserFingerprint) { + ExamSession sessionCopy = new ExamSession(); + sessionCopy.setId(session.getId()); + sessionCopy.setSuspiciousReasons(new HashSet<>()); + sessionCopy.setBrowserFingerprintHash(session.getBrowserFingerprintHash()); + sessionCopy.setIpAddress(session.getIpAddress()); + sessionCopy.setUserAgent(session.getUserAgent()); + sessionCopy.setStudentExam(session.getStudentExam()); + sessionCopy.setCreatedDate(session.getCreatedDate()); + sessionCopy.setInstanceId(session.getInstanceId()); + sessionCopy.setSessionToken(session.getSessionToken()); + for (var relatedExamSession : relatedExamSessions) { - if (relatedExamSession.hasSameBrowserFingerprint(session)) { - relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT); - session.addSuspiciousReason(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT); + relatedExamSession.setSuspiciousReasons(new HashSet<>()); + // if we do not check the analysis criteria, we might add reasons that should not be analyzed + if (relatedExamSession.hasSameBrowserFingerprint(session) && analyzeDifferentStudentExamsSameBrowserFingerprint) { + relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.DIFFERENT_STUDENT_EXAMS_SAME_BROWSER_FINGERPRINT); + sessionCopy.addSuspiciousReason(SuspiciousSessionReason.DIFFERENT_STUDENT_EXAMS_SAME_BROWSER_FINGERPRINT); } - if (relatedExamSession.hasSameIpAddress(session)) { - relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.SAME_IP_ADDRESS); - session.addSuspiciousReason(SuspiciousSessionReason.SAME_IP_ADDRESS); + if (relatedExamSession.hasSameIpAddress(session) && analyzeDifferentStudentExamsSameIp) { + relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.DIFFERENT_STUDENT_EXAMS_SAME_IP_ADDRESS); + sessionCopy.addSuspiciousReason(SuspiciousSessionReason.DIFFERENT_STUDENT_EXAMS_SAME_IP_ADDRESS); } } + return sessionCopy; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisRateLimitService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisRateLimitService.java index b43979dd6168..02265c7261f9 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisRateLimitService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisRateLimitService.java @@ -1,8 +1,8 @@ package de.tum.in.www1.artemis.service.iris; import java.time.ZonedDateTime; +import java.util.Objects; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -19,14 +19,11 @@ public class IrisRateLimitService { private final IrisMessageRepository irisMessageRepository; - @Value("${artemis.iris.rate-limit:5}") - private int rateLimit; + private final IrisSettingsService irisSettingsService; - @Value("${artemis.iris.rate-limit-timeframe-hours:24}") - private int rateLimitTimeframeHours; - - public IrisRateLimitService(IrisMessageRepository irisMessageRepository) { + public IrisRateLimitService(IrisMessageRepository irisMessageRepository, IrisSettingsService irisSettingsService) { this.irisMessageRepository = irisMessageRepository; + this.irisSettingsService = irisSettingsService; } /** @@ -37,11 +34,15 @@ public IrisRateLimitService(IrisMessageRepository irisMessageRepository) { * @return the rate limit information */ public IrisRateLimitInformation getRateLimitInformation(User user) { + var globalSettings = irisSettingsService.getGlobalSettings(); + var irisChatSettings = globalSettings.getIrisChatSettings(); + var rateLimitTimeframeHours = Objects.requireNonNullElse(irisChatSettings.getRateLimitTimeframeHours(), 0); var start = ZonedDateTime.now().minusHours(rateLimitTimeframeHours); var end = ZonedDateTime.now(); var currentMessageCount = irisMessageRepository.countLlmResponsesOfUserWithinTimeframe(user.getId(), start, end); + var rateLimit = Objects.requireNonNullElse(irisChatSettings.getRateLimit(), -1); - return new IrisRateLimitInformation(currentMessageCount, rateLimit); + return new IrisRateLimitInformation(currentMessageCount, rateLimit, rateLimitTimeframeHours); } /** @@ -65,7 +66,7 @@ public void checkRateLimitElseThrow(User user) { * @param currentMessageCount the current rate limit * @param rateLimit the max rate limit */ - public record IrisRateLimitInformation(int currentMessageCount, int rateLimit) { + public record IrisRateLimitInformation(int currentMessageCount, int rateLimit, int rateLimitTimeframeHours) { /** * Checks if the rate limit is exceeded. diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java index 9cf590bfc52e..e01cd22855cd 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSessionService.java @@ -17,6 +17,7 @@ import de.tum.in.www1.artemis.service.iris.session.IrisChatSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisHestiaSessionService; import de.tum.in.www1.artemis.service.iris.session.IrisSessionSubServiceInterface; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; /** * Service for managing Iris sessions. @@ -58,6 +59,9 @@ public void checkIsIrisActivated(IrisSession session) { * @return The created session */ public IrisSession createChatSessionForProgrammingExercise(ProgrammingExercise exercise, User user) { + if (exercise.isExamExercise()) { + throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); + } var irisSession = new IrisChatSession(); irisSession.setExercise(exercise); irisSession.setUser(user); diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSettingsService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSettingsService.java index 02f75f29f7e6..11f5215fd7d2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSettingsService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisSettingsService.java @@ -21,8 +21,10 @@ import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.iris.IrisSettingsRepository; +import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; /** * Service for managing {@link IrisSettings}. @@ -39,12 +41,15 @@ public class IrisSettingsService { private final ProgrammingExerciseRepository programmingExerciseRepository; + private final AuthorizationCheckService authCheckService; + public IrisSettingsService(CourseRepository courseRepository, ApplicationContext applicationContext, IrisSettingsRepository irisSettingsRepository, - ProgrammingExerciseRepository programmingExerciseRepository) { + ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService) { this.courseRepository = courseRepository; this.applicationContext = applicationContext; this.irisSettingsRepository = irisSettingsRepository; this.programmingExerciseRepository = programmingExerciseRepository; + this.authCheckService = authCheckService; } /** @@ -360,6 +365,9 @@ public IrisSettings saveIrisSettings(Course course, IrisSettings settings) { * @return the saved Iris settings */ public IrisSettings saveIrisSettings(ProgrammingExercise exercise, IrisSettings settings) { + if (exercise.isExamExercise()) { + throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); + } var existingSettingsOptional = getIrisSettings(exercise); if (existingSettingsOptional.isPresent()) { var existingSettings = existingSettingsOptional.get(); @@ -389,8 +397,12 @@ private IrisSubSettings copyIrisSubSettings(IrisSubSettings target, IrisSubSetti if (target == null || source == null) { return source; } - target.setEnabled(source.isEnabled()); - target.setPreferredModel(source.getPreferredModel()); + if (authCheckService.isAdmin()) { + target.setEnabled(source.isEnabled()); + target.setPreferredModel(source.getPreferredModel()); + target.setRateLimit(source.getRateLimit()); + target.setRateLimitTimeframeHours(source.getRateLimitTimeframeHours()); + } if (!Objects.equals(source.getTemplate(), target.getTemplate())) { target.setTemplate(source.getTemplate()); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatSessionService.java index 508e8c5b7afa..32e73233c803 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/session/IrisChatSessionService.java @@ -32,6 +32,7 @@ import de.tum.in.www1.artemis.service.iris.IrisWebsocketService; import de.tum.in.www1.artemis.service.iris.exception.IrisNoResponseException; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; import de.tum.in.www1.artemis.web.rest.errors.InternalServerErrorException; /** @@ -123,6 +124,9 @@ public void requestAndHandleResponse(IrisSession session) { if (!(fullSession instanceof IrisChatSession chatSession)) { throw new BadRequestException("Trying to get Iris response for session " + session.getId() + " without exercise"); } + if (((IrisChatSession) fullSession).getExercise().isExamExercise()) { + throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); + } var exercise = chatSession.getExercise(); parameters.put("exercise", exercise); parameters.put("course", exercise.getCourseViaExerciseGroupOrCourseMember()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerMessageService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerMessageService.java index 4ab685d8ab6e..d5b18bbcb5fe 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerMessageService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerMessageService.java @@ -87,7 +87,7 @@ public AnswerPost createAnswerMessage(Long courseId, AnswerPost answerMessage) { channelAuthorizationService.isAllowedToCreateNewAnswerPostInChannel(channel, author); } - parseUserMentions(course, answerMessage.getContent()); + Set mentionedUsers = parseUserMentions(course, answerMessage.getContent()); // use post from database rather than user input answerMessage.setPost(post); @@ -98,12 +98,7 @@ public AnswerPost createAnswerMessage(Long courseId, AnswerPost answerMessage) { AnswerPost savedAnswerMessage = answerPostRepository.save(answerMessage); savedAnswerMessage.getPost().setConversation(conversation); this.preparePostAndBroadcast(savedAnswerMessage, course); - Set usersInvolved = conversationMessageRepository.findUsersWhoRepliedInMessage(post.getId()); - // do not notify the author of the post if they are not part of the conversation (e.g. if they left or have been removed from the conversation) - if (conversationService.isMember(post.getConversation().getId(), post.getAuthor().getId())) { - usersInvolved.add(post.getAuthor()); - } - usersInvolved.forEach(userInvolved -> singleUserNotificationService.notifyUserAboutNewMessageReply(savedAnswerMessage, userInvolved, author)); + this.singleUserNotificationService.notifyInvolvedUsersAboutNewMessageReply(post, mentionedUsers, savedAnswerMessage, author); return savedAnswerMessage; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java index c4c0443ef63d..c1210490f282 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java @@ -101,7 +101,7 @@ public Post createMessage(Long courseId, Post newMessage) { channelAuthorizationService.isAllowedToCreateNewPostInChannel(channel, author); } - parseUserMentions(course, newMessage.getContent()); + Set mentionedUsers = parseUserMentions(course, newMessage.getContent()); // update last message date of conversation conversation.setLastMessageDate(ZonedDateTime.now()); @@ -121,14 +121,16 @@ public Post createMessage(Long courseId, Post newMessage) { } // TODO: we should consider invoking the following method async to avoid that authors wait for the message creation if many notifications are sent - notifyAboutMessageCreation(author, savedConversation, course, createdMessage); + notifyAboutMessageCreation(author, savedConversation, course, createdMessage, mentionedUsers); return createdMessage; } - private void notifyAboutMessageCreation(User author, Conversation conversation, Course course, Post createdMessage) { + private void notifyAboutMessageCreation(User author, Conversation conversation, Course course, Post createdMessage, Set mentionedUsers) { Set webSocketRecipients = getWebSocketRecipients(conversation).collect(Collectors.toSet()); Set broadcastRecipients = webSocketRecipients.stream().map(ConversationWebSocketRecipientSummary::user).collect(Collectors.toSet()); + // Add all mentioned users, including the author (if mentioned). Since working with sets, there are no duplicate user entries + broadcastRecipients.addAll(mentionedUsers); // Websocket notification 1: this notifies everyone including the author that there is a new message broadcastForPost(new PostDTO(createdMessage, MetisCrudAction.CREATE), course, broadcastRecipients); @@ -150,7 +152,7 @@ private void notifyAboutMessageCreation(User author, Conversation conversation, // creation of message posts should not trigger entity creation alert // Websocket notification 3 - var notificationRecipients = filterNotificationRecipients(author, conversation, webSocketRecipients); + Set notificationRecipients = filterNotificationRecipients(author, conversation, webSocketRecipients, mentionedUsers); conversationNotificationService.notifyAboutNewMessage(createdMessage, notificationRecipients, course); } @@ -164,16 +166,18 @@ private void notifyAboutMessageCreation(User author, Conversation conversation, * @param author the author of the message * @param conversation the conversation the new message has been written in * @param webSocketRecipients the list of users that should be filtered + * @param mentionedUsers users mentioend within the message * @return filtered list of users that are supposed to receive a notification */ - private Set filterNotificationRecipients(User author, Conversation conversation, Set webSocketRecipients) { + private Set filterNotificationRecipients(User author, Conversation conversation, Set webSocketRecipients, + Set mentionedUsers) { // Initialize filter with check for author Predicate filter = recipientSummary -> !Objects.equals(recipientSummary.user().getId(), author.getId()); if (conversation instanceof Channel channel) { // If a channel is not an announcement channel, filter out users, that hid the conversation if (!channel.getIsAnnouncementChannel()) { - filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden()); + filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden() || mentionedUsers.contains(recipientSummary.user())); } // If a channel is not visible to students, filter out participants that are only students diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java index d0eb3dbff757..14a5cb90fa8c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java @@ -243,8 +243,9 @@ else if (authorizationCheckService.isTeachingAssistantInCourse(postingCourse, po * * @param course course of the posting * @param postingContent content of the posting + * @return set of mentioned users */ - protected void parseUserMentions(@NotNull Course course, String postingContent) { + protected Set parseUserMentions(@NotNull Course course, String postingContent) { // Define a regular expression to match text enclosed in [user]...[/user] tags, along with login inside parentheses () within those tags. // It makes use of the possessive quantifier "*+" to avoid backtracking and increase performance. // Explanation: @@ -269,7 +270,7 @@ protected void parseUserMentions(@NotNull Course course, String postingContent) matches.put(userLogin, fullName); } - List mentionedUsers = userRepository.findAllByLogins(matches.keySet()); + Set mentionedUsers = userRepository.findAllByLogins(matches.keySet()); if (mentionedUsers.size() != matches.size()) { throw new BadRequestAlertException("At least one of the mentioned users does not exist", METIS_POST_ENTITY_NAME, "invalidUserMention"); @@ -285,5 +286,7 @@ protected void parseUserMentions(@NotNull Course course, String postingContent) throw new BadRequestAlertException("The user " + user.getLogin() + " is not a member of the course", METIS_POST_ENTITY_NAME, "invalidUserMention"); } }); + + return mentionedUsers; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/notifications/SingleUserNotificationService.java b/src/main/java/de/tum/in/www1/artemis/service/notifications/SingleUserNotificationService.java index cb79cddfb225..98a90822e83c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/notifications/SingleUserNotificationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/notifications/SingleUserNotificationService.java @@ -3,14 +3,12 @@ import static de.tum.in.www1.artemis.domain.enumeration.NotificationType.*; import static de.tum.in.www1.artemis.domain.notification.NotificationConstants.*; import static de.tum.in.www1.artemis.domain.notification.SingleUserNotificationFactory.createNotification; -import static de.tum.in.www1.artemis.service.notifications.NotificationSettingsCommunicationChannel.*; +import static de.tum.in.www1.artemis.service.notifications.NotificationSettingsCommunicationChannel.WEBAPP; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.*; @@ -18,6 +16,7 @@ import de.tum.in.www1.artemis.domain.metis.AnswerPost; import de.tum.in.www1.artemis.domain.metis.Post; import de.tum.in.www1.artemis.domain.metis.Posting; +import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.metis.conversation.Conversation; import de.tum.in.www1.artemis.domain.notification.NotificationConstants; import de.tum.in.www1.artemis.domain.notification.SingleUserNotification; @@ -27,8 +26,12 @@ import de.tum.in.www1.artemis.repository.SingleUserNotificationRepository; import de.tum.in.www1.artemis.repository.StudentParticipationRepository; import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.repository.metis.ConversationMessageRepository; +import de.tum.in.www1.artemis.security.SecurityUtils; +import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ExerciseDateService; import de.tum.in.www1.artemis.service.WebsocketMessagingService; +import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; @Service public class SingleUserNotificationService { @@ -45,15 +48,25 @@ public class SingleUserNotificationService { private final StudentParticipationRepository studentParticipationRepository; + private final ConversationMessageRepository conversationMessageRepository; + + private final ConversationService conversationService; + + private final AuthorizationCheckService authorizationCheckService; + public SingleUserNotificationService(SingleUserNotificationRepository singleUserNotificationRepository, UserRepository userRepository, WebsocketMessagingService websocketMessagingService, GeneralInstantNotificationService notificationService, NotificationSettingsService notificationSettingsService, - StudentParticipationRepository studentParticipationRepository) { + StudentParticipationRepository studentParticipationRepository, ConversationMessageRepository conversationMessageRepository, ConversationService conversationService, + AuthorizationCheckService authorizationCheckService) { this.singleUserNotificationRepository = singleUserNotificationRepository; this.userRepository = userRepository; this.websocketMessagingService = websocketMessagingService; this.notificationService = notificationService; this.notificationSettingsService = notificationSettingsService; this.studentParticipationRepository = studentParticipationRepository; + this.conversationMessageRepository = conversationMessageRepository; + this.conversationService = conversationService; + this.authorizationCheckService = authorizationCheckService; } /** @@ -362,6 +375,39 @@ public void notifyUserAboutNewMessageReply(AnswerPost answerPost, User user, Use notifyRecipientWithNotificationType(new NewReplyNotificationSubject(answerPost, user, responsibleUser), CONVERSATION_NEW_REPLY_MESSAGE, null, responsibleUser); } + /** + * Notifies involved users about the new answer message, i.e. the author of the original message, users that have also replied, and mentioned users + * + * @param post the message the answer belongs to + * @param mentionedUsers users mentioned in the answer message + * @param savedAnswerMessage the answer message + * @param author the author of the answer message + */ + @Async + public void notifyInvolvedUsersAboutNewMessageReply(Post post, Set mentionedUsers, AnswerPost savedAnswerMessage, User author) { + SecurityUtils.setAuthorizationObject(); // required for async + Set usersInvolved = conversationMessageRepository.findUsersWhoRepliedInMessage(post.getId()); + // do not notify the author of the post if they are not part of the conversation (e.g. if they left or have been removed from the conversation) + if (conversationService.isMember(post.getConversation().getId(), post.getAuthor().getId())) { + usersInvolved.add(post.getAuthor()); + } + + mentionedUsers.forEach(user -> { + boolean isChannelAndCourseWide = post.getConversation() instanceof Channel channel && channel.getIsCourseWide(); + boolean isChannelVisibleToStudents = !(post.getConversation() instanceof Channel channel) || conversationService.isChannelVisibleToStudents(channel); + boolean isChannelVisibleToMentionedUser = isChannelVisibleToStudents || authorizationCheckService.isAtLeastTeachingAssistantInCourse(post.getCourse(), user); + + // Only send a notification to the mentioned user if... + // (for course-wide channels) ...the course-wide channel is visible + // (for all other cases) ...the user is a member of the conversation + if ((isChannelAndCourseWide && isChannelVisibleToMentionedUser) || conversationService.isMember(post.getConversation().getId(), user.getId())) { + usersInvolved.add(user); + } + }); + + usersInvolved.forEach(userInvolved -> notifyUserAboutNewMessageReply(savedAnswerMessage, userInvolved, author)); + } + /** * Saves the given notification in database and sends it to the client via websocket. * Also creates and sends an instant notification. diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java index 4cc23398df63..e061f54e99d7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java @@ -286,7 +286,7 @@ public ProgrammingExercise importProgrammingExercise(ProgrammingExercise origina if (recreateBuildPlans) { // Create completely new build plans for the exercise - programmingExerciseService.setupBuildPlansForNewExercise(importedProgrammingExercise, true); + programmingExerciseService.setupBuildPlansForNewExercise(importedProgrammingExercise, false); } else { // We have removed the automatic build trigger from test to base for new programming exercises. diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java index bfad5dbc39e4..3805305e6917 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java @@ -193,14 +193,14 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc * * * @param programmingExercise The programmingExercise that should be setup - * @param imported defines if the programming exercise is imported + * @param isImportedFromFile defines if the programming exercise is imported from a file * @return The new setup exercise * @throws GitAPIException If something during the communication with the remote Git repository went wrong * @throws IOException If the template files couldn't be read */ @Transactional // TODO: apply the transaction on a smaller scope // ok because we create many objects in a rather complex way and need a rollback in case of exceptions - public ProgrammingExercise createProgrammingExercise(ProgrammingExercise programmingExercise, boolean imported) throws GitAPIException, IOException { + public ProgrammingExercise createProgrammingExercise(ProgrammingExercise programmingExercise, boolean isImportedFromFile) throws GitAPIException, IOException { programmingExercise.generateAndSetProjectKey(); final User exerciseCreator = userRepository.getUser(); @@ -223,7 +223,7 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program channelService.createExerciseChannel(savedProgrammingExercise, Optional.ofNullable(programmingExercise.getChannelName())); - setupBuildPlansForNewExercise(savedProgrammingExercise, imported); + setupBuildPlansForNewExercise(savedProgrammingExercise, isImportedFromFile); // save to get the id required for the webhook savedProgrammingExercise = programmingExerciseRepository.saveAndFlush(savedProgrammingExercise); @@ -346,10 +346,10 @@ public void validateStaticCodeAnalysisSettings(ProgrammingExercise programmingEx * * @param programmingExercise Programming exercise for the build plans should be generated. The programming * exercise should contain a fully initialized template and solution participation. - * @param isImported defines if the programming exercise is imported from a source exercise, if the + * @param isImportedFromFile defines if the programming exercise is imported from a file, if the * exercise is imported, the build plans will not be triggered to prevent erroneous builds */ - public void setupBuildPlansForNewExercise(ProgrammingExercise programmingExercise, boolean isImported) { + public void setupBuildPlansForNewExercise(ProgrammingExercise programmingExercise, boolean isImportedFromFile) { String projectKey = programmingExercise.getProjectKey(); // Get URLs for repos var exerciseRepoUrl = programmingExercise.getVcsTemplateRepositoryUrl(); @@ -370,7 +370,7 @@ public void setupBuildPlansForNewExercise(ProgrammingExercise programmingExercis // if the exercise is imported from a file, the changes fixing the project name will trigger a first build anyway, so // we do not trigger them here - if (!isImported) { + if (!isImportedFromFile) { // trigger BASE and SOLUTION build plans once here continuousIntegrationTriggerService.orElseThrow().triggerBuild(programmingExercise.getTemplateParticipation()); continuousIntegrationTriggerService.orElseThrow().triggerBuild(programmingExercise.getSolutionParticipation()); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java index fd60d8d6d1d7..5fc8d4b36b10 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java @@ -17,6 +17,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; @@ -77,9 +78,9 @@ public class CourseResource { private final AuthorizationCheckService authCheckService; - private final OnlineCourseConfigurationService onlineCourseConfigurationService; + private final Optional onlineCourseConfigurationService; - private final OAuth2JWKSService oAuth2JWKSService; + private final Optional oAuth2JWKSService; private final CourseRepository courseRepository; @@ -115,7 +116,7 @@ public class CourseResource { private final LearningPathService learningPathService; public CourseResource(UserRepository userRepository, CourseService courseService, CourseRepository courseRepository, ExerciseService exerciseService, - OAuth2JWKSService oAuth2JWKSService, OnlineCourseConfigurationService onlineCourseConfigurationService, AuthorizationCheckService authCheckService, + Optional oAuth2JWKSService, Optional onlineCourseConfigurationService, AuthorizationCheckService authCheckService, TutorParticipationRepository tutorParticipationRepository, SubmissionService submissionService, Optional optionalVcsUserManagementService, AssessmentDashboardService assessmentDashboardService, ExerciseRepository exerciseRepository, Optional optionalCiUserManagementService, FileService fileService, TutorialGroupsConfigurationService tutorialGroupsConfigurationService, GradingScaleService gradingScaleService, @@ -231,8 +232,8 @@ public ResponseEntity updateCourse(@PathVariable Long courseId, @Request } if (courseUpdate.isOnlineCourse() != existingCourse.isOnlineCourse()) { - if (courseUpdate.isOnlineCourse()) { - onlineCourseConfigurationService.createOnlineCourseConfiguration(courseUpdate); + if (courseUpdate.isOnlineCourse() && onlineCourseConfigurationService.isPresent()) { + onlineCourseConfigurationService.get().createOnlineCourseConfiguration(courseUpdate); } else { courseUpdate.setOnlineCourseConfiguration(null); @@ -278,6 +279,7 @@ public ResponseEntity updateCourse(@PathVariable Long courseId, @Request */ @PutMapping("courses/{courseId}/onlineCourseConfiguration") @EnforceAtLeastInstructor + @Profile("lti") public ResponseEntity updateOnlineCourseConfiguration(@PathVariable Long courseId, @RequestBody OnlineCourseConfiguration onlineCourseConfiguration) { log.debug("REST request to update the online course configuration for Course : {}", courseId); @@ -294,12 +296,14 @@ public ResponseEntity updateOnlineCourseConfiguration OnlineCourseConfiguration.ENTITY_NAME, "idMismatch"); } - onlineCourseConfigurationService.validateOnlineCourseConfiguration(onlineCourseConfiguration); - course.setOnlineCourseConfiguration(onlineCourseConfiguration); + if (onlineCourseConfigurationService.isPresent()) { + onlineCourseConfigurationService.get().validateOnlineCourseConfiguration(onlineCourseConfiguration); + course.setOnlineCourseConfiguration(onlineCourseConfiguration); + } courseRepository.save(course); - oAuth2JWKSService.updateKey(course.getOnlineCourseConfiguration().getRegistrationId()); + oAuth2JWKSService.ifPresent(auth2JWKSService -> auth2JWKSService.updateKey(course.getOnlineCourseConfiguration().getRegistrationId())); return ResponseEntity.ok(onlineCourseConfiguration); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 25ba7982aa8c..0baac7253b6e 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -31,9 +31,7 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.exam.Exam; -import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; -import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.domain.exam.*; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.participation.TutorParticipation; import de.tum.in.www1.artemis.repository.*; @@ -191,6 +189,7 @@ public ResponseEntity createExam(@PathVariable Long courseId, @RequestBody @EnforceAtLeastInstructor public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody Exam updatedExam) throws URISyntaxException { log.debug("REST request to update an exam : {}", updatedExam); + if (updatedExam.getId() == null) { return createExam(courseId, updatedExam); } @@ -201,6 +200,7 @@ public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody // Make sure that the original references are preserved. Exam originalExam = examRepository.findByIdElseThrow(updatedExam.getId()); + var originalExamDuration = originalExam.getDuration(); // The Exam Mode cannot be changed after creation -> Compare request with version in the database if (updatedExam.isTestExam() != originalExam.isTestExam()) { @@ -216,21 +216,22 @@ public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody Exam savedExam = examRepository.save(updatedExam); + // NOTE: We have to get exercises and groups as we need them for re-scheduling + Exam examWithExercises = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(savedExam.getId()); + // We can't test dates for equality as the dates retrieved from the database lose precision. Also use instant to take timezones into account Comparator comparator = Comparator.comparing(date -> date.truncatedTo(ChronoUnit.SECONDS).toInstant()); if (comparator.compare(originalExam.getVisibleDate(), updatedExam.getVisibleDate()) != 0 || comparator.compare(originalExam.getStartDate(), updatedExam.getStartDate()) != 0) { - // get all exercises - Exam examWithExercises = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(savedExam.getId()); // for all programming exercises in the exam, send their ids for scheduling examWithExercises.getExerciseGroups().stream().flatMap(group -> group.getExercises().stream()).filter(ProgrammingExercise.class::isInstance).map(Exercise::getId) .forEach(instanceMessageSendService::sendProgrammingExerciseSchedule); } - if (comparator.compare(originalExam.getEndDate(), updatedExam.getEndDate()) != 0) { - // get all exercises - Exam examWithExercises = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(savedExam.getId()); - examService.scheduleModelingExercises(examWithExercises); + // NOTE: if the end date was changed, we need to update student exams and re-schedule exercises + if (!originalExam.getEndDate().equals(savedExam.getEndDate())) { + int workingTimeChange = savedExam.getDuration() - originalExamDuration; + updateStudentExamsAndRescheduleExercises(examWithExercises, originalExamDuration, workingTimeChange); } if (updatedChannel != null) { @@ -259,9 +260,7 @@ public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @ throw new BadRequestException(); } - var now = now(); - - // We have to get exercise groups as `scheduleModelingExercises` needs them + // NOTE: We have to get exercise groups as `scheduleModelingExercises` needs them Exam exam = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(examId); var originalExamDuration = exam.getDuration(); @@ -270,10 +269,18 @@ public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @ exam.setWorkingTime(exam.getWorkingTime() + workingTimeChange); examRepository.save(exam); + // 2. Re-calculate the working times of all student exams + updateStudentExamsAndRescheduleExercises(exam, originalExamDuration, workingTimeChange); + + return ResponseEntity.ok(exam); + } + + private void updateStudentExamsAndRescheduleExercises(Exam exam, Integer originalExamDuration, Integer workingTimeChange) { + var now = now(); + User instructor = userRepository.getUser(); - // 2. Re-calculate the working times of all student exams - var studentExams = studentExamRepository.findByExamId(examId); + var studentExams = studentExamRepository.findByExamId(exam.getId()); for (var studentExam : studentExams) { Integer originalStudentWorkingTime = studentExam.getWorkingTime(); int originalTimeExtension = originalStudentWorkingTime - originalExamDuration; @@ -288,7 +295,9 @@ public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @ int adjustedWorkingTime = Math.max(newNormalWorkingTime + timeAdjustment, 0); studentExam.setWorkingTime(adjustedWorkingTime); } + // TODO: probably batch these updates? var savedStudentExam = studentExamRepository.save(studentExam); + // NOTE: if the exam is already visible, notify the student about the working time change if (now.isAfter(exam.getVisibleDate())) { examLiveEventsService.createAndSendWorkingTimeUpdateEvent(savedStudentExam, savedStudentExam.getWorkingTime(), originalStudentWorkingTime, true, instructor); @@ -300,12 +309,10 @@ public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @ instanceMessageSendService.sendRescheduleAllStudentExams(exam.getId()); } + // NOTE: potentially re-schedule clustering of modeling submissions (in case Compass is active) if (now.isBefore(examDateService.getLatestIndividualExamEndDate(exam))) { - // potentially re-schedule clustering of modeling submissions (in case Compass is active) examService.scheduleModelingExercises(exam); } - - return ResponseEntity.ok(exam); } /** @@ -1307,19 +1314,43 @@ public List getAllExercisesWithPotentialP /** * GET /courses/{courseId}/exams/{examId}/suspicious-sessions : Get all exam sessions that are suspicious for exam. - * For an explanation when a session is suspicious, see {@link ExamSessionService#retrieveAllSuspiciousExamSessionsByExamId(long)} + * For an explanation when a session is suspicious, see {@link ExamSessionService#retrieveAllSuspiciousExamSessionsByExamId(long, SuspiciousSessionsAnalysisOptions, Optional)} * - * @param courseId the id of the course - * @param examId the id of the exam + * @param courseId the id of the course + * @param examId the id of the exam + * @param analyzeSessionsWithTheSameIp whether to analyze for sessions with the same IP address that belong to different student exams + * @param analyzeSessionsWithTheSameBrowserFingerprint whether to analyze sessions with the same browser fingerprint that belong to different student + * exams + * @param analyzeSessionsForTheSameStudentExamWithDifferentIpAddresses whether to analyze sessions with different IP addresses that belong to the same student exam + * @param analyzeSessionsForTheSameStudentExamWithDifferentBrowserFingerprints whether to analyze sessions with different browser fingerprints that belong to the same student + * exam + * @param analyzeSessionsIpOutsideOfRange whether to analyze sessions with IP addresses outside a given subnet + * If this is true, the subnet needs to be provided as a request parameter + * @param ipSubnet the subnet to use for analyzing sessions with IP addresses outside the subnet (optional) * @return a set containing all tuples of exam sessions that are suspicious. */ @GetMapping("courses/{courseId}/exams/{examId}/suspicious-sessions") @EnforceAtLeastInstructor - public Set getAllSuspiciousExamSessions(@PathVariable long courseId, @PathVariable long examId) { + public Set getAllSuspiciousExamSessions(@PathVariable long courseId, @PathVariable long examId, + @RequestParam("differentStudentExamsSameIPAddress") boolean analyzeSessionsWithTheSameIp, + @RequestParam("differentStudentExamsSameBrowserFingerprint") boolean analyzeSessionsWithTheSameBrowserFingerprint, + @RequestParam("sameStudentExamDifferentIPAddresses") boolean analyzeSessionsForTheSameStudentExamWithDifferentIpAddresses, + @RequestParam("sameStudentExamDifferentBrowserFingerprints") boolean analyzeSessionsForTheSameStudentExamWithDifferentBrowserFingerprints, + @RequestParam("ipOutsideOfRange") boolean analyzeSessionsIpOutsideOfRange, @RequestParam(required = false) String ipSubnet) { log.debug("REST request to get all exam sessions that are suspicious for exam : {}", examId); Course course = courseRepository.findByIdElseThrow(courseId); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); - return examSessionService.retrieveAllSuspiciousExamSessionsByExamId(examId); + if (analyzeSessionsIpOutsideOfRange) { + if (ipSubnet == null) { + throw new BadRequestAlertException("If you want to analyze sessions with IP outside of range, you need to provide a subnet", ENTITY_NAME, + "missingLowerOrUpperBoundIp"); + } + } + SuspiciousSessionsAnalysisOptions options = new SuspiciousSessionsAnalysisOptions(analyzeSessionsWithTheSameIp, analyzeSessionsWithTheSameBrowserFingerprint, + analyzeSessionsForTheSameStudentExamWithDifferentIpAddresses, analyzeSessionsForTheSameStudentExamWithDifferentBrowserFingerprints, + analyzeSessionsIpOutsideOfRange); + return examSessionService.retrieveAllSuspiciousExamSessionsByExamId(examId, options, Optional.ofNullable(ipSubnet)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java index ae684d3fcce3..fff5e0e73b15 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java @@ -771,11 +771,12 @@ public ResponseEntity submitStudentExam(@PathVariable Long courseId if (studentExam.isSubmitted()) { throw new BadRequestException(); } - if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(now())) { - throw new AccessForbiddenException("Exam", examId); + ZonedDateTime submissionTime = now(); + if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(submissionTime)) { + throw new AccessForbiddenException("FORBIDDEN: You tried to toggle a student exam " + studentExamId + " to submitted before the individual end date " + + studentExam.getIndividualEndDateWithGracePeriod()); } - ZonedDateTime submissionTime = now(); studentExam.setSubmissionDate(submissionTime); studentExam.setSubmitted(true); @@ -809,7 +810,8 @@ public ResponseEntity unsubmitStudentExam(@PathVariable Long course throw new BadRequestException(); } if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(now())) { - throw new AccessForbiddenException("Exam", examId); + throw new AccessForbiddenException("FORBIDDEN: You tried to toggle a student exam " + studentExamId + " to unsubmitted before the individual end date " + + studentExam.getIndividualEndDateWithGracePeriod()); } studentExam.setSubmissionDate(null); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java index dae6f829e55f..c59f592b73b3 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminCourseResource.java @@ -53,10 +53,10 @@ public class AdminCourseResource { private final FileService fileService; - private final OnlineCourseConfigurationService onlineCourseConfigurationService; + private final Optional onlineCourseConfigurationService; public AdminCourseResource(UserRepository userRepository, CourseService courseService, CourseRepository courseRepository, AuditEventRepository auditEventRepository, - FileService fileService, OnlineCourseConfigurationService onlineCourseConfigurationService, ChannelService channelService) { + FileService fileService, Optional onlineCourseConfigurationService, ChannelService channelService) { this.courseService = courseService; this.courseRepository = courseRepository; this.auditEventRepository = auditEventRepository; @@ -117,8 +117,8 @@ public ResponseEntity createCourse(@RequestPart Course course, @RequestP course.validateAccuracyOfScores(); course.validateStartAndEndDate(); - if (course.isOnlineCourse()) { - onlineCourseConfigurationService.createOnlineCourseConfiguration(course); + if (course.isOnlineCourse() && onlineCourseConfigurationService.isPresent()) { + onlineCourseConfigurationService.get().createOnlineCourseConfiguration(course); } courseService.createOrValidateGroups(course); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSessionResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSessionResource.java index 6d80df7a6874..0d35f8481fd0 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSessionResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisSessionResource.java @@ -145,9 +145,9 @@ public ResponseEntity isIrisActive(@PathVariable Long sessionId) var rateLimitInfo = irisRateLimitService.getRateLimitInformation(user); - return ResponseEntity.ok(new IrisHealthDTO(specificModelStatus, rateLimitInfo.currentMessageCount(), rateLimitInfo.rateLimit())); + return ResponseEntity.ok(new IrisHealthDTO(specificModelStatus, rateLimitInfo)); } - public record IrisHealthDTO(boolean active, int currentMessageCount, int rateLimit) { + public record IrisHealthDTO(boolean active, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo) { } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/OpenBuildPlanResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicBuildPlanResource.java similarity index 95% rename from src/main/java/de/tum/in/www1/artemis/web/rest/open/OpenBuildPlanResource.java rename to src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicBuildPlanResource.java index 092952d1636a..7ac09c330947 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/OpenBuildPlanResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicBuildPlanResource.java @@ -16,13 +16,13 @@ @Profile("gitlabci | jenkins") @RestController @RequestMapping("api/public/") -public class OpenBuildPlanResource { +public class PublicBuildPlanResource { private final Logger log = LoggerFactory.getLogger(getClass()); private final BuildPlanRepository buildPlanRepository; - public OpenBuildPlanResource(BuildPlanRepository buildPlanRepository) { + public PublicBuildPlanResource(BuildPlanRepository buildPlanRepository) { this.buildPlanRepository = buildPlanRepository; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/OAuth2JWKSResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicOAuth2JWKSResource.java similarity index 81% rename from src/main/java/de/tum/in/www1/artemis/web/rest/OAuth2JWKSResource.java rename to src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicOAuth2JWKSResource.java index 3c94e688b4d5..ae0437801c6a 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/OAuth2JWKSResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicOAuth2JWKSResource.java @@ -1,5 +1,6 @@ -package de.tum.in.www1.artemis.web.rest; +package de.tum.in.www1.artemis.web.rest.open; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -15,11 +16,12 @@ * REST controller to serve the public JWKSet related to all OAuth2 clients. */ @RestController -public class OAuth2JWKSResource { +@Profile("lti") +public class PublicOAuth2JWKSResource { private final OAuth2JWKSService jwksService; - public OAuth2JWKSResource(OAuth2JWKSService jwksService) { + public PublicOAuth2JWKSResource(OAuth2JWKSService jwksService) { this.jwksService = jwksService; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicProgrammingSubmissionResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicProgrammingSubmissionResource.java index dd51e9e63b9b..2a41f0202336 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicProgrammingSubmissionResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicProgrammingSubmissionResource.java @@ -80,9 +80,10 @@ public ResponseEntity processNewProgrammingSubmission(@PathVariable("parti // Therefore, a mock auth object has to be created. SecurityUtils.setAuthorizationObject(); - Participation participation = participationRepository.findByIdWithLegalSubmissionsElseThrow(participationId); + Participation participation = participationRepository.findByIdWithSubmissionsElseThrow(participationId); if (!(participation instanceof ProgrammingExerciseParticipation programmingExerciseParticipation)) { - throw new EntityNotFoundException("Programming Exercise Participation", participationId); + throw new BadRequestAlertException("The referenced participation " + participationId + " is not of type ProgrammingExerciseParticipation", "ProgrammingSubmission", + "participationWrongType"); } ProgrammingSubmission newProgrammingSubmission = programmingSubmissionService.processNewProgrammingSubmission(programmingExerciseParticipation, requestBody); @@ -106,8 +107,11 @@ public ResponseEntity processNewProgrammingSubmission(@PathVariable("parti return ResponseEntity.status(HttpStatus.OK).build(); } catch (EntityNotFoundException ex) { - log.error("Participation with id {} is not a ProgrammingExerciseParticipation: processing submission for participation {} failed with request body {}", participationId, - participationId, requestBody, ex); + log.error("Participation with id {} not found: processing submission failed for request body {}", participationId, requestBody, ex); + throw ex; + } + catch (BadRequestAlertException ex) { + log.error("Participation with id {} is not a ProgrammingExerciseParticipation: processing submission failed for request body {}", participationId, requestBody, ex); throw ex; } catch (VersionControlException ex) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/TimeResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicTimeResource.java similarity index 95% rename from src/main/java/de/tum/in/www1/artemis/web/rest/open/TimeResource.java rename to src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicTimeResource.java index 6b364cdefca4..e4cf8fe7fa21 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/TimeResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicTimeResource.java @@ -11,7 +11,7 @@ @RestController @RequestMapping("api/public/") -public class TimeResource { +public class PublicTimeResource { /** * {@code GET /time}: diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/UserJwtResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicUserJwtResource.java similarity index 95% rename from src/main/java/de/tum/in/www1/artemis/web/rest/open/UserJwtResource.java rename to src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicUserJwtResource.java index d312d82b2dab..d194f9ff8755 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/UserJwtResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicUserJwtResource.java @@ -35,9 +35,9 @@ */ @RestController @RequestMapping("api/public/") -public class UserJwtResource { +public class PublicUserJwtResource { - private final Logger log = LoggerFactory.getLogger(UserJwtResource.class); + private final Logger log = LoggerFactory.getLogger(PublicUserJwtResource.class); private final JWTCookieService jwtCookieService; @@ -45,7 +45,7 @@ public class UserJwtResource { private final Optional saml2Service; - public UserJwtResource(JWTCookieService jwtCookieService, AuthenticationManagerBuilder authenticationManagerBuilder, Optional saml2Service) { + public PublicUserJwtResource(JWTCookieService jwtCookieService, AuthenticationManagerBuilder authenticationManagerBuilder, Optional saml2Service) { this.jwtCookieService = jwtCookieService; this.authenticationManagerBuilder = authenticationManagerBuilder; this.saml2Service = saml2Service; diff --git a/src/main/resources/config/liquibase/changelog/20230922222222_changelog.xml b/src/main/resources/config/liquibase/changelog/20230922222222_changelog.xml new file mode 100644 index 000000000000..fbc5184d957b --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230922222222_changelog.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 2d7584cbf99f..a387a1af2d6f 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -61,6 +61,7 @@ + diff --git a/src/main/resources/templates/java/maven_blackbox/solution/src/${packageNameFolder}/Client.java b/src/main/resources/templates/java/maven_blackbox/solution/src/${packageNameFolder}/Client.java index d2bb7a3b8799..38c971afe5dd 100644 --- a/src/main/resources/templates/java/maven_blackbox/solution/src/${packageNameFolder}/Client.java +++ b/src/main/resources/templates/java/maven_blackbox/solution/src/${packageNameFolder}/Client.java @@ -38,11 +38,17 @@ public static void main(String[] args) throws IOException { private static CommandRunResult runCommand(final Command command) { switch (command) { - case Command.AddCommand addCommand -> CONTEXT.addDates(addCommand.dates()); + case Command.AddCommand addCommand -> { + CONTEXT.addDates(addCommand.dates()); + } case Command.SortCommand ignored -> CONTEXT.sort(); case Command.ClearCommand ignored -> CONTEXT.clearDates(); - case Command.HelpCommand helpCommand -> System.out.println(helpCommand.helpMessage()); - case Command.PrintCommand ignored -> System.out.println(CONTEXT.getDates()); + case Command.HelpCommand helpCommand -> { + System.out.println(helpCommand.helpMessage()); + } + case Command.PrintCommand ignored -> { + System.out.println(CONTEXT.getDates()); + } case Command.QuitCommand ignored -> { return CommandRunResult.QUIT; } diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 810d930794ec..9c752ad9b211 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -59,9 +59,13 @@ export class AccountService implements IAccountService { // We only subscribe the feature toggle updates when the user is logged in, otherwise we unsubscribe them. if (user) { this.websocketService.enableReconnect(); + this.websocketService.connect(); this.featureToggleService.subscribeFeatureToggleUpdates(); } else { this.websocketService.disableReconnect(); + if (this.websocketService.isConnected()) { + this.websocketService.disconnect(); + } this.featureToggleService.unsubscribeFeatureToggleUpdates(); } } @@ -141,7 +145,6 @@ export class AccountService implements IAccountService { map((response: HttpResponse) => { const user = response.body!; if (user) { - this.websocketService.connect(); this.userIdentity = user; // improved error tracking in sentry @@ -157,10 +160,6 @@ export class AccountService implements IAccountService { return this.userIdentity; }), catchError(() => { - // this will be called during logout - if (this.websocketService.isConnected()) { - this.websocketService.disconnect(); - } this.userIdentity = undefined; return of(undefined); }), diff --git a/src/main/webapp/app/core/theme/theme.service.ts b/src/main/webapp/app/core/theme/theme.service.ts index 660877215912..a6fa87c0c01a 100644 --- a/src/main/webapp/app/core/theme/theme.service.ts +++ b/src/main/webapp/app/core/theme/theme.service.ts @@ -149,19 +149,28 @@ export class ThemeService { /** * Prints the current page. * Disables any theme override before doing that to ensure that we print in default theme. - * Resets the theme afterwards if needed + * Resets the theme afterward if needed */ - public print() { - const overrideTag: any = document.getElementById(THEME_OVERRIDE_ID); - if (overrideTag) { - overrideTag.rel = 'none-tmp'; - } - setTimeout(() => window.print(), 250); - setTimeout(() => { + public async print(): Promise { + return new Promise((resolve) => { + const overrideTag: any = document.getElementById(THEME_OVERRIDE_ID); if (overrideTag) { - overrideTag.rel = 'stylesheet'; + overrideTag.rel = 'none-tmp'; } - }, 500); + setTimeout(() => { + const notificationSidebarDisplayAttribute = this.hideNotificationSidebar(); + + window.print(); + + this.showNotificationSidebar(notificationSidebarDisplayAttribute); + }, 250); + setTimeout(() => { + if (overrideTag) { + overrideTag.rel = 'stylesheet'; + } + resolve(); + }, 500); + }); } /** @@ -235,4 +244,39 @@ export class ThemeService { this.preferenceSubject.next(theme); } } + + /** + * Hides the notification sidebar as there will be an overlay ove the whole page + * that covers details of the exam summary (=> exam summary cannot be read). + * + * @return displayAttribute of the notification sidebar before hiding it + */ + private hideNotificationSidebar(): string { + return this.modifyNotificationSidebarDisplayStyling(); + } + + /** + * After printing the notification sidebar shall be displayed again. + * + * @param displayAttributeBeforeHide to reset the notification sidebar to its previous state + * @return displayAttribute of the notification sidebar before hiding it + */ + private showNotificationSidebar(displayAttributeBeforeHide: string): string { + return this.modifyNotificationSidebarDisplayStyling(displayAttributeBeforeHide); + } + + /** + * @param newDisplayAttribute that is set for the {@link NotificationSidebarComponent} + * @return displayAttribute of the notification sidebar before hiding it + */ + private modifyNotificationSidebarDisplayStyling(newDisplayAttribute?: string): string { + const notificationSidebarElement: any = document.getElementById('notification-sidebar'); + let displayBefore = ''; + + if (notificationSidebarElement) { + displayBefore = notificationSidebarElement.style.display; + notificationSidebarElement.style.display = newDisplayAttribute !== undefined ? newDisplayAttribute : 'none'; + } + return displayBefore; + } } diff --git a/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts b/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts index b700b643d025..e3f890c4be1f 100644 --- a/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts +++ b/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts @@ -218,7 +218,7 @@ export class PlagiarismCaseInstructorDetailViewComponent implements OnInit, OnDe /** * Prints the whole page using the theme service */ - printPlagiarismCase(): void { - this.themeService.print(); + async printPlagiarismCase() { + return await this.themeService.print(); } } diff --git a/src/main/webapp/app/entities/exam-session.model.ts b/src/main/webapp/app/entities/exam-session.model.ts index a528a4b0a45d..feb4670ac5c9 100644 --- a/src/main/webapp/app/entities/exam-session.model.ts +++ b/src/main/webapp/app/entities/exam-session.model.ts @@ -3,8 +3,12 @@ import dayjs from 'dayjs/esm'; import { StudentExam } from './student-exam.model'; export enum SuspiciousSessionReason { - SAME_IP_ADDRESS = 'SAME_IP_ADDRESS', - SAME_BROWSER_FINGERPRINT = 'SAME_BROWSER_FINGERPRINT', + DIFFERENT_STUDENT_EXAMS_SAME_IP_ADDRESS = 'DIFFERENT_STUDENT_EXAMS_SAME_IP_ADDRESS', + DIFFERENT_STUDENT_EXAMS_SAME_BROWSER_FINGERPRINT = 'DIFFERENT_STUDENT_EXAMS_SAME_BROWSER_FINGERPRINT', + + SAME_STUDENT_EXAM_DIFFERENT_IP_ADDRESSES = 'SAME_STUDENT_EXAM_DIFFERENT_IP_ADDRESSES', + SAME_STUDENT_EXAM_DIFFERENT_BROWSER_FINGERPRINTS = 'SAME_STUDENT_EXAM_DIFFERENT_BROWSER_FINGERPRINTS', + IP_ADDRESS_OUTSIDE_OF_RANGE = 'IP_ADDRESS_OUTSIDE_OF_RANGE', } export class ExamSession implements BaseEntity { public id?: number; @@ -19,9 +23,32 @@ export class ExamSession implements BaseEntity { public lastModifiedBy?: string; public createdDate?: dayjs.Dayjs; public lastModifiedDate?: Date; - public suspiciousReasons: SuspiciousSessionReason[] = []; + public suspiciousReasons: SuspiciousSessionReason[]; } export class SuspiciousExamSessions { - examSessions: ExamSession[] = []; + examSessions: ExamSession[]; +} +export class SuspiciousSessionsAnalysisOptions { + constructor( + sameIpAddressDifferentStudentExams: boolean, + sameBrowserFingerprintDifferentStudentExams: boolean, + differentIpAddressesSameStudentExam: boolean, + differentBrowserFingerprintsSameStudentExam: boolean, + ipAddressOutsideOfRange: boolean, + subnet?: string, + ) { + this.sameIpAddressDifferentStudentExams = sameIpAddressDifferentStudentExams; + this.sameBrowserFingerprintDifferentStudentExams = sameBrowserFingerprintDifferentStudentExams; + this.differentIpAddressesSameStudentExam = differentIpAddressesSameStudentExam; + this.differentBrowserFingerprintsSameStudentExam = differentBrowserFingerprintsSameStudentExam; + this.ipAddressOutsideOfRange = ipAddressOutsideOfRange; + this.ipSubnet = subnet; + } + sameIpAddressDifferentStudentExams = false; + sameBrowserFingerprintDifferentStudentExams = false; + differentIpAddressesSameStudentExam = false; + differentBrowserFingerprintsSameStudentExam = false; + ipAddressOutsideOfRange = false; + ipSubnet?: string; } diff --git a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts index e436e7b3427b..aa2981af0411 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts @@ -6,4 +6,6 @@ export class IrisSubSettings implements BaseEntity { enabled = false; template?: IrisTemplate; preferredModel?: string; + rateLimit?: number; + rateLimitTimeframeHours?: number; } diff --git a/src/main/webapp/app/entities/quiz/quiz-configuration.model.ts b/src/main/webapp/app/entities/quiz/quiz-configuration.model.ts new file mode 100644 index 000000000000..675374f7d56a --- /dev/null +++ b/src/main/webapp/app/entities/quiz/quiz-configuration.model.ts @@ -0,0 +1,13 @@ +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; +import { IncludedInOverallScore } from 'app/entities/exercise.model'; +import { ExerciseGroup } from 'app/entities/exercise-group.model'; + +export interface QuizConfiguration { + id?: number; + exerciseGroup?: ExerciseGroup; + quizQuestions?: QuizQuestion[]; + randomizeQuestionOrder?: boolean; + title?: string; + maxPoints?: number; + includedInOverallScore?: IncludedInOverallScore; +} diff --git a/src/main/webapp/app/entities/quiz/quiz-exercise.model.ts b/src/main/webapp/app/entities/quiz/quiz-exercise.model.ts index b1cbb3261cf8..247aa6f9b11c 100644 --- a/src/main/webapp/app/entities/quiz/quiz-exercise.model.ts +++ b/src/main/webapp/app/entities/quiz/quiz-exercise.model.ts @@ -4,6 +4,8 @@ import { QuizPointStatistic } from 'app/entities/quiz/quiz-point-statistic.model import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; import { Course } from 'app/entities/course.model'; import { ExerciseGroup } from 'app/entities/exercise-group.model'; +import { QuizConfiguration } from 'app/entities/quiz/quiz-configuration.model'; +import { QuizParticipation } from 'app/entities/quiz/quiz-participation.model'; export enum QuizStatus { CLOSED, @@ -31,7 +33,7 @@ export class QuizBatch { startTimeError?: boolean; } -export class QuizExercise extends Exercise { +export class QuizExercise extends Exercise implements QuizConfiguration, QuizParticipation { public visibleToStudents?: boolean; // (computed by server) public allowedNumberOfAttempts?: number; public remainingNumberOfAttempts?: number; diff --git a/src/main/webapp/app/entities/quiz/quiz-participation.model.ts b/src/main/webapp/app/entities/quiz/quiz-participation.model.ts new file mode 100644 index 000000000000..64540fc801b6 --- /dev/null +++ b/src/main/webapp/app/entities/quiz/quiz-participation.model.ts @@ -0,0 +1,7 @@ +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; + +export interface QuizParticipation { + quizQuestions?: QuizQuestion[]; + studentParticipations?: StudentParticipation[]; +} diff --git a/src/main/webapp/app/exam/manage/exam-management-resolve.service.ts b/src/main/webapp/app/exam/manage/exam-management-resolve.service.ts index 0b35c150992e..49a8462bc98d 100644 --- a/src/main/webapp/app/exam/manage/exam-management-resolve.service.ts +++ b/src/main/webapp/app/exam/manage/exam-management-resolve.service.ts @@ -8,6 +8,27 @@ import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; import { Observable, filter, map, of } from 'rxjs'; import { HttpResponse } from '@angular/common/http'; import { StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; +import { Course } from 'app/entities/course.model'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { catchError } from 'rxjs/operators'; + +@Injectable({ providedIn: 'root' }) +export class CourseResolve implements Resolve { + constructor(private courseManagementService: CourseManagementService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const courseId = route.params['courseId']; + + if (courseId) { + return this.courseManagementService.find(courseId).pipe( + map((response) => response.body), + catchError(() => of(null)), + ); + } + + return of(null); + } +} @Injectable({ providedIn: 'root' }) export class ExamResolve implements Resolve { diff --git a/src/main/webapp/app/exam/manage/exam-management.route.ts b/src/main/webapp/app/exam/manage/exam-management.route.ts index cf7b396060f0..5d9b09e74882 100644 --- a/src/main/webapp/app/exam/manage/exam-management.route.ts +++ b/src/main/webapp/app/exam/manage/exam-management.route.ts @@ -53,7 +53,7 @@ import { OrionTutorAssessmentComponent } from 'app/orion/assessment/orion-tutor- import { isOrion } from 'app/shared/orion/orion'; import { FileUploadExerciseManagementResolve } from 'app/exercises/file-upload/manage/file-upload-exercise-management-resolve.service'; import { ModelingExerciseResolver } from 'app/exercises/modeling/manage/modeling-exercise-resolver.service'; -import { ExamResolve, ExerciseGroupResolve, StudentExamResolve } from 'app/exam/manage/exam-management-resolve.service'; +import { CourseResolve, ExamResolve, ExerciseGroupResolve, StudentExamResolve } from 'app/exam/manage/exam-management-resolve.service'; import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; import { SuspiciousBehaviorComponent } from 'app/exam/manage/suspicious-behavior/suspicious-behavior.component'; import { SuspiciousSessionsOverviewComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; @@ -73,6 +73,7 @@ export const examManagementRoute: Routes = [ component: ExamUpdateComponent, resolve: { exam: ExamResolve, + course: CourseResolve, }, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], @@ -85,6 +86,7 @@ export const examManagementRoute: Routes = [ component: ExamUpdateComponent, resolve: { exam: ExamResolve, + course: CourseResolve, }, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], @@ -113,6 +115,7 @@ export const examManagementRoute: Routes = [ component: ExamUpdateComponent, resolve: { exam: ExamResolve, + course: CourseResolve, }, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html index 36e7d8210cbb..f1847f493456 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html @@ -11,16 +11,14 @@