diff --git a/docker/atlassian.yml b/docker/atlassian.yml index 529125d5146f..1b3db46a182f 100644 --- a/docker/atlassian.yml +++ b/docker/atlassian.yml @@ -9,7 +9,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" image: ghcr.io/ls1intum/artemis-jira:9.4.3 - pull_policy: always + pull_policy: if_not_present volumes: - artemis-jira-data:/var/atlassian/application-data/jira ports: @@ -25,7 +25,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" image: ghcr.io/ls1intum/artemis-bitbucket:8.13.1 - pull_policy: always + pull_policy: if_not_present volumes: - artemis-bitbucket-data:/var/atlassian/application-data/bitbucket environment: @@ -45,7 +45,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" image: ghcr.io/ls1intum/artemis-bamboo:9.3.3 - pull_policy: always + pull_policy: if_not_present volumes: - artemis-bamboo-data:/var/atlassian/application-data/bamboo ports: @@ -70,7 +70,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" image: ghcr.io/ls1intum/artemis-bamboo-build-agent:9.3.3 - pull_policy: always + pull_policy: if_not_present volumes: # The following path needs to be the same absolute path on the host because of the docker socket: # https://confluence.atlassian.com/bamkb/bamboo-in-docker-build-fails-due-to-a-missing-working-directory-when-using-docker-runner-1027119339.html diff --git a/docker/broker-registry.yml b/docker/broker-registry.yml index 74b9b79a3325..1e5dd59e0026 100644 --- a/docker/broker-registry.yml +++ b/docker/broker-registry.yml @@ -2,7 +2,7 @@ services: jhipster-registry: container_name: artemis-jhipster-registry image: docker.io/jhipster/jhipster-registry:v6.1.2 - pull_policy: always + pull_policy: if_not_present volumes: - ./registry:/central-config # When run with the "dev" Spring profile, the JHipster Registry will @@ -25,7 +25,7 @@ services: activemq-broker: container_name: artemis-activemq-broker image: docker.io/vromero/activemq-artemis:latest - pull_policy: always + pull_policy: if_not_present environment: ARTEMIS_USERNAME: guest ARTEMIS_PASSWORD: guest diff --git a/docker/cypress-E2E-tests-mysql.yml b/docker/cypress-E2E-tests-mysql.yml index 99e96f8b7c87..a14c4c163ce7 100644 --- a/docker/cypress-E2E-tests-mysql.yml +++ b/docker/cypress-E2E-tests-mysql.yml @@ -46,6 +46,8 @@ services: CYPRESS_DB_TYPE: "MySQL" SORRY_CYPRESS_PROJECT_ID: "artemis-mysql" command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:mysql & sleep 60 && npm run cypress:record:mysql & wait)" +# Old run method using plain cypress kept here as backup +# command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:run" networks: artemis: diff --git a/docker/cypress-E2E-tests-postgres.yml b/docker/cypress-E2E-tests-postgres.yml index 0c2ed641ee70..41fe8edf5faa 100644 --- a/docker/cypress-E2E-tests-postgres.yml +++ b/docker/cypress-E2E-tests-postgres.yml @@ -47,6 +47,8 @@ services: CYPRESS_DB_TYPE: "Postgres" SORRY_CYPRESS_PROJECT_ID: "artemis-postgres" command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:postgres & sleep 60 && npm run cypress:record:postgres & wait)" +# Old run method using plain cypress kept here as backup +# command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:run" networks: artemis: diff --git a/docker/cypress.yml b/docker/cypress.yml index 494d78473672..2e337c158c4d 100644 --- a/docker/cypress.yml +++ b/docker/cypress.yml @@ -6,7 +6,7 @@ services: artemis-cypress: # Cypress image with node and chrome browser installed (Cypress installation needs to be done separately because we require additional dependencies) image: docker.io/cypress/browsers:node-18.16.0-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1 - pull_policy: always + pull_policy: if_not_present environment: CYPRESS_baseUrl: "https://artemis-nginx" CYPRESS_video: "${bamboo_cypress_video_enabled}" diff --git a/docker/gitlab-gitlabci.yml b/docker/gitlab-gitlabci.yml index c454136ffa4e..d5dac2dc635f 100644 --- a/docker/gitlab-gitlabci.yml +++ b/docker/gitlab-gitlabci.yml @@ -34,7 +34,7 @@ services: shm_size: "256m" gitlab-runner: image: docker.io/gitlab/gitlab-runner:latest - pull_policy: always + pull_policy: if_not_present container_name: artemis-gitlab-runner volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/docker/mailhog.yml b/docker/mailhog.yml index 70af79096071..fb93d5784b4d 100644 --- a/docker/mailhog.yml +++ b/docker/mailhog.yml @@ -8,7 +8,7 @@ services: mailhog: container_name: artemis-mailhog image: docker.io/mailhog/mailhog - pull_policy: always + pull_policy: if_not_present ports: - "1025:1025" - "8025:8025" diff --git a/docker/monitoring.yml b/docker/monitoring.yml index 9c237f83b69b..f9f4f5b7ac08 100644 --- a/docker/monitoring.yml +++ b/docker/monitoring.yml @@ -3,14 +3,14 @@ # ---------------------------------------------------------------------------------------------------------------------- # This configuration is intended for development purpose, it's **your** responsibility to harden it for production # -# Out of the box this setup just works with a non-containerized Artemis instancezs +# Out of the box this setup just works with a non-containerized Artemis instances # ---------------------------------------------------------------------------------------------------------------------- services: prometheus: container_name: artemis-prometheus image: docker.io/prom/prometheus:v2.34.0 - pull_policy: always + pull_policy: if_not_present volumes: - ./monitoring/prometheus/:/etc/prometheus/ # If you want to expose these ports outside your dev PC, @@ -26,7 +26,7 @@ services: grafana: container_name: artemis-grafana image: docker.io/grafana/grafana:9.0.2 - pull_policy: always + pull_policy: if_not_present volumes: - ./monitoring/grafana/provisioning/:/etc/grafana/provisioning/ environment: diff --git a/docker/mysql.yml b/docker/mysql.yml index f7b79b40ba63..97ae038e67fa 100644 --- a/docker/mysql.yml +++ b/docker/mysql.yml @@ -6,7 +6,7 @@ services: mysql: container_name: artemis-mysql image: docker.io/library/mysql:8.0.33 - pull_policy: always + pull_policy: if_not_present volumes: - artemis-mysql-data:/var/lib/mysql # DO NOT use this default file for production systems! diff --git a/docker/nginx.yml b/docker/nginx.yml index aeb196c5ea07..43807eed4a37 100644 --- a/docker/nginx.yml +++ b/docker/nginx.yml @@ -7,7 +7,7 @@ services: # nginx setup based on artemis prod ansible repository container_name: artemis-nginx image: docker.io/library/nginx:1.23 - pull_policy: always + pull_policy: if_not_present volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/timeouts.conf:/etc/nginx/conf.d/timeouts.conf:ro diff --git a/docker/postgres.yml b/docker/postgres.yml index 097c9530baee..9feba0d54638 100644 --- a/docker/postgres.yml +++ b/docker/postgres.yml @@ -6,7 +6,7 @@ services: postgres: container_name: artemis-postgres image: docker.io/library/postgres:15.3-alpine - pull_policy: always + pull_policy: if_not_present user: postgres command: ["postgres", "-c", "max_connections=200"] volumes: diff --git a/docker/saml-test.yml b/docker/saml-test.yml index 63fb677b0741..a71d69dee02e 100644 --- a/docker/saml-test.yml +++ b/docker/saml-test.yml @@ -13,7 +13,7 @@ services: saml-test: container_name: artemis-saml-test image: docker.io/jamedjo/test-saml-idp - pull_policy: always + pull_policy: if_not_present ports: - "9980:8080" # expose the port to make it reachable docker internally even if the external port mapping changes diff --git a/docs/index.rst b/docs/index.rst index 2d769bb8de6e..912c3aa9570c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,6 +38,7 @@ All these exercises are supposed to be run either live in the lecture with insta user/courses/customizable user/scaling user/markdown-support + user/exports user/mobile-applications diff --git a/docs/user/exports.rst b/docs/user/exports.rst new file mode 100644 index 000000000000..b2ac771276b7 --- /dev/null +++ b/docs/user/exports.rst @@ -0,0 +1,91 @@ +.. _exports: + +.. |archive_course| image:: exports/archive_course.png +.. |archive_exam| image:: exports/archive_exam.png +.. |download_archive| image:: exports/download_archive.png +.. |export_quiz| image:: exports/export_quiz.png +.. |export_results| image:: exports/export_results.png +.. |export_submissions| image:: exports/export_submissions.png +.. |download_exercise| image:: exports/download_exercise.png +.. |download_repos| image:: exports/download_repos.png +.. |download_scores| image:: exports/scores.png +.. |export_scores| image:: exports/export_scores.png +.. |export| image:: exports/export.png +.. |scores_navigation_bar| image:: exports/scores_navigation_bar.png +.. |privacy_statement| image:: exports/privacy_statement.png +.. |data_export| image:: exports/data_export.png + + +Exports +======= + +.. contents:: Table of Contents + :local: + :depth: 2 + +Overview +-------- +Artemis offers several options to export or archive different data. The following table gives an overview of the available export options. + +.. list-table:: Export options + :widths: 100 + :header-rows: 1 + + * - Export/Archive option + * - Archive course/exam + * - Export programming exercise material + * - Export programming exercise student repositories + * - Export exercise results + * - Export quiz questions + * - Export exercise submissions + * - Export user data + +Archive course/exam +------------------- +Export all course/exam data, including all exercises and student submissions. +To archive a course or an exam the end date of the entity needs to be in the past. +You can archive a course by clicking |archive_course| on the course management overview page or an exam by clicking |archive_exam| on the exam checklist page. This will create a zip file containing all exercises of the exam or course and all student submissions. For a course all exams are exported as well. +For each exercise the problem statement and a JSON file with exercise details such as points are exported. For programming exercises, the template, solution, test, and auxiliary (if existing) repository is exported as well. +The creation is done asynchronously. You will receive a notification once the archive is ready to download. You can then download the archive by clicking |download_archive| on the course management overview page or the exam checklist page. + +Export programming exercise material +------------------------------------ +Export the exercise material (template, solution, test, and auxiliary repositories as well as the problem statement and other general exercise information) of a programming exercise. +To export the material click the |download_exercise| button on the exercise details page. + +Export quiz exercise +-------------------- +Exports the questions and the sample solution of a quiz in JSON format. +You can export a a quiz exercise by clicking the |export_quiz| button on the exercises overview page. + + +Export programming exercise student repositories +------------------------------------------------ +Export the student repositories (this can include the repositories for both graded and practice participations) of a programming exercise. +To export the repositories click the |export| button and then the |download_repos| button on the |download_scores| page. + +Export exercise submissions +--------------------------- +Export the submissions of all students that participated in a specific exercise. This is supported for text, modeling and file upload exercises. +Text submissions are exported as a zip file containing all submissions as text files. +Modeling submissions are exported as a zip file containing all submissions as json files. +File upload submissions are exported as a zip file containing all submitted files. +To export the submissions click the |export_scores| button and then the |export_submissions| button on the |download_scores| page. + +Export exercise results +----------------------------------- +Export the results of students for a specific exercise as CSV file. This is supported for all exercise types. +To export the results click the |export| button and then the |export_results| button on the |download_scores| page. + +Export course/exam scores +------------------------- +Export the scores of all students that participated in a specific course or exam. This is supported for all exercise types. +The scores are exported in CSV format. +To export the scores of a course click on the |scores_navigation_bar| tab in the course management navigation bar and then the |export_scores| button. +For exams you can export the scores by clicking the |download_scores| button on the exam checklist page and then |export_scores| button. + +Export user data +---------------- +Export all data Artemis stores about a specific user. This includes information such as name or email, exercise submissions, results, feedbacks the user received, messages they've sent. +You can request a data export by clicking |privacy_statement| and |data_export|. Once the export has been created you will receive an email with a download link. + diff --git a/docs/user/exports/archive_course.png b/docs/user/exports/archive_course.png new file mode 100644 index 000000000000..29c0e8a08687 Binary files /dev/null and b/docs/user/exports/archive_course.png differ diff --git a/docs/user/exports/archive_exam.png b/docs/user/exports/archive_exam.png new file mode 100644 index 000000000000..e767b4186ed9 Binary files /dev/null and b/docs/user/exports/archive_exam.png differ diff --git a/docs/user/exports/data_export.png b/docs/user/exports/data_export.png new file mode 100644 index 000000000000..a91999f778ba Binary files /dev/null and b/docs/user/exports/data_export.png differ diff --git a/docs/user/exports/download_archive.png b/docs/user/exports/download_archive.png new file mode 100644 index 000000000000..03e4715ea1e8 Binary files /dev/null and b/docs/user/exports/download_archive.png differ diff --git a/docs/user/exports/download_exercise.png b/docs/user/exports/download_exercise.png new file mode 100644 index 000000000000..7f1c243d15d3 Binary files /dev/null and b/docs/user/exports/download_exercise.png differ diff --git a/docs/user/exports/download_repos.png b/docs/user/exports/download_repos.png new file mode 100644 index 000000000000..67713a16a48d Binary files /dev/null and b/docs/user/exports/download_repos.png differ diff --git a/docs/user/exports/export.png b/docs/user/exports/export.png new file mode 100644 index 000000000000..b5d7029c404c Binary files /dev/null and b/docs/user/exports/export.png differ diff --git a/docs/user/exports/export_quiz.png b/docs/user/exports/export_quiz.png new file mode 100644 index 000000000000..cdb9ffb9b287 Binary files /dev/null and b/docs/user/exports/export_quiz.png differ diff --git a/docs/user/exports/export_results.png b/docs/user/exports/export_results.png new file mode 100644 index 000000000000..3768f43c4d8f Binary files /dev/null and b/docs/user/exports/export_results.png differ diff --git a/docs/user/exports/export_scores.png b/docs/user/exports/export_scores.png new file mode 100644 index 000000000000..d3bf384e4b1a Binary files /dev/null and b/docs/user/exports/export_scores.png differ diff --git a/docs/user/exports/export_submissions.png b/docs/user/exports/export_submissions.png new file mode 100644 index 000000000000..1181a1fbcbbb Binary files /dev/null and b/docs/user/exports/export_submissions.png differ diff --git a/docs/user/exports/privacy_statement.png b/docs/user/exports/privacy_statement.png new file mode 100644 index 000000000000..8d0e3a57155f Binary files /dev/null and b/docs/user/exports/privacy_statement.png differ diff --git a/docs/user/exports/scores.png b/docs/user/exports/scores.png new file mode 100644 index 000000000000..fc5494bae030 Binary files /dev/null and b/docs/user/exports/scores.png differ diff --git a/docs/user/exports/scores_navigation_bar.png b/docs/user/exports/scores_navigation_bar.png new file mode 100644 index 000000000000..24f11a2f940e Binary files /dev/null and b/docs/user/exports/scores_navigation_bar.png differ diff --git a/package.json b/package.json index 00573d87d6c0..486e88a2608a 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,6 @@ "test-diff:ci": "git fetch origin develop && npm run prebuild && ng test --log-heap-usage -w=4 --ci --reporters=default --reporters=jest-junit --pass-with-no-tests --changed-since=origin/develop", "test:leaks": "npm run prebuild && ng test --log-heap-usage --detect-leaks", "test:open-handles": "npm run prebuild && ng test --detect-open-handles", - "test:server-api-tests": "`pwd`/src/test/k6/api_tests.sh", "test:watch": "npm run prebuild && npm run test -- --watch", "webapp:build": "npm run clean-www && npm run webapp:build:dev", "webapp:build:dev": "npm run prebuild -- --develop && ng build --configuration development", diff --git a/src/main/java/de/tum/in/www1/artemis/config/SecurityConfiguration.java b/src/main/java/de/tum/in/www1/artemis/config/SecurityConfiguration.java index 2ebb088e3689..a1d323f04cdf 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/SecurityConfiguration.java +++ b/src/main/java/de/tum/in/www1/artemis/config/SecurityConfiguration.java @@ -1,9 +1,6 @@ package de.tum.in.www1.artemis.config; -import static de.tum.in.www1.artemis.config.Constants.*; - -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import javax.annotation.PostConstruct; @@ -12,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; @@ -62,8 +60,11 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Value("#{'${spring.prometheus.monitoringIp:127.0.0.1}'.split(',')}") private List monitoringIpAddresses; + private final Environment env; + public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerBuilder, UserDetailsService userDetailsService, TokenProvider tokenProvider, - CorsFilter corsFilter, SecurityProblemSupport problemSupport, PasswordService passwordService, Optional remoteUserAuthenticationProvider) { + CorsFilter corsFilter, SecurityProblemSupport problemSupport, PasswordService passwordService, Optional remoteUserAuthenticationProvider, + Environment env) { this.authenticationManagerBuilder = authenticationManagerBuilder; this.userDetailsService = userDetailsService; this.tokenProvider = tokenProvider; @@ -71,6 +72,7 @@ public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerB this.problemSupport = problemSupport; this.passwordService = passwordService; this.remoteUserAuthenticationProvider = remoteUserAuthenticationProvider; + this.env = env; } /** @@ -170,7 +172,6 @@ protected void configure(HttpSecurity http) throws Exception { .antMatchers(HttpMethod.POST, "/api/programming-exercises/new-result").permitAll() .antMatchers(HttpMethod.POST, "/api/programming-submissions/*").permitAll() .antMatchers(HttpMethod.POST, "/api/programming-exercises/test-cases-changed/*").permitAll() - .antMatchers(HttpMethod.POST, "/api/lti/launch/*").permitAll() .antMatchers("/websocket/**").permitAll() .antMatchers("/.well-known/jwks.json").permitAll() .antMatchers("/management/prometheus/**").access(getMonitoringAccessDefinition()) @@ -178,7 +179,10 @@ protected void configure(HttpSecurity http) throws Exception { .and() .apply(securityConfigurerAdapter()); - http.apply(new CustomLti13Configurer()); + Collection activeProfiles = Arrays.asList(env.getActiveProfiles()); + if (activeProfiles.contains("lti")) { + http.apply(new CustomLti13Configurer()); + } // @formatter:on } diff --git a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java index 6dfa3b6b57a1..dd4f1de01542 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java +++ b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.config.lti; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; @@ -18,6 +19,7 @@ /** * Configures and registers Security Filters to handle LTI 1.3 Resource Link Launches */ +@Profile("lti") public class CustomLti13Configurer extends Lti13Configurer { private static final String LOGIN_PATH = "/auth-login"; diff --git a/src/main/java/de/tum/in/www1/artemis/domain/plagiarism/PlagiarismDetectionConfig.java b/src/main/java/de/tum/in/www1/artemis/domain/plagiarism/PlagiarismDetectionConfig.java new file mode 100644 index 000000000000..6f455b2f539a --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/plagiarism/PlagiarismDetectionConfig.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.domain.plagiarism; + +/** + * Stores configuration for plagiarism detection. + */ +public record PlagiarismDetectionConfig(float similarityThreshold, int minimumScore, int minimumSize) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRelationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRelationRepository.java index bea1a8864da1..57a60304e674 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRelationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRelationRepository.java @@ -33,4 +33,10 @@ public interface CompetencyRelationRepository extends JpaRepository findAllByCourseId(@Param("courseId") Long courseId); + @Query(""" + SELECT count(cr) + FROM CompetencyRelation cr + WHERE cr.headCompetency.course.id = :courseId OR cr.tailCompetency.course.id = :courseId + """) + long countByCourseId(@Param("courseId") long courseId); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java index 18be4967ccf2..cfffc608f4fd 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.competency.Competency; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -180,4 +181,6 @@ default Competency findByIdWithExercisesAndLectureUnitsElseThrow(Long competency default Competency findByIdWithExercisesElseThrow(Long competencyId) { return findByIdWithExercises(competencyId).orElseThrow(() -> new EntityNotFoundException("Competency", competencyId)); } + + long countByCourse(Course course); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java index ba0402ff8808..6abb8eae5d58 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java @@ -184,17 +184,31 @@ Optional findByExerciseIdAndStudentIdAndTestRunWithEagerSu * @return participations for exercise. */ @Query(""" - SELECT DISTINCT p FROM StudentParticipation p - LEFT JOIN FETCH p.results r - LEFT JOIN FETCH r.submission s - LEFT JOIN FETCH p.submissions - WHERE p.exercise.id = :#{#exerciseId} - AND (r.id = (SELECT max(id) FROM p.results) + SELECT DISTINCT p + FROM StudentParticipation p + LEFT JOIN FETCH p.results r + LEFT JOIN FETCH r.submission s + LEFT JOIN FETCH p.submissions + WHERE p.exercise.id = :exerciseId + AND (r.id = (SELECT max(p_r.id) FROM p.results p_r) OR r.assessmentType <> 'AUTOMATIC' OR r IS NULL) """) Set findByExerciseIdWithLatestAndManualResults(@Param("exerciseId") Long exerciseId); + @Query(""" + SELECT DISTINCT p + FROM StudentParticipation p + LEFT JOIN FETCH p.results r + LEFT JOIN FETCH r.submission s + LEFT JOIN FETCH p.submissions + WHERE p.exercise.id = :exerciseId + AND (r.id = (SELECT max(p_r.id) FROM p.results p_r WHERE p_r.rated = true) + OR r.assessmentType <> 'AUTOMATIC' + OR r IS NULL) + """) + Set findByExerciseIdWithLatestAndManualRatedResults(@Param("exerciseId") Long exerciseId); + @Query(""" SELECT DISTINCT p FROM StudentParticipation p LEFT JOIN FETCH p.results r @@ -979,6 +993,14 @@ GROUP BY COALESCE(p.student.id, ts.id) """) Set sumPresentationScoreByStudentIdsAndCourseId(@Param("courseId") long courseId, @Param("studentIds") Set studentIds); + @Query(""" + SELECT p FROM StudentParticipation p + LEFT JOIN FETCH p.submissions s + WHERE p.exercise.id = :exerciseId + + """) + Set findByExerciseIdWithEagerSubmissions(long exerciseId); + /** * Helper interface to map the result of the {@link #sumPresentationScoreByStudentIdsAndCourseId(long, Set)} query to a map. */ diff --git a/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisMessageRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisMessageRepository.java index 0400efcdf8dc..749903a605b7 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisMessageRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/iris/IrisMessageRepository.java @@ -1,11 +1,13 @@ package de.tum.in.www1.artemis.repository.iris; +import java.time.ZonedDateTime; import java.util.List; import javax.validation.constraints.NotNull; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import de.tum.in.www1.artemis.domain.iris.IrisMessage; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -26,6 +28,25 @@ public interface IrisMessageRepository extends JpaRepository """) List findAllExceptSystemMessagesWithContentBySessionId(Long sessionId); + /** + * Counts the number of LLM responses the user got within the given timeframe. + * + * @param userId the id of the user + * @param start the start of the timeframe + * @param end the end of the timeframe + * @return the number of chat messages sent by the user within the given timeframe + */ + @Query(""" + SELECT COUNT(DISTINCT m) + FROM IrisMessage m + LEFT JOIN m.session as s + WHERE type(s) = de.tum.in.www1.artemis.domain.iris.session.IrisChatSession + AND s.user.id = :userId + AND m.sender = 'LLM' + AND m.sentAt BETWEEN :start AND :end + """) + int countLlmResponsesOfUserWithinTimeframe(@Param("userId") Long userId, @Param("start") ZonedDateTime start, @Param("end") ZonedDateTime end); + @NotNull default IrisMessage findByIdElseThrow(long messageId) throws EntityNotFoundException { return findById(messageId).orElseThrow(() -> new EntityNotFoundException("Iris Message", messageId)); diff --git a/src/main/java/de/tum/in/www1/artemis/security/ArtemisAuthenticationProvider.java b/src/main/java/de/tum/in/www1/artemis/security/ArtemisAuthenticationProvider.java index 28ab83ca2853..2849126ec584 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/ArtemisAuthenticationProvider.java +++ b/src/main/java/de/tum/in/www1/artemis/security/ArtemisAuthenticationProvider.java @@ -2,29 +2,13 @@ import java.util.Optional; -import javax.annotation.Nullable; - import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.core.Authentication; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.service.connectors.ConnectorHealth; public interface ArtemisAuthenticationProvider extends AuthenticationProvider { - /** - * Gets the user object for the specified authentication or creates one in Artemis based on the passed information (possibly asking an external authentication source). - * Note: This method does not create a new user in the external authentication source. - * - * @param authentication the Spring authentication object which includes the username and password - * @param firstName The first name of the user that should get created if not present - * @param lastName The last name of the user that should get created if not present - * @param email The email of the user that should get created if not present - * @param skipPasswordCheck whether the password against the by the user management system provided user should be skipped - * @return The Artemis user identified by the provided credentials - */ - User getOrCreateUser(Authentication authentication, @Nullable String firstName, @Nullable String lastName, @Nullable String email, boolean skipPasswordCheck); - /** * Adds a user to the specified group * diff --git a/src/main/java/de/tum/in/www1/artemis/security/ArtemisInternalAuthenticationProvider.java b/src/main/java/de/tum/in/www1/artemis/security/ArtemisInternalAuthenticationProvider.java index b043de565e85..696bdc0cbb08 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/ArtemisInternalAuthenticationProvider.java +++ b/src/main/java/de/tum/in/www1/artemis/security/ArtemisInternalAuthenticationProvider.java @@ -4,7 +4,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.AuthenticationServiceException; -import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -39,24 +38,6 @@ public Authentication authenticate(Authentication authentication) throws Authent return new UsernamePasswordAuthenticationToken(user.get().getLogin(), user.get().getPassword(), user.get().getGrantedAuthorities()); } - @Override - public User getOrCreateUser(Authentication authentication, String firstName, String lastName, String email, boolean skipPasswordCheck) { - final var password = authentication.getCredentials().toString(); - final var optionalUser = userRepository.findOneByLogin(authentication.getName().toLowerCase()); - final User user; - if (optionalUser.isEmpty()) { - user = userCreationService.createUser(authentication.getName(), password, null, firstName, lastName, email, null, null, "en", true); - } - else { - user = optionalUser.get(); - if (!skipPasswordCheck && !passwordService.checkPasswordMatch(password, user.getPassword())) { - throw new InternalAuthenticationServiceException("Authentication failed for user " + user.getLogin()); - } - } - - return user; - } - @Override public void addUserToGroup(User user, String group) { // nothing to do, this was already done by the UserService, this method is only needed when external management is active diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 7a172a5ae87c..c29b4584e56a 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -29,6 +30,7 @@ * Step 3. of OpenID Connect Third Party Initiated Login is handled solely by spring-security-lti13 * OAuth2LoginAuthenticationFilter. */ +@Profile("lti") public class Lti13LaunchFilter extends OncePerRequestFilter { private final OAuth2LoginAuthenticationFilter defaultFilter; diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java index 877705ec36c4..0d015b913ea1 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java @@ -7,6 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.http.*; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.stereotype.Component; @@ -31,6 +32,7 @@ * This class is responsible to retrieve access tokens from an LTI 1.3 platform of a specific ClientRegistration. */ @Component +@Profile("lti") public class Lti13TokenRetriever { private final OAuth2JWKSService oAuth2JWKSService; diff --git a/src/main/java/de/tum/in/www1/artemis/service/AssessmentService.java b/src/main/java/de/tum/in/www1/artemis/service/AssessmentService.java index 39e347927409..6b48589ea2c8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/AssessmentService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/AssessmentService.java @@ -42,12 +42,12 @@ public class AssessmentService { private final SubmissionService submissionService; - private final LtiNewResultService ltiNewResultService; + private final Optional ltiNewResultService; public AssessmentService(ComplaintResponseService complaintResponseService, ComplaintRepository complaintRepository, FeedbackRepository feedbackRepository, ResultRepository resultRepository, StudentParticipationRepository studentParticipationRepository, ResultService resultService, SubmissionService submissionService, SubmissionRepository submissionRepository, ExamDateService examDateService, GradingCriterionRepository gradingCriterionRepository, UserRepository userRepository, - LtiNewResultService ltiNewResultService) { + Optional ltiNewResultService) { this.complaintResponseService = complaintResponseService; this.complaintRepository = complaintRepository; this.feedbackRepository = feedbackRepository; @@ -216,8 +216,12 @@ public Result submitManualAssessment(long resultId, Exercise exercise) { result.setRatedIfNotAfterDueDate(); result.setCompletionDate(ZonedDateTime.now()); result = resultRepository.submitResult(result, exercise, ExerciseDateService.getDueDate(result.getParticipation())); - // Note: we always need to report the result (independent of the assessment due date) over LTI, otherwise it might never become visible in the external system - ltiNewResultService.onNewResult((StudentParticipation) result.getParticipation()); + + if (ltiNewResultService.isPresent()) { + // Note: we always need to report the result (independent of the assessment due date) over LTI, if LTI is configured. + // Otherwise, it might never become visible in the external system + ltiNewResultService.get().onNewResult((StudentParticipation) result.getParticipation()); + } return result; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileService.java b/src/main/java/de/tum/in/www1/artemis/service/FileService.java index ac239e2b645d..2226bf6962eb 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/FileService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/FileService.java @@ -79,6 +79,10 @@ public class FileService implements DisposableBean { public static final String DEFAULT_FILE_SUBPATH = "/api/files/temp/"; + public static final String BACKGROUND_FILE_SUBPATH = "/api/files/drag-and-drop/backgrounds/"; + + public static final String PICTURE_FILE_SUBPATH = "/api/files/drag-and-drop/drag-items/"; + /** * Filenames for which the template filename differs from the filename it should have in the repository. */ @@ -248,6 +252,25 @@ public Path copyExistingFileToTarget(Path oldFilePath, Path targetFolder) { return null; } + /** + * Checks whether the path starts with the provided sub-path. + * + * @param path URI to check if it starts with the sub-pat + * @param subPath sub-path URI to search for + * @throws IllegalArgumentException if the provided path does not start with the provided sub-path or the provided legacy-sub-path + */ + public static void sanitizeByCheckingIfPathStartsWithSubPathElseThrow(@NotNull URI path, @NotNull URI subPath) { + // Removes redundant elements (e.g. ../ or ./) from the path and sub-path + URI normalisedPath = path.normalize(); + URI normalisedSubPath = subPath.normalize(); + // Indicates whether the path starts with the subPath + boolean normalisedPathStartsWithNormalisedSubPath = normalisedPath.getPath().startsWith(normalisedSubPath.getPath()); + // Throws a IllegalArgumentException in case the normalisedPath does not start with the normalisedSubPath + if (!normalisedPathStartsWithNormalisedSubPath) { + throw new IllegalArgumentException("Path is not valid!"); + } + } + /** * Generates a prefix for the filename based on the target folder * diff --git a/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java b/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java index 2329e5c5f757..e16f38edc41b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java @@ -49,14 +49,17 @@ public class LearningPathService { private final CourseRepository courseRepository; + private final CompetencyRepository competencyRepository; + private final CompetencyRelationRepository competencyRelationRepository; public LearningPathService(UserRepository userRepository, LearningPathRepository learningPathRepository, CompetencyProgressRepository competencyProgressRepository, - CourseRepository courseRepository, CompetencyRelationRepository competencyRelationRepository) { + CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyRelationRepository competencyRelationRepository) { this.userRepository = userRepository; this.learningPathRepository = learningPathRepository; this.competencyProgressRepository = competencyProgressRepository; this.courseRepository = courseRepository; + this.competencyRepository = competencyRepository; this.competencyRelationRepository = competencyRelationRepository; } @@ -178,17 +181,46 @@ private void updateLearningPathProgress(@NotNull LearningPath learningPath) { */ public LearningPathHealthDTO getHealthStatusForCourse(@NotNull Course course) { if (!course.getLearningPathsEnabled()) { - return new LearningPathHealthDTO(LearningPathHealthDTO.HealthStatus.DISABLED); + return new LearningPathHealthDTO(Set.of(LearningPathHealthDTO.HealthStatus.DISABLED)); + } + + Set status = new HashSet<>(); + Long numberOfMissingLearningPaths = checkMissingLearningPaths(course, status); + checkNoCompetencies(course, status); + checkNoRelations(course, status); + + // if no issues where found, add OK status + if (status.isEmpty()) { + status.add(LearningPathHealthDTO.HealthStatus.OK); } + return new LearningPathHealthDTO(status, numberOfMissingLearningPaths); + } + + private Long checkMissingLearningPaths(@NotNull Course course, @NotNull Set status) { long numberOfStudents = userRepository.countUserInGroup(course.getStudentGroupName()); long numberOfLearningPaths = learningPathRepository.countLearningPathsOfEnrolledStudentsInCourse(course.getId()); + Long numberOfMissingLearningPaths = numberOfStudents - numberOfLearningPaths; - if (numberOfStudents == numberOfLearningPaths) { - return new LearningPathHealthDTO(LearningPathHealthDTO.HealthStatus.OK); + if (numberOfMissingLearningPaths != 0) { + status.add(LearningPathHealthDTO.HealthStatus.MISSING); } else { - return new LearningPathHealthDTO(LearningPathHealthDTO.HealthStatus.MISSING, numberOfStudents - numberOfLearningPaths); + numberOfMissingLearningPaths = null; + } + + return numberOfMissingLearningPaths; + } + + private void checkNoCompetencies(@NotNull Course course, @NotNull Set status) { + if (competencyRepository.countByCourse(course) == 0) { + status.add(LearningPathHealthDTO.HealthStatus.NO_COMPETENCIES); + } + } + + private void checkNoRelations(@NotNull Course course, @NotNull Set status) { + if (competencyRelationRepository.countByCourseId(course.getId()) == 0) { + status.add(LearningPathHealthDTO.HealthStatus.NO_RELATIONS); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java index 29638f7d66b5..7b4c96579929 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java @@ -104,8 +104,13 @@ private void copyQuizQuestions(QuizExercise importedExercise, QuizExercise newEx } else if (quizQuestion instanceof DragAndDropQuestion dndQuestion) { if (dndQuestion.getBackgroundFilePath() != null) { + URI backgroundFilePublicPath = URI.create(dndQuestion.getBackgroundFilePath()); + URI backgroundFileIntendedPath = URI.create(FileService.BACKGROUND_FILE_SUBPATH); + // Check whether pictureFilePublicPath is actually a picture file path + // (which is the case when its path starts with the path backgroundFileIntendedPath) + FileService.sanitizeByCheckingIfPathStartsWithSubPathElseThrow(backgroundFilePublicPath, backgroundFileIntendedPath); // Need to copy the file and get a new path, otherwise two different questions would share the same image and would cause problems in case one was deleted - Path oldPath = filePathService.actualPathForPublicPath(URI.create(dndQuestion.getBackgroundFilePath())); + Path oldPath = filePathService.actualPathForPublicPath(backgroundFilePublicPath); Path newPath = fileService.copyExistingFileToTarget(oldPath, FilePathService.getDragAndDropBackgroundFilePath()); dndQuestion.setBackgroundFilePath(filePathService.publicPathForActualPath(newPath, null).toString()); } @@ -121,8 +126,13 @@ else if (quizQuestion instanceof DragAndDropQuestion dndQuestion) { dragItem.setId(null); dragItem.setQuestion(dndQuestion); if (dragItem.getPictureFilePath() != null) { + URI pictureFilePublicPath = URI.create(dragItem.getPictureFilePath()); + URI pictureFileIntendedPath = URI.create(FileService.PICTURE_FILE_SUBPATH); + // Check whether pictureFilePublicPath is actually a picture file path + // (which is the case when its path starts with the path pictureFileIntendedPath) + FileService.sanitizeByCheckingIfPathStartsWithSubPathElseThrow(pictureFilePublicPath, pictureFileIntendedPath); // Need to copy the file and get a new path, same as above - Path oldDragItemPath = filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath())); + Path oldDragItemPath = filePathService.actualPathForPublicPath(pictureFilePublicPath); Path newDragItemPath = fileService.copyExistingFileToTarget(oldDragItemPath, FilePathService.getDragItemFilePath()); dragItem.setPictureFilePath(filePathService.publicPathForActualPath(newDragItemPath, null).toString()); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java index 92a8ecb3f772..e2b5af5545fa 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java @@ -34,7 +34,7 @@ public class ResultService { private final ResultRepository resultRepository; - private final LtiNewResultService ltiNewResultService; + private final Optional ltiNewResultService; private final ResultWebsocketService resultWebsocketService; @@ -60,10 +60,11 @@ public class ResultService { private final StudentExamRepository studentExamRepository; - public ResultService(UserRepository userRepository, ResultRepository resultRepository, LtiNewResultService ltiNewResultService, ResultWebsocketService resultWebsocketService, - ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, - ComplaintRepository complaintRepository, ParticipantScoreRepository participantScoreRepository, AuthorizationCheckService authCheckService, - ExerciseDateService exerciseDateService, TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, + ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, + FeedbackRepository feedbackRepository, ComplaintRepository complaintRepository, ParticipantScoreRepository participantScoreRepository, + AuthorizationCheckService authCheckService, ExerciseDateService exerciseDateService, + TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository) { this.userRepository = userRepository; @@ -110,8 +111,8 @@ public Result createNewManualResult(Result result, boolean ratedResult) { // if it is an example result we do not have any participation (isExampleResult can be also null) if (Boolean.FALSE.equals(savedResult.isExampleResult()) || savedResult.isExampleResult() == null) { - if (savedResult.getParticipation() instanceof ProgrammingExerciseStudentParticipation) { - ltiNewResultService.onNewResult((StudentParticipation) savedResult.getParticipation()); + if (savedResult.getParticipation() instanceof ProgrammingExerciseStudentParticipation && ltiNewResultService.isPresent()) { + ltiNewResultService.get().onNewResult((StudentParticipation) savedResult.getParticipation()); } resultWebsocketService.broadcastNewResult(savedResult.getParticipation(), savedResult); diff --git a/src/main/java/de/tum/in/www1/artemis/service/TextAssessmentService.java b/src/main/java/de/tum/in/www1/artemis/service/TextAssessmentService.java index e544c2eac462..32ecc49d75e0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/TextAssessmentService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/TextAssessmentService.java @@ -3,6 +3,7 @@ import static org.hibernate.Hibernate.isInitialized; import java.util.List; +import java.util.Optional; import javax.annotation.Nullable; @@ -22,7 +23,7 @@ public class TextAssessmentService extends AssessmentService { public TextAssessmentService(UserRepository userRepository, ComplaintResponseService complaintResponseService, ComplaintRepository complaintRepository, FeedbackRepository feedbackRepository, ResultRepository resultRepository, StudentParticipationRepository studentParticipationRepository, ResultService resultService, SubmissionRepository submissionRepository, TextBlockService textBlockService, ExamDateService examDateService, GradingCriterionRepository gradingCriterionRepository, - SubmissionService submissionService, LtiNewResultService ltiNewResultService) { + SubmissionService submissionService, Optional ltiNewResultService) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, submissionRepository, examDateService, gradingCriterionRepository, userRepository, ltiNewResultService); this.textBlockService = textBlockService; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/jira/JiraAuthenticationProvider.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/jira/JiraAuthenticationProvider.java index d72e1bc17c2f..03083d8659b0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/jira/JiraAuthenticationProvider.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/jira/JiraAuthenticationProvider.java @@ -101,20 +101,14 @@ public ConnectorHealth health() { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - User user = getOrCreateUser(authentication, false); + User user = getOrCreateUser(authentication); if (user != null) { return new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), user.getGrantedAuthorities()); } return null; } - @Override - public User getOrCreateUser(Authentication authentication, String firstName, String lastName, String email, boolean skipPasswordCheck) { - // NOTE: firstName, lastName, email is not needed in this case since we always get these values from Jira - return getOrCreateUser(authentication, skipPasswordCheck); - } - - private User getOrCreateUser(Authentication authentication, Boolean skipPasswordCheck) { + private User getOrCreateUser(Authentication authentication) { String username = authentication.getName().toLowerCase(); String password = authentication.getCredentials().toString(); @@ -129,18 +123,10 @@ private User getOrCreateUser(Authentication authentication, Boolean skipPassword ResponseEntity authenticationResponse = null; try { final var path = jiraUrl + "/rest/api/2/user?username=" + username + "&expand=groups"; - // If we want to skip the password check, we can just use the ADMIN auth, which is already injected in the default restTemplate - // Otherwise, we create our own authorization and use the credentials of the user. - if (skipPasswordCheck) { - // this is only the case if the systems wants to log in a user automatically (e.g. based on Oauth in LTI) - // when we provide null, the default restTemplate header will be used automatically - authenticationResponse = restTemplate.exchange(path, HttpMethod.GET, null, JiraUserDTO.class); - } - else { - // this is the normal case, where we use the username and password provided by the user so that JIRA checks for us if this is valid - final var entity = new HttpEntity<>(HeaderUtil.createAuthorization(username, password)); - authenticationResponse = restTemplate.exchange(path, HttpMethod.GET, entity, JiraUserDTO.class); - } + // We create our own authorization and use the credentials of the user. + // We use the username and password provided by the user so that JIRA checks for us if this is valid + final var entity = new HttpEntity<>(HeaderUtil.createAuthorization(username, password)); + authenticationResponse = restTemplate.exchange(path, HttpMethod.GET, entity, JiraUserDTO.class); } catch (HttpStatusCodeException e) { if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) { @@ -162,7 +148,7 @@ else if (e.getStatusCode().is5xxServerError()) { if (authenticationResponse != null && authenticationResponse.getBody() != null) { final var jiraUserDTO = authenticationResponse.getBody(); - // If the user has already existed, the check has already been completed and we can continue + // If the user has already existed, the check has already been completed, and we can continue // Otherwise, we have to create it in the Artemis database User user = optionalUser.orElseGet(() -> userCreationService.createUser(jiraUserDTO.getName(), null, null, jiraUserDTO.getDisplayName(), "", jiraUserDTO.getEmailAddress(), null, null, "en", false)); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/ldap/LdapAuthenticationProvider.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/ldap/LdapAuthenticationProvider.java index 9ef60f8d72c4..32b88f0449ce 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/ldap/LdapAuthenticationProvider.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/ldap/LdapAuthenticationProvider.java @@ -53,20 +53,14 @@ public LdapAuthenticationProvider(UserRepository userRepository, LdapUserService @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - User user = getOrCreateUser(authentication, false); + User user = getOrCreateUser(authentication); if (user != null) { return new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), user.getGrantedAuthorities()); } return null; } - @Override - public User getOrCreateUser(Authentication authentication, String firstName, String lastName, String email, boolean skipPasswordCheck) { - // NOTE: firstName, lastName, email is not needed in this case since we always get these values from LDAP - return getOrCreateUser(authentication, skipPasswordCheck); - } - - private User getOrCreateUser(Authentication authentication, Boolean skipPasswordCheck) { + private User getOrCreateUser(Authentication authentication) { String username = authentication.getName().toLowerCase(); String password = authentication.getCredentials().toString(); @@ -88,16 +82,13 @@ private User getOrCreateUser(Authentication authentication, Boolean skipPassword log.info("Finished ldapUserService.findByUsername in {}", TimeLogUtil.formatDurationFrom(start)); start = System.nanoTime(); - // If we want to skip the password check, we can just use the ADMIN auth, which is already injected in the default restTemplate - // Otherwise, we create our own authorization and use the credentials of the user. - if (!skipPasswordCheck) { - byte[] passwordBytes = Utf8.encode(password); - boolean passwordCorrect = ldapTemplate.compare(ldapUserDto.getUid().toString(), "userPassword", passwordBytes); - log.debug("Compare password with LDAP entry for user " + username + " to validate login"); - // this is the normal case, where the password is validated - if (!passwordCorrect) { - throw new BadCredentialsException("Wrong credentials"); - } + // We create our own authorization and use the credentials of the user. + byte[] passwordBytes = Utf8.encode(password); + boolean passwordCorrect = ldapTemplate.compare(ldapUserDto.getUid().toString(), "userPassword", passwordBytes); + log.debug("Compare password with LDAP entry for user " + username + " to validate login"); + // this is the normal case, where the password is validated + if (!passwordCorrect) { + throw new BadCredentialsException("Wrong credentials"); } log.info("Finished ldapTemplate.compare password in {}", TimeLogUtil.formatDurationFrom(start)); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti10Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti10Service.java index 0ec104be151d..6c027df4b19a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti10Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti10Service.java @@ -21,6 +21,7 @@ import org.imsglobal.pox.IMSPOXRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; @@ -36,6 +37,7 @@ import oauth.signpost.exception.OAuthException; @Service +@Profile("lti") public class Lti10Service { private final Logger log = LoggerFactory.getLogger(Lti10Service.class); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 8f3acbc36502..7697abf862d6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -13,6 +13,7 @@ import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -35,6 +36,7 @@ import net.minidev.json.JSONObject; @Service +@Profile("lti") public class Lti13Service { private static final String EXERCISE_PATH_PATTERN = "/courses/{courseId}/exercises/{exerciseId}"; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDynamicRegistrationService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDynamicRegistrationService.java index 16b3f1bc6d30..e09f5f1357af 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDynamicRegistrationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDynamicRegistrationService.java @@ -5,6 +5,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.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -22,6 +23,7 @@ import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @Service +@Profile("lti") public class LtiDynamicRegistrationService { @Value("${server.url}") diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiNewResultService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiNewResultService.java index b9cfd6a2aa33..1568c07b084f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiNewResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiNewResultService.java @@ -1,10 +1,12 @@ package de.tum.in.www1.artemis.service.connectors.lti; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; @Service +@Profile("lti") public class LtiNewResultService { private final Lti10Service lti10Service; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index 8605d20766d6..32ac14e5fcea 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -11,6 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.InternalAuthenticationServiceException; @@ -38,6 +39,7 @@ import tech.jhipster.security.RandomUtil; @Service +@Profile("lti") public class LtiService { public static final String LTI_GROUP_NAME = "lti"; @@ -95,11 +97,11 @@ public void authenticateLtiUser(String email, String username, String firstName, } } - // 2. Case: Lookup user with the LTI email address and sign in as this user + // 2. Case: Lookup user with the LTI email address and make sure it's not in use final var usernameLookupByEmail = artemisAuthenticationProvider.getUsernameForEmail(email); if (usernameLookupByEmail.isPresent()) { - SecurityContextHolder.getContext().setAuthentication(loginUserByEmail(usernameLookupByEmail.get(), email)); - return; + throw new InternalAuthenticationServiceException( + "Email address is already in use by Artemis. Please use a different address with your service or contact your instructor to gain direct access."); } // 3. Case: Create new user if an existing user is not required @@ -111,12 +113,6 @@ public void authenticateLtiUser(String email, String username, String firstName, throw new InternalAuthenticationServiceException("Could not find existing user or create new LTI user."); // If user couldn't be authenticated, throw an error } - private Authentication loginUserByEmail(String username, String email) { - log.info("Signing in as {}", username); - final var user = artemisAuthenticationProvider.getOrCreateUser(new UsernamePasswordAuthenticationToken(username, ""), null, null, email, true); - return new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY); - } - @NotNull private Authentication createNewUserFromLaunchRequest(String email, String username, String firstName, String lastName) { final var user = userRepository.findOneByLogin(username).orElseGet(() -> { diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java index ddd5c15e5a5a..371e7ce1c2f6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/export/CourseExamExportService.java @@ -49,6 +49,8 @@ public class CourseExamExportService { private final ModelingExerciseWithSubmissionsExportService modelingExerciseWithSubmissionsExportService; + private final QuizExerciseWithSubmissionsExportService quizExerciseWithSubmissionsExportService; + private final FileService fileService; private final ExamRepository examRepository; @@ -58,14 +60,15 @@ public class CourseExamExportService { public CourseExamExportService(ProgrammingExerciseExportService programmingExerciseExportService, ZipFileService zipFileService, FileService fileService, TextExerciseWithSubmissionsExportService textExerciseWithSubmissionsExportService, FileUploadExerciseWithSubmissionsExportService fileUploadExerciseWithSubmissionsExportService, - ModelingExerciseWithSubmissionsExportService modelingExerciseWithSubmissionsExportService, WebsocketMessagingService websocketMessagingService, - ExamRepository examRepository) { + ModelingExerciseWithSubmissionsExportService modelingExerciseWithSubmissionsExportService, + QuizExerciseWithSubmissionsExportService quizExerciseWithSubmissionsExportService, WebsocketMessagingService websocketMessagingService, ExamRepository examRepository) { this.programmingExerciseExportService = programmingExerciseExportService; this.zipFileService = zipFileService; this.fileService = fileService; this.textExerciseWithSubmissionsExportService = textExerciseWithSubmissionsExportService; this.fileUploadExerciseWithSubmissionsExportService = fileUploadExerciseWithSubmissionsExportService; this.modelingExerciseWithSubmissionsExportService = modelingExerciseWithSubmissionsExportService; + this.quizExerciseWithSubmissionsExportService = quizExerciseWithSubmissionsExportService; this.websocketMessagingService = websocketMessagingService; this.examRepository = examRepository; } @@ -397,9 +400,9 @@ else if (exercise instanceof ModelingExercise) { exportedExercises.add(modelingExerciseWithSubmissionsExportService.exportModelingExerciseWithSubmissions(exercise, submissionsExportOptions, exerciseExportDir, exportErrors, reportData)); } - else if (exercise instanceof QuizExercise) { - // TODO: Quiz submissions aren't supported yet - continue; + else if (exercise instanceof QuizExercise quizExercise) { + exportedExercises.add(quizExerciseWithSubmissionsExportService.exportExerciseWithSubmissions(quizExercise, exerciseExportDir, exportErrors, reportData)); + } else { // Exercise is not supported so skip diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java index dcb16d013298..df65b9cbb935 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportQuizExerciseCreationService.java @@ -7,6 +7,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.validation.constraints.NotNull; + import org.apache.commons.io.FileUtils; import org.springframework.stereotype.Service; @@ -14,13 +16,16 @@ import de.tum.in.www1.artemis.domain.quiz.*; import de.tum.in.www1.artemis.repository.QuizQuestionRepository; import de.tum.in.www1.artemis.repository.QuizSubmissionRepository; +import de.tum.in.www1.artemis.repository.StudentParticipationRepository; import de.tum.in.www1.artemis.service.DragAndDropQuizAnswerConversionService; +import de.tum.in.www1.artemis.service.archival.ArchivalReportEntry; /** * A service to create the data export for quiz exercise participations. * This includes creating a pdf highlighting the submitted answers for drag and drop questions and * txt files containing the submitted answers for multiple choice and short answer questions. * Additionally, the results can be included in the export if the due date is over. + * This service is also used to export the student submissions for archival. */ @Service public class DataExportQuizExerciseCreationService { @@ -33,11 +38,26 @@ public class DataExportQuizExerciseCreationService { private final DragAndDropQuizAnswerConversionService dragAndDropQuizAnswerConversionService; + private final StudentParticipationRepository studentParticipationRepository; + public DataExportQuizExerciseCreationService(QuizSubmissionRepository quizSubmissionRepository, QuizQuestionRepository quizQuestionRepository, - DragAndDropQuizAnswerConversionService dragAndDropQuizAnswerConversionService) { + DragAndDropQuizAnswerConversionService dragAndDropQuizAnswerConversionService, StudentParticipationRepository studentParticipationRepository) { this.quizSubmissionRepository = quizSubmissionRepository; this.quizQuestionRepository = quizQuestionRepository; this.dragAndDropQuizAnswerConversionService = dragAndDropQuizAnswerConversionService; + this.studentParticipationRepository = studentParticipationRepository; + } + + /** + * Creates an export for an exercise participation of a quiz exercise. + * + * @param quizExercise the quiz exercise for which the export should be created + * @param participation the participation for which the export should be created + * @param outputDir the directory in which the export should be stored + * @param includeResults true if the results should be included in the export (if the due date is over) + */ + public void createQuizAnswersExport(QuizExercise quizExercise, StudentParticipation participation, Path outputDir, boolean includeResults) { + createQuizAnswersExport(quizExercise, participation, outputDir, includeResults, Optional.empty()); } /** @@ -48,14 +68,15 @@ public DataExportQuizExerciseCreationService(QuizSubmissionRepository quizSubmis * @param participation the participation for which the export should be created * @param outputDir the directory in which the export should be stored * @param includeResults true if the results should be included in the export (if the due date is over) - * @throws IOException if an error occurs while accessing the file system. + * @param exportErrors an optional list of errors that occurred during the export + * @return true if the export was successful, false otherwise */ - public void createQuizAnswersExport(QuizExercise quizExercise, StudentParticipation participation, Path outputDir, boolean includeResults) throws IOException { + private boolean createQuizAnswersExport(QuizExercise quizExercise, StudentParticipation participation, Path outputDir, boolean includeResults, + Optional> exportErrors) { Set quizQuestions = quizQuestionRepository.getQuizQuestionsByExerciseId(quizExercise.getId()); - QuizSubmission quizSubmission; - + boolean errorOccurred = false; for (var submission : participation.getSubmissions()) { - quizSubmission = quizSubmissionRepository.findWithEagerSubmittedAnswersById(submission.getId()); + QuizSubmission quizSubmission = quizSubmissionRepository.findWithEagerSubmittedAnswersById(submission.getId()); List multipleChoiceQuestionsSubmissions = new ArrayList<>(); List shortAnswerQuestionsSubmissions = new ArrayList<>(); for (var question : quizQuestions) { @@ -63,7 +84,14 @@ public void createQuizAnswersExport(QuizExercise quizExercise, StudentParticipat // if this question wasn't answered, the submitted answer is null if (submittedAnswer != null) { if (submittedAnswer instanceof DragAndDropSubmittedAnswer dragAndDropSubmittedAnswer) { - dragAndDropQuizAnswerConversionService.convertDragAndDropQuizAnswerAndStoreAsPdf(dragAndDropSubmittedAnswer, outputDir, includeResults); + try { + dragAndDropQuizAnswerConversionService.convertDragAndDropQuizAnswerAndStoreAsPdf(dragAndDropSubmittedAnswer, outputDir, includeResults); + } + catch (IOException e) { + errorOccurred = true; + exportErrors.ifPresent(errors -> errors.add("Failed to export drag and drop answers for quiz submission " + submission.getId() + " of quiz exercise " + + quizExercise.getTitle() + " with id " + quizExercise.getId())); + } } else if (submittedAnswer instanceof ShortAnswerSubmittedAnswer shortAnswerSubmittedAnswer) { shortAnswerQuestionsSubmissions.add(createExportForShortAnswerQuestion(shortAnswerSubmittedAnswer, includeResults)); @@ -74,15 +102,65 @@ else if (submittedAnswer instanceof MultipleChoiceSubmittedAnswer multipleChoice } } if (!multipleChoiceQuestionsSubmissions.isEmpty()) { - FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_multiple_choice_questions_answers" + TXT_FILE_EXTENSION).toFile(), - StandardCharsets.UTF_8.name(), multipleChoiceQuestionsSubmissions); + try { + FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_multiple_choice_questions_answers" + TXT_FILE_EXTENSION).toFile(), + StandardCharsets.UTF_8.name(), multipleChoiceQuestionsSubmissions); + } + catch (IOException e) { + errorOccurred = true; + exportErrors.ifPresent(errors -> errors.add("Failed to export multiple choice answers for quiz submission " + submission.getId() + " of quiz exercise " + + quizExercise.getTitle() + " with id " + quizExercise.getId())); + } } if (!shortAnswerQuestionsSubmissions.isEmpty()) { - FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_short_answer_questions_answers" + TXT_FILE_EXTENSION).toFile(), - StandardCharsets.UTF_8.name(), shortAnswerQuestionsSubmissions); + try { + FileUtils.writeLines(outputDir.resolve("quiz_submission_" + submission.getId() + "_short_answer_questions_answers" + TXT_FILE_EXTENSION).toFile(), + StandardCharsets.UTF_8.name(), shortAnswerQuestionsSubmissions); + } + catch (IOException e) { + errorOccurred = true; + exportErrors.ifPresent(errors -> errors.add("Failed to export short answer answers for quiz submission " + submission.getId() + " of quiz exercise " + + quizExercise.getTitle() + " with id " + quizExercise.getId())); + } } } + return !errorOccurred; + } + /** + * Exports the student submissions for a quiz exercise. + * + * @param quizExercise the quiz exercise for which the submissions should be exported + * @param exerciseDir the directory in which the submissions should be stored + * @param exportErrors a list of errors that occurred during the export + * @param archivalReportEntries a list of report entries to report failed/successful exports + */ + public void exportStudentSubmissionsForArchival(QuizExercise quizExercise, Path exerciseDir, @NotNull List exportErrors, + List archivalReportEntries) { + var participations = studentParticipationRepository.findByExerciseIdWithEagerSubmissions(quizExercise.getId()); + int participationsWithoutSubmission = 0; + int successfulExports = 0; + for (var participation : participations) { + if (participation.getSubmissions().isEmpty()) { + participationsWithoutSubmission++; + continue; + } + var outputDir = exerciseDir.resolve("participation-" + participation.getId() + "-" + participation.getParticipantIdentifier()); + try { + DataExportUtil.createDirectoryIfNotExistent(outputDir); + } + catch (IOException e) { + exportErrors.add("Failed to create directory for quiz exercise participation " + participation.getId() + "of quiz exercise " + quizExercise.getTitle() + " with id " + + quizExercise.getId() + ". Won't export this participation."); + continue; + } + boolean successful = createQuizAnswersExport(quizExercise, participation, outputDir, true, Optional.ofNullable(exportErrors)); + if (successful) { + successfulExports++; + } + } + archivalReportEntries + .add(new ArchivalReportEntry(quizExercise, quizExercise.getSanitizedExerciseTitle(), participations.size(), successfulExports, participationsWithoutSubmission)); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportUtil.java b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportUtil.java index fcd11d0267f0..7b27e55e7df7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/export/DataExportUtil.java +++ b/src/main/java/de/tum/in/www1/artemis/service/export/DataExportUtil.java @@ -9,7 +9,7 @@ /** * A utility class for data export containing helper methods that are frequently used in the different services responsible for creating data exports. */ -final class DataExportUtil { +public final class DataExportUtil { private static final String COURSE_DIRECTORY_PREFIX = "course_"; @@ -23,7 +23,7 @@ private DataExportUtil() { * @param directory the directory to create * @throws IOException if an error occurs while accessing the file system */ - static void createDirectoryIfNotExistent(Path directory) throws IOException { + public static void createDirectoryIfNotExistent(Path directory) throws IOException { if (!Files.exists(directory)) { Files.createDirectories(directory); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/export/QuizExerciseWithSubmissionsExportService.java b/src/main/java/de/tum/in/www1/artemis/service/export/QuizExerciseWithSubmissionsExportService.java new file mode 100644 index 000000000000..38ce256caf01 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/export/QuizExerciseWithSubmissionsExportService.java @@ -0,0 +1,97 @@ +package de.tum.in.www1.artemis.service.export; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.io.FileUtils; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.in.www1.artemis.domain.quiz.DragAndDropQuestion; +import de.tum.in.www1.artemis.domain.quiz.QuizExercise; +import de.tum.in.www1.artemis.repository.QuizExerciseRepository; +import de.tum.in.www1.artemis.service.FilePathService; +import de.tum.in.www1.artemis.service.FileService; +import de.tum.in.www1.artemis.service.archival.ArchivalReportEntry; + +/** + * Service responsible for exporting quiz exercises with their submissions. + */ +@Service +public class QuizExerciseWithSubmissionsExportService { + + private final QuizExerciseRepository quizExerciseRepository; + + private final ObjectMapper objectMapper; + + private final DataExportQuizExerciseCreationService dataExportQuizExerciseCreationService; + + private final FileService fileService; + + private final FilePathService filePathService; + + public QuizExerciseWithSubmissionsExportService(QuizExerciseRepository quizExerciseRepository, MappingJackson2HttpMessageConverter springMvcJacksonConverter, + DataExportQuizExerciseCreationService dataExportQuizExerciseCreationService, FileService fileService, FilePathService filePathService) { + this.quizExerciseRepository = quizExerciseRepository; + this.objectMapper = springMvcJacksonConverter.getObjectMapper(); + this.dataExportQuizExerciseCreationService = dataExportQuizExerciseCreationService; + this.fileService = fileService; + this.filePathService = filePathService; + } + + /** + * Exports the given quiz exercise as JSON file with all its submissions and stores it in the given directory. + * + * @param quizExercise the quiz exercise to export + * @param exerciseExportDir the directory where the quiz exercise should be exported to + * @param exportErrors a list of errors that occurred during the export + * @param reportEntries a list of report entries that occurred during the export + * @return the path to the directory where the quiz exercise was exported to + */ + public Path exportExerciseWithSubmissions(QuizExercise quizExercise, Path exerciseExportDir, List exportErrors, List reportEntries) { + quizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsAndCompetenciesElseThrow(quizExercise.getId()); + // do not store unnecessary information in the JSON file + quizExercise.setCourse(null); + quizExercise.setExerciseGroup(null); + try { + fileService.writeObjectToJsonFile(quizExercise, objectMapper, exerciseExportDir.resolve("Exercise-Details-" + quizExercise.getSanitizedExerciseTitle() + ".json")); + } + catch (IOException e) { + exportErrors.add("Failed to export quiz exercise details " + quizExercise.getTitle() + " with id " + quizExercise.getId() + " due to a JSON processing error."); + } + List imagesToExport = new ArrayList<>(); + for (var quizQuestion : quizExercise.getQuizQuestions()) { + if (quizQuestion instanceof DragAndDropQuestion dragAndDropQuestion) { + if (dragAndDropQuestion.getBackgroundFilePath() != null) { + imagesToExport.add(filePathService.actualPathForPublicPath(URI.create(dragAndDropQuestion.getBackgroundFilePath()))); + } + for (var dragItem : dragAndDropQuestion.getDragItems()) { + if (dragItem.getPictureFilePath() != null) { + imagesToExport.add(filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath()))); + + } + } + if (!imagesToExport.isEmpty()) { + var imagesDir = exerciseExportDir.resolve("images-for-drag-and-drop-question-" + dragAndDropQuestion.getId()); + fileService.createDirectory(imagesDir); + imagesToExport.forEach(path -> { + try { + FileUtils.copyFile(path.toFile(), imagesDir.resolve(path.getFileName()).toFile()); + } + catch (IOException e) { + exportErrors.add("Failed to export image file with file path " + path + " for drag and drop question with id " + dragAndDropQuestion.getId()); + } + }); + } + } + } + dataExportQuizExerciseCreationService.exportStudentSubmissionsForArchival(quizExercise, exerciseExportDir, exportErrors, reportEntries); + return exerciseExportDir; + } + +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/hestia/ProgrammingExerciseGitDiffReportService.java b/src/main/java/de/tum/in/www1/artemis/service/hestia/ProgrammingExerciseGitDiffReportService.java index f34867ea864c..8444525dad34 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/hestia/ProgrammingExerciseGitDiffReportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/hestia/ProgrammingExerciseGitDiffReportService.java @@ -2,6 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -19,6 +20,7 @@ import de.tum.in.www1.artemis.domain.DomainObject; import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.VcsRepositoryUrl; import de.tum.in.www1.artemis.domain.hestia.ProgrammingExerciseGitDiffEntry; import de.tum.in.www1.artemis.domain.hestia.ProgrammingExerciseGitDiffReport; import de.tum.in.www1.artemis.domain.participation.SolutionProgrammingExerciseParticipation; @@ -158,6 +160,33 @@ public ProgrammingExerciseGitDiffReport getOrCreateReportOfExercise(ProgrammingE } } + /** + * Calculates git diff between two repositories and returns the cumulative number of diff lines. + * + * @param urlRepoA url of the first repo to compare + * @param localPathRepoA local path to the checked out instance of the first repo to compare + * @param urlRepoB url of the second repo to compare + * @param localPathRepoB local path to the checked out instance of the second repo to compare + * @return cumulative number of lines in the git diff of given repositories + */ + public int calculateNumberOfDiffLinesBetweenRepos(VcsRepositoryUrl urlRepoA, Path localPathRepoA, VcsRepositoryUrl urlRepoB, Path localPathRepoB) { + var repoA = gitService.getExistingCheckedOutRepositoryByLocalPath(localPathRepoA, urlRepoA); + var repoB = gitService.getExistingCheckedOutRepositoryByLocalPath(localPathRepoB, urlRepoB); + + var treeParserRepoA = new FileTreeIterator(repoA); + var treeParserRepoB = new FileTreeIterator(repoB); + + try (var diffOutputStream = new ByteArrayOutputStream(); var git = Git.wrap(repoB)) { + git.diff().setOldTree(treeParserRepoB).setNewTree(treeParserRepoA).setOutputStream(diffOutputStream).call(); + var diff = diffOutputStream.toString(); + return extractDiffEntries(diff, true).stream().mapToInt(ProgrammingExerciseGitDiffEntry::getLineCount).sum(); + } + catch (IOException | GitAPIException e) { + log.error("Error calculating number of diff lines between repositories: urlRepoA={}, urlRepoB={}.", urlRepoA, urlRepoB, e); + return Integer.MAX_VALUE; + } + } + /** * Creates a new ProgrammingExerciseGitDiffReport for an exercise. * It will take the git-diff between the template and solution repositories and return all changes. @@ -183,7 +212,7 @@ private ProgrammingExerciseGitDiffReport generateReport(TemplateProgrammingExerc try (ByteArrayOutputStream diffOutputStream = new ByteArrayOutputStream(); Git git = Git.wrap(templateRepo)) { git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).setOutputStream(diffOutputStream).call(); var diff = diffOutputStream.toString(); - var programmingExerciseGitDiffEntries = extractDiffEntries(diff); + var programmingExerciseGitDiffEntries = extractDiffEntries(diff, false); var report = new ProgrammingExerciseGitDiffReport(); for (ProgrammingExerciseGitDiffEntry gitDiffEntry : programmingExerciseGitDiffEntries) { gitDiffEntry.setGitDiffReport(report); @@ -199,7 +228,7 @@ private ProgrammingExerciseGitDiffReport generateReport(TemplateProgrammingExerc * @param diff The raw git-diff output * @return The extracted ProgrammingExerciseGitDiffEntries */ - private List extractDiffEntries(String diff) { + private List extractDiffEntries(String diff, boolean useAbsoluteLineCount) { var lines = diff.split("\n"); var parserState = new ParserState(); @@ -216,7 +245,7 @@ private List extractDiffEntries(String diff) { else if (!parserState.deactivateCodeReading) { switch (line.charAt(0)) { case '+' -> handleAddition(parserState); - case '-' -> handleRemoval(parserState); + case '-' -> handleRemoval(parserState, useAbsoluteLineCount); case ' ' -> handleUnchanged(parserState); default -> parserState.deactivateCodeReading = true; } @@ -262,7 +291,7 @@ private void handleUnchanged(ParserState parserState) { parserState.currentPreviousLineCount++; } - private void handleRemoval(ParserState parserState) { + private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount) { var entry = parserState.currentEntry; if (!parserState.lastLineRemoveOperation && !entry.isEmpty()) { parserState.entries.add(entry); @@ -274,7 +303,16 @@ private void handleRemoval(ParserState parserState) { entry.setPreviousLineCount(0); entry.setPreviousStartLine(parserState.currentPreviousLineCount); } - entry.setPreviousLineCount(entry.getPreviousLineCount() + 1); + if (useAbsoluteLineCount) { + if (parserState.currentEntry.getLineCount() == null) { + parserState.currentEntry.setLineCount(0); + parserState.currentEntry.setStartLine(parserState.currentLineCount); + } + parserState.currentEntry.setLineCount(parserState.currentEntry.getLineCount() + 1); + } + else { + entry.setPreviousLineCount(entry.getPreviousLineCount() + 1); + } parserState.currentEntry = entry; parserState.lastLineRemoveOperation = true; 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 new file mode 100644 index 000000000000..b43979dd6168 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisRateLimitService.java @@ -0,0 +1,80 @@ +package de.tum.in.www1.artemis.service.iris; + +import java.time.ZonedDateTime; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.repository.iris.IrisMessageRepository; +import de.tum.in.www1.artemis.service.iris.exception.IrisRateLimitExceededException; + +/** + * Service for the rate limit of the iris chatbot. + */ +@Service +@Profile("iris") +public class IrisRateLimitService { + + private final IrisMessageRepository irisMessageRepository; + + @Value("${artemis.iris.rate-limit:5}") + private int rateLimit; + + @Value("${artemis.iris.rate-limit-timeframe-hours:24}") + private int rateLimitTimeframeHours; + + public IrisRateLimitService(IrisMessageRepository irisMessageRepository) { + this.irisMessageRepository = irisMessageRepository; + } + + /** + * Get the rate limit information for the given user. + * See {@link IrisRateLimitInformation} and {@link IrisRateLimitInformation#isRateLimitExceeded()} for more information. + * + * @param user the user + * @return the rate limit information + */ + public IrisRateLimitInformation getRateLimitInformation(User user) { + var start = ZonedDateTime.now().minusHours(rateLimitTimeframeHours); + var end = ZonedDateTime.now(); + var currentMessageCount = irisMessageRepository.countLlmResponsesOfUserWithinTimeframe(user.getId(), start, end); + + return new IrisRateLimitInformation(currentMessageCount, rateLimit); + } + + /** + * Checks if the rate limit of the given user is exceeded. + * If it is exceeded, an {@link IrisRateLimitExceededException} is thrown. + * See {@link #getRateLimitInformation(User)} and {@link IrisRateLimitInformation#isRateLimitExceeded()} for more information. + * + * @param user the user + * @throws IrisRateLimitExceededException if the rate limit is exceeded + */ + public void checkRateLimitElseThrow(User user) { + var rateLimitInfo = getRateLimitInformation(user); + if (rateLimitInfo.isRateLimitExceeded()) { + throw new IrisRateLimitExceededException(rateLimitInfo); + } + } + + /** + * Contains information about the rate limit of a user. + * + * @param currentMessageCount the current rate limit + * @param rateLimit the max rate limit + */ + public record IrisRateLimitInformation(int currentMessageCount, int rateLimit) { + + /** + * Checks if the rate limit is exceeded. + * It is exceeded if the rateLimit is set and the currentMessageCount is greater or equal to the rateLimit. + * + * @return true if the rate limit is exceeded, false otherwise + */ + public boolean isRateLimitExceeded() { + return rateLimit != -1 && currentMessageCount >= rateLimit; + } + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisWebsocketService.java b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisWebsocketService.java index 13ded18a2503..dbb6acfd6280 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/IrisWebsocketService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/IrisWebsocketService.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Objects; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import com.fasterxml.jackson.annotation.JsonInclude; @@ -18,14 +19,18 @@ * A service to send a message over the websocket to a specific user */ @Service +@Profile("iris") public class IrisWebsocketService { private static final String IRIS_WEBSOCKET_TOPIC_PREFIX = "/topic/iris"; private final WebsocketMessagingService websocketMessagingService; - public IrisWebsocketService(WebsocketMessagingService websocketMessagingService) { + private final IrisRateLimitService rateLimitService; + + public IrisWebsocketService(WebsocketMessagingService websocketMessagingService, IrisRateLimitService rateLimitService) { this.websocketMessagingService = websocketMessagingService; + this.rateLimitService = rateLimitService; } /** @@ -38,9 +43,11 @@ public void sendMessage(IrisMessage irisMessage) { throw new UnsupportedOperationException("Only IrisChatSession is supported"); } Long irisSessionId = irisMessage.getSession().getId(); - String userLogin = ((IrisChatSession) irisMessage.getSession()).getUser().getLogin(); + var user = ((IrisChatSession) irisMessage.getSession()).getUser(); + String userLogin = user.getLogin(); String irisWebsocketTopic = String.format("%s/sessions/%d", IRIS_WEBSOCKET_TOPIC_PREFIX, irisSessionId); - websocketMessagingService.sendMessageToUser(userLogin, irisWebsocketTopic, new IrisWebsocketDTO(irisMessage)); + var rateLimitInfo = rateLimitService.getRateLimitInformation(user); + websocketMessagingService.sendMessageToUser(userLogin, irisWebsocketTopic, new IrisWebsocketDTO(irisMessage, rateLimitInfo)); } /** @@ -54,9 +61,11 @@ public void sendException(IrisSession irisSession, Throwable throwable) { throw new UnsupportedOperationException("Only IrisChatSession is supported"); } Long irisSessionId = irisSession.getId(); - String userLogin = ((IrisChatSession) irisSession).getUser().getLogin(); + var user = ((IrisChatSession) irisSession).getUser(); + String userLogin = user.getLogin(); String irisWebsocketTopic = String.format("%s/sessions/%d", IRIS_WEBSOCKET_TOPIC_PREFIX, irisSessionId); - websocketMessagingService.sendMessageToUser(userLogin, irisWebsocketTopic, new IrisWebsocketDTO(throwable)); + var rateLimitInfo = rateLimitService.getRateLimitInformation(user); + websocketMessagingService.sendMessageToUser(userLogin, irisWebsocketTopic, new IrisWebsocketDTO(throwable, rateLimitInfo)); } @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -72,7 +81,10 @@ public static class IrisWebsocketDTO { private final Map translationParams; - public IrisWebsocketDTO(IrisMessage message) { + private final IrisRateLimitService.IrisRateLimitInformation rateLimitInfo; + + public IrisWebsocketDTO(IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo) { + this.rateLimitInfo = rateLimitInfo; this.type = IrisWebsocketMessageType.MESSAGE; this.message = message; this.errorMessage = null; @@ -80,7 +92,8 @@ public IrisWebsocketDTO(IrisMessage message) { this.translationParams = null; } - public IrisWebsocketDTO(Throwable throwable) { + public IrisWebsocketDTO(Throwable throwable, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo) { + this.rateLimitInfo = rateLimitInfo; this.type = IrisWebsocketMessageType.ERROR; this.message = null; this.errorMessage = throwable.getMessage(); @@ -108,6 +121,10 @@ public Map getTranslationParams() { return translationParams != null ? Collections.unmodifiableMap(translationParams) : null; } + public IrisRateLimitService.IrisRateLimitInformation getRateLimitInfo() { + return rateLimitInfo; + } + public enum IrisWebsocketMessageType { MESSAGE, ERROR } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisException.java b/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisException.java index c1e5ded8ee66..4aca58b81234 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisException.java +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisException.java @@ -21,6 +21,12 @@ public IrisException(String translationKey, Map translationParam this.translationParams = translationParams; } + public IrisException(String defaultMessage, Status status, String entityName, String translationKey, Map translationParams) { + super(ErrorConstants.DEFAULT_TYPE, defaultMessage, status, entityName, translationKey, getAlertParameters(translationKey, translationParams)); + this.translationKey = translationKey; + this.translationParams = translationParams; + } + public String getTranslationKey() { return translationKey; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisRateLimitExceededException.java b/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisRateLimitExceededException.java new file mode 100644 index 000000000000..68978d1e0812 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/iris/exception/IrisRateLimitExceededException.java @@ -0,0 +1,24 @@ +package de.tum.in.www1.artemis.service.iris.exception; + +import java.util.Map; + +import org.zalando.problem.Status; + +import de.tum.in.www1.artemis.service.iris.IrisRateLimitService; + +/** + * Exception that is thrown when the rate limit of Iris is exceeded. + * See {@link IrisRateLimitService} for more information. + * It is mapped to the "429 Too Many Requests" HTTP status code. + */ +public class IrisRateLimitExceededException extends IrisException { + + public IrisRateLimitExceededException(int currentMessageCount, int rateLimit) { + super("You have exceeded the rate limit of Iris", Status.TOO_MANY_REQUESTS, "Iris", "artemisApp.exerciseChatbot.errors.rateLimitExceeded", + Map.of("currentMessageCount", currentMessageCount, "rateLimit", rateLimit)); + } + + public IrisRateLimitExceededException(IrisRateLimitService.IrisRateLimitInformation rateLimit) { + this(rateLimit.currentMessageCount(), rateLimit.rateLimit()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ModelingPlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ModelingPlagiarismDetectionService.java index 24ddbfbd39bb..026799d8661c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ModelingPlagiarismDetectionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ModelingPlagiarismDetectionService.java @@ -149,7 +149,7 @@ public ModelingPlagiarismResult checkPlagiarism(List modelin final double similarity = model1.similarity(model2); log.debug("Compare result {} with {}: {}", i, j, similarity); - if (similarity < minimumSimilarity) { + if (similarity * 100 < minimumSimilarity) { // ignore comparison results with too small similarity continue; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismDetectionService.java new file mode 100644 index 000000000000..d2bbef6a087d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismDetectionService.java @@ -0,0 +1,131 @@ +package de.tum.in.www1.artemis.service.plagiarism; + +import java.io.File; +import java.io.IOException; +import java.util.Optional; + +import org.jvnet.hk2.annotations.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import de.jplag.exceptions.ExitException; +import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismDetectionConfig; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismResult; +import de.tum.in.www1.artemis.domain.plagiarism.modeling.ModelingPlagiarismResult; +import de.tum.in.www1.artemis.domain.plagiarism.text.TextPlagiarismResult; +import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismResultRepository; +import de.tum.in.www1.artemis.service.programming.ProgrammingLanguageFeatureService; + +/** + * Service for triggering plagiarism checks. + */ +@Service +@Component +public class PlagiarismDetectionService { + + private static final Logger log = LoggerFactory.getLogger(PlagiarismDetectionService.class); + + private final TextPlagiarismDetectionService textPlagiarismDetectionService; + + private final Optional programmingLanguageFeatureService; + + private final ProgrammingPlagiarismDetectionService programmingPlagiarismDetectionService; + + private final ModelingPlagiarismDetectionService modelingPlagiarismDetectionService; + + private final PlagiarismResultRepository plagiarismResultRepository; + + public PlagiarismDetectionService(TextPlagiarismDetectionService textPlagiarismDetectionService, Optional programmingLanguageFeatureService, + ProgrammingPlagiarismDetectionService programmingPlagiarismDetectionService, ModelingPlagiarismDetectionService modelingPlagiarismDetectionService, + PlagiarismResultRepository plagiarismResultRepository) { + this.textPlagiarismDetectionService = textPlagiarismDetectionService; + this.programmingLanguageFeatureService = programmingLanguageFeatureService; + this.programmingPlagiarismDetectionService = programmingPlagiarismDetectionService; + this.modelingPlagiarismDetectionService = modelingPlagiarismDetectionService; + this.plagiarismResultRepository = plagiarismResultRepository; + } + + /** + * Check plagiarism in given text exercise + * + * @param exercise exercise to check plagiarism + * @param config configuration for plagiarism detection + * @return result of plagiarism checks + */ + public TextPlagiarismResult checkTextExercise(TextExercise exercise, PlagiarismDetectionConfig config) throws ExitException { + var plagiarismResult = textPlagiarismDetectionService.checkPlagiarism(exercise, config.similarityThreshold(), config.minimumScore(), config.minimumSize()); + log.info("Finished textPlagiarismDetectionService.checkPlagiarism for exercise {} with {} comparisons,", exercise.getId(), plagiarismResult.getComparisons().size()); + + trimAndSavePlagiarismResult(plagiarismResult); + return plagiarismResult; + } + + /** + * Check plagiarism in given programing exercise + * + * @param exercise exercise to check plagiarism + * @param config configuration for plagiarism detection + * @return result of plagiarism checks + */ + public TextPlagiarismResult checkProgrammingExercise(ProgrammingExercise exercise, PlagiarismDetectionConfig config) + throws ExitException, IOException, ProgrammingLanguageNotSupportedForPlagiarismDetectionException { + checkProgrammingLanguageSupport(exercise); + + var plagiarismResult = programmingPlagiarismDetectionService.checkPlagiarism(exercise.getId(), config.similarityThreshold(), config.minimumScore(), config.minimumSize()); + log.info("Finished programmingExerciseExportService.checkPlagiarism call for {} comparisons", plagiarismResult.getComparisons().size()); + + plagiarismResultRepository.prepareResultForClient(plagiarismResult); + + // make sure that participation is included in the exercise + plagiarismResult.setExercise(exercise); + return plagiarismResult; + } + + /** + * Check plagiarism in given programing exercise and outputs a Jplag report + * + * @param exercise exercise to check plagiarism + * @param config configuration for plagiarism detection + * @return Jplag report of plagiarism checks + */ + public File checkProgrammingExerciseWithJplagReport(ProgrammingExercise exercise, PlagiarismDetectionConfig config) + throws ProgrammingLanguageNotSupportedForPlagiarismDetectionException { + checkProgrammingLanguageSupport(exercise); + return programmingPlagiarismDetectionService.checkPlagiarismWithJPlagReport(exercise.getId(), config.similarityThreshold(), config.minimumScore(), config.minimumSize()); + } + + /** + * Check plagiarism in given modeling exercise + * + * @param exercise exercise to check plagiarism + * @param config configuration for plagiarism detection + * @return result of plagiarism checks + */ + public ModelingPlagiarismResult checkModelingExercise(ModelingExercise exercise, PlagiarismDetectionConfig config) { + var plagiarismResult = modelingPlagiarismDetectionService.checkPlagiarism(exercise, config.similarityThreshold(), config.minimumSize(), config.minimumScore()); + log.info("Finished modelingPlagiarismDetectionService.checkPlagiarism call for {} comparisons", plagiarismResult.getComparisons().size()); + + trimAndSavePlagiarismResult(plagiarismResult); + return plagiarismResult; + } + + private void trimAndSavePlagiarismResult(PlagiarismResult plagiarismResult) { + // Limit the amount temporarily because of database issues + plagiarismResult.sortAndLimit(100); + plagiarismResultRepository.savePlagiarismResultAndRemovePrevious(plagiarismResult); + + plagiarismResultRepository.prepareResultForClient(plagiarismResult); + } + + private void checkProgrammingLanguageSupport(ProgrammingExercise exercise) throws ProgrammingLanguageNotSupportedForPlagiarismDetectionException { + var language = exercise.getProgrammingLanguage(); + var programmingLanguageFeature = programmingLanguageFeatureService.orElseThrow().getProgrammingLanguageFeatures(language); + if (!programmingLanguageFeature.plagiarismCheckSupported()) { + throw new ProgrammingLanguageNotSupportedForPlagiarismDetectionException(language); + } + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingLanguageNotSupportedForPlagiarismDetectionException.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingLanguageNotSupportedForPlagiarismDetectionException.java new file mode 100644 index 000000000000..756feb454337 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingLanguageNotSupportedForPlagiarismDetectionException.java @@ -0,0 +1,10 @@ +package de.tum.in.www1.artemis.service.plagiarism; + +import de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage; + +public class ProgrammingLanguageNotSupportedForPlagiarismDetectionException extends Exception { + + ProgrammingLanguageNotSupportedForPlagiarismDetectionException(ProgrammingLanguage language) { + super("Artemis does not support plagiarism checks for the programming language " + language); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java index b135284995e9..3d0373e7c3f4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -40,12 +41,13 @@ import de.tum.in.www1.artemis.service.UrlService; import de.tum.in.www1.artemis.service.connectors.GitService; import de.tum.in.www1.artemis.service.export.ProgrammingExerciseExportService; +import de.tum.in.www1.artemis.service.hestia.ProgrammingExerciseGitDiffReportService; import de.tum.in.www1.artemis.service.plagiarism.cache.PlagiarismCacheService; import de.tum.in.www1.artemis.service.util.TimeLogUtil; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @Service -public class ProgrammingPlagiarismDetectionService { +class ProgrammingPlagiarismDetectionService { @Value("${artemis.repo-download-clone-path}") private Path repoDownloadClonePath; @@ -72,10 +74,12 @@ public class ProgrammingPlagiarismDetectionService { private final UrlService urlService; + private final ProgrammingExerciseGitDiffReportService programmingExerciseGitDiffReportService; + public ProgrammingPlagiarismDetectionService(ProgrammingExerciseRepository programmingExerciseRepository, FileService fileService, GitService gitService, StudentParticipationRepository studentParticipationRepository, PlagiarismResultRepository plagiarismResultRepository, ProgrammingExerciseExportService programmingExerciseExportService, PlagiarismWebsocketService plagiarismWebsocketService, PlagiarismCacheService plagiarismCacheService, - UrlService urlService) { + UrlService urlService, ProgrammingExerciseGitDiffReportService programmingExerciseGitDiffReportService) { this.programmingExerciseRepository = programmingExerciseRepository; this.fileService = fileService; this.gitService = gitService; @@ -85,6 +89,7 @@ public ProgrammingPlagiarismDetectionService(ProgrammingExerciseRepository progr this.plagiarismWebsocketService = plagiarismWebsocketService; this.plagiarismCacheService = plagiarismCacheService; this.urlService = urlService; + this.programmingExerciseGitDiffReportService = programmingExerciseGitDiffReportService; } /** @@ -97,7 +102,7 @@ public ProgrammingPlagiarismDetectionService(ProgrammingExerciseRepository progr * @throws ExitException is thrown if JPlag exits unexpectedly * @throws IOException is thrown for file handling errors */ - public TextPlagiarismResult checkPlagiarism(long programmingExerciseId, float similarityThreshold, int minimumScore) throws ExitException, IOException { + public TextPlagiarismResult checkPlagiarism(long programmingExerciseId, float similarityThreshold, int minimumScore, int minimumSize) throws ExitException, IOException { long start = System.nanoTime(); String topic = plagiarismWebsocketService.getProgrammingExercisePlagiarismCheckTopic(programmingExerciseId); @@ -112,7 +117,7 @@ public TextPlagiarismResult checkPlagiarism(long programmingExerciseId, float si } plagiarismCacheService.setActivePlagiarismCheck(courseId); - JPlagResult jPlagResult = computeJPlagResult(programmingExercise, similarityThreshold, minimumScore); + JPlagResult jPlagResult = computeJPlagResult(programmingExercise, similarityThreshold, minimumScore, minimumSize); if (jPlagResult == null) { log.info("Insufficient amount of submissions for plagiarism detection. Return empty result."); TextPlagiarismResult textPlagiarismResult = new TextPlagiarismResult(); @@ -148,11 +153,11 @@ public TextPlagiarismResult checkPlagiarism(long programmingExerciseId, float si * @param minimumScore consider only submissions whose score is greater or equal to this value * @return a zip file that can be returned to the client */ - public File checkPlagiarismWithJPlagReport(long programmingExerciseId, float similarityThreshold, int minimumScore) { + public File checkPlagiarismWithJPlagReport(long programmingExerciseId, float similarityThreshold, int minimumScore, int minimumSize) { long start = System.nanoTime(); final var programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(programmingExerciseId); - JPlagResult result = computeJPlagResult(programmingExercise, similarityThreshold, minimumScore); + JPlagResult result = computeJPlagResult(programmingExercise, similarityThreshold, minimumScore, minimumSize); log.info("JPlag programming comparison finished with {} comparisons in {}", result.getAllComparisons().size(), TimeLogUtil.formatDurationFrom(start)); return generateJPlagReportZip(result, programmingExercise); @@ -167,7 +172,7 @@ public File checkPlagiarismWithJPlagReport(long programmingExerciseId, float sim * @return the JPlag result or null if there are not enough participations */ @NotNull - private JPlagResult computeJPlagResult(ProgrammingExercise programmingExercise, float similarityThreshold, int minimumScore) { + private JPlagResult computeJPlagResult(ProgrammingExercise programmingExercise, float similarityThreshold, int minimumScore, int minimumSize) { long programmingExerciseId = programmingExercise.getId(); final var targetPath = fileService.getTemporaryUniqueSubfolderPath(repoDownloadClonePath, 60); List participations = filterStudentParticipationsForComparison(programmingExercise, minimumScore); @@ -177,7 +182,7 @@ private JPlagResult computeJPlagResult(ProgrammingExercise programmingExercise, throw new BadRequestAlertException("Insufficient amount of valid and long enough submissions available for comparison", "Plagiarism Check", "notEnoughSubmissions"); } - List repositories = downloadRepositories(programmingExercise, participations, targetPath.toString()); + List repositories = downloadRepositories(programmingExercise, participations, targetPath.toString(), minimumSize); log.info("Downloading repositories done for programming exercise {}", programmingExerciseId); final var projectKey = programmingExercise.getProjectKey(); @@ -343,12 +348,41 @@ public List filterStudentParticipationsForComp }).toList(); } - private List downloadRepositories(ProgrammingExercise programmingExercise, List participations, String targetPath) { + private Optional cloneTemplateRepository(ProgrammingExercise programmingExercise, String targetPath) { + try { + var templateRepo = gitService.getOrCheckoutRepository(programmingExercise.getTemplateParticipation(), targetPath); + gitService.resetToOriginHead(templateRepo); // start with clean state + return Optional.of(templateRepo); + } + catch (GitException | GitAPIException ex) { + log.error("Clone template repository {} in exercise '{}' did not work as expected: {}", programmingExercise.getTemplateParticipation().getVcsRepositoryUrl(), + programmingExercise.getTitle(), ex.getMessage()); + return Optional.empty(); + } + } + + private boolean shouldAddRepo(int minimumSize, Repository repo, Optional templateRepo) { + if (templateRepo.isEmpty()) { + return true; + } + + var diffToTemplate = programmingExerciseGitDiffReportService.calculateNumberOfDiffLinesBetweenRepos(repo.getRemoteRepositoryUrl(), repo.getLocalPath(), + templateRepo.get().getRemoteRepositoryUrl(), templateRepo.get().getLocalPath()); + return diffToTemplate >= minimumSize; + } + + private List downloadRepositories(ProgrammingExercise programmingExercise, List participations, String targetPath, + int minimumSize) { // Used for sending progress notifications var topic = plagiarismWebsocketService.getProgrammingExercisePlagiarismCheckTopic(programmingExercise.getId()); int maxRepositories = participations.size() + 1; List downloadedRepositories = new ArrayList<>(); + + plagiarismWebsocketService.notifyInstructorAboutPlagiarismState(topic, PlagiarismCheckState.RUNNING, List.of("Downloading repositories: 0/" + maxRepositories)); + var templateRepo = cloneTemplateRepository(programmingExercise, targetPath); + templateRepo.ifPresent(downloadedRepositories::add); + participations.parallelStream().forEach(participation -> { try { var progressMessage = "Downloading repositories: " + (downloadedRepositories.size() + 1) + "/" + maxRepositories; @@ -356,7 +390,13 @@ private List downloadRepositories(ProgrammingExercise programmingExe Repository repo = gitService.getOrCheckoutRepositoryForJPlag(participation, targetPath); gitService.resetToOriginHead(repo); // start with clean state - downloadedRepositories.add(repo); + + if (shouldAddRepo(minimumSize, repo, templateRepo)) { + downloadedRepositories.add(repo); + } + else { + deleteTempLocalRepository(repo); + } } catch (GitException | GitAPIException | InvalidPathException ex) { log.error("Clone student repository {} in exercise '{}' did not work as expected: {}", participation.getVcsRepositoryUrl(), programmingExercise.getTitle(), @@ -364,20 +404,6 @@ private List downloadRepositories(ProgrammingExercise programmingExe } }); - // clone the template repo - try { - var progressMessage = "Downloading repositories: " + maxRepositories + "/" + maxRepositories; - plagiarismWebsocketService.notifyInstructorAboutPlagiarismState(topic, PlagiarismCheckState.RUNNING, List.of(progressMessage)); - - Repository templateRepo = gitService.getOrCheckoutRepository(programmingExercise.getTemplateParticipation(), targetPath); - gitService.resetToOriginHead(templateRepo); // start with clean state - downloadedRepositories.add(templateRepo); - } - catch (GitException | GitAPIException ex) { - log.error("Clone template repository {} in exercise '{}' did not work as expected: {}", programmingExercise.getTemplateParticipation().getVcsRepositoryUrl(), - programmingExercise.getTitle(), ex.getMessage()); - } - return downloadedRepositories; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/TextPlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/TextPlagiarismDetectionService.java index c0e141e604dd..25e8ee3cb22c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/TextPlagiarismDetectionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/TextPlagiarismDetectionService.java @@ -31,7 +31,7 @@ import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @Service -public class TextPlagiarismDetectionService { +class TextPlagiarismDetectionService { private final Logger log = LoggerFactory.getLogger(TextPlagiarismDetectionService.class); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingAssessmentService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingAssessmentService.java index 010db24151dd..eb9f1be27c74 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingAssessmentService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingAssessmentService.java @@ -1,5 +1,7 @@ package de.tum.in.www1.artemis.service.programming; +import java.util.Optional; + import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.*; @@ -15,7 +17,7 @@ public class ProgrammingAssessmentService extends AssessmentService { public ProgrammingAssessmentService(ComplaintResponseService complaintResponseService, ComplaintRepository complaintRepository, FeedbackRepository feedbackRepository, ResultRepository resultRepository, StudentParticipationRepository studentParticipationRepository, ResultService resultService, SubmissionService submissionService, SubmissionRepository submissionRepository, ExamDateService examDateService, UserRepository userRepository, GradingCriterionRepository gradingCriterionRepository, - LtiNewResultService ltiNewResultService) { + Optional ltiNewResultService) { super(complaintResponseService, complaintRepository, feedbackRepository, resultRepository, studentParticipationRepository, resultService, submissionService, submissionRepository, examDateService, gradingCriterionRepository, userRepository, ltiNewResultService); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingMessagingService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingMessagingService.java index 98ff77a0bb21..9e6ed460d85a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingMessagingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingMessagingService.java @@ -2,6 +2,8 @@ import static de.tum.in.www1.artemis.config.Constants.*; +import java.util.Optional; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -26,10 +28,10 @@ public class ProgrammingMessagingService { private final ResultWebsocketService resultWebsocketService; - private final LtiNewResultService ltiNewResultService; + private final Optional ltiNewResultService; public ProgrammingMessagingService(GroupNotificationService groupNotificationService, WebsocketMessagingService websocketMessagingService, - ResultWebsocketService resultWebsocketService, LtiNewResultService ltiNewResultService) { + ResultWebsocketService resultWebsocketService, Optional ltiNewResultService) { this.groupNotificationService = groupNotificationService; this.websocketMessagingService = websocketMessagingService; this.resultWebsocketService = resultWebsocketService; @@ -138,9 +140,9 @@ public void notifyUserAboutNewResult(Result result, ProgrammingExerciseParticipa // notify user via websocket resultWebsocketService.broadcastNewResult((Participation) participation, result); - if (participation instanceof ProgrammingExerciseStudentParticipation studentParticipation) { + if (participation instanceof ProgrammingExerciseStudentParticipation studentParticipation && ltiNewResultService.isPresent()) { // do not try to report results for template or solution participations - ltiNewResultService.onNewResult(studentParticipation); + ltiNewResultService.get().onNewResult(studentParticipation); } } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ComplaintResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ComplaintResource.java index aa44ed9df1b1..8158b1891e7d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ComplaintResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ComplaintResource.java @@ -102,7 +102,7 @@ public ResponseEntity createComplaint(@RequestBody Complaint complain Complaint savedComplaint = complaintService.createComplaint(complaint, OptionalLong.empty(), principal); // Remove assessor information from client request - savedComplaint.getResult().setAssessor(null); + savedComplaint.getResult().filterSensitiveInformation(); return ResponseEntity.created(new URI("/api/complaints/" + savedComplaint.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, entityName, savedComplaint.getId().toString())).body(savedComplaint); @@ -138,7 +138,7 @@ public ResponseEntity createComplaintForExamExercise(@PathVariable Lo Complaint savedComplaint = complaintService.createComplaint(complaint, OptionalLong.of(examId), principal); // Remove assessor information from client request - savedComplaint.getResult().setAssessor(null); + savedComplaint.getResult().filterSensitiveInformation(); return ResponseEntity.created(new URI("/api/complaints/" + savedComplaint.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, entityName, savedComplaint.getId().toString())).body(savedComplaint); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java index be11819bc2aa..b2bbb88ec56a 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java @@ -26,6 +26,7 @@ import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathPageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.ConflictException; @RestController @RequestMapping("api/") @@ -138,7 +139,7 @@ public ResponseEntity getHealthStatusForCourse(@PathVaria } /** - * GET /learning-path/:learningPathId/graph : Gets the ngx representation of the learning path. + * GET /learning-path/:learningPathId/graph : Gets the ngx representation of the learning path as a graph. * * @param learningPathId the id of the learning path that should be fetched * @return the ResponseEntity with status 200 (OK) and with body the ngx representation of the learning path @@ -147,7 +148,7 @@ public ResponseEntity getHealthStatusForCourse(@PathVaria @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathNgxGraph(@PathVariable Long learningPathId) { - log.debug("REST request to get ngx representation of learning path with id: {}", learningPathId); + log.debug("REST request to get ngx graph representation of learning path with id: {}", learningPathId); LearningPath learningPath = learningPathRepository.findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersByIdElseThrow(learningPathId); Course course = courseRepository.findByIdElseThrow(learningPath.getCourse().getId()); if (!course.getLearningPathsEnabled()) { @@ -165,4 +166,37 @@ else if (!authorizationCheckService.isAtLeastInstructorInCourse(course, user) && NgxLearningPathDTO graph = learningPathService.generateNgxGraphRepresentation(learningPath); return ResponseEntity.ok(graph); } + + /** + * GET /courses/:courseId/learning-path-id : Gets the id of the learning path. + * If the learning path has not been generated although the course has learning paths enabled, the corresponding learning path will be created. + * + * @param courseId the id of the course from which the learning path id should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the id of the learning path + */ + @GetMapping("/courses/{courseId}/learning-path-id") + @EnforceAtLeastStudent + public ResponseEntity getLearningPathId(@PathVariable Long courseId) { + log.debug("REST request to get learning path id for course with id: {}", courseId); + Course course = courseRepository.findByIdElseThrow(courseId); + if (!authorizationCheckService.isStudentInCourse(course, null)) { + throw new AccessForbiddenException("You are not a student in this course."); + } + if (!course.getLearningPathsEnabled()) { + throw new ConflictException("Learning paths are not enabled for this course.", "LearningPath", "learningPathsNotEnabled"); + } + + // generate learning path if missing + User user = userRepository.getUser(); + final var learningPathOptional = learningPathRepository.findByCourseIdAndUserId(course.getId(), user.getId()); + LearningPath learningPath; + if (learningPathOptional.isEmpty()) { + course = courseRepository.findWithEagerCompetenciesByIdElseThrow(courseId); + learningPath = learningPathService.generateLearningPathForUser(course, user); + } + else { + learningPath = learningPathOptional.get(); + } + return ResponseEntity.ok(learningPath.getId()); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LegacyResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LegacyResource.java index 9659a44855d0..713a55c31bf1 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LegacyResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LegacyResource.java @@ -1,10 +1,5 @@ package de.tum.in.www1.artemis.web.rest; -import java.io.IOException; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - import org.springframework.http.ResponseEntity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.web.bind.annotation.*; @@ -12,8 +7,6 @@ import de.tum.in.www1.artemis.config.SecurityConfiguration; import de.tum.in.www1.artemis.security.annotations.EnforceNothing; import de.tum.in.www1.artemis.security.annotations.ManualConfig; -import de.tum.in.www1.artemis.web.rest.dto.LtiLaunchRequestDTO; -import de.tum.in.www1.artemis.web.rest.open.PublicLtiResource; import de.tum.in.www1.artemis.web.rest.open.PublicProgrammingSubmissionResource; import de.tum.in.www1.artemis.web.rest.open.PublicResultResource; @@ -26,37 +19,15 @@ @Deprecated(forRemoval = true) public class LegacyResource { - private final PublicLtiResource publicLtiResource; - private final PublicProgrammingSubmissionResource publicProgrammingSubmissionResource; private final PublicResultResource publicResultResource; - public LegacyResource(PublicLtiResource publicLtiResource, PublicProgrammingSubmissionResource publicProgrammingSubmissionResource, PublicResultResource publicResultResource) { - this.publicLtiResource = publicLtiResource; + public LegacyResource(PublicProgrammingSubmissionResource publicProgrammingSubmissionResource, PublicResultResource publicResultResource) { this.publicProgrammingSubmissionResource = publicProgrammingSubmissionResource; this.publicResultResource = publicResultResource; } - /** - * POST lti/launch/:exerciseId : Launch the exercise app using request by an LTI consumer. Redirects the user to - * the exercise on success. - * - * @param launchRequest the LTI launch request (ExerciseLtiConfigurationDTO) - * @param exerciseId the id of the exercise the user wants to open - * @param request the request - * @param response the response - * @deprecated use {@link PublicLtiResource#launch(LtiLaunchRequestDTO, Long, HttpServletRequest, HttpServletResponse)} instead - */ - @PostMapping("lti/launch/{exerciseId}") - @EnforceNothing - @ManualConfig - @Deprecated(forRemoval = true) - public void legacyLtiLaunch(@ModelAttribute LtiLaunchRequestDTO launchRequest, @PathVariable("exerciseId") Long exerciseId, HttpServletRequest request, - HttpServletResponse response) throws IOException { - publicLtiResource.launch(launchRequest, exerciseId, request, response); - } - /** * Receive a new push notification from the VCS server and save a submission in the database * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index 99f661058c31..c2d644b36085 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.web.rest; +import org.springframework.context.annotation.Profile; import org.springframework.web.bind.annotation.*; import de.tum.in.www1.artemis.domain.Course; @@ -14,6 +15,7 @@ */ @RestController @RequestMapping("/api") +@Profile("lti") public class LtiResource { private final LtiDynamicRegistrationService ltiDynamicRegistrationService; diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java index dfcf760984a9..8ee88209dfd8 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java @@ -20,6 +20,7 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismDetectionConfig; import de.tum.in.www1.artemis.domain.plagiarism.modeling.ModelingPlagiarismResult; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; @@ -34,7 +35,7 @@ import de.tum.in.www1.artemis.service.feature.FeatureToggle; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; -import de.tum.in.www1.artemis.service.plagiarism.ModelingPlagiarismDetectionService; +import de.tum.in.www1.artemis.service.plagiarism.PlagiarismDetectionService; import de.tum.in.www1.artemis.service.util.TimeLogUtil; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; @@ -86,7 +87,7 @@ public class ModelingExerciseResource { private final GradingCriterionRepository gradingCriterionRepository; - private final ModelingPlagiarismDetectionService modelingPlagiarismDetectionService; + private final PlagiarismDetectionService plagiarismDetectionService; private final ChannelService channelService; @@ -97,7 +98,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos ModelingExerciseService modelingExerciseService, ExerciseDeletionService exerciseDeletionService, PlagiarismResultRepository plagiarismResultRepository, ModelingExerciseImportService modelingExerciseImportService, SubmissionExportService modelingSubmissionExportService, ExerciseService exerciseService, GroupNotificationScheduleService groupNotificationScheduleService, GradingCriterionRepository gradingCriterionRepository, - ModelingPlagiarismDetectionService modelingPlagiarismDetectionService, ChannelService channelService, ChannelRepository channelRepository) { + PlagiarismDetectionService plagiarismDetectionService, ChannelService channelService, ChannelRepository channelRepository) { this.modelingExerciseRepository = modelingExerciseRepository; this.courseService = courseService; this.modelingExerciseService = modelingExerciseService; @@ -112,7 +113,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos this.groupNotificationScheduleService = groupNotificationScheduleService; this.exerciseService = exerciseService; this.gradingCriterionRepository = gradingCriterionRepository; - this.modelingPlagiarismDetectionService = modelingPlagiarismDetectionService; + this.plagiarismDetectionService = plagiarismDetectionService; this.channelService = channelService; this.channelRepository = channelRepository; } @@ -390,17 +391,11 @@ public ResponseEntity checkPlagiarism(@PathVariable lo var modelingExercise = modelingExerciseRepository.findByIdWithStudentParticipationsSubmissionsResultsElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, modelingExercise, null); long start = System.nanoTime(); - log.info("Start modelingPlagiarismDetectionService.checkPlagiarism for exercise {}", exerciseId); - var plagiarismResult = modelingPlagiarismDetectionService.checkPlagiarism(modelingExercise, similarityThreshold / 100, minimumSize, minimumScore); - log.info("Finished modelingPlagiarismDetectionService.checkPlagiarism call for {} comparisons in {}", plagiarismResult.getComparisons().size(), - TimeLogUtil.formatDurationFrom(start)); - // TODO: limit the amount temporarily because of database issues - plagiarismResult.sortAndLimit(100); - log.info("Limited number of comparisons to {} to avoid performance issues when saving to database", plagiarismResult.getComparisons().size()); - start = System.nanoTime(); - plagiarismResultRepository.savePlagiarismResultAndRemovePrevious(plagiarismResult); - log.info("Finished plagiarismResultRepository.savePlagiarismResultAndRemovePrevious call in {}", TimeLogUtil.formatDurationFrom(start)); - plagiarismResultRepository.prepareResultForClient(plagiarismResult); + log.info("Started manual plagiarism checks for modeling exercise: exerciseId={}.", exerciseId); + var config = new PlagiarismDetectionConfig(similarityThreshold, minimumScore, minimumSize); + var plagiarismResult = plagiarismDetectionService.checkModelingExercise(modelingExercise, config); + log.info("Finished manual plagiarism checks for modeling exercise: exerciseId={}, elapsed={}.", exerciseId, TimeLogUtil.formatDurationFrom(start)); + return ResponseEntity.ok(plagiarismResult); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java index bef5f75f0b42..d0f41c78f81c 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java @@ -26,9 +26,7 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.config.GuidedTourConfiguration; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; -import de.tum.in.www1.artemis.domain.enumeration.InitializationState; -import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; +import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; import de.tum.in.www1.artemis.domain.participation.*; import de.tum.in.www1.artemis.domain.quiz.AbstractQuizSubmission; @@ -516,6 +514,13 @@ public ResponseEntity> updateParticipationDueDates(@P return ResponseEntity.ok().body(updatedParticipations); } + private Set findParticipationWithLatestResults(Exercise exercise) { + if (exercise.getExerciseType() == ExerciseType.QUIZ) { + return studentParticipationRepository.findByExerciseIdWithLatestAndManualRatedResults(exercise.getId()); + } + return studentParticipationRepository.findByExerciseIdWithLatestAndManualResults(exercise.getId()); + } + /** * GET /exercises/:exerciseId/participations : get all the participations for an exercise * @@ -532,7 +537,7 @@ public ResponseEntity> getAllParticipationsForExercise authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, exercise, null); Set participations; if (withLatestResults) { - participations = studentParticipationRepository.findByExerciseIdWithLatestAndManualResults(exerciseId); + participations = findParticipationWithLatestResults(exercise); participations.forEach(participation -> { participation.setSubmissionCount(participation.getSubmissions().size()); if (participation.getResults() != null && !participation.getResults().isEmpty()) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java index 4b34fa6951b8..b6174e2948e2 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingAssessmentResource.java @@ -45,7 +45,7 @@ public class ProgrammingAssessmentResource extends AssessmentResource { private final ProgrammingSubmissionRepository programmingSubmissionRepository; - private final LtiNewResultService ltiNewResultService; + private final Optional ltiNewResultService; private final StudentParticipationRepository studentParticipationRepository; @@ -53,7 +53,7 @@ public class ProgrammingAssessmentResource extends AssessmentResource { public ProgrammingAssessmentResource(AuthorizationCheckService authCheckService, UserRepository userRepository, ProgrammingAssessmentService programmingAssessmentService, ProgrammingSubmissionRepository programmingSubmissionRepository, ExerciseRepository exerciseRepository, ResultRepository resultRepository, ExamService examService, - ResultWebsocketService resultWebsocketService, LtiNewResultService ltiNewResultService, StudentParticipationRepository studentParticipationRepository, + ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, StudentParticipationRepository studentParticipationRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, SingleUserNotificationService singleUserNotificationService, ProgrammingExerciseParticipationService programmingExerciseParticipationService) { super(authCheckService, userRepository, exerciseRepository, programmingAssessmentService, resultRepository, examService, resultWebsocketService, @@ -202,7 +202,9 @@ public ResponseEntity saveProgrammingAssessment(@PathVariable Long parti newManualResult.getParticipation().filterSensitiveInformation(); } // Note: we always need to report the result over LTI, otherwise it might never become visible in the external system - ltiNewResultService.onNewResult((StudentParticipation) newManualResult.getParticipation()); + if (ltiNewResultService.isPresent()) { + ltiNewResultService.get().onNewResult((StudentParticipation) newManualResult.getParticipation()); + } if (submit && ExerciseDateService.isAfterAssessmentDueDate(programmingExercise)) { resultWebsocketService.broadcastNewResult(newManualResult.getParticipation(), newManualResult); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExercisePlagiarismResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExercisePlagiarismResource.java index b170639fbb0d..9fe72ab56e03 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExercisePlagiarismResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExercisePlagiarismResource.java @@ -2,10 +2,8 @@ import static de.tum.in.www1.artemis.web.rest.ProgrammingExerciseResourceEndpoints.*; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,7 +16,7 @@ import de.jplag.exceptions.ExitException; import de.tum.in.www1.artemis.domain.ProgrammingExercise; -import de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismDetectionConfig; import de.tum.in.www1.artemis.domain.plagiarism.text.TextPlagiarismResult; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismResultRepository; @@ -27,9 +25,8 @@ import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; -import de.tum.in.www1.artemis.service.plagiarism.ProgrammingPlagiarismDetectionService; -import de.tum.in.www1.artemis.service.programming.ProgrammingLanguageFeature; -import de.tum.in.www1.artemis.service.programming.ProgrammingLanguageFeatureService; +import de.tum.in.www1.artemis.service.plagiarism.PlagiarismDetectionService; +import de.tum.in.www1.artemis.service.plagiarism.ProgrammingLanguageNotSupportedForPlagiarismDetectionException; import de.tum.in.www1.artemis.service.util.TimeLogUtil; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @@ -53,18 +50,14 @@ public class ProgrammingExercisePlagiarismResource { private final PlagiarismResultRepository plagiarismResultRepository; - private final Optional programmingLanguageFeatureService; - - private final ProgrammingPlagiarismDetectionService programmingPlagiarismDetectionService; + private final PlagiarismDetectionService plagiarismDetectionService; public ProgrammingExercisePlagiarismResource(ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, - PlagiarismResultRepository plagiarismResultRepository, Optional programmingLanguageFeatureService, - ProgrammingPlagiarismDetectionService programmingPlagiarismDetectionService) { + PlagiarismResultRepository plagiarismResultRepository, PlagiarismDetectionService plagiarismDetectionService) { this.programmingExerciseRepository = programmingExerciseRepository; this.authCheckService = authCheckService; this.plagiarismResultRepository = plagiarismResultRepository; - this.programmingLanguageFeatureService = programmingLanguageFeatureService; - this.programmingPlagiarismDetectionService = programmingPlagiarismDetectionService; + this.plagiarismDetectionService = plagiarismDetectionService; } /** @@ -91,6 +84,7 @@ public ResponseEntity getPlagiarismResult(@PathVariable lo * @param exerciseId The ID of the programming exercise for which the plagiarism check should be executed * @param similarityThreshold ignore comparisons whose similarity is below this threshold (in % between 0 and 100) * @param minimumScore consider only submissions whose score is greater or equal to this value + * @param minimumSize consider only submissions whose number of diff to template lines is greate or equal to this value * @return the ResponseEntity with status 200 (OK) and the list of at most 500 pair-wise submissions with a similarity above the given threshold (e.g. 50%). * @throws ExitException is thrown if JPlag exits unexpectedly * @throws IOException is thrown for file handling errors @@ -98,25 +92,24 @@ public ResponseEntity getPlagiarismResult(@PathVariable lo @GetMapping(CHECK_PLAGIARISM) @EnforceAtLeastEditor @FeatureToggle({ Feature.ProgrammingExercises, Feature.PlagiarismChecks }) - public ResponseEntity checkPlagiarism(@PathVariable long exerciseId, @RequestParam float similarityThreshold, @RequestParam int minimumScore) - throws ExitException, IOException { + public ResponseEntity checkPlagiarism(@PathVariable long exerciseId, @RequestParam float similarityThreshold, @RequestParam int minimumScore, + @RequestParam int minimumSize) throws ExitException, IOException { ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, programmingExercise, null); - ProgrammingLanguage language = programmingExercise.getProgrammingLanguage(); - ProgrammingLanguageFeature programmingLanguageFeature = programmingLanguageFeatureService.orElseThrow().getProgrammingLanguageFeatures(language); - - if (!programmingLanguageFeature.plagiarismCheckSupported()) { - throw new BadRequestAlertException("Artemis does not support plagiarism checks for the programming language " + programmingExercise.getProgrammingLanguage(), - ENTITY_NAME, "programmingLanguageNotSupported"); - } long start = System.nanoTime(); - log.info("Start programmingPlagiarismDetectionService.checkPlagiarism for exercise {}", exerciseId); - var plagiarismResult = programmingPlagiarismDetectionService.checkPlagiarism(exerciseId, similarityThreshold, minimumScore); - log.info("Finished programmingExerciseExportService.checkPlagiarism call for {} comparisons in {}", plagiarismResult.getComparisons().size(), - TimeLogUtil.formatDurationFrom(start)); - plagiarismResultRepository.prepareResultForClient(plagiarismResult); - return ResponseEntity.ok(plagiarismResult); + log.info("Started manual plagiarism checks for programming exercise: exerciseId={}.", exerciseId); + var config = new PlagiarismDetectionConfig(similarityThreshold, minimumScore, minimumSize); + try { + var plagiarismResult = plagiarismDetectionService.checkProgrammingExercise(programmingExercise, config); + return ResponseEntity.ok(plagiarismResult); + } + catch (ProgrammingLanguageNotSupportedForPlagiarismDetectionException e) { + throw new BadRequestAlertException(e.getMessage(), ENTITY_NAME, "programmingLanguageNotSupported"); + } + finally { + log.info("Finished manual plagiarism checks for programming exercise: exerciseId={}, elapsed={}.", exerciseId, TimeLogUtil.formatDurationFrom(start)); + } } /** @@ -125,29 +118,37 @@ public ResponseEntity checkPlagiarism(@PathVariable long e * @param exerciseId The ID of the programming exercise for which the plagiarism check should be executed * @param similarityThreshold ignore comparisons whose similarity is below this threshold (in % between 0 and 100) * @param minimumScore consider only submissions whose score is greater or equal to this value + * @param minimumSize consider only submissions whose number of diff to template lines is greate or equal to this value * @return The ResponseEntity with status 201 (Created) or with status 400 (Bad Request) if the parameters are invalid * @throws IOException is thrown for file handling errors */ @GetMapping(value = CHECK_PLAGIARISM_JPLAG_REPORT) @EnforceAtLeastEditor @FeatureToggle(Feature.ProgrammingExercises) - public ResponseEntity checkPlagiarismWithJPlagReport(@PathVariable long exerciseId, @RequestParam float similarityThreshold, @RequestParam int minimumScore) - throws IOException { + public ResponseEntity checkPlagiarismWithJPlagReport(@PathVariable long exerciseId, @RequestParam float similarityThreshold, @RequestParam int minimumScore, + @RequestParam int minimumSize) throws IOException { log.debug("REST request to check plagiarism for ProgrammingExercise with id: {}", exerciseId); ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, programmingExercise, null); - var programmingLanguageFeature = programmingLanguageFeatureService.orElseThrow().getProgrammingLanguageFeatures(programmingExercise.getProgrammingLanguage()); - if (!programmingLanguageFeature.plagiarismCheckSupported()) { - throw new BadRequestAlertException("Artemis does not support plagiarism checks for the programming language " + programmingExercise.getProgrammingLanguage(), - "Plagiarism Check", "programmingLanguageNotSupported"); - } - File zipFile = programmingPlagiarismDetectionService.checkPlagiarismWithJPlagReport(exerciseId, similarityThreshold, minimumScore); - if (zipFile == null) { - throw new BadRequestAlertException("Insufficient amount of valid and long enough submissions available for comparison.", "Plagiarism Check", "notEnoughSubmissions"); + long start = System.nanoTime(); + log.info("Started manual plagiarism checks with Jplag report for programming exercise: exerciseId={}.", exerciseId); + var config = new PlagiarismDetectionConfig(similarityThreshold, minimumScore, minimumSize); + try { + var zipFile = plagiarismDetectionService.checkProgrammingExerciseWithJplagReport(programmingExercise, config); + if (zipFile == null) { + throw new BadRequestAlertException("Insufficient amount of valid and long enough submissions available for comparison.", "Plagiarism Check", + "notEnoughSubmissions"); + } + + var resource = new InputStreamResource(new FileInputStream(zipFile)); + return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()).body(resource); + } + catch (ProgrammingLanguageNotSupportedForPlagiarismDetectionException e) { + throw new BadRequestAlertException(e.getMessage(), ENTITY_NAME, "programmingLanguageNotSupported"); + } + finally { + log.info("Finished manual plagiarism checks with Jplag report for programming exercise: exerciseId={}, elapsed={}.", exerciseId, TimeLogUtil.formatDurationFrom(start)); } - - InputStreamResource resource = new InputStreamResource(new FileInputStream(zipFile)); - return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()).body(resource); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/RatingResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/RatingResource.java index dc35510c7c3c..e1c622f47262 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/RatingResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/RatingResource.java @@ -11,10 +11,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.Rating; -import de.tum.in.www1.artemis.domain.Result; -import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.ResultRepository; @@ -59,37 +56,37 @@ public RatingResource(RatingService ratingService, UserRepository userRepository } /** - * Return Rating referencing resultId or null + * GET /results/:resultId/rating : Return Rating referencing resultId or null * * @param resultId - Id of result that is referenced with the rating - * @return Rating or null + * @return saved star rating value or empty optional */ @GetMapping("/results/{resultId}/rating") @EnforceAtLeastStudent - public ResponseEntity> getRatingForResult(@PathVariable Long resultId) { + public ResponseEntity> getRatingForResult(@PathVariable Long resultId) { // TODO allow for Instructors if (!authCheckService.isAdmin()) { checkIfUserIsOwnerOfSubmissionElseThrow(resultId); } Optional rating = ratingService.findRatingByResultId(resultId); - return ResponseEntity.ok(rating); + return ResponseEntity.ok(rating.map(Rating::getRating)); } /** - * Persist a new Rating + * POST /results/:resultId/rating/:ratingValue : Persist a new Rating * * @param resultId - Id of result that is referenced with the rating that should be persisted * @param ratingValue - Value of the updated rating - * @return inserted Rating + * @return inserted star rating value * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("/results/{resultId}/rating/{ratingValue}") @EnforceAtLeastStudent - public ResponseEntity createRatingForResult(@PathVariable long resultId, @PathVariable int ratingValue) throws URISyntaxException { + public ResponseEntity createRatingForResult(@PathVariable long resultId, @PathVariable int ratingValue) throws URISyntaxException { checkRating(ratingValue); checkIfUserIsOwnerOfSubmissionElseThrow(resultId); Rating savedRating = ratingService.saveRating(resultId, ratingValue); - return ResponseEntity.created(new URI("/api/results/" + savedRating.getId() + "/rating")).body(savedRating); + return ResponseEntity.created(new URI("/api/results/" + savedRating.getId() + "/rating")).body(savedRating.getRating()); } private void checkRating(int ratingValue) { @@ -99,23 +96,23 @@ private void checkRating(int ratingValue) { } /** - * Update a Rating + * PUT /results/:resultId/rating/:ratingValue : Update a Rating * * @param resultId - Id of result that is referenced with the rating that should be updated * @param ratingValue - Value of the updated rating - * @return updated Rating + * @return updated star rating value */ @PutMapping("/results/{resultId}/rating/{ratingValue}") @EnforceAtLeastStudent - public ResponseEntity updateRatingForResult(@PathVariable long resultId, @PathVariable int ratingValue) { + public ResponseEntity updateRatingForResult(@PathVariable long resultId, @PathVariable int ratingValue) { checkRating(ratingValue); checkIfUserIsOwnerOfSubmissionElseThrow(resultId); Rating savedRating = ratingService.updateRating(resultId, ratingValue); - return ResponseEntity.ok(savedRating); + return ResponseEntity.ok(savedRating.getRating()); } /** - * Get all ratings for the "courseId" Course + * GET /course/:courseId/rating : Get all ratings for the "courseId" Course * * @param courseId - Id of the course that the ratings are fetched for * @return List of Ratings for the course @@ -126,6 +123,11 @@ public ResponseEntity> getRatingForInstructorDashboard(@PathVariabl Course course = courseRepository.findByIdElseThrow(courseId); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); List responseRatings = ratingService.getAllRatingsByCourse(courseId); + responseRatings.forEach(rating -> { + rating.getResult().setSubmission(null); + rating.getResult().getParticipation().getExercise().setCourse(null); + rating.getResult().getParticipation().getExercise().setExerciseGroup(null); + }); return ResponseEntity.ok(responseRatings); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java index 2d4f41229288..ec59a8308d6b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java @@ -16,6 +16,7 @@ import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismDetectionConfig; import de.tum.in.www1.artemis.domain.plagiarism.text.TextPlagiarismResult; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; @@ -29,7 +30,7 @@ import de.tum.in.www1.artemis.service.messaging.InstanceMessageSendService; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; -import de.tum.in.www1.artemis.service.plagiarism.TextPlagiarismDetectionService; +import de.tum.in.www1.artemis.service.plagiarism.PlagiarismDetectionService; import de.tum.in.www1.artemis.service.util.TimeLogUtil; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; @@ -93,7 +94,7 @@ public class TextExerciseResource { private final InstanceMessageSendService instanceMessageSendService; - private final TextPlagiarismDetectionService textPlagiarismDetectionService; + private final PlagiarismDetectionService plagiarismDetectionService; private final CourseRepository courseRepository; @@ -107,7 +108,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE ParticipationRepository participationRepository, ResultRepository resultRepository, TextExerciseImportService textExerciseImportService, TextSubmissionExportService textSubmissionExportService, ExampleSubmissionRepository exampleSubmissionRepository, ExerciseService exerciseService, GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, - InstanceMessageSendService instanceMessageSendService, TextPlagiarismDetectionService textPlagiarismDetectionService, CourseRepository courseRepository, + InstanceMessageSendService instanceMessageSendService, PlagiarismDetectionService plagiarismDetectionService, CourseRepository courseRepository, ChannelService channelService, ChannelRepository channelRepository) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; @@ -128,7 +129,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.exerciseService = exerciseService; this.gradingCriterionRepository = gradingCriterionRepository; this.instanceMessageSendService = instanceMessageSendService; - this.textPlagiarismDetectionService = textPlagiarismDetectionService; + this.plagiarismDetectionService = plagiarismDetectionService; this.courseRepository = courseRepository; this.channelService = channelService; this.channelRepository = channelRepository; @@ -495,18 +496,12 @@ public ResponseEntity checkPlagiarism(@PathVariable long e @RequestParam int minimumSize) throws ExitException { TextExercise textExercise = textExerciseRepository.findByIdWithStudentParticipationsAndSubmissionsElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, textExercise, null); - log.info("Start textPlagiarismDetectionService.checkPlagiarism for exercise {}", exerciseId); + long start = System.nanoTime(); - var plagiarismResult = textPlagiarismDetectionService.checkPlagiarism(textExercise, similarityThreshold, minimumScore, minimumSize); - log.info("Finished textPlagiarismDetectionService.checkPlagiarism for exercise {} with {} comparisons in {}", exerciseId, plagiarismResult.getComparisons().size(), - TimeLogUtil.formatDurationFrom(start)); - // TODO: limit the amount temporarily because of database issues - plagiarismResult.sortAndLimit(100); - log.info("Limited number of comparisons to {} to avoid performance issues when saving to database", plagiarismResult.getComparisons().size()); - start = System.nanoTime(); - plagiarismResultRepository.savePlagiarismResultAndRemovePrevious(plagiarismResult); - log.info("Finished plagiarismResultRepository.savePlagiarismResultAndRemovePrevious call in {}", TimeLogUtil.formatDurationFrom(start)); - plagiarismResultRepository.prepareResultForClient(plagiarismResult); + log.info("Started manual plagiarism checks for text exercise: exerciseId={}.", exerciseId); + var config = new PlagiarismDetectionConfig(similarityThreshold, minimumScore, minimumSize); + var plagiarismResult = plagiarismDetectionService.checkTextExercise(textExercise, config); + log.info("Finished manual plagiarism checks for text exercise: exerciseId={}, elapsed={}.", exerciseId, TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.ok(plagiarismResult); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java index ae58d67b2022..72c1be292734 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java @@ -2,6 +2,7 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,11 +59,11 @@ public class UserResource { private final UserCreationService userCreationService; - private final LtiService ltiService; + private final Optional ltiService; private final UserRepository userRepository; - public UserResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, LtiService ltiService) { + public UserResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, Optional ltiService) { this.userRepository = userRepository; this.userService = userService; this.ltiService = ltiService; @@ -152,7 +153,7 @@ public ResponseEntity initializeUser() { if (user.getActivated()) { return ResponseEntity.ok().body(new UserInitializationDTO()); } - if (!ltiService.isLtiCreatedUser(user) || !user.isInternal()) { + if ((ltiService.isPresent() && !ltiService.get().isLtiCreatedUser(user)) || !user.isInternal()) { user.setActivated(true); userRepository.save(user); return ResponseEntity.ok().body(new UserInitializationDTO()); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathHealthDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathHealthDTO.java index f261bf7cb26a..cea77a15d594 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathHealthDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathHealthDTO.java @@ -1,17 +1,19 @@ package de.tum.in.www1.artemis.web.rest.dto.competency; -import javax.validation.constraints.NotNull; +import java.util.Set; + +import javax.validation.constraints.NotEmpty; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) -public record LearningPathHealthDTO(@NotNull HealthStatus status, Long missingLearningPaths) { +public record LearningPathHealthDTO(@NotEmpty Set status, Long missingLearningPaths) { - public LearningPathHealthDTO(HealthStatus status) { + public LearningPathHealthDTO(Set status) { this(status, null); } public enum HealthStatus { - OK, DISABLED, MISSING + OK, DISABLED, MISSING, NO_COMPETENCIES, NO_RELATIONS } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisMessageResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisMessageResource.java index 2025a24d9de7..30eb0aab418e 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisMessageResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/iris/IrisMessageResource.java @@ -14,12 +14,11 @@ import de.tum.in.www1.artemis.domain.iris.IrisMessage; import de.tum.in.www1.artemis.domain.iris.IrisMessageSender; import de.tum.in.www1.artemis.domain.iris.session.IrisSession; +import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.iris.IrisMessageRepository; import de.tum.in.www1.artemis.repository.iris.IrisSessionRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; -import de.tum.in.www1.artemis.service.iris.IrisMessageService; -import de.tum.in.www1.artemis.service.iris.IrisSessionService; -import de.tum.in.www1.artemis.service.iris.IrisWebsocketService; +import de.tum.in.www1.artemis.service.iris.*; import de.tum.in.www1.artemis.web.rest.errors.ConflictException; /** @@ -40,13 +39,19 @@ public class IrisMessageResource { private final IrisWebsocketService irisWebsocketService; + private final IrisRateLimitService rateLimitService; + + private final UserRepository userRepository; + public IrisMessageResource(IrisSessionRepository irisSessionRepository, IrisSessionService irisSessionService, IrisMessageService irisMessageService, - IrisMessageRepository irisMessageRepository, IrisWebsocketService irisWebsocketService) { + IrisMessageRepository irisMessageRepository, IrisWebsocketService irisWebsocketService, IrisRateLimitService rateLimitService, UserRepository userRepository) { this.irisSessionRepository = irisSessionRepository; this.irisSessionService = irisSessionService; this.irisMessageService = irisMessageService; this.irisMessageRepository = irisMessageRepository; this.irisWebsocketService = irisWebsocketService; + this.rateLimitService = rateLimitService; + this.userRepository = userRepository; } /** @@ -77,7 +82,10 @@ public ResponseEntity> getMessages(@PathVariable Long sessionI public ResponseEntity createMessage(@PathVariable Long sessionId, @RequestBody IrisMessage message) throws URISyntaxException { var session = irisSessionRepository.findByIdElseThrow(sessionId); irisSessionService.checkIsIrisActivated(session); - irisSessionService.checkHasAccessToIrisSession(session, null); + var user = userRepository.getUser(); + irisSessionService.checkHasAccessToIrisSession(session, user); + rateLimitService.checkRateLimitElseThrow(user); + var savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.USER); irisSessionService.requestMessageFromIris(session); savedMessage.setMessageDifferentiator(message.getMessageDifferentiator()); @@ -100,7 +108,10 @@ public ResponseEntity createMessage(@PathVariable Long sessionId, @ public ResponseEntity resendMessage(@PathVariable Long sessionId, @PathVariable Long messageId) { var session = irisSessionRepository.findByIdWithMessagesElseThrow(sessionId); irisSessionService.checkIsIrisActivated(session); - irisSessionService.checkHasAccessToIrisSession(session, null); + var user = userRepository.getUser(); + irisSessionService.checkHasAccessToIrisSession(session, user); + rateLimitService.checkRateLimitElseThrow(user); + var message = irisMessageRepository.findByIdElseThrow(messageId); if (session.getMessages().lastIndexOf(message) != session.getMessages().size() - 1) { throw new BadRequestException("Only the last message can be resent"); 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 3d81309bd6da..6d80df7a6874 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 @@ -19,6 +19,7 @@ import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.connectors.iris.IrisHealthIndicator; import de.tum.in.www1.artemis.service.connectors.iris.dto.IrisStatusDTO; +import de.tum.in.www1.artemis.service.iris.IrisRateLimitService; import de.tum.in.www1.artemis.service.iris.IrisSessionService; import de.tum.in.www1.artemis.service.iris.IrisSettingsService; @@ -44,9 +45,11 @@ public class IrisSessionResource { private final IrisHealthIndicator irisHealthIndicator; + private final IrisRateLimitService irisRateLimitService; + public IrisSessionResource(ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, IrisChatSessionRepository irisChatSessionRepository, UserRepository userRepository, IrisSessionService irisSessionService, IrisSettingsService irisSettingsService, - IrisHealthIndicator irisHealthIndicator) { + IrisHealthIndicator irisHealthIndicator, IrisRateLimitService irisRateLimitService) { this.programmingExerciseRepository = programmingExerciseRepository; this.authCheckService = authCheckService; this.irisChatSessionRepository = irisChatSessionRepository; @@ -54,6 +57,7 @@ public IrisSessionResource(ProgrammingExerciseRepository programmingExerciseRepo this.irisSessionService = irisSessionService; this.irisSettingsService = irisSettingsService; this.irisHealthIndicator = irisHealthIndicator; + this.irisRateLimitService = irisRateLimitService; } /** @@ -125,7 +129,7 @@ public ResponseEntity createSessionForProgrammingExercise(@PathVari */ @GetMapping("/sessions/{sessionId}/active") @EnforceAtLeastStudent - public ResponseEntity isIrisActive(@PathVariable Long sessionId) { + public ResponseEntity isIrisActive(@PathVariable Long sessionId) { var session = irisChatSessionRepository.findByIdElseThrow(sessionId); var user = userRepository.getUser(); irisSessionService.checkHasAccessToIrisSession(session, user); @@ -138,6 +142,12 @@ public ResponseEntity isIrisActive(@PathVariable Long sessionId) { specificModelStatus = Arrays.stream(modelStatuses).filter(x -> x.model().equals(settings.getIrisChatSettings().getPreferredModel())) .anyMatch(x -> x.status() == IrisStatusDTO.ModelStatus.UP); } - return ResponseEntity.ok(specificModelStatus); + + var rateLimitInfo = irisRateLimitService.getRateLimitInformation(user); + + return ResponseEntity.ok(new IrisHealthDTO(specificModelStatus, rateLimitInfo.currentMessageCount(), rateLimitInfo.rateLimit())); + } + + public record IrisHealthDTO(boolean active, int currentMessageCount, int rateLimit) { } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java index aa546ee0de94..0cd72162c321 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java @@ -9,6 +9,7 @@ import org.glassfish.jersey.uri.UriComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.web.bind.annotation.ModelAttribute; @@ -36,6 +37,7 @@ */ @RestController @RequestMapping("api/public/") +@Profile("lti") public class PublicLtiResource { private final Logger log = LoggerFactory.getLogger(PublicLtiResource.class); diff --git a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts index c2220bf609c4..2ff807085f83 100644 --- a/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts +++ b/src/main/webapp/app/account/password-reset/finish/password-reset-finish.component.ts @@ -2,7 +2,6 @@ import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angula import { ActivatedRoute } from '@angular/router'; import { PasswordResetFinishService } from './password-reset-finish.service'; -import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from 'app/app.constants'; @@ -28,7 +27,6 @@ export class PasswordResetFinishComponent implements OnInit, AfterViewInit { constructor( private passwordResetFinishService: PasswordResetFinishService, private route: ActivatedRoute, - private profileService: ProfileService, private fb: FormBuilder, ) {} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html index b31670b4d330..b4a52421a5fc 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html @@ -12,7 +12,7 @@

Mastered Optional

-
{{ competency.description }}
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.html new file mode 100644 index 000000000000..27a2240410b4 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.html @@ -0,0 +1,9 @@ +
+
+
{{ getWarningTitle(status) | artemisTranslate }}
+

{{ getWarningBody(status) | artemisTranslate }}

+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.ts new file mode 100644 index 000000000000..0084bf57ed9c --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component.ts @@ -0,0 +1,16 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { HealthStatus, getWarningAction, getWarningBody, getWarningHint, getWarningTitle } from 'app/entities/competency/learning-path-health.model'; + +@Component({ + selector: 'jhi-learning-path-health-status-warning', + templateUrl: './learning-path-health-status-warning.component.html', +}) +export class LearningPathHealthStatusWarningComponent { + @Input() status: HealthStatus; + @Output() onButtonClicked: EventEmitter = new EventEmitter(); + + readonly getWarningTitle = getWarningTitle; + readonly getWarningBody = getWarningBody; + readonly getWarningHint = getWarningHint; + readonly getWarningAction = getWarningAction; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html index c6e89d1e7a8c..5542ff44ebf6 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html @@ -6,7 +6,7 @@

Learning Path Management

-
+
Disabled
-
-
-
{{ 'artemisApp.learningPath.manageLearningPaths.health.missing.title' | artemisTranslate }}
-

{{ 'artemisApp.learningPath.manageLearningPaths.health.missing.body' | artemisTranslate }}

- -
-
-
+ + + + + +
Search for Learning Path: diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts index 60d0b168c6ec..e5c9d0c19160 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators'; @@ -11,7 +11,7 @@ import { LearningPathPagingService } from 'app/course/learning-paths/learning-pa import { SortService } from 'app/shared/service/sort.service'; import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; import { faSort, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; -import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { HealthStatus, LearningPathHealthDTO, getWarningAction, getWarningBody, getWarningHint, getWarningTitle } from 'app/entities/competency/learning-path-health.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; @@ -53,6 +53,7 @@ export class LearningPathManagementComponent implements OnInit { constructor( private activatedRoute: ActivatedRoute, + private router: Router, private learningPathService: LearningPathService, private alertService: AlertService, private pagingService: LearningPathPagingService, @@ -134,7 +135,7 @@ export class LearningPathManagementComponent implements OnInit { .subscribe({ next: (res) => { this.health = res.body!; - if (this.health.status !== HealthStatus.DISABLED) { + if (!this.health.status?.includes(HealthStatus.DISABLED)) { this.performSearch(this.sort, 0); this.performSearch(this.search, 300); } @@ -163,6 +164,10 @@ export class LearningPathManagementComponent implements OnInit { }); } + routeToCompetencyManagement() { + this.router.navigate(['../competency-management'], { relativeTo: this.activatedRoute }); + } + /** * Method to perform the search based on a search subject * @@ -214,4 +219,8 @@ export class LearningPathManagementComponent implements OnInit { } protected readonly HealthStatus = HealthStatus; + protected readonly getWarningTitle = getWarningTitle; + protected readonly getWarningBody = getWarningBody; + protected readonly getWarningAction = getWarningAction; + protected readonly getWarningHint = getWarningHint; } diff --git a/src/main/webapp/app/course/learning-paths/learning-path.service.ts b/src/main/webapp/app/course/learning-paths/learning-path.service.ts index b201a57f84d3..f0ea55d45eb2 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path.service.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path.service.ts @@ -36,4 +36,8 @@ export class LearningPathService { }), ); } + + getLearningPathId(courseId: number) { + return this.httpClient.get(`${this.resourceURL}/courses/${courseId}/learning-path-id`, { observe: 'response' }); + } } diff --git a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts index 8c37905813e9..862086b32d3e 100644 --- a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts +++ b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts @@ -13,11 +13,66 @@ import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learni import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; +import { LearningPathHealthStatusWarningComponent } from 'app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component'; +import { LearningPathContainerComponent } from 'app/course/learning-paths/participate/learning-path-container.component'; +import { Authority } from 'app/shared/constants/authority.constants'; +import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; +import { RouterModule, Routes } from '@angular/router'; +import { LearningPathGraphSidebarComponent } from 'app/course/learning-paths/participate/learning-path-graph-sidebar.component'; + +const routes: Routes = [ + { + path: '', + component: LearningPathContainerComponent, + data: { + authorities: [Authority.USER], + pageTitle: 'overview.learningPath', + }, + canActivate: [UserRouteAccessService], + children: [ + { + path: 'lecture-unit', + pathMatch: 'full', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => + import('app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module').then( + (m) => m.ArtemisLearningPathLectureUnitViewModule, + ), + }, + ], + }, + { + path: 'exercise', + pathMatch: 'full', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => import('app/overview/exercise-details/course-exercise-details.module').then((m) => m.CourseExerciseDetailsModule), + }, + ], + }, + ], + }, +]; @NgModule({ - imports: [ArtemisSharedModule, FormsModule, ReactiveFormsModule, ArtemisSharedComponentModule, NgxGraphModule, ArtemisLectureUnitsModule, ArtemisCompetenciesModule], + imports: [ + ArtemisSharedModule, + FormsModule, + ReactiveFormsModule, + ArtemisSharedComponentModule, + NgxGraphModule, + ArtemisLectureUnitsModule, + ArtemisCompetenciesModule, + RouterModule.forChild(routes), + ], declarations: [ LearningPathManagementComponent, + LearningPathHealthStatusWarningComponent, LearningPathProgressModalComponent, LearningPathProgressNavComponent, LearningPathGraphComponent, @@ -25,7 +80,9 @@ import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning CompetencyNodeDetailsComponent, LectureUnitNodeDetailsComponent, ExerciseNodeDetailsComponent, + LearningPathContainerComponent, + LearningPathGraphSidebarComponent, ], - exports: [], + exports: [LearningPathContainerComponent], }) export class ArtemisLearningPathsModule {} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html new file mode 100644 index 000000000000..a0b7ee135370 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html @@ -0,0 +1,24 @@ +
+
+ +
+
+
+
+ No task selected +
+ +
+
+
+ + diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.scss b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.scss new file mode 100644 index 000000000000..f1d6cecd0efc --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.scss @@ -0,0 +1,16 @@ +.lp-participation-view-container { + width: 100%; + margin-left: 0; +} + +.graph-wrapper { + max-width: min-content; +} + +.learning-object-wrapper { + border-style: none; +} + +.exercise-wrapper { + padding: 1rem; +} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts new file mode 100644 index 000000000000..0f85e88d14ae --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts @@ -0,0 +1,164 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { Exercise } from 'app/entities/exercise.model'; +import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; +import { Lecture } from 'app/entities/lecture.model'; +import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; +import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; +import { LectureService } from 'app/lecture/lecture.service'; +import { onError } from 'app/shared/util/global.utils'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; +import { LearningPathLectureUnitViewComponent } from 'app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component'; +import { CourseExerciseDetailsComponent } from 'app/overview/exercise-details/course-exercise-details.component'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseEntry, LearningPathHistoryStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-history-storage.service'; + +@Component({ + selector: 'jhi-learning-path-container', + styleUrls: ['./learning-path-container.component.scss'], + templateUrl: './learning-path-container.component.html', +}) +export class LearningPathContainerComponent implements OnInit { + @Input() courseId: number; + learningPathId: number; + + learningObjectId: number; + lectureId?: number; + lecture?: Lecture; + lectureUnit?: LectureUnit; + exercise?: Exercise; + + // icons + faChevronLeft = faChevronLeft; + faChevronRight = faChevronRight; + + constructor( + private router: Router, + private activatedRoute: ActivatedRoute, + private alertService: AlertService, + private learningPathService: LearningPathService, + private lectureService: LectureService, + private exerciseService: ExerciseService, + public learningPathHistoryStorageService: LearningPathHistoryStorageService, + ) {} + + ngOnInit() { + if (!this.courseId) { + this.activatedRoute.parent!.parent!.params.subscribe((params) => { + this.courseId = params['courseId']; + }); + } + this.learningPathService.getLearningPathId(this.courseId).subscribe((learningPathIdResponse) => { + this.learningPathId = learningPathIdResponse.body!; + + // load latest lecture unit or exercise that was accessed + this.onPrevTask(); + }); + } + + onNextTask() { + if (this.lectureUnit?.id) { + this.learningPathHistoryStorageService.storeLectureUnit(this.learningPathId, this.lectureId!, this.lectureUnit.id); + } else if (this.exercise?.id) { + this.learningPathHistoryStorageService.storeExercise(this.learningPathId, this.exercise.id); + } + // reset state to avoid invalid states + this.undefineAll(); + // todo: load recommendation, part of next pr + } + + undefineAll() { + this.lecture = undefined; + this.lectureUnit = undefined; + this.exercise = undefined; + } + + onPrevTask() { + // reset state to avoid invalid states + this.undefineAll(); + if (this.learningPathHistoryStorageService.hasPrevious(this.learningPathId)) { + const entry = this.learningPathHistoryStorageService.getPrevious(this.learningPathId); + if (entry instanceof LectureUnitEntry) { + this.learningObjectId = entry.lectureUnitId; + this.lectureId = entry.lectureId; + this.loadLectureUnit(); + } else if (entry instanceof ExerciseEntry) { + this.learningObjectId = entry.exerciseId; + this.loadExercise(); + } + } + } + + loadLectureUnit() { + this.lectureService.findWithDetails(this.lectureId!).subscribe({ + next: (findLectureResult) => { + this.lecture = findLectureResult.body!; + if (this.lecture?.lectureUnits) { + this.lectureUnit = this.lecture.lectureUnits.find((lectureUnit) => lectureUnit.id === this.learningObjectId); + } + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + this.router.navigate(['lecture-unit'], { relativeTo: this.activatedRoute }); + } + + loadExercise() { + this.exerciseService.getExerciseDetails(this.learningObjectId).subscribe({ + next: (exerciseResponse) => { + this.exercise = exerciseResponse.body!; + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + this.router.navigate(['exercise'], { relativeTo: this.activatedRoute }); + } + + /** + * This function gets called if the router outlet gets activated. This is + * used only for the LearningPathLectureUnitViewComponent + * @param instance The component instance + */ + onChildActivate(instance: LearningPathLectureUnitViewComponent | CourseExerciseDetailsComponent) { + if (instance instanceof LearningPathLectureUnitViewComponent) { + this.setupLectureUnitView(instance); + } else { + this.setupExerciseView(instance); + } + } + + setupLectureUnitView(instance: LearningPathLectureUnitViewComponent) { + if (this.lecture) { + instance.lecture = this.lecture; + instance.lectureUnit = this.lectureUnit!; + } + } + + setupExerciseView(instance: CourseExerciseDetailsComponent) { + if (this.exercise) { + instance.learningPathMode = true; + instance.courseId = this.courseId; + instance.exerciseId = this.learningObjectId; + } + } + + onNodeClicked(node: NgxLearningPathNode) { + if (node.type !== NodeType.LECTURE_UNIT && node.type !== NodeType.EXERCISE) { + return; + } + if (this.lectureUnit?.id) { + this.learningPathHistoryStorageService.storeLectureUnit(this.learningPathId, this.lectureId!, this.lectureUnit.id); + } else if (this.exercise?.id) { + this.learningPathHistoryStorageService.storeExercise(this.learningPathId, this.exercise.id); + } + // reset state to avoid invalid states + this.undefineAll(); + this.learningObjectId = node.linkedResource!; + this.lectureId = node.linkedResourceParent; + if (node.type === NodeType.LECTURE_UNIT) { + this.loadLectureUnit(); + } else if (node.type === NodeType.EXERCISE) { + this.loadExercise(); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html new file mode 100644 index 000000000000..0c64d339f083 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html @@ -0,0 +1,36 @@ +
+
+
+ +
+

+ + {{ 'artemisApp.learningPath.sideBar.header' | artemisTranslate }} +

+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ + Learning Path + + +
+
diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.scss b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.scss new file mode 100644 index 000000000000..ee0763fe5218 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.scss @@ -0,0 +1,77 @@ +@import 'src/main/webapp/content/scss/artemis-variables'; + +$draggable-width: 15px; +$graph-min-width: 215px; + +.learning-path-sidebar { + .expanded-graph { + display: flex; + width: calc(#{$draggable-width} + #{$graph-min-width}); + min-height: 500px; + margin-left: auto; + + .draggable-right { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: $draggable-width; + } + + .card { + width: inherit; + min-width: $graph-min-width; + + .card-header { + display: inline-flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + .card-title { + display: flex; + } + + .row > .col-auto:last-child { + display: flex; + flex-direction: column; + justify-content: center; + } + } + + .card-body { + padding: 0; + } + } + } + + .collapsed-graph { + display: flex; + width: 38px; + justify-content: space-between; + flex-flow: column; + cursor: pointer; + + span { + writing-mode: vertical-lr; + transform: rotate(180deg); + margin: auto; + } + + .expand-graph-icon { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + place-self: center; + } + } + + @media screen and (max-width: 992px) { + .expanded-graph { + width: 94vw; + + .draggable-right { + display: none; + } + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.ts new file mode 100644 index 000000000000..c21da727e371 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.ts @@ -0,0 +1,54 @@ +import { AfterViewInit, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import interact from 'interactjs'; +import { faChevronLeft, faChevronRight, faGripLinesVertical, faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { NgxLearningPathNode } from 'app/entities/competency/learning-path.model'; + +@Component({ + selector: 'jhi-learning-path-graph-sidebar', + styleUrls: ['./learning-path-graph-sidebar.component.scss'], + templateUrl: './learning-path-graph-sidebar.component.html', +}) +export class LearningPathGraphSidebarComponent implements AfterViewInit { + @Input() courseId: number; + @Input() learningPathId: number; + collapsed: boolean; + + // Icons + faChevronLeft = faChevronLeft; + faChevronRight = faChevronRight; + faGripLinesVertical = faGripLinesVertical; + faNetworkWired = faNetworkWired; + + @ViewChild('learningPathGraphComponent', { static: false }) + learningPathGraphComponent: LearningPathGraphComponent; + + @Output() nodeClicked: EventEmitter = new EventEmitter(); + + ngAfterViewInit(): void { + // allows the sidebar to be resized towards the right-hand side + interact('.expanded-graph') + .resizable({ + edges: { left: false, right: '.draggable-right', bottom: false, top: false }, + modifiers: [ + // Set maximum width of the sidebar + interact.modifiers!.restrictSize({ + min: { width: 230, height: 0 }, + max: { width: 500, height: 4000 }, + }), + ], + inertia: true, + }) + .on('resizestart', (event: any) => { + event.target.classList.add('card-resizable'); + }) + .on('resizeend', (event: any) => { + event.target.classList.remove('card-resizable'); + this.learningPathGraphComponent.onResize(); + }) + .on('resizemove', (event: any) => { + const target = event.target; + target.style.width = event.rect.width + 'px'; + }); + } +} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts new file mode 100644 index 000000000000..47b75bdd4ccb --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; + +/** + * This service is used to store the histories of learning path participation for the currently logged-in user. + */ +@Injectable({ providedIn: 'root' }) +export class LearningPathHistoryStorageService { + private readonly learningPathHistories: Map = new Map(); + + /** + * Stores the lecture unit in the learning path's history. + * + * @param learningPathId the id of the learning path to which the new entry should be added + * @param lectureId the id of the lecture, the lecture unit belongs to + * @param lectureUnitId the id of the lecture unit + */ + storeLectureUnit(learningPathId: number, lectureId: number, lectureUnitId: number) { + this.store(learningPathId, new LectureUnitEntry(lectureId, lectureUnitId)); + } + + /** + * Stores the exercise in the learning path's history. + * + * @param learningPathId the id of the learning path to which the new entry should be added + * @param exerciseId the id of the exercise + */ + storeExercise(learningPathId: number, exerciseId: number) { + this.store(learningPathId, new ExerciseEntry(exerciseId)); + } + + private store(learningPathId: number, entry: HistoryEntry) { + if (!entry) { + return; + } + if (!this.learningPathHistories.has(learningPathId)) { + this.learningPathHistories.set(learningPathId, []); + } + this.learningPathHistories.get(learningPathId)!.push(entry); + } + + /** + * Returns if the learning path's history stores at least one entry. + * + * @param learningPathId the id of the learning path for which the history should be checked + */ + hasPrevious(learningPathId: number): boolean { + if (this.learningPathHistories.has(learningPathId)) { + return this.learningPathHistories.get(learningPathId)!.length !== 0; + } + return false; + } + + /** + * Gets and removes the latest stored entry from the learning path's history. + * + * @param learningPathId + */ + getPrevious(learningPathId: number) { + if (!this.hasPrevious(learningPathId)) { + return undefined; + } + return this.learningPathHistories.get(learningPathId)!.pop(); + } +} + +export abstract class HistoryEntry {} + +export class LectureUnitEntry extends HistoryEntry { + lectureUnitId: number; + lectureId: number; + + constructor(lectureId: number, lectureUnitId: number) { + super(); + this.lectureId = lectureId; + this.lectureUnitId = lectureUnitId; + } +} + +export class ExerciseEntry extends HistoryEntry { + readonly exerciseId: number; + + constructor(exerciseId: number) { + super(); + this.exerciseId = exerciseId; + } +} diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.html b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.html new file mode 100644 index 000000000000..2bf7446b1d04 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.html @@ -0,0 +1,17 @@ +
+
+
+ + + + +
+
+
+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.scss b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.scss new file mode 100644 index 000000000000..886feddbee00 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.scss @@ -0,0 +1,3 @@ +.communication-wrapper { + max-width: min-content; +} diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts new file mode 100644 index 000000000000..1941ce7abfb3 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts @@ -0,0 +1,59 @@ +import { Component, Input } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; +import { onError } from 'app/shared/util/global.utils'; +import { Lecture } from 'app/entities/lecture.model'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { isCommunicationEnabled, isMessagingEnabled } from 'app/entities/course.model'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; + +export interface LectureUnitCompletionEvent { + lectureUnit: LectureUnit; + completed: boolean; +} + +@Component({ + selector: 'jhi-learning-path-lecture-unit-view', + styleUrls: ['./learning-path-lecture-unit-view.component.scss'], + templateUrl: './learning-path-lecture-unit-view.component.html', +}) +export class LearningPathLectureUnitViewComponent { + @Input() lecture: Lecture; + @Input() lectureUnit: LectureUnit; + readonly LectureUnitType = LectureUnitType; + + discussionComponent?: DiscussionSectionComponent; + + protected readonly isMessagingEnabled = isMessagingEnabled; + protected readonly isCommunicationEnabled = isCommunicationEnabled; + + constructor( + private lectureUnitService: LectureUnitService, + private alertService: AlertService, + ) {} + + completeLectureUnit(event: LectureUnitCompletionEvent): void { + if (this.lecture && event.lectureUnit.visibleToStudents && event.lectureUnit.completed !== event.completed) { + this.lectureUnitService.setCompletion(event.lectureUnit.id!, this.lecture.id!, event.completed).subscribe({ + next: () => { + event.lectureUnit.completed = event.completed; + }, + error: (res: HttpErrorResponse) => onError(this.alertService, res), + }); + } + } + + /** + * This function gets called if the router outlet gets activated. This is + * used only for the DiscussionComponent + * @param instance The component instance + */ + onChildActivate(instance: DiscussionSectionComponent) { + this.discussionComponent = instance; // save the reference to the component instance + if (this.lecture) { + instance.lecture = this.lecture; + instance.isCommunicationPage = false; + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts new file mode 100644 index 000000000000..33dc55941bda --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts @@ -0,0 +1,33 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { LearningPathLectureUnitViewComponent } from 'app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component'; +import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture-units.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { Authority } from 'app/shared/constants/authority.constants'; +import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; + +const routes: Routes = [ + { + path: '', + component: LearningPathLectureUnitViewComponent, + data: { + authorities: [Authority.USER], + pageTitle: 'overview.learningPath', + }, + canActivate: [UserRouteAccessService], + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => import('app/overview/discussion-section/discussion-section.module').then((m) => m.DiscussionSectionModule), + }, + ], + }, +]; + +@NgModule({ + imports: [ArtemisSharedModule, RouterModule.forChild(routes), ArtemisLectureUnitsModule], + declarations: [LearningPathLectureUnitViewComponent], + exports: [LearningPathLectureUnitViewComponent], +}) +export class ArtemisLearningPathLectureUnitViewModule {} diff --git a/src/main/webapp/app/course/manage/course-management.component.ts b/src/main/webapp/app/course/manage/course-management.component.ts index 37b6dfe29abc..d09576e30598 100644 --- a/src/main/webapp/app/course/manage/course-management.component.ts +++ b/src/main/webapp/app/course/manage/course-management.component.ts @@ -7,8 +7,6 @@ import { onError } from 'app/shared/util/global.utils'; import { GuidedTourService } from 'app/guided-tour/guided-tour.service'; import { tutorAssessmentTour } from 'app/guided-tour/tours/tutor-assessment-tour'; import { AlertService } from 'app/core/util/alert.service'; -import { ExamManagementService } from 'app/exam/manage/exam-management.service'; -import { LectureService } from 'app/lecture/lecture.service'; import { CourseManagementOverviewStatisticsDto } from 'app/course/manage/overview/course-management-overview-statistics-dto.model'; import { EventManager } from 'app/core/util/event-manager.service'; import { faAngleDown, faAngleUp, faPlus } from '@fortawesome/free-solid-svg-icons'; @@ -44,14 +42,36 @@ export class CourseManagementComponent implements OnInit, OnDestroy, AfterViewIn faAngleUp = faAngleUp; constructor( - private examService: ExamManagementService, - private lectureService: LectureService, private courseManagementService: CourseManagementService, private alertService: AlertService, private eventManager: EventManager, private guidedTourService: GuidedTourService, ) {} + /** + * loads all courses and subscribes to courseListModification + */ + ngOnInit() { + this.loadAll(); + this.registerChangeInCourses(); + } + + /** + * notifies the guided-tour service that the current component has + * been fully loaded + */ + ngAfterViewInit(): void { + this.guidedTourService.componentPageLoaded(); + } + + /** + * unsubscribe on component destruction + */ + ngOnDestroy() { + this.eventManager.destroy(this.eventSubscriber); + this.dialogErrorSource.unsubscribe(); + } + /** * loads all courses from courseService */ @@ -188,30 +208,6 @@ export class CourseManagementComponent implements OnInit, OnDestroy, AfterViewIn }); } - /** - * loads all courses and subscribes to courseListModification - */ - ngOnInit() { - this.loadAll(); - this.registerChangeInCourses(); - } - - /** - * notifies the guided-tour service that the current component has - * been fully loaded - */ - ngAfterViewInit(): void { - this.guidedTourService.componentPageLoaded(); - } - - /** - * unsubscribe on component destruction - */ - ngOnDestroy() { - this.eventManager.destroy(this.eventSubscriber); - this.dialogErrorSource.unsubscribe(); - } - /** * subscribes to courseListModification event */ diff --git a/src/main/webapp/app/course/manage/course-update.component.html b/src/main/webapp/app/course/manage/course-update.component.html index b943661e4fa1..040fc4f8768b 100644 --- a/src/main/webapp/app/course/manage/course-update.component.html +++ b/src/main/webapp/app/course/manage/course-update.component.html @@ -363,7 +363,7 @@
-
+
Course Details:{{ 'global.generic.yes' | artemisTranslate }} {{ 'global.generic.no' | artemisTranslate }} -
Online Course
-
- {{ 'global.generic.yes' | artemisTranslate }} - {{ 'global.generic.no' | artemisTranslate }} -
- + +
Online Course
+
+ {{ 'global.generic.yes' | artemisTranslate }} + {{ 'global.generic.no' | artemisTranslate }} +
+
+
LTI Configuration
diff --git a/src/main/webapp/app/course/manage/detail/course-detail.component.ts b/src/main/webapp/app/course/manage/detail/course-detail.component.ts index 56c3114f5763..a7f81434d675 100644 --- a/src/main/webapp/app/course/manage/detail/course-detail.component.ts +++ b/src/main/webapp/app/course/manage/detail/course-detail.component.ts @@ -1,6 +1,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { PROFILE_LOCALVC } from 'app/app.constants'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { Subscription } from 'rxjs'; import { Course } from 'app/entities/course.model'; import { CourseManagementService } from '../course-management.service'; @@ -34,6 +36,8 @@ export class CourseDetailComponent implements OnInit, OnDestroy { activeStudents?: number[]; course: Course; + ltiEnabled = false; + private eventSubscriber: Subscription; paramSub: Subscription; @@ -52,12 +56,16 @@ export class CourseDetailComponent implements OnInit, OnDestroy { private courseManagementService: CourseManagementService, private route: ActivatedRoute, private alertService: AlertService, + private profileService: ProfileService, ) {} /** * On init load the course information and subscribe to listen for changes in courses. */ ngOnInit() { + this.profileService.getProfileInfo().subscribe((profileInfo) => { + this.ltiEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); + }); this.route.data.subscribe(({ course }) => { if (course) { this.course = course; diff --git a/src/main/webapp/app/entities/competency/learning-path-health.model.ts b/src/main/webapp/app/entities/competency/learning-path-health.model.ts index bf3a9794d178..1cbcb13ba367 100644 --- a/src/main/webapp/app/entities/competency/learning-path-health.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path-health.model.ts @@ -1,8 +1,8 @@ export class LearningPathHealthDTO { - public status?: HealthStatus; + public status?: HealthStatus[]; public missingLearningPaths?: number; - constructor(status: HealthStatus) { + constructor(status: HealthStatus[]) { this.status = status; } } @@ -11,4 +11,51 @@ export enum HealthStatus { OK = 'OK', DISABLED = 'DISABLED', MISSING = 'MISSING', + NO_COMPETENCIES = 'NO_COMPETENCIES', + NO_RELATIONS = 'NO_RELATIONS', +} + +function getWarningTranslation(status: HealthStatus, element: string) { + if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { + return ''; + } + + const translation = { + [HealthStatus.MISSING]: 'missing', + [HealthStatus.NO_COMPETENCIES]: 'noCompetencies', + [HealthStatus.NO_RELATIONS]: 'noRelations', + }; + return `artemisApp.learningPath.manageLearningPaths.health.${translation[status]}.${element}`; +} + +export function getWarningTitle(status: HealthStatus) { + if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { + return ''; + } + + return getWarningTranslation(status, 'title'); +} + +export function getWarningBody(status: HealthStatus) { + if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { + return ''; + } + + return getWarningTranslation(status, 'body'); +} + +export function getWarningAction(status: HealthStatus) { + if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { + return ''; + } + + return getWarningTranslation(status, 'action'); +} + +export function getWarningHint(status: HealthStatus) { + if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { + return ''; + } + + return getWarningTranslation(status, 'hint'); } diff --git a/src/main/webapp/app/entities/iris/iris-errors.model.ts b/src/main/webapp/app/entities/iris/iris-errors.model.ts index a115874a1732..fc15951e105d 100644 --- a/src/main/webapp/app/entities/iris/iris-errors.model.ts +++ b/src/main/webapp/app/entities/iris/iris-errors.model.ts @@ -16,6 +16,7 @@ export enum IrisErrorMessageKey { PARSE_RESPONSE = 'artemisApp.exerciseChatbot.errors.parseResponse', TECHNICAL_ERROR_RESPONSE = 'artemisApp.exerciseChatbot.errors.technicalError', IRIS_NOT_AVAILABLE = 'artemisApp.exerciseChatbot.errors.irisNotAvailable', + RATE_LIMIT_EXCEEDED = 'artemisApp.exerciseChatbot.errors.rateLimitExceeded', } export interface IrisErrorType { @@ -42,6 +43,7 @@ const IrisErrors: IrisErrorType[] = [ { key: IrisErrorMessageKey.FORBIDDEN, fatal: true }, { key: IrisErrorMessageKey.TECHNICAL_ERROR_RESPONSE, fatal: true }, { key: IrisErrorMessageKey.IRIS_NOT_AVAILABLE, fatal: true }, + { key: IrisErrorMessageKey.RATE_LIMIT_EXCEEDED, fatal: true }, ]; export const errorMessages: Readonly<{ [key in IrisErrorMessageKey]: IrisErrorType }> = Object.freeze( diff --git a/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.ts b/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.ts index be3ba91c83fc..0f212060dccf 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component.ts @@ -1,10 +1,9 @@ import { Component, Input, OnInit } from '@angular/core'; import { Exam } from 'app/entities/exam.model'; import { faCheckDouble, faFont } from '@fortawesome/free-solid-svg-icons'; -import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { Exercise, ExerciseType, getIcon } from 'app/entities/exercise.model'; import { ExerciseGroup } from 'app/entities/exercise-group.model'; import { SHORT_NAME_PATTERN } from 'app/shared/constants/input.constants'; -import { getIcon } from 'app/entities/exercise.model'; @Component({ selector: 'jhi-exam-exercise-import', diff --git a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/apollon-diagram-detail.component.ts b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/apollon-diagram-detail.component.ts index 9ca9b4796321..6caa91f19a16 100644 --- a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/apollon-diagram-detail.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/apollon-diagram-detail.component.ts @@ -1,5 +1,5 @@ import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { ApollonEditor, ApollonMode, Locale, UMLModel } from '@ls1intum/apollon'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { JhiLanguageHelper } from 'app/core/language/language.helper'; @@ -52,6 +52,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy { private languageHelper: JhiLanguageHelper, private modalService: NgbModal, private route: ActivatedRoute, + private router: Router, ) {} /** @@ -153,6 +154,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy { */ async generateExercise() { if (!this.hasInteractive) { + this.alertService.error('artemisApp.apollonDiagram.create.validationError'); return; } @@ -165,6 +167,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy { const result = await modalRef.result; if (result) { this.alertService.success('artemisApp.apollonDiagram.create.success', { title: result.title }); + this.router.navigate(['course-management', this.courseId, 'quiz-exercises', result.id, 'edit']); } } catch (error) { this.alertService.error('artemisApp.apollonDiagram.create.error'); diff --git a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts index 10128f71a4d1..0222ed96cf77 100644 --- a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts +++ b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts @@ -68,9 +68,9 @@ export async function generateDragAndDropQuizExercise( const quizExercise = createDragAndDropQuizExercise(course, title, dragAndDropQuestion); // Save the quiz exercise - await lastValueFrom(quizExerciseService.create(quizExercise)); + const creationResponse = await lastValueFrom(quizExerciseService.create(quizExercise)); - return quizExercise; + return creationResponse.body ?? quizExercise; } /** diff --git a/src/main/webapp/app/exercises/quiz/shared/questions/drag-and-drop-question/drag-and-drop-question.component.html b/src/main/webapp/app/exercises/quiz/shared/questions/drag-and-drop-question/drag-and-drop-question.component.html index 08f652e0df4a..6f8f8d96dfb7 100644 --- a/src/main/webapp/app/exercises/quiz/shared/questions/drag-and-drop-question/drag-and-drop-question.component.html +++ b/src/main/webapp/app/exercises/quiz/shared/questions/drag-and-drop-question/drag-and-drop-question.component.html @@ -96,6 +96,7 @@

(onDragOver)="preventDefault($event)" (onDragLeave)="preventDefault($event)" cdkDropList + [cdkDropListAutoScrollStep]="60" >

-
+
@@ -244,6 +245,7 @@

(onDragOver)="preventDefault($event)" (onDragLeave)="preventDefault($event)" cdkDropList + [cdkDropListAutoScrollStep]="60" >

!value.results[correctionRound].completionDate && value.results[correctionRound].assessmentType !== AssessmentType.AUTOMATIC " - (click)="cancelAssessment(value.results[correctionRound])" + (click)="cancelAssessment(value.results[correctionRound], value)" [disabled]="isLoading" class="btn btn-danger btn-sm mb-1" > diff --git a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts index ecfa39723900..9d42ed13d6ba 100644 --- a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts +++ b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts @@ -396,7 +396,7 @@ export class ExerciseScoresComponent implements OnInit, OnDestroy { /** * Cancel the current assessment and reload the submissions to reflect the change. */ - cancelAssessment(result: Result) { + cancelAssessment(result: Result, participation: Participation) { const confirmCancel = window.confirm(this.cancelConfirmationText); if (confirmCancel && result.submission?.id) { @@ -409,7 +409,7 @@ export class ExerciseScoresComponent implements OnInit, OnDestroy { cancelSubscription = this.modelingAssessmentService.cancelAssessment(result.submission.id); break; case ExerciseType.TEXT: - cancelSubscription = this.textAssessmentService.cancelAssessment(result.participation!.id!, result.submission.id); + cancelSubscription = this.textAssessmentService.cancelAssessment(participation.id!, result.submission.id); break; case ExerciseType.FILE_UPLOAD: cancelSubscription = this.fileUploadAssessmentService.cancelAssessment(result.submission.id); diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.html index b0cddf737e1b..9bd26fc8c330 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.html +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.html @@ -91,7 +91,7 @@
-
+
diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts index 47505af1cced..e69d8875925d 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts @@ -384,17 +384,15 @@ export class PlagiarismInspectorComponent implements OnInit { * Return the translation identifier of the minimum size tooltip for the current exercise type. */ getMinimumSizeTooltip() { - const tooltip = 'artemisApp.plagiarism.minimumSizeTooltip'; - switch (this.exercise.type) { + case ExerciseType.PROGRAMMING: { + return 'artemisApp.plagiarism.minimumSizeTooltipProgrammingExercise'; + } case ExerciseType.TEXT: { - return tooltip + 'Text'; + return 'artemisApp.plagiarism.minimumSizeTooltipTextExercise'; } case ExerciseType.MODELING: { - return tooltip + 'Modeling'; - } - default: { - return tooltip; + return 'artemisApp.plagiarism.minimumSizeTooltipModelingExercise'; } } } diff --git a/src/main/webapp/app/exercises/shared/rating/rating.component.html b/src/main/webapp/app/exercises/shared/rating/rating.component.html index 4631f776caaa..6b55339ca456 100644 --- a/src/main/webapp/app/exercises/shared/rating/rating.component.html +++ b/src/main/webapp/app/exercises/shared/rating/rating.component.html @@ -1,6 +1,6 @@ -
+
- +
diff --git a/src/main/webapp/app/exercises/shared/rating/rating.component.ts b/src/main/webapp/app/exercises/shared/rating/rating.component.ts index 2dde045d5be2..d7c81c0c68eb 100644 --- a/src/main/webapp/app/exercises/shared/rating/rating.component.ts +++ b/src/main/webapp/app/exercises/shared/rating/rating.component.ts @@ -2,9 +2,9 @@ import { Component, Input, OnInit } from '@angular/core'; import { RatingService } from 'app/exercises/shared/rating/rating.service'; import { StarRatingComponent } from 'app/exercises/shared/rating/star-rating/star-rating.component'; import { Result } from 'app/entities/result.model'; -import { Rating } from 'app/entities/rating.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { AccountService } from 'app/core/auth/account.service'; +import { Observable } from 'rxjs'; @Component({ selector: 'jhi-rating', @@ -12,7 +12,7 @@ import { AccountService } from 'app/core/auth/account.service'; styleUrls: ['./rating.component.scss'], }) export class RatingComponent implements OnInit { - public rating: Rating; + public rating: number; public disableRating = false; @Input() result?: Result; @@ -22,16 +22,12 @@ export class RatingComponent implements OnInit { ) {} ngOnInit(): void { - if (!this.result || !this.result.id || !this.result.participation || !this.accountService.isOwnerOfParticipation(this.result.participation as StudentParticipation)) { + if (!this.result?.id || !this.result.participation || !this.accountService.isOwnerOfParticipation(this.result.participation as StudentParticipation)) { return; } this.ratingService.getRating(this.result.id).subscribe((rating) => { - if (rating) { - this.rating = rating; - } else { - this.rating = new Rating(this.result, 0); - } + this.rating = rating ?? 0; }); } @@ -41,24 +37,22 @@ export class RatingComponent implements OnInit { */ onRate(event: { oldValue: number; newValue: number; starRating: StarRatingComponent }) { // block rating to prevent double sending of post request - if (this.disableRating || !this.rating.result) { + if (this.disableRating || !this.result) { return; } - // update feedback locally - this.rating.rating = event.newValue; + const oldRating = this.rating; + this.rating = event.newValue; + this.disableRating = true; + let observable: Observable; // set/update feedback on the server - if (this.rating.id) { - this.ratingService.updateRating(this.rating).subscribe((rating) => { - this.rating = rating; - }); + if (oldRating) { + observable = this.ratingService.updateRating(this.rating, this.result.id!); } else { - this.disableRating = true; - this.ratingService.createRating(this.rating).subscribe((rating) => { - this.rating = rating; - this.disableRating = false; - }); + observable = this.ratingService.createRating(this.rating, this.result.id!); } + + observable.subscribe((rating) => (this.rating = rating)).add(() => (this.disableRating = false)); } } diff --git a/src/main/webapp/app/exercises/shared/rating/rating.service.ts b/src/main/webapp/app/exercises/shared/rating/rating.service.ts index 34df5d2d2d16..728e9415a6bd 100644 --- a/src/main/webapp/app/exercises/shared/rating/rating.service.ts +++ b/src/main/webapp/app/exercises/shared/rating/rating.service.ts @@ -13,26 +13,28 @@ export class RatingService { /** * Create the student rating for feedback on the server. - * @param rating - Rating for the result + * @param rating - star rating for the result + * @param resultId - id of the linked result */ - createRating(rating: Rating): Observable { - return this.http.post(this.ratingResourceUrl + `${rating.result!.id!}/rating/${rating.rating}`, null); + createRating(rating: number, resultId: number): Observable { + return this.http.post(this.ratingResourceUrl + `${resultId}/rating/${rating}`, null); } /** * Get rating for "resultId" Result - * @param ratingId - Id of Result who's rating is received + * @param resultId - id of result who's rating is received */ - getRating(ratingId: number): Observable { - return this.http.get(this.ratingResourceUrl + `${ratingId}/rating`); + getRating(resultId: number): Observable { + return this.http.get(this.ratingResourceUrl + `${resultId}/rating`); } /** * Update rating for "resultId" Result - * @param rating - Rating for the result + * @param rating - star rating for the result + * @param resultId - id of the linked result */ - updateRating(rating: Rating): Observable { - return this.http.put(this.ratingResourceUrl + `${rating.result!.id!}/rating/${rating.rating}`, null); + updateRating(rating: number, resultId: number): Observable { + return this.http.put(this.ratingResourceUrl + `${resultId}/rating/${rating}`, null); } /** diff --git a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatwidget/exercise-chat-widget.component.html b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatwidget/exercise-chat-widget.component.html index 0ad22902b511..e6b5e8c55eab 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatwidget/exercise-chat-widget.component.html +++ b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatwidget/exercise-chat-widget.component.html @@ -11,21 +11,26 @@

-
- - - - + - - + + + + +
@@ -59,7 +64,7 @@
Preferred Model: -
+
@@ -16,6 +16,7 @@
+
diff --git a/src/main/webapp/app/iris/state-store.model.ts b/src/main/webapp/app/iris/state-store.model.ts index b62d2d406a71..0137ae10141c 100644 --- a/src/main/webapp/app/iris/state-store.model.ts +++ b/src/main/webapp/app/iris/state-store.model.ts @@ -9,6 +9,7 @@ export enum ActionType { STUDENT_MESSAGE_SENT = 'student-message-sent', SESSION_CHANGED = 'session-changed', RATE_MESSAGE_SUCCESS = 'rate-message-success', + RATE_LIMIT_UPDATED = 'rate-limit-updated', } export interface MessageStoreAction { @@ -89,6 +90,17 @@ export class RateMessageSuccessAction implements MessageStoreAction { } } +export class RateLimitUpdatedAction implements MessageStoreAction { + readonly type: ActionType; + + public constructor( + public readonly currentMessageCount: number, + public readonly rateLimit: number, + ) { + this.type = ActionType.RATE_LIMIT_UPDATED; + } +} + export function isNumNewMessagesResetAction(action: MessageStoreAction): action is NumNewMessagesResetAction { return action.type === ActionType.NUM_NEW_MESSAGES_RESET; } @@ -117,6 +129,10 @@ export function isRateMessageSuccessAction(action: MessageStoreAction): action i return action.type === ActionType.RATE_MESSAGE_SUCCESS; } +export function isRateLimitUpdatedAction(action: MessageStoreAction): action is RateLimitUpdatedAction { + return action.type === ActionType.RATE_LIMIT_UPDATED; +} + export class MessageStoreState { public constructor( public messages: ReadonlyArray, @@ -125,5 +141,7 @@ export class MessageStoreState { public numNewMessages: number, public error: IrisErrorType | null, public serverResponseTimeout: ReturnType | null, + public currentMessageCount: number, + public rateLimit: number, ) {} } diff --git a/src/main/webapp/app/iris/state-store.service.ts b/src/main/webapp/app/iris/state-store.service.ts index f457c7d17002..53b7342a6b51 100644 --- a/src/main/webapp/app/iris/state-store.service.ts +++ b/src/main/webapp/app/iris/state-store.service.ts @@ -7,6 +7,7 @@ import { HistoryMessageLoadedAction, MessageStoreAction, MessageStoreState, + RateLimitUpdatedAction, RateMessageSuccessAction, SessionReceivedAction, StudentMessageSentAction, @@ -14,6 +15,7 @@ import { isConversationErrorOccurredAction, isHistoryMessageLoadedAction, isNumNewMessagesResetAction, + isRateLimitUpdatedAction, isRateMessageSuccessAction, isSessionReceivedAction, isStudentMessageSentAction, @@ -35,6 +37,8 @@ export class IrisStateStore implements OnDestroy { numNewMessages: 0, error: null, serverResponseTimeout: null, + currentMessageCount: -1, + rateLimit: -1, }; private readonly action = new Subject(); @@ -151,6 +155,8 @@ export class IrisStateStore implements OnDestroy { numNewMessages: state.numNewMessages + 1, error: defaultError, serverResponseTimeout: null, + currentMessageCount: state.currentMessageCount, + rateLimit: state.rateLimit, }; } if (isConversationErrorOccurredAction(action)) { @@ -219,6 +225,15 @@ export class IrisStateStore implements OnDestroy { return state; } + if (isRateLimitUpdatedAction(action)) { + const castedAction = action as RateLimitUpdatedAction; + return { + ...state, + error: castedAction.rateLimit >= 0 && castedAction.currentMessageCount >= castedAction.rateLimit ? errorMessages[IrisErrorMessageKey.RATE_LIMIT_EXCEEDED] : null, + currentMessageCount: castedAction.currentMessageCount, + rateLimit: castedAction.rateLimit, + }; + } IrisStateStore.exhaustiveCheck(action); return state; diff --git a/src/main/webapp/app/iris/websocket.service.ts b/src/main/webapp/app/iris/websocket.service.ts index 68660454f5e5..0b0ffd81dcc8 100644 --- a/src/main/webapp/app/iris/websocket.service.ts +++ b/src/main/webapp/app/iris/websocket.service.ts @@ -6,6 +6,7 @@ import { ActiveConversationMessageLoadedAction, ConversationErrorOccurredAction, MessageStoreAction, + RateLimitUpdatedAction, StudentMessageSentAction, isSessionReceivedAction, } from 'app/iris/state-store.model'; @@ -20,6 +21,11 @@ export enum IrisWebsocketMessageType { ERROR = 'ERROR', } +class IrisRateLimitInformation { + currentMessageCount: number; + rateLimit: number; +} + /** * The IrisWebsocketDTO is the data transfer object for messages sent over the websocket. * It either contains an IrisMessage or an error message. @@ -29,6 +35,7 @@ export class IrisWebsocketDTO { message?: IrisMessage; errorTranslationKey?: IrisErrorMessageKey; translationParams?: Map; + rateLimitInfo?: IrisRateLimitInformation; } /** @@ -87,6 +94,10 @@ export class IrisWebsocketService implements OnDestroy { this.subscriptionChannel = channel; this.jhiWebsocketService.subscribe(this.subscriptionChannel); this.jhiWebsocketService.receive(this.subscriptionChannel).subscribe((websocketResponse: IrisWebsocketDTO) => { + if (websocketResponse.rateLimitInfo) { + this.stateStore.dispatch(new RateLimitUpdatedAction(websocketResponse.rateLimitInfo.currentMessageCount, websocketResponse.rateLimitInfo.rateLimit)); + } + if (websocketResponse.type === IrisWebsocketMessageType.ERROR) { if (!websocketResponse.errorTranslationKey) { this.stateStore.dispatch(new ConversationErrorOccurredAction(IrisErrorMessageKey.TECHNICAL_ERROR_RESPONSE)); diff --git a/src/main/webapp/app/overview/course-overview.component.html b/src/main/webapp/app/overview/course-overview.component.html index 937df274a472..6a54cc3c3f15 100644 --- a/src/main/webapp/app/overview/course-overview.component.html +++ b/src/main/webapp/app/overview/course-overview.component.html @@ -50,6 +50,18 @@ Competencies + + + Learning Path +