diff --git a/.github/workflows/testserver.yml b/.github/workflows/testserver.yml
index db5b0fce1000..f99ae615b36b 100644
--- a/.github/workflows/testserver.yml
+++ b/.github/workflows/testserver.yml
@@ -138,12 +138,13 @@ jobs:
folder: /opt/artemis
host_keys: |
- #- environment: artemis-test7.artemis.cit.tum.de
- # label-identifier: artemis-test7
- # url: https://artemis-test7.artemis.cit.tum.de
- # user: deployment
- # hosts: artemis-test7.artemis.cit.tum.de
- # folder: /opt/artemis
+ - environment: artemis-test7.artemis.cit.tum.de
+ label-identifier: artemis-test7
+ url: https://artemis-test7.artemis.cit.tum.de
+ user: deployment
+ hosts: artemis-test7.artemis.cit.tum.de
+ folder: /opt/artemis
+ host_keys: |
#- environment: artemis-test8.artemis.cit.tum.de
# label-identifier: artemis-test8
diff --git a/.gitignore b/.gitignore
index 75cb003dda0c..fee3f7347d4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -193,6 +193,7 @@ data-exports/
######################
/src/test/playwright/test-reports/
/src/test/playwright/test-results/*
+/src/test/playwright/ssh-keys/known_hosts
#################################
# Files generated by prebuild.mjs
diff --git a/LICENSE b/LICENSE
index a7504c5ef00b..507cc7deda09 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 TUM Applied Software Engineering
+Copyright (c) 2024 TUM Applied Education Technologies
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/build.gradle b/build.gradle
index 00f7bbb49d39..5cdc8cab7613 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,7 +12,7 @@ plugins {
id "idea"
id "jacoco"
id "org.springframework.boot" version "${spring_boot_version}"
- id "io.spring.dependency-management" version "1.1.6"
+ id "io.spring.dependency-management" version "1.1.7"
id "com.google.cloud.tools.jib" version "3.4.4"
id "com.github.node-gradle.node" version "${gradle_node_plugin_version}"
id "com.diffplug.spotless" version "6.25.0"
@@ -257,7 +257,7 @@ dependencies {
implementation "org.apache.lucene:lucene-queryparser:${lucene_version}"
implementation "org.apache.lucene:lucene-core:${lucene_version}"
implementation "org.apache.lucene:lucene-analyzers-common:${lucene_version}"
- implementation "com.google.protobuf:protobuf-java:4.29.1"
+ implementation "com.google.protobuf:protobuf-java:4.29.2"
// we have to override those values to use the latest version
implementation "org.slf4j:jcl-over-slf4j:${slf4j_version}"
@@ -384,6 +384,13 @@ dependencies {
exclude module: "spring-boot-starter-undertow"
}
implementation "org.springframework.boot:spring-boot-starter-tomcat:${spring_boot_version}"
+
+ // Avoid security issues in Tomcat 10.1.33
+ implementation "org.apache.tomcat.embed:tomcat-embed-core:${tomcat_version}"
+ implementation "org.apache.tomcat.embed:tomcat-embed-el:${tomcat_version}"
+ implementation "org.apache.tomcat.embed:tomcat-embed-websocket:${tomcat_version}"
+ implementation "org.apache.tomcat:tomcat-annotations-api:${tomcat_version}"
+
implementation "org.springframework.boot:spring-boot-starter-websocket:${spring_boot_version}"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf:${spring_boot_version}"
implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${spring_boot_version}"
@@ -400,7 +407,7 @@ dependencies {
implementation "org.springframework.cloud:spring-cloud-starter-config:${spring_cloud_version}"
implementation "org.springframework.cloud:spring-cloud-commons:${spring_cloud_version}"
- implementation "io.netty:netty-all:4.1.115.Final"
+ implementation "io.netty:netty-all:4.1.116.Final"
implementation "io.projectreactor.netty:reactor-netty:1.2.1"
implementation "org.springframework:spring-messaging:${spring_framework_version}"
implementation "org.springframework.retry:spring-retry:2.0.11"
@@ -451,7 +458,7 @@ dependencies {
implementation "org.apfloat:apfloat:1.14.0"
// use newest version of guava to avoid security issues through outdated dependencies
- implementation "com.google.guava:guava:33.3.1-jre"
+ implementation "com.google.guava:guava:33.4.0-jre"
implementation "com.sun.activation:jakarta.activation:2.0.1"
// use newest version of gson to avoid security issues through outdated dependencies
@@ -518,7 +525,7 @@ dependencies {
}
testImplementation "org.springframework.security:spring-security-test:${spring_security_version}"
testImplementation "org.springframework.boot:spring-boot-test:${spring_boot_version}"
- testImplementation "org.assertj:assertj-core:3.26.3"
+ testImplementation "org.assertj:assertj-core:3.27.0"
testImplementation "org.mockito:mockito-core:${mockito_version}"
testImplementation "org.mockito:mockito-junit-jupiter:${mockito_version}"
@@ -607,7 +614,7 @@ tasks.withType(Test).configureEach {
}
wrapper {
- gradleVersion = "8.12-rc-1"
+ gradleVersion = "8.12"
}
tasks.register("stage") {
diff --git a/docker/artemis/config/playwright-local.env b/docker/artemis/config/playwright-local.env
index 6b7805deacca..ecc6eefc0387 100644
--- a/docker/artemis/config/playwright-local.env
+++ b/docker/artemis/config/playwright-local.env
@@ -1,5 +1,5 @@
# ----------------------------------------------------------------------------------------------------------------------
-# Artemis configuration overrides for the Playwright E2E Postgres setups
+# Artemis configuration overrides for the Playwright E2E Local CI/VC setups
# ----------------------------------------------------------------------------------------------------------------------
SPRING_PROFILES_ACTIVE="artemis,scheduling,localvc,localci,buildagent,core,prod,docker"
@@ -12,3 +12,5 @@ ARTEMIS_CONTINUOUSINTEGRATION_ARTEMISAUTHENTICATIONTOKENVALUE='demo'
ARTEMIS_CONTINUOUSINTEGRATION_DOCKERCONNECTIONURI='unix:///var/run/docker.sock'
ARTEMIS_GIT_NAME='artemis'
ARTEMIS_GIT_EMAIL='artemis@example.com'
+ARTEMIS_VERSIONCONTROL_SSHHOSTKEYPATH='/app/artemis/src/test/playwright/ssh-keys'
+ARTEMIS_VERSIONCONTROL_SSHPORT='7921'
diff --git a/docker/playwright-E2E-tests-mysql-localci.yml b/docker/playwright-E2E-tests-mysql-localci.yml
index cf677b01fec6..13d2ba3b00ef 100644
--- a/docker/playwright-E2E-tests-mysql-localci.yml
+++ b/docker/playwright-E2E-tests-mysql-localci.yml
@@ -47,6 +47,8 @@ services:
condition: service_healthy
environment:
PLAYWRIGHT_DB_TYPE: 'MySQL'
+ network_mode: service:artemis-app
+ networks: !reset []
networks:
artemis:
diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml
index 4e14204d7703..332190edd7bd 100644
--- a/docs/.readthedocs.yaml
+++ b/docs/.readthedocs.yaml
@@ -2,11 +2,12 @@
version: 2
build:
- os: ubuntu-22.04
+ os: ubuntu-24.04
tools:
- python: "3.12"
+ python: "3.13"
sphinx:
fail_on_warning: true
+ configuration: docs/conf.py
python:
install:
- requirements: docs/requirements.txt
diff --git a/docs/admin/setup/distributed.rst b/docs/admin/setup/distributed.rst
index def7d2b7a980..5c6e5ce6248d 100644
--- a/docs/admin/setup/distributed.rst
+++ b/docs/admin/setup/distributed.rst
@@ -17,7 +17,7 @@ Setup with multiple instances
There are certain scenarios, where a setup with multiple instances of the application server is required.
This can e.g. be due to special requirements regarding fault tolerance or performance.
-Artemis also supports this setup (which is also used at the Chair for Applied Software Engineering at TUM).
+Artemis also supports this setup (which is also used at TUM).
Multiple instances of the application server are used to distribute the load:
diff --git a/docs/conf.py b/docs/conf.py
index ec9f22d2b6ac..705a2a5e18db 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,8 +18,8 @@
# -- Project information -----------------------------------------------------
project = 'Artemis'
-copyright = '2024, Technical University of Munich, Applied Software Engineering'
-author = 'Technical University of Munich, Applied Software Engineering'
+copyright = '2024, Applied Education Technologies, Technical University of Munich'
+author = 'Applied Education Technologies, Technical University of Munich'
# -- General configuration ---------------------------------------------------
diff --git a/docs/dev/cypress/cypress-open-screenshot.png b/docs/dev/cypress/cypress-open-screenshot.png
deleted file mode 100644
index 7c32c512b6e5..000000000000
Binary files a/docs/dev/cypress/cypress-open-screenshot.png and /dev/null differ
diff --git a/docs/dev/cypress/cypress_bamboo_deployment_diagram.svg b/docs/dev/cypress/cypress_bamboo_deployment_diagram.svg
deleted file mode 100644
index 8ff7e84792a7..000000000000
--- a/docs/dev/cypress/cypress_bamboo_deployment_diagram.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/dev/cypress/cypress_test_environment_deployment_diagram.svg b/docs/dev/cypress/cypress_test_environment_deployment_diagram.svg
deleted file mode 100644
index d7f6ccc7eac7..000000000000
--- a/docs/dev/cypress/cypress_test_environment_deployment_diagram.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/docs/dev/cypress/sorry-cypress-dashboard.png b/docs/dev/cypress/sorry-cypress-dashboard.png
deleted file mode 100644
index bf2d38932af2..000000000000
Binary files a/docs/dev/cypress/sorry-cypress-dashboard.png and /dev/null differ
diff --git a/docs/dev/cypress/sorry-cypress-run.png b/docs/dev/cypress/sorry-cypress-run.png
deleted file mode 100644
index ca2e93daea70..000000000000
Binary files a/docs/dev/cypress/sorry-cypress-run.png and /dev/null differ
diff --git a/docs/dev/cypress/sorry-cypress-runs.png b/docs/dev/cypress/sorry-cypress-runs.png
deleted file mode 100644
index 60a47a352dbb..000000000000
Binary files a/docs/dev/cypress/sorry-cypress-runs.png and /dev/null differ
diff --git a/docs/dev/cypress/sorry-cypress-test.png b/docs/dev/cypress/sorry-cypress-test.png
deleted file mode 100644
index 83148bb2fed3..000000000000
Binary files a/docs/dev/cypress/sorry-cypress-test.png and /dev/null differ
diff --git a/docs/dev/playwright.rst b/docs/dev/playwright.rst
index c4edd85d5399..ce6536c96e54 100644
--- a/docs/dev/playwright.rst
+++ b/docs/dev/playwright.rst
@@ -1,6 +1,23 @@
E2E Testing with Playwright
===========================
+**Background**
+
+The Playwright test suite contains system tests verifying the most important features of Artemis.
+System tests test the whole system and therefore require a complete deployment of Artemis first.
+In order to prevent as many faults (bugs) as possible from being introduced into the develop branch,
+we want to execute the Playwright test suite whenever new commits are pushed to a Git branch
+(just like the unit and integration test suites).
+
+To accomplish this we need to be able to dynamically deploy multiple different instances of Artemis at the same time.
+An ideal setup would be to deploy the whole Artemis system using Kubernetes.
+However, this setup is too complex at the moment.
+The main reason for the complexity is that it is very hard to automatically setup Docker containers for
+the external services (e.g. Gitlab, Jenkins) and connect them directly with Artemis.
+
+Therefore, the current setup only dynamically deploys the Artemis server and configures it to connect to
+the prelive system, which is already properly setup in the university data center.
+
Set up Playwright locally
-------------------------
@@ -137,8 +154,72 @@ To run tests sequentially (one after another), set the ``workers`` option to ``1
sequentially, while running test files in parallel, set the ``fullyParallel`` option to ``false``.
-Best practices for writing tests in Playwright
-----------------------------------------------
+Best practices when writing new E2E tests
+-----------------------------------------
+
+**Understanding the System and Requirements**
+
+Before writing tests, a deep understanding of the system and its requirements is crucial.
+This understanding guides determining what needs testing and what defines a successful test.
+The best way to understand is to consolidate the original system`s developer or a person actively working on this
+component.
+
+**Identify Main Test Scenarios**
+
+Identify what are the main ways the component is supposed to be used. Try
+the action with all involved user roles and test as many different inputs as
+feasible.
+
+**Identify Edge Test Scenarios**
+
+Next to the main test scenarios, there are also edge case scenarios. These
+tests include inputs/actions that are not supposed to be performed (e.g. enter
+a too-long input into a field) and test the error-handling capabilities of the
+platform.
+
+**Write Tests as Development Progresses**
+
+Rather than leaving testing until the end, write tests alongside each piece of
+functionality. This approach ensures the code remains testable and makes
+identifying and fixing issues as they arise easier.
+
+**Keep Tests Focused**
+
+Keep each test focused on one specific aspect of the code. If a test fails, it is
+easier to identify the issue when it does not check multiple functionalities at
+the same time.
+
+**Make Tests Independent**
+
+Tests should operate independently from each other and external factors like
+the current date or time. Each test should be isolated. Use API calls for unrelated tasks, such as creating a
+course, and UI interaction for the appropriate testing steps. This also involves
+setting up a clean environment for every test suite.
+
+**Use Descriptive Test Names**
+
+Ensure each test name clearly describes what the test does. This strategy
+makes the test suite easier to understand and quickly identifies which test
+has failed.
+
+**Use Similar Test Setups**
+
+Avoid using different setups for each test suit. For example, always check
+for the same HTTP response when deleting a course.
+
+**Do Not Ignore Failing Tests**
+
+If a test consistently fails, pay attention to it. Investigate as soon as possible
+and fx the issue, or update the test if the requirements have changed.
+
+**Regularly Review and Refactor Your Tests**
+
+Tests, like code, can accumulate technical debt. Regular reviews for duplication,
+unnecessary complexity, and other issues help maintain tests and enhance reliability.
+
+
+Playwright testing best practices
+---------------------------------
1. **Use page objects for common interactions**:
@@ -230,3 +311,102 @@ Best practices for writing tests in Playwright
Waiting for the page load state is not recommended if we are only interested in specific elements appearing on
the page - use ``waitFor()`` function of a locator instead.
+
+Artemis Deployment on Bamboo Build Agent
+----------------------------------------
+Every execution of the Playwright test suite requires its own deployment of Artemis.
+The easiest way to accomplish this is to deploy Artemis locally on the build agent, which executes the Playwright tests.
+Using ``docker compose`` we can start a MySQL database and the Artemis server locally on the build agent and
+connect it to the prelive system in the university data center.
+
+.. figure:: playwright/playwright_bamboo_deployment_diagram.svg
+ :align: center
+ :alt: Artemis Deployment on Bamboo Build Agent for Playwright
+
+ Artemis Deployment on Bamboo Build Agent for Playwright
+
+In total there are three Docker containers started in the Bamboo build agent:
+
+1. MySQL
+
+ This container starts a MySQL database and exposes it on port 3306.
+ The container automatically creates a new database 'Artemis' and configures it
+ with the recommended settings for Artemis.
+ The Playwright setup reuses the already existing
+ `MySQL docker image `__
+ from the standard Artemis Docker setup.
+
+2. Artemis Application
+
+ The Docker image for the Artemis container is created from the already existing
+ `Dockerfile `__.
+ When the Bamboo build of the Playwright test suite starts, it retrieves the Artemis executable (.war file)
+ from the `Artemis build plan `_.
+ Upon creation of the Artemis Docker image the executable is copied into the image together with configuration files
+ for the Artemis server.
+
+ The main configuration of the Artemis server are contained in the
+ `Playwright environment configuration files `__.
+ However, those files do not contain any security relevant information.
+ Security relevant settings are instead passed to the Docker container via environment variables. This information is
+ accessible to the Bamboo build agent via
+ `Bamboo plan variables `__.
+
+ The Artemis container is also configured to
+ `depend on `__
+ the MySQL container and uses
+ `health checks `__
+ to wait until the MySQL container is up and running.
+
+3. Playwright
+
+ Playwright offers a test environment `docker image `__
+ to execute Playwright tests.
+ The image contains Playwright browsers and browser system dependencies.
+ However, Playwright itself is not included in the image.
+ This is convenient for us because the image is smaller and the Artemis Playwright project requires
+ additional dependencies to fully function.
+ Therefore, the Artemis Playwright Docker container is configured to install all dependencies
+ (using :code:`npm ci`) upon start. This will also install Playwright itself.
+ Afterwards the Artemis Playwright test suite is executed.
+
+ The necessary configuration for the Playwright test suite is also passed in via environment variables.
+ Furthermore, the Playwright container depends on the Artemis container and is only started
+ once Artemis has been fully booted.
+
+**Bamboo webhook**
+
+The Artemis instance deployed on the build agent is not publicly available to improve the security of this setup.
+However, in order to get the build results for programming exercise submissions Artemis relies on a webhook from Bamboo
+to send POST requests to Artemis.
+To allow this, an extra rule has been added to the firewall allowing only the Bamboo instance in the prelive system
+to connect to the Artemis instance in the build agent.
+
+**Timing**
+
+As mentioned above, we want the Playwright test suite to be executed whenever new commits are pushed to a Git branch.
+This has been achieved by adding the
+`Playwright build plan `__
+as a `child dependency `__
+to the `Artemis Build build plan `__.
+The *Artemis Build* build plan is triggered whenever a new commit has been pushed to a branch.
+
+The Playwright build plan is only triggered after a successful build of the Artemis executable.
+This does imply a delay (about 10 minutes on average) between the push of new commits and the execution
+of the Playwright test suite, since the new Artemis executable first has to be built.
+
+**NOTE:** The Playwright test suite is only automatically executed for internal branches and pull requests
+(requires access to this GitHub repository), **not** for external ones.
+In case you need access rights, please contact the maintainer `Stephan Krusche `__.
+
+Maintenance
+-----------
+The Artemis Dockerfile as well as the MySQL image are already maintained because they are used in
+other Artemis Docker setups.
+Therefore, only Playwright and the Playwright Docker image require active maintenance.
+Since the Playwright test suite simulates a real user, it makes sense to execute the test suite with
+the latest browser versions.
+The Playwright Docker image we use always has browsers with specific versions installed.
+Therefore, the
+`docker-compose file `__
+should be updated every month to make sure that the latest Playwright image is used.
diff --git a/docs/dev/playwright/playwright_bamboo_deployment_diagram.svg b/docs/dev/playwright/playwright_bamboo_deployment_diagram.svg
new file mode 100644
index 000000000000..14bdb96ef47e
--- /dev/null
+++ b/docs/dev/playwright/playwright_bamboo_deployment_diagram.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/docs/user/communication.rst b/docs/user/communication.rst
index cf28dce9c8d8..026785e4861b 100644
--- a/docs/user/communication.rst
+++ b/docs/user/communication.rst
@@ -100,6 +100,240 @@ for multiple links.
|link-preview-multiple|
+
+.. _communication features availability list:
+
+Communication Features Availability
+-----------------------------------
+
+.. |AVAILABLE| raw:: html
+
+ AVAILABLE
+
+.. |UNAVAILABLE| raw:: html
+
+ UNAVAILABLE
+
+.. |PLANNED| raw:: html
+
+ PLANNED
+
+.. |WIP| raw:: html
+
+ WIP
+
+.. |NOT PLANNED| raw:: html
+
+ NOT PLANNED
+
+
+The following table represents the currently available communication features of Artemis on the different platforms. Note that not all
+features are available to every user, which is why **Actor restrictions** have been added. The following sections will explore this in more
+detail.
+
+Status explained
+^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :widths: 15 74
+
+ * - |AVAILABLE|
+ - This feature has been released to production.
+ * - |UNAVAILABLE|
+ - This feature is currently not available and not planned yet.
+ * - |PLANNED|
+ - This feature is planned and implemented within the next 2-4 months.
+ * - |WIP|
+ - This feature is currently being worked on and will be released soon.
+ * - |NOT PLANNED|
+ - This feature will not be implemented due to platform restrictions, or it does not make sense to implement it.
+
+
+
+
+Available features on each platform
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Feature | Actor Restrictions | Web App | iOS | Android |
++======================================================+======================================+====================+=====================+=====================+
+| **General** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Send Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Receive Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Post Actions** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| React to Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Reply in Thread | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Copy Text | | |NOT PLANNED| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Pin Messages | | Groups: group creators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | | Channels: moderators | | | |
+| | | DM: members of DM | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Delete Message | Moderators and authors | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Edit Message | Authors only | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Save Message for later | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Forward Messages | | |WIP| | |UNAVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Resolve Messages | At least tutor and authors | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Post action bar (thread view) | ||NOT PLANNED| | |AVAILABLE| | |WIP| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Markdown Textfield Options** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Tag other users | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Reference channels, lectures and exercises | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Tag FAQ | | |AVAILABLE| | |WIP| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Basic formatting (underline, bold, italic) | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Strikethrough formatting | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Preview | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Code Block and inline code formatting | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Reference formatting | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Link formatting | | |AVAILABLE| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Messages** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Profile pictures | | |AVAILABLE| | |AVAILABLE| | |WIP| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Show if message was edited, resolved or pinned | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Render links to exercises, lectures, other chats, | | |AVAILABLE| | |AVAILABLE| | |WIP| |
+| | lecture-units, slides, lecture-attachment with | | | | |
+| | correct icon | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Render FAQ links | | |AVAILABLE| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Mark unread messages | | |UNAVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Render images | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Render links to uploaded files | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Filter messages (unresolved, own, reacted) | | |AVAILABLE| | |AVAILABLE| | |UNAVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Sort messages (ascending, descending) | | |AVAILABLE| | |NOT PLANNED| | |UNAVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Search for messages in chat | | |UNAVAILABLE| | |UNAVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Search for messages across all chats | | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Open Profile info by clicking profile picture | | |PLANNED| | |AVAILABLE| | |WIP| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Start a conversation from Profile | | |WIP| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Link/Attachment Handling** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Open lecture, exercise, chat links correctly in | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | the appropriate view | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Open sent images full-screen | | |AVAILABLE| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Download sent images | | |AVAILABLE| | |PLANNED| | |UNAVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| View and download attachments | | |AVAILABLE| | |PLANNED| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Conversation Management** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Search for chats | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Filter chats (all, unread, favorites) | | |UNAVAILABLE| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Mark unread chats | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Mute, hide, favorite chat | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Edit Chat information (name, topic, description) | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Archive Chat | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Delete Chat | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| View Members | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Search Members | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Filter Members (All Members, Instructors, | | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| |
+| | Tutors, Students, Moderators) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Add Members to existing chat | | Group: members of group | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | | Channel: at least instructor | | | |
+| | | or moderator | | | |
+| | | DM: not possible | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Filter Members while adding (Students, Tutors, | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| |
+| | Instructors) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Add whole groups (All Students, All Tutors, All | | |AVAILABLE| | |PLANNED| | |UNAVAILABLE| |
+| | Instructors) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Grant moderator roles in channels / revoke | Moderators only | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| |
+| | moderation roles | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Create direct chat | Everyone | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Create channel (public/private, | At least teaching assistant | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | announcement/unrestricted) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Update channel information (name, topic, | Moderators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | description) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Create group chat | Everyone | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Remove users from group chat | Members of group chat | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Browse channels | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Show info in chat overview | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | (created by, created on) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Leave chat | For groups only | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Delete channel | | Creators with moderation | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| |
+| | | rights and instructors | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Archive channel | Moderators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| **Notifications** |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| Notification overview for past notifications | | |AVAILABLE| | |AVAILABLE| | |PLANNED| |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+| | Notification settings (unsubscribe/subscribe | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| |
+| | to various notification types) | | | | |
++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+
+
+.. note::
+ - Leave chat option is available on the web app for groups only, on iOS for groups and non course-wide channels, and on Android for channels, groups, and DMs.
+ - Creating a group chat on iOS and Android can be achieved via the 'Create Chat' option. It becomes a group when more than one user is added.
+ - Downloading sent images in the chat is only available through the browser option on the web app.
+
Features for Users
------------------
diff --git a/docs/user/mobile-applications.rst b/docs/user/mobile-applications.rst
index 1182384974fe..64485cb2e2fd 100644
--- a/docs/user/mobile-applications.rst
+++ b/docs/user/mobile-applications.rst
@@ -10,7 +10,7 @@ Mobile Applications
Overview
--------
-Artemis supports native mobile applications available for both `Android `_ and `iOS `_. We designed them to be applicable in lecture usage. Users can, for example, participate in quizzes and write questions. Furthermore, they can communicate with each other.
+Artemis supports native mobile applications available for both `Android `_ and `iOS `_. We designed them to be applicable in lecture usage. Users can, for example, participate in quizzes and write questions. Furthermore, they can communicate with each other (available communication features on iOS and Android can be checked using :ref:`this list `).
Both apps use native user interface components and are adapted to their associated operating system. Therefore, they can differ in their usage.
diff --git a/gradle.properties b/gradle.properties
index de944d16001d..c32073344f44 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,7 +10,7 @@ jhipster_dependencies_version=8.7.2
spring_boot_version=3.4.0
spring_framework_version=6.2.1
spring_cloud_version=4.2.0
-spring_security_version=6.4.1
+spring_security_version=6.4.2
# TODO: upgrading to 6.6.x currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code
hibernate_version=6.4.10.Final
# TODO: can we update to 5.x?
@@ -19,7 +19,8 @@ jwt_version=0.12.6
jaxb_runtime_version=4.0.5
hazelcast_version=5.5.0
fasterxml_version=2.18.2
-jgit_version=7.1.0.202411261347-r
+# TODO: 7.1.0 includes bugs related to git diffs, therefore we cannot update
+jgit_version=7.0.0.202409031743-r
sshd_version=2.14.0
checkstyle_version=10.21.0
jplag_version=5.1.0
@@ -30,15 +31,16 @@ slf4j_version=2.0.16
sentry_version=7.19.0
liquibase_version=4.30.0
docker_java_version=3.4.1
-logback_version=1.5.12
+logback_version=1.5.14
java_parser_version=3.26.2
-byte_buddy_version=1.15.10
+byte_buddy_version=1.15.11
netty_version=4.1.115.Final
+tomcat_version=10.1.34
# testing
# make sure both versions are compatible
junit_version=5.11.3
-junit_platform_version=1.11.3
+junit_platform_version=1.11.4
mockito_version=5.14.2
testcontainer_version=1.20.4
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 9e40988550fd..cea7a793a84b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-rc-1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java
index 71c6b73a208f..3919ec9cd858 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java
@@ -1,4 +1,4 @@
package de.tum.cit.aet.artemis.assessment.dto;
-public record FeedbackAffectedStudentDTO(long courseId, long participationId, String firstName, String lastName, String login, String repositoryURI) {
+public record FeedbackAffectedStudentDTO(long participationId, String firstName, String lastName, String login, String repositoryURI) {
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java
index e56722f079cf..c93578cd10c5 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java
@@ -9,5 +9,5 @@
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record FeedbackAnalysisResponseDTO(SearchResultPageDTO feedbackDetails, long totalItems, Set taskNames, List testCaseNames,
- List errorCategories) {
+ List errorCategories, long highestOccurrenceOfGroupedFeedback) {
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java
index d22a036e7489..0fee28e9672c 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java
@@ -6,11 +6,11 @@
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
-public record FeedbackDetailDTO(List concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName,
- String errorCategory) {
+public record FeedbackDetailDTO(List feedbackIds, long count, double relativeCount, List detailTexts, String testCaseName, String taskName, String errorCategory) {
- public FeedbackDetailDTO(String concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) {
- this(Arrays.stream(concatenatedFeedbackIds.split(",")).map(Long::valueOf).toList(), count, relativeCount, detailText, testCaseName, taskName, errorCategory);
+ public FeedbackDetailDTO(String feedbackId, long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) {
+ // Feedback IDs are gathered in the query using a comma separator, and the detail texts are stored in a list because, in case aggregation is applied, the detail texts are
+ // grouped together
+ this(Arrays.stream(feedbackId.split(",")).map(Long::valueOf).toList(), count, relativeCount, List.of(detailText), testCaseName, taskName, errorCategory);
}
-
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/FeedbackCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/FeedbackCleanupRepository.java
index e0fa96965e4a..47e6e2e2f406 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/FeedbackCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/FeedbackCleanupRepository.java
@@ -43,6 +43,23 @@ WHERE f.result IN (
""")
int deleteFeedbackForOrphanResults();
+ /**
+ * Counts {@link Feedback} entries where the associated {@link Result} has no submission and no participation.
+ *
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(f)
+ FROM Feedback f
+ WHERE f.result IN (
+ SELECT r
+ FROM Result r
+ WHERE r.submission IS NULL
+ AND r.participation IS NULL
+ )
+ """)
+ int countFeedbackForOrphanResults();
+
/**
* Deletes {@link Feedback} entries with a {@code null} result.
*
@@ -56,6 +73,18 @@ WHERE f.result IN (
""")
int deleteOrphanFeedback();
+ /**
+ * Counts {@link Feedback} entries with a {@code null} result.
+ *
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(f)
+ FROM Feedback f
+ WHERE f.result IS NULL
+ """)
+ int countOrphanFeedback();
+
/**
* Deletes {@link Feedback} entries associated with rated {@link Result} that are not the latest rated result
* for a {@link Participation}, within courses conducted between the specified date range.
@@ -89,6 +118,36 @@ SELECT MAX(r2.id)
""")
int deleteOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+ /**
+ * Counts {@link Feedback} entries associated with rated {@link Result} that are not the latest rated result
+ * for a {@link Participation}, within courses conducted between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(f)
+ FROM Feedback f
+ WHERE f.result IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = TRUE
+ )
+ AND r.rated = TRUE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes non-rated {@link Feedback} entries that are not the latest non-rated result, where the associated course's start and end dates
* are between the specified date range.
@@ -120,4 +179,33 @@ SELECT MAX(r2.id)
)
""")
int deleteOldNonRatedFeedbackWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts non-rated {@link Feedback} entries that are not the latest non-rated result, where the associated course's start and end dates
+ * are between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(f)
+ FROM Feedback f
+ WHERE f.result IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countOldNonRatedFeedbackWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java
index 09dbf01baff2..f91bac77e415 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java
@@ -44,6 +44,24 @@ WHERE lft.feedback.id IN (
""")
int deleteLongFeedbackTextForOrphanResult();
+ /**
+ * Counts {@link LongFeedbackText} entries linked to {@link Feedback} where the associated
+ * {@link Result} has no participation and no submission.
+ *
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(lft)
+ FROM LongFeedbackText lft
+ WHERE lft.feedback.id IN (
+ SELECT f.id
+ FROM Feedback f
+ WHERE f.result.participation IS NULL
+ AND f.result.submission IS NULL
+ )
+ """)
+ int countLongFeedbackTextForOrphanResult();
+
/**
* Deletes {@link LongFeedbackText} linked to {@link Feedback} with a {@code null} result.
*
@@ -61,6 +79,22 @@ WHERE lft.feedback IN (
""")
int deleteLongFeedbackTextForOrphanedFeedback();
+ /**
+ * Counts {@link LongFeedbackText} linked to {@link Feedback} with a {@code null} result.
+ *
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(lft)
+ FROM LongFeedbackText lft
+ WHERE lft.feedback IN (
+ SELECT f
+ FROM Feedback f
+ WHERE f.result IS NULL
+ )
+ """)
+ int countLongFeedbackTextForOrphanedFeedback();
+
/**
* Deletes {@link LongFeedbackText} entries associated with rated {@link Result} that are not the latest rated result
* for a {@link Participation}, within courses conducted between the specified date range.
@@ -95,6 +129,37 @@ SELECT MAX(r2.id)
""")
int deleteLongFeedbackTextForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+ /**
+ * Counts {@link LongFeedbackText} entries associated with rated {@link Result} that are not the latest rated result
+ * for a {@link Participation}, within courses conducted between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(lft)
+ FROM LongFeedbackText lft
+ WHERE lft.feedback IN (
+ SELECT f
+ FROM Feedback f
+ LEFT JOIN f.result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE f.result.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = TRUE
+ )
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ AND r.rated = TRUE
+ )
+ """)
+ int countLongFeedbackTextForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link LongFeedbackText} entries linked to non-rated {@link Feedback} that are not the latest non-rated result where the associated course's start
* and end dates are between the specified date range.
@@ -128,4 +193,35 @@ SELECT MAX(r2.id)
)
""")
int deleteLongFeedbackTextForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts {@link LongFeedbackText} entries linked to non-rated {@link Feedback} that are not the latest non-rated result where the associated course's start
+ * and end dates are between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(lft)
+ FROM LongFeedbackText lft
+ WHERE lft.feedback IN (
+ SELECT f
+ FROM Feedback f
+ LEFT JOIN f.result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE f.result.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = FALSE
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countLongFeedbackTextForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java
index 50fcdd5b14b3..7770731b4d25 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java
@@ -57,6 +57,37 @@ SELECT MAX(r2.id)
""")
int deleteParticipantScoresForNonLatestLastResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+ /**
+ * Counts {@link ParticipantScore} entries where the associated {@link Result} is not the latest rated result
+ * for a {@link Participation}, within courses conducted between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted upon execution of the cleanup operation
+ *
+ */
+ @Query("""
+ SELECT COUNT(ps)
+ FROM ParticipantScore ps
+ WHERE ps.lastResult IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = TRUE
+ )
+ AND r.rated = TRUE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countParticipantScoresForNonLatestLastResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link ParticipantScore} entries where the associated last rated {@link Result} is not the latest rated result
* for a {@link Participation}, within courses conducted between the specified date range.
@@ -90,6 +121,36 @@ SELECT MAX(r2.id)
""")
int deleteParticipantScoresForNonLatestLastRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+ /**
+ * Counts {@link ParticipantScore} entries where the associated last rated {@link Result} is not the latest rated result
+ * for a {@link Participation}, within courses conducted between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted upon execution of the cleanup operation
+ */
+ @Query("""
+ SELECT COUNT(ps)
+ FROM ParticipantScore ps
+ WHERE ps.lastRatedResult IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = TRUE
+ )
+ AND r.rated = TRUE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countParticipantScoresForNonLatestLastRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link ParticipantScore} entries where the associated {@link Result} is not the latest result and is non-rated,
* and the course's start and end dates are between the specified date range.
@@ -122,6 +183,36 @@ SELECT MAX(r2.id)
""")
int deleteParticipantScoresForLatestNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+ /**
+ * Counts {@link ParticipantScore} entries where the associated {@link Result} is not the latest result and is non-rated,
+ * and the course's start and end dates are between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted upon execution of the cleanup operation
+ */
+ @Query("""
+ SELECT COUNT(ps)
+ FROM ParticipantScore ps
+ WHERE ps.lastResult IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = FALSE
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countParticipantScoresForLatestNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link ParticipantScore} entries where the associated {@link Result} is not latest and is non-rated, even though
* it is marked as the last rated result, to prevent potential integrity violations.
@@ -155,4 +246,34 @@ SELECT MAX(r2.id)
)
""")
int deleteParticipantScoresForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts {@link ParticipantScore} entries where the associated {@link Result} is not latest and is non-rated, even though
+ * it is marked as the last rated result.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted upon execution of the cleanup operation
+ */
+ @Query("""
+ SELECT COUNT(ps)
+ FROM ParticipantScore ps
+ WHERE ps.lastRatedResult IN (
+ SELECT r
+ FROM Result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE r.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = FALSE
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countParticipantScoresForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java
index a93ba9604223..543c6255639b 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java
@@ -41,6 +41,13 @@ public interface PlagiarismComparisonCleanupRepository extends ArtemisJpaReposit
""")
int deletePlagiarismSubmissionElementsByComparisonIdsIn(@Param("ids") List ids);
+ @Query("""
+ SELECT COUNT(e)
+ FROM PlagiarismSubmissionElement e
+ WHERE e.plagiarismSubmission.plagiarismComparison.id IN :ids
+ """)
+ int countPlagiarismSubmissionElementsByComparisonIdsIn(@Param("ids") List ids);
+
@Modifying
@Transactional // ok because of delete
@Query("""
@@ -50,6 +57,13 @@ public interface PlagiarismComparisonCleanupRepository extends ArtemisJpaReposit
""")
int deletePlagiarismSubmissionsByComparisonIdsIn(@Param("ids") List ids);
+ @Query("""
+ SELECT COUNT(s)
+ FROM PlagiarismSubmission s
+ WHERE s.plagiarismComparison.id IN :ids
+ """)
+ int countPlagiarismSubmissionsByComparisonIdsIn(@Param("ids") List ids);
+
@Modifying
@Transactional // ok because of modifying
@Query("""
@@ -68,6 +82,13 @@ public interface PlagiarismComparisonCleanupRepository extends ArtemisJpaReposit
""")
int deletePlagiarismComparisonMatchesByComparisonIdsIn(@Param("ids") List ids);
+ @Query(nativeQuery = true, value = """
+ SELECT COUNT(*)
+ FROM plagiarism_comparison_matches m
+ WHERE m.plagiarism_comparison_id IN :ids
+ """)
+ int countPlagiarismComparisonMatchesByComparisonIdsIn(@Param("ids") List ids);
+
/**
* Retrieves a list of unnecessary plagiarism comparison IDs based on the associated course's date range.
* A plagiarism comparison is considered unnecessary if its status is 'NONE' and the related course's
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java
index 07f10ec6d3d3..c923cffbcef6 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java
@@ -38,4 +38,21 @@ WHERE rt.result IN (
)
""")
int deleteOrphanRating();
+
+ /**
+ * Counts {@link Rating} entries where the associated {@link Result} has no submission and no participation.
+ *
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(rt)
+ FROM Rating rt
+ WHERE rt.result IN (
+ SELECT r
+ FROM Result r
+ WHERE r.submission IS NULL
+ AND r.participation IS NULL
+ )
+ """)
+ int countOrphanRating();
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java
index 7d9e413aa5e7..7bf86fe0c730 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java
@@ -39,6 +39,19 @@ public interface ResultCleanupRepository extends ArtemisJpaRepository :deleteFrom
+ )
+ AND r.id NOT IN (
+ SELECT max_id
+ FROM (
+ SELECT MAX(r2.id) AS max_id
+ FROM Result r2
+ WHERE r2.rated = FALSE
+ GROUP BY r2.participation.id
+ )
+ )
+ """)
+ int countNonLatestNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes rated {@link Result} entries that are not the latest rated result for a {@link Participation}, within courses
* conducted between the specified date range.
@@ -112,4 +159,38 @@ SELECT MAX(r2.id) AS max_id
)
""")
int deleteNonLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts rated {@link Result} entries that are not the latest rated result for a {@link Participation}, within courses
+ * conducted between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(r)
+ FROM Result r
+ WHERE r.rated = TRUE
+ AND r.participation IS NOT NULL
+ AND r.participation.exercise IS NOT NULL
+ AND EXISTS (
+ SELECT 1
+ FROM Course c
+ LEFT JOIN c.exercises e
+ WHERE e = r.participation.exercise
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ AND r.id NOT IN (
+ SELECT max_id
+ FROM (
+ SELECT MAX(r2.id) AS max_id
+ FROM Result r2
+ WHERE r2.rated = TRUE
+ GROUP BY r2.participation.id
+ )
+ )
+ """)
+ int countNonLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java
index d45bf8243e50..e7c679fe34cd 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java
@@ -31,4 +31,16 @@ public interface StudentScoreCleanupRepository extends ArtemisJpaRepository {
+
+ /**
+ * Deletes {@link SubmissionVersion} entities where the created date is after {@code deleteFrom}
+ * and before {@code deleteTo}.
+ *
+ * @param deleteFrom the start date for selecting submissions
+ * @param deleteTo the end date for selecting submissions
+ * @return the number of deleted entities
+ */
+ @Modifying
+ @Transactional // ok because of delete
+ @Query("""
+ DELETE FROM SubmissionVersion sv
+ WHERE sv.createdDate > :deleteFrom
+ AND sv.createdDate < :deleteTo
+ """)
+ int deleteSubmissionVersionsByCreatedDateRange(@Param("deleteFrom") Instant deleteFrom, @Param("deleteTo") Instant deleteTo);
+
+ /**
+ * Counts {@link SubmissionVersion} entities where the created date is after {@code deleteFrom}
+ * and before {@code deleteTo}.
+ *
+ * @param deleteFrom the start date for selecting submissions
+ * @param deleteTo the end date for selecting submissions
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(sv)
+ FROM SubmissionVersion sv
+ WHERE sv.createdDate > :deleteFrom
+ AND sv.createdDate < :deleteTo
+ """)
+ int countSubmissionVersionsByCreatedDateRange(@Param("deleteFrom") Instant deleteFrom, @Param("deleteTo") Instant deleteTo);
+
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java
index 2c1009f481b8..b1a17076fb47 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java
@@ -31,4 +31,16 @@ public interface TeamScoreCleanupRepository extends ArtemisJpaRepository :deleteFrom
+ )
+ """)
+ int countTextBlockForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link TextBlock} entries linked to non-rated {@link Result} that are not the latest non-rated result
* for a {@link Participation}, where the associated course's start and end dates
@@ -130,4 +194,36 @@ SELECT MAX(r2.id)
)
""")
int deleteTextBlockForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts {@link TextBlock} entries linked to non-rated {@link Result} that are not the latest non-rated result
+ * for a {@link Participation}, where the associated course's start and end dates
+ * are between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(tb)
+ FROM TextBlock tb
+ WHERE tb.feedback IN (
+ SELECT f
+ FROM Feedback f
+ LEFT JOIN f.result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE f.result.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = FALSE
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countTextBlockForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
index 1f62ef78665b..1dce0090c001 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
@@ -4,7 +4,6 @@
import java.time.ZonedDateTime;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
@@ -25,7 +24,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import de.tum.cit.aet.artemis.assessment.domain.AssessmentType;
@@ -49,7 +48,7 @@
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO;
-import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO;
+import de.tum.cit.aet.artemis.core.dto.SortingOrder;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.Role;
@@ -64,6 +63,7 @@
import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository;
import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService;
import de.tum.cit.aet.artemis.lti.service.LtiNewResultService;
+import de.tum.cit.aet.artemis.modeling.service.compass.strategy.NameSimilarity;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation;
@@ -126,6 +126,10 @@ public class ResultService {
private final ProgrammingExerciseRepository programmingExerciseRepository;
+ private static final int MAX_FEEDBACK_IDS = 5;
+
+ private static final double SIMILARITY_THRESHOLD = 0.9;
+
public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService,
ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository,
FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository,
@@ -570,10 +574,12 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) {
* Pagination and sorting:
* - Sorting is applied based on the specified column and order (ascending or descending).
* - The result is paginated according to the provided page number and page size.
+ * Additionally one can group the feedback detail text.
*
- * @param exerciseId The ID of the exercise for which feedback details should be retrieved.
- * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters
- * (task names, test cases, occurrence range, error categories).
+ * @param exerciseId The ID of the exercise for which feedback details should be retrieved.
+ * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters
+ * (task names, test cases, occurrence range, error categories).
+ * @param groupFeedback The flag to enable grouping and aggregation of feedback details.
* @return A {@link FeedbackAnalysisResponseDTO} object containing:
* - A {@link SearchResultPageDTO} of paginated feedback details.
* - The total number of distinct results for the exercise.
@@ -581,7 +587,7 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) {
* - A list of active test case names used in the feedback.
* - A list of predefined error categories ("Student Error," "Ares Error," "AST Error") available for filtering.
*/
- public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data) {
+ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data, boolean groupFeedback) {
// 1. Fetch programming exercise with associated test cases
ProgrammingExercise programmingExercise = programmingExerciseRepository.findWithTestCasesByIdElseThrow(exerciseId);
@@ -598,12 +604,12 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee
Set taskNames = tasks.stream().map(ProgrammingExerciseTask::getTaskName).collect(Collectors.toSet());
// 5. Include unassigned tasks if specified by the filter; otherwise, only include specified tasks
- List includeUnassignedTasks = new ArrayList<>(taskNames);
+ List includeNotAssignedToTask = new ArrayList<>(taskNames);
if (!data.getFilterTasks().isEmpty()) {
- includeUnassignedTasks.removeAll(data.getFilterTasks());
+ includeNotAssignedToTask.removeAll(data.getFilterTasks());
}
else {
- includeUnassignedTasks.clear();
+ includeNotAssignedToTask.clear();
}
// 6. Define the occurrence range based on filter parameters
@@ -614,22 +620,113 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee
List filterErrorCategories = data.getFilterErrorCategories();
// 8. Set up pagination and sorting based on input data
- final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS);
+ final Pageable pageable = groupFeedback ? Pageable.unpaged() : PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS);
- // 9. Query the database to retrieve paginated and filtered feedback
+ // 9. Query the database based on groupFeedback attribute to retrieve paginated and filtered feedback
final Page feedbackDetailPage = studentParticipationRepository.findFilteredFeedbackByExerciseId(exerciseId,
- StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeUnassignedTasks, minOccurrence,
+ StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeNotAssignedToTask, minOccurrence,
maxOccurrence, filterErrorCategories, pageable);
+ ;
+ List processedDetails;
+ int totalPages = 0;
+ long totalCount = 0;
+ long highestOccurrenceOfGroupedFeedback = 0;
+ if (!groupFeedback) {
+ // Process and map feedback details, calculating relative count and assigning task names
+ processedDetails = feedbackDetailPage.getContent().stream()
+ .map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), MAX_FEEDBACK_IDS)), detail.count(),
+ (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory()))
+ .toList();
+ totalPages = feedbackDetailPage.getTotalPages();
+ totalCount = feedbackDetailPage.getTotalElements();
+ }
+ else {
+ // Fetch all feedback details
+ List allFeedbackDetails = feedbackDetailPage.getContent();
+
+ // Apply grouping and aggregation with a similarity threshold of 90%
+ List aggregatedFeedbackDetails = aggregateFeedback(allFeedbackDetails, SIMILARITY_THRESHOLD);
+
+ highestOccurrenceOfGroupedFeedback = aggregatedFeedbackDetails.stream().mapToLong(FeedbackDetailDTO::count).max().orElse(0);
+ // Apply manual sorting
+ Comparator comparator = getComparatorForFeedbackDetails(data);
+ List processedDetailsPreSort = new ArrayList<>(aggregatedFeedbackDetails);
+ processedDetailsPreSort.sort(comparator);
+ // Apply manual pagination
+ int page = data.getPage();
+ int pageSize = data.getPageSize();
+ int start = Math.max(0, (page - 1) * pageSize);
+ int end = Math.min(start + pageSize, processedDetailsPreSort.size());
+ processedDetails = processedDetailsPreSort.subList(start, end);
+ processedDetails = processedDetails.stream().map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), 5)),
+ detail.count(), (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory()))
+ .toList();
+ totalPages = (int) Math.ceil((double) processedDetailsPreSort.size() / pageSize);
+ totalCount = aggregatedFeedbackDetails.size();
+ }
- // 10. Process and map feedback details, calculating relative count and assigning task names
- List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> new FeedbackDetailDTO(detail.concatenatedFeedbackIds(), detail.count(),
- (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), detail.taskName(), detail.errorCategory())).toList();
- // 11. Predefined error categories available for filtering on the client side
+ // 10. Predefined error categories available for filtering on the client side
final List ERROR_CATEGORIES = List.of("Student Error", "Ares Error", "AST Error");
- // 12. Return response containing processed feedback details, task names, active test case names, and error categories
- return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), taskNames,
- activeTestCaseNames, ERROR_CATEGORIES);
+ // 11. Return response containing processed feedback details, task names, active test case names, and error categories
+ return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, totalPages), totalCount, taskNames, activeTestCaseNames, ERROR_CATEGORIES,
+ highestOccurrenceOfGroupedFeedback);
+ }
+
+ private Comparator getComparatorForFeedbackDetails(FeedbackPageableDTO search) {
+ Map> comparators = Map.of("count", Comparator.comparingLong(FeedbackDetailDTO::count), "detailTexts",
+ Comparator.comparing(detail -> detail.detailTexts().isEmpty() ? "" : detail.detailTexts().getFirst(), // Sort by the first element of the list
+ String.CASE_INSENSITIVE_ORDER),
+ "testCaseName", Comparator.comparing(FeedbackDetailDTO::testCaseName, String.CASE_INSENSITIVE_ORDER), "taskName",
+ Comparator.comparing(FeedbackDetailDTO::taskName, String.CASE_INSENSITIVE_ORDER));
+
+ Comparator comparator = comparators.getOrDefault(search.getSortedColumn(), (a, b) -> 0);
+ return search.getSortingOrder() == SortingOrder.ASCENDING ? comparator : comparator.reversed();
+ }
+
+ private List aggregateFeedback(List feedbackDetails, double similarityThreshold) {
+ List processedDetails = new ArrayList<>();
+
+ for (FeedbackDetailDTO base : feedbackDetails) {
+ boolean isMerged = false;
+
+ for (FeedbackDetailDTO processed : processedDetails) {
+ // Ensure feedbacks have the same testCaseName and taskName
+ if (base.testCaseName().equals(processed.testCaseName()) && base.taskName().equals(processed.taskName())) {
+ double similarity = NameSimilarity.levenshteinSimilarity(base.detailTexts().getFirst(), processed.detailTexts().getFirst());
+
+ if (similarity > similarityThreshold) {
+ // Merge the current base feedback into the processed feedback
+ List mergedFeedbackIds = new ArrayList<>(processed.feedbackIds());
+ if (processed.feedbackIds().size() < MAX_FEEDBACK_IDS) {
+ mergedFeedbackIds.addAll(base.feedbackIds());
+ }
+
+ List mergedTexts = new ArrayList<>(processed.detailTexts());
+ mergedTexts.add(base.detailTexts().getFirst());
+
+ long mergedCount = processed.count() + base.count();
+
+ // Replace the processed entry with the updated one
+ processedDetails.remove(processed);
+ FeedbackDetailDTO updatedProcessed = new FeedbackDetailDTO(mergedFeedbackIds, mergedCount, 0, mergedTexts, processed.testCaseName(), processed.taskName(),
+ processed.errorCategory());
+ processedDetails.add(updatedProcessed); // Add the updated entry
+ isMerged = true;
+ break; // No need to check further
+ }
+ }
+ }
+
+ if (!isMerged) {
+ // If not merged, add it as a new entry in processedDetails
+ FeedbackDetailDTO newEntry = new FeedbackDetailDTO(base.feedbackIds(), base.count(), 0, List.of(base.detailTexts().getFirst()), base.testCaseName(),
+ base.taskName(), base.errorCategory());
+ processedDetails.add(newEntry);
+ }
+ }
+
+ return processedDetails;
}
/**
@@ -648,20 +745,15 @@ public long getMaxCountForExercise(long exerciseId) {
/**
* Retrieves a paginated list of students affected by specific feedback entries for a given exercise.
*
- * This method filters students based on feedback IDs and returns participation details for each affected student. It uses
- * pagination and sorting (order based on the {@link PageUtil.ColumnMapping#AFFECTED_STUDENTS}) to allow efficient retrieval and sorting of the results, thus supporting large
- * datasets.
+ * This method filters students based on feedback IDs and returns participation details for each affected student.
*
*
* @param exerciseId for which the affected student participation data is requested.
* @param feedbackIds used to filter the participation to only those affected by specific feedback entries.
- * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters.
- * @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback.
+ * @return A {@link List} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback.
*/
- public Page getAffectedStudentsWithFeedbackId(long exerciseId, String feedbackIds, PageableSearchDTO data) {
- List feedbackIdLongs = Arrays.stream(feedbackIds.split(",")).map(Long::valueOf).toList();
- PageRequest pageRequest = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.AFFECTED_STUDENTS);
- return studentParticipationRepository.findAffectedStudentsByFeedbackId(exerciseId, feedbackIdLongs, pageRequest);
+ public List getAffectedStudentsWithFeedbackIds(long exerciseId, List feedbackIds) {
+ return studentParticipationRepository.findAffectedStudentsByFeedbackIds(exerciseId, feedbackIds);
}
/**
@@ -692,15 +784,4 @@ public void deleteLongFeedback(List feedbackList, Result result) {
List feedbacks = new ArrayList<>(feedbackList);
result.updateAllFeedbackItems(feedbacks, true);
}
-
- /**
- * Retrieves the number of students affected by a specific feedback detail text for a given exercise.
- *
- * @param exerciseId for which the affected student count is requested.
- * @param detailText used to filter affected students.
- * @return the total number of distinct students affected by the feedback detail text.
- */
- public long getAffectedStudentCountByFeedbackDetailText(long exerciseId, String detailText) {
- return studentParticipationRepository.countAffectedStudentsByFeedbackDetailText(exerciseId, detailText);
- }
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java
index ed6bc5ce12d3..a78718f35a39 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java
@@ -7,14 +7,15 @@
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
-import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -23,7 +24,6 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -39,7 +39,6 @@
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO;
-import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.Role;
@@ -297,7 +296,7 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo
* Pagination, sorting, and filtering options allow flexible data retrieval:
*
*
Pagination: Based on page number and page size, as specified in the request.
- *
Sorting: By column (e.g., "count" or "detailText") and sorting order (ASCENDING or DESCENDING).
+ *
Sorting: By column (e.g., "count" or "detailTexts") and sorting order (ASCENDING or DESCENDING).
* If the specified column is not valid for sorting, the default sorting column is "count".
*
Filtering:
*
@@ -310,18 +309,19 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo
*
*
*
- * @param exerciseId The unique identifier of the exercise for which feedback details are requested.
- * @param data A {@link FeedbackPageableDTO} object containing pagination, sorting, and filtering parameters, including:
- *
- *
Page number and page size
- *
Search term (optional)
- *
Sorting order (ASCENDING or DESCENDING)
- *
Sorted column
- *
Filter task names (optional)
- *
Filter test case names (optional)
- *
Occurrence range (optional)
- *
Error categories (optional)
- *
+ * @param exerciseId The unique identifier of the exercise for which feedback details are requested.
+ * @param groupFeedback Should the feedback be grouped
+ * @param data A {@link FeedbackPageableDTO} object containing pagination, sorting, and filtering parameters, including:
+ *
+ *
Page number and page size
+ *
Search term (optional)
+ *
Sorting order (ASCENDING or DESCENDING)
+ *
Sorted column
+ *
Filter task names (optional)
+ *
Filter test case names (optional)
+ *
Occurrence range (optional)
+ *
Error categories (optional)
+ *
* @return A {@link ResponseEntity} containing a {@link FeedbackAnalysisResponseDTO}, which includes:
*
*
{@link SearchResultPageDTO < FeedbackDetailDTO >} feedbackDetails: Paginated and filtered feedback details for the exercise.
@@ -333,8 +333,9 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo
*/
@GetMapping("exercises/{exerciseId}/feedback-details")
@EnforceAtLeastEditorInExercise
- public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @ModelAttribute FeedbackPageableDTO data) {
- FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data);
+ public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @RequestParam("groupFeedback") boolean groupFeedback,
+ @ModelAttribute FeedbackPageableDTO data) {
+ FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data, groupFeedback);
return ResponseEntity.ok(response);
}
@@ -359,32 +360,24 @@ public ResponseEntity getMaxCount(@PathVariable long exerciseId) {
* and participation details.
*
*
- * @param exerciseId for which the participation data is requested.
- * @param feedbackIdsHeader to filter affected students by specific feedback entries.
- * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters.
- * @return A {@link ResponseEntity} containing a {@link Page} of {@link FeedbackAffectedStudentDTO}, each representing a student affected by the feedback entries.
+ * @param exerciseId for which the participation data is requested.
+ * @param feedbackId1 Optional first detail text id to filter affected students by specific feedback entries.
+ * @param feedbackId2 Optional second detail text id to filter affected students by specific feedback entries.
+ * @param feedbackId3 Optional third detail text id to filter affected students by specific feedback entries.
+ * @param feedbackId4 Optional fourth detail text id to filter affected students by specific feedback entries.
+ * @param feedbackId5 Optional fifth detail text id to filter affected students by specific feedback entries.
+ * @return A {@link ResponseEntity} containing a {@link List} of {@link FeedbackAffectedStudentDTO}, each representing a student affected by the feedback entries.
*/
@GetMapping("exercises/{exerciseId}/feedback-details-participation")
@EnforceAtLeastEditorInExercise
- public ResponseEntity> getAffectedStudentsWithFeedback(@PathVariable long exerciseId, @RequestHeader("feedbackIds") String feedbackIdsHeader,
- @ModelAttribute PageableSearchDTO data) {
+ public ResponseEntity> getAffectedStudentsWithFeedback(@PathVariable long exerciseId,
+ @RequestParam(value = "feedbackId1", required = false) Long feedbackId1, @RequestParam(value = "feedbackId2", required = false) Long feedbackId2,
+ @RequestParam(value = "feedbackId3", required = false) Long feedbackId3, @RequestParam(value = "feedbackId4", required = false) Long feedbackId4,
+ @RequestParam(value = "feedbackId5", required = false) Long feedbackId5) {
- Page participation = resultService.getAffectedStudentsWithFeedbackId(exerciseId, feedbackIdsHeader, data);
+ List feedbackIds = Stream.of(feedbackId1, feedbackId2, feedbackId3, feedbackId4, feedbackId5).filter(Objects::nonNull).toList();
+ List participation = resultService.getAffectedStudentsWithFeedbackIds(exerciseId, feedbackIds);
return ResponseEntity.ok(participation);
}
-
- /**
- * GET /exercises/{exerciseId}/feedback-detail/affected-students : Retrieves the count of students affected by a specific feedback detail text.
- *
- * @param exerciseId The ID of the exercise for which affected students are counted.
- * @param detailText The feedback detail text to filter by.
- * @return A {@link ResponseEntity} containing the count of affected students.
- */
- @GetMapping("exercises/{exerciseId}/feedback-detail/affected-students")
- @EnforceAtLeastEditorInExercise
- public ResponseEntity countAffectedStudentsByFeedbackDetailText(@PathVariable long exerciseId, @RequestParam("detailText") String detailText) {
- long affectedStudentCount = resultService.getAffectedStudentCountByFeedbackDetailText(exerciseId, detailText);
- return ResponseEntity.ok(affectedStudentCount);
- }
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java
index d38b1c1d90f2..ca47c30d1be4 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java
@@ -1,7 +1,9 @@
package de.tum.cit.aet.artemis.communication.dto;
+import java.util.List;
+
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
-public record FeedbackChannelRequestDTO(ChannelDTO channel, String feedbackDetailText) {
+public record FeedbackChannelRequestDTO(ChannelDTO channel, List feedbackDetailTexts, String testCaseName) {
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java
index c0c7303336c1..df3782a95a22 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java
@@ -89,4 +89,12 @@ SELECT COUNT(p.id) > 0
)
""")
boolean userHasUnreadMessageInCourse(@Param("courseId") Long courseId, @Param("userId") Long userId);
+
+ /**
+ * Retrieves a list of conversations for the given course
+ *
+ * @param courseId the course id
+ * @return a list of conversations for the given course
+ */
+ List findAllByCourseId(Long courseId);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java
index 791847b75670..d16a17430138 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java
@@ -418,15 +418,16 @@ private static String generateChannelNameFromTitle(@NotNull String prefix, Optio
/**
* Creates a feedback-specific channel for an exercise within a course.
*
- * @param course in which the channel is being created.
- * @param exerciseId of the exercise associated with the feedback channel.
- * @param channelDTO containing the properties of the channel to be created, such as name, description, and visibility.
- * @param feedbackDetailText used to identify the students affected by the feedback.
- * @param requestingUser initiating the channel creation request.
+ * @param course in which the channel is being created.
+ * @param exerciseId of the exercise associated with the feedback channel.
+ * @param channelDTO containing the properties of the channel to be created, such as name, description, and visibility.
+ * @param feedbackDetailTexts used to identify the students affected by the feedback.
+ * @param requestingUser initiating the channel creation request.
+ * @param testCaseName to filter student submissions according to a specific feedback
* @return the created {@link Channel} object with its properties.
* @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$").
*/
- public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, String feedbackDetailText, User requestingUser) {
+ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, List feedbackDetailTexts, String testCaseName, User requestingUser) {
Channel channelToCreate = new Channel();
channelToCreate.setName(channelDTO.getName());
channelToCreate.setIsPublic(channelDTO.getIsPublic());
@@ -440,7 +441,7 @@ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO
Channel createdChannel = createChannel(course, channelToCreate, Optional.of(requestingUser));
- List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailText);
+ List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailTexts, testCaseName);
if (userLogins != null && !userLogins.isEmpty()) {
var registeredUsers = registerUsersToChannel(false, false, false, userLogins, course, createdChannel);
@@ -450,4 +451,14 @@ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO
return createdChannel;
}
+
+ /**
+ * Marks all channels of a course as read for the requesting user.
+ *
+ * @param course the course for which all channels should be marked as read.
+ * @param requestingUser the user requesting the marking of all channels as read.
+ */
+ public void markAllChannelsOfCourseAsRead(Course course, User requestingUser) {
+ conversationService.markAllConversationOfAUserAsRead(course.getId(), requestingUser);
+ }
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java
index 1a981ee84f99..56afa4a5c497 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java
@@ -444,6 +444,30 @@ public void setIsMuted(Long conversationId, User requestingUser, boolean isMuted
conversationParticipantRepository.save(conversationParticipant);
}
+ /**
+ * Mark all conversation of a user as read
+ *
+ * @param courseId the id of the course
+ * @param requestingUser the user that wants to mark the conversation as read
+ */
+ public void markAllConversationOfAUserAsRead(Long courseId, User requestingUser) {
+ List conversations = conversationRepository.findAllByCourseId(courseId);
+ ZonedDateTime now = ZonedDateTime.now();
+ List participants = new ArrayList<>();
+ for (Conversation conversation : conversations) {
+ boolean userCanBePartOfConversation = conversationParticipantRepository
+ .findConversationParticipantByConversationIdAndUserId(conversation.getId(), requestingUser.getId()).isPresent()
+ || (conversation instanceof Channel channel && channel.getIsCourseWide());
+ if (userCanBePartOfConversation) {
+ ConversationParticipant conversationParticipant = getOrCreateConversationParticipant(conversation.getId(), requestingUser);
+ conversationParticipant.setLastRead(now);
+ conversationParticipant.setUnreadMessagesCount(0L);
+ participants.add(conversationParticipant);
+ }
+ conversationParticipantRepository.saveAll(participants);
+ }
+ }
+
/**
* The user can select one of these roles to filter the conversation members by role
*/
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java
index 7758e905c06f..aab5aac4c3ac 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java
@@ -57,17 +57,6 @@ public abstract class PushNotificationService implements InstantNotificationServ
protected final ObjectMapper mapper = new ObjectMapper();
- private static final Cipher cipher;
-
- static {
- try {
- cipher = Cipher.getInstance(Constants.PUSH_NOTIFICATION_ENCRYPTION_ALGORITHM);
- }
- catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
- throw new RuntimeException(e);
- }
- }
-
private static final Logger log = LoggerFactory.getLogger(PushNotificationService.class);
private final RestTemplate restTemplate;
@@ -204,11 +193,14 @@ record PushNotificationData(String[] notificationPlaceholders, String target, St
*/
private static Optional encrypt(@NotNull String payload, SecretKey key, byte[] initializationVector) {
try {
+ // We need to get a fresh instance here for every notification to avoid a race condition between tasks
+ var cipher = Cipher.getInstance(Constants.PUSH_NOTIFICATION_ENCRYPTION_ALGORITHM);
+
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(initializationVector));
return Optional.of(Base64.getEncoder().encodeToString(cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8))));
}
- catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
+ catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException e) {
log.error("Error encrypting push notification payload!", e);
return Optional.empty();
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java
index cbb59c4b7e46..ed1b4e45d478 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java
@@ -486,16 +486,35 @@ public ResponseEntity createFeedbackChannel(@PathVariable Long cours
log.debug("REST request to create feedback channel for course {} and exercise {} with properties: {}", courseId, exerciseId, feedbackChannelRequest);
ChannelDTO channelDTO = feedbackChannelRequest.channel();
- String feedbackDetailText = feedbackChannelRequest.feedbackDetailText();
+ List feedbackDetailTexts = feedbackChannelRequest.feedbackDetailTexts();
+ String testCaseName = feedbackChannelRequest.testCaseName();
User requestingUser = userRepository.getUserWithGroupsAndAuthorities();
Course course = courseRepository.findByIdElseThrow(courseId);
checkCommunicationEnabledElseThrow(course);
- Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailText, requestingUser);
+ Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailTexts, testCaseName, requestingUser);
return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel));
}
+ /**
+ * PUT /api/courses/:courseId/channels/mark-as-read: Marks all channels of a course as read for the current user.
+ *
+ * @param courseId the id of the course.
+ * @return ResponseEntity with status 200 (Ok).
+ */
+ @PutMapping("{courseId}/channels/mark-as-read")
+ @EnforceAtLeastStudent
+ public ResponseEntity markAllChannelsOfCourseAsRead(@PathVariable Long courseId) {
+ log.debug("REST request to mark all channels of course {} as read", courseId);
+ var requestingUser = userRepository.getUserWithGroupsAndAuthorities();
+ var course = courseRepository.findByIdElseThrow(courseId);
+ checkCommunicationEnabledElseThrow(course);
+ authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser);
+ channelService.markAllChannelsOfCourseAsRead(course, requestingUser);
+ return ResponseEntity.ok().build();
+ }
+
private void checkEntityIdMatchesPathIds(Channel channel, Optional courseId, Optional conversationId) {
courseId.ifPresent(courseIdValue -> {
if (!channel.getCourse().getId().equals(courseIdValue)) {
diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java
index a5fe0e2c7356..b7825b920da7 100644
--- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java
@@ -25,6 +25,8 @@
import de.tum.cit.aet.artemis.communication.service.conversation.OneToOneChatService;
import de.tum.cit.aet.artemis.communication.service.conversation.auth.OneToOneChatAuthorizationService;
import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService;
+import de.tum.cit.aet.artemis.core.domain.Course;
+import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.repository.CourseRepository;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
@@ -66,7 +68,8 @@ public OneToOneChatResource(SingleUserNotificationService singleUserNotification
*
* @param courseId the id of the course
* @param otherChatParticipantLogins logins of other participants (must be 1 for one to one chat) excluding the requesting user
- * @return ResponseEntity with status 201 (Created) and with body containing the created one to one chat
+ *
+ * @return ResponseEntity according to createOneToOneChat function
*/
@PostMapping("{courseId}/one-to-one-chats")
@EnforceAtLeastStudent
@@ -74,8 +77,8 @@ public ResponseEntity startOneToOneChat(@PathVariable Long cour
var requestingUser = userRepository.getUserWithGroupsAndAuthorities();
log.debug("REST request to create one to one chat in course {} between : {} and : {}", courseId, requestingUser.getLogin(), otherChatParticipantLogins);
var course = courseRepository.findByIdElseThrow(courseId);
- checkMessagingEnabledElseThrow(course);
- oneToOneChatAuthorizationService.isAllowedToCreateOneToOneChat(course, requestingUser);
+
+ validateInputElseThrow(requestingUser, course);
var loginsToSearchFor = new HashSet<>(otherChatParticipantLogins);
loginsToSearchFor.add(requestingUser.getLogin());
@@ -88,8 +91,53 @@ public ResponseEntity startOneToOneChat(@PathVariable Long cour
var userA = chatMembers.getFirst();
var userB = chatMembers.get(1);
- var oneToOneChat = oneToOneChatService.startOneToOneChat(course, userA, userB);
var userToBeNotified = userA.getLogin().equals(requestingUser.getLogin()) ? userB : userA;
+ return createOneToOneChat(requestingUser, userToBeNotified, course);
+ }
+
+ /**
+ * POST /api/courses/:courseId/one-to-one-chats/:userId: Starts a new one to one chat in a course
+ *
+ * @param courseId the id of the course
+ * @param userId the id of the participating user
+ *
+ * @return ResponseEntity according to createOneToOneChat function
+ */
+ @PostMapping("{courseId}/one-to-one-chats/{userId}")
+ @EnforceAtLeastStudent
+ public ResponseEntity startOneToOneChat(@PathVariable Long courseId, @PathVariable Long userId) throws URISyntaxException {
+ var requestingUser = userRepository.getUserWithGroupsAndAuthorities();
+ var otherUser = userRepository.findByIdElseThrow(userId);
+ log.debug("REST request to create one to one chat by id in course {} between : {} and : {}", courseId, requestingUser.getLogin(), otherUser.getLogin());
+ var course = courseRepository.findByIdElseThrow(courseId);
+
+ validateInputElseThrow(requestingUser, course);
+
+ return createOneToOneChat(requestingUser, otherUser, course);
+ }
+
+ /**
+ * Function to validate incoming request data
+ *
+ * @param requestingUser user that wants to create the one to one chat
+ * @param course course to create the one to one chat
+ */
+ private void validateInputElseThrow(User requestingUser, Course course) {
+ checkMessagingEnabledElseThrow(course);
+ oneToOneChatAuthorizationService.isAllowedToCreateOneToOneChat(course, requestingUser);
+ }
+
+ /**
+ * Function to create a one to one chat and return the corresponding response to the client
+ *
+ * @param requestingUser user that wants to create the one to one chat
+ * @param userToBeNotified user that is invited into the one to one chat
+ * @param course course to create the one to one chat
+ *
+ * @return ResponseEntity with status 201 (Created) and with body containing the created one to one chat
+ */
+ private ResponseEntity createOneToOneChat(User requestingUser, User userToBeNotified, Course course) throws URISyntaxException {
+ var oneToOneChat = oneToOneChatService.startOneToOneChat(course, requestingUser, userToBeNotified);
singleUserNotificationService.notifyClientAboutConversationCreationOrDeletion(oneToOneChat, userToBeNotified, requestingUser,
NotificationType.CONVERSATION_CREATE_ONE_TO_ONE_CHAT);
// also send notification to the author in order for the author to subscribe to the new chat (this notification won't be saved and shown to author)
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/StaticCodeAnalysisConfigurer.java b/src/main/java/de/tum/cit/aet/artemis/core/config/StaticCodeAnalysisConfigurer.java
index 54ef93ca02c7..4946db25ead2 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/config/StaticCodeAnalysisConfigurer.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/config/StaticCodeAnalysisConfigurer.java
@@ -13,8 +13,74 @@
*/
public class StaticCodeAnalysisConfigurer {
+ // @formatter:off
+ private static final List CATEGORY_NAMES_PYTHON = List.of(
+ "Pyflakes",
+ "pycodestyle",
+ "mccabe",
+ "isort",
+ "pep8-naming",
+ "pydocstyle",
+ "pyupgrade",
+ "flake8-2020",
+ "flake8-annotations",
+ "flake8-async",
+ "flake8-bandit",
+ "flake8-blind-except",
+ "flake8-boolean-trap",
+ "flake8-bugbear",
+ "flake8-builtins",
+ "flake8-commas",
+ "flake8-copyright",
+ "flake8-comprehensions",
+ "flake8-datetimez",
+ "flake8-debugger",
+ "flake8-django",
+ "flake8-errmsg",
+ "flake8-executable",
+ "flake8-future-annotations",
+ "flake8-implicit-str-concat",
+ "flake8-import-conventions",
+ "flake8-logging",
+ "flake8-logging-format",
+ "flake8-no-pep420",
+ "flake8-pie",
+ "flake8-print",
+ "flake8-pyi",
+ "flake8-pytest-style",
+ "flake8-quotes",
+ "flake8-raise",
+ "flake8-return",
+ "flake8-self",
+ "flake8-slots",
+ "flake8-simplify",
+ "flake8-tidy-imports",
+ "flake8-type-checking",
+ "flake8-gettext",
+ "flake8-unused-arguments",
+ "flake8-use-pathlib",
+ "flake8-todos",
+ "flake8-fixme",
+ "eradicate",
+ "pandas-vet",
+ "pygrep-hooks",
+ "Pylint",
+ "tryceratops",
+ "flynt",
+ "NumPy-specific rules",
+ "FastAPI",
+ "Airflow",
+ "Perflint",
+ "refurb",
+ "pydoclint",
+ "Ruff-specific rules",
+ "Unknown"
+ );
+ // @formatter:on
+
private static final Map> languageToDefaultCategories = Map.of(ProgrammingLanguage.JAVA,
- createDefaultCategoriesForJava(), ProgrammingLanguage.SWIFT, createDefaultCategoriesForSwift(), ProgrammingLanguage.C, createDefaultCategoriesForC());
+ createDefaultCategoriesForJava(), ProgrammingLanguage.SWIFT, createDefaultCategoriesForSwift(), ProgrammingLanguage.C, createDefaultCategoriesForC(),
+ ProgrammingLanguage.PYTHON, createDefaultCategoriesForPython());
/**
* Create an unmodifiable List of default static code analysis categories for Java
@@ -85,6 +151,11 @@ private static List createDefaultCategoriesFo
new StaticCodeAnalysisDefaultCategory("Miscellaneous", 0.2D, 2D, CategoryState.INACTIVE, List.of(createMapping(StaticCodeAnalysisTool.GCC, "Misc"))));
}
+ private static List createDefaultCategoriesForPython() {
+ return CATEGORY_NAMES_PYTHON.stream()
+ .map(name -> new StaticCodeAnalysisDefaultCategory(name, 0.0, 1.0, CategoryState.FEEDBACK, List.of(createMapping(StaticCodeAnalysisTool.RUFF, name)))).toList();
+ }
+
public static Map> staticCodeAnalysisConfiguration() {
return languageToDefaultCategories;
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestNonRatedResultsCleanupCountDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestNonRatedResultsCleanupCountDTO.java
new file mode 100644
index 000000000000..1f76bf550c21
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestNonRatedResultsCleanupCountDTO.java
@@ -0,0 +1,7 @@
+package de.tum.cit.aet.artemis.core.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record NonLatestNonRatedResultsCleanupCountDTO(int longFeedbackText, int textBlock, int feedback) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestRatedResultsCleanupCountDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestRatedResultsCleanupCountDTO.java
new file mode 100644
index 000000000000..988077e66314
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/NonLatestRatedResultsCleanupCountDTO.java
@@ -0,0 +1,7 @@
+package de.tum.cit.aet.artemis.core.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record NonLatestRatedResultsCleanupCountDTO(int longFeedbackText, int textBlock, int feedback) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/OrphanCleanupCountDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/OrphanCleanupCountDTO.java
new file mode 100644
index 000000000000..00e61959a0ec
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/OrphanCleanupCountDTO.java
@@ -0,0 +1,9 @@
+package de.tum.cit.aet.artemis.core.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record OrphanCleanupCountDTO(int orphanFeedback, int orphanLongFeedbackText, int orphanTextBlock, int orphanStudentScore, int orphanTeamScore,
+ int orphanFeedbackForOrphanResults, int orphanLongFeedbackTextForOrphanResults, int orphanTextBlockForOrphanResults, int orphanRating,
+ int orphanResultsWithoutParticipation) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/PlagiarismComparisonCleanupCountDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/PlagiarismComparisonCleanupCountDTO.java
new file mode 100644
index 000000000000..0c18b3dc19b2
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/PlagiarismComparisonCleanupCountDTO.java
@@ -0,0 +1,7 @@
+package de.tum.cit.aet.artemis.core.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record PlagiarismComparisonCleanupCountDTO(int plagiarismComparison, int plagiarismElements, int plagiarismSubmissions, int plagiarismMatches) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/SubmissionVersionsCleanupCountDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/SubmissionVersionsCleanupCountDTO.java
new file mode 100644
index 000000000000..29261e752188
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/SubmissionVersionsCleanupCountDTO.java
@@ -0,0 +1,7 @@
+package de.tum.cit.aet.artemis.core.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record SubmissionVersionsCleanupCountDTO(int submissionVersions) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java
index 2ed93c8d8926..1a48825b0267 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java
@@ -17,11 +17,17 @@
import de.tum.cit.aet.artemis.assessment.repository.cleanup.RatingCleanupRepository;
import de.tum.cit.aet.artemis.assessment.repository.cleanup.ResultCleanupRepository;
import de.tum.cit.aet.artemis.assessment.repository.cleanup.StudentScoreCleanupRepository;
+import de.tum.cit.aet.artemis.assessment.repository.cleanup.SubmissionVersionCleanupRepository;
import de.tum.cit.aet.artemis.assessment.repository.cleanup.TeamScoreCleanupRepository;
import de.tum.cit.aet.artemis.assessment.repository.cleanup.TextBlockCleanupRepository;
import de.tum.cit.aet.artemis.core.domain.CleanupJobExecution;
import de.tum.cit.aet.artemis.core.domain.CleanupJobType;
import de.tum.cit.aet.artemis.core.dto.CleanupServiceExecutionRecordDTO;
+import de.tum.cit.aet.artemis.core.dto.NonLatestNonRatedResultsCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.NonLatestRatedResultsCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.OrphanCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.PlagiarismComparisonCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.SubmissionVersionsCleanupCountDTO;
import de.tum.cit.aet.artemis.core.repository.cleanup.CleanupJobExecutionRepository;
@Profile(PROFILE_CORE)
@@ -48,10 +54,13 @@ public class DataCleanupService {
private final TeamScoreCleanupRepository teamScoreCleanupRepository;
+ private final SubmissionVersionCleanupRepository submissionVersionCleanupRepository;
+
public DataCleanupService(CleanupJobExecutionRepository cleanupJobExecutionRepository, PlagiarismComparisonCleanupRepository plagiarismComparisonCleanupRepository,
ResultCleanupRepository resultCleanupRepository, RatingCleanupRepository ratingCleanupRepository, FeedbackCleanupRepository feedbackCleanupRepository,
TextBlockCleanupRepository textBlockCleanupRepository, LongFeedbackTextCleanupRepository longFeedbackTextCleanupRepository,
- StudentScoreCleanupRepository studentScoreCleanupRepository, TeamScoreCleanupRepository teamScoreCleanupRepository) {
+ StudentScoreCleanupRepository studentScoreCleanupRepository, TeamScoreCleanupRepository teamScoreCleanupRepository,
+ SubmissionVersionCleanupRepository submissionVersionCleanupRepository) {
this.resultCleanupRepository = resultCleanupRepository;
this.ratingCleanupRepository = ratingCleanupRepository;
this.feedbackCleanupRepository = feedbackCleanupRepository;
@@ -61,10 +70,9 @@ public DataCleanupService(CleanupJobExecutionRepository cleanupJobExecutionRepos
this.teamScoreCleanupRepository = teamScoreCleanupRepository;
this.cleanupJobExecutionRepository = cleanupJobExecutionRepository;
this.plagiarismComparisonCleanupRepository = plagiarismComparisonCleanupRepository;
+ this.submissionVersionCleanupRepository = submissionVersionCleanupRepository;
}
- // TODO: offer the possibility to delete old submission versions
-
/**
* Deletes orphaned entities that are no longer associated with valid results or participations.
* This includes feedback, text blocks, and scores that reference null results, participations, or submissions.
@@ -158,16 +166,6 @@ public CleanupServiceExecutionRecordDTO deleteNonLatestNonRatedResultsFeedback(Z
int deletedFeedback = feedbackCleanupRepository.deleteOldNonRatedFeedbackWhereCourseDateBetween(deleteFrom, deleteTo);
log.info("Deleted {} feedback entries for non-rated results between {} and {}", deletedFeedback, deleteFrom, deleteTo);
- // TODO: old results and participant scores should not be deleted automatically: if at all this could be offered as an option for the admin
- // int deletedParticipantScoresForLatest = participantScoreCleanupRepository.deleteParticipantScoresForLatestNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
- // log.info("Deleted {} participant scores for latest non-rated results between {} and {}", deletedParticipantScoresForLatest, deleteFrom, deleteTo);
- //
- // int deletedParticipantScores = participantScoreCleanupRepository.deleteParticipantScoresForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
- // log.info("Deleted {} participant scores for non-rated results between {} and {}", deletedParticipantScores, deleteFrom, deleteTo);
-
- // int deletedNonLatestNonRatedResults = resultCleanupRepository.deleteNonLatestNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
- // log.info("Deleted {} non-latest non-rated results between {} and {}", deletedNonLatestNonRatedResults, deleteFrom, deleteTo);
-
return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.NON_RATED_RESULTS, deleteFrom, deleteTo));
}
@@ -189,18 +187,93 @@ public CleanupServiceExecutionRecordDTO deleteNonLatestRatedResultsFeedback(Zone
int deletedFeedback = feedbackCleanupRepository.deleteOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
log.info("Deleted {} feedback entries for rated results between {} and {}", deletedFeedback, deleteFrom, deleteTo);
- // TODO: old results and participant scores should not be deleted automatically: if at all this could be offered as an option for the admin
- // int deletedParticipantScoresForNonLatest = participantScoreCleanupRepository.deleteParticipantScoresForNonLatestLastResultsWhereCourseDateBetween(deleteFrom, deleteTo);
- // log.info("Deleted {} participant scores for non-latest rated results between {} and {}", deletedParticipantScoresForNonLatest, deleteFrom, deleteTo);
- //
- // int deletedParticipantScoresForNonLatestLast = participantScoreCleanupRepository.deleteParticipantScoresForNonLatestLastRatedResultsWhereCourseDateBetween(deleteFrom,
- // deleteTo);
- // log.info("Deleted {} participant scores for non-latest last rated results between {} and {}", deletedParticipantScoresForNonLatestLast, deleteFrom, deleteTo);
+ return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.RATED_RESULTS, deleteFrom, deleteTo));
+ }
- // int deletedNonLatestRatedResults = resultCleanupRepository.deleteNonLatestRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
- // log.info("Deleted {} non-latest rated results between {} and {}", deletedNonLatestRatedResults, deleteFrom, deleteTo);
+ public CleanupServiceExecutionRecordDTO deleteSubmissionVersions(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) {
+ int deletedSubmissionVersions = submissionVersionCleanupRepository.deleteSubmissionVersionsByCreatedDateRange(deleteFrom.toInstant(), deleteTo.toInstant());
+ log.info("Deleted {} submission versions entries between {} and {}", deletedSubmissionVersions, deleteFrom, deleteTo);
- return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.RATED_RESULTS, deleteFrom, deleteTo));
+ return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.SUBMISSION_VERSIONS, deleteFrom, deleteTo));
+ }
+
+ /**
+ * Counts orphaned entities that are no longer associated with valid results or participations.
+ * This includes feedback, text blocks, and scores that reference null results, participations, or submissions.
+ *
+ * @return an {@link OrphanCleanupCountDTO} representing the counts of orphaned entities that would be deleted
+ */
+ public OrphanCleanupCountDTO countOrphans() {
+ int orphanFeedbackCount = feedbackCleanupRepository.countOrphanFeedback();
+ int orphanLongFeedbackTextCount = longFeedbackTextCleanupRepository.countLongFeedbackTextForOrphanedFeedback();
+ int orphanTextBlockCount = textBlockCleanupRepository.countTextBlockForEmptyFeedback();
+ int orphanStudentScoreCount = studentScoreCleanupRepository.countOrphanStudentScore();
+ int orphanTeamScoreCount = teamScoreCleanupRepository.countOrphanTeamScore();
+ int orphanLongFeedbackTextForOrphanResultsCount = longFeedbackTextCleanupRepository.countLongFeedbackTextForOrphanResult();
+ int orphanTextBlockForOrphanResultsCount = textBlockCleanupRepository.countTextBlockForOrphanResults();
+ int orphanFeedbackForOrphanResultsCount = feedbackCleanupRepository.countFeedbackForOrphanResults();
+ int orphanRatingCount = ratingCleanupRepository.countOrphanRating();
+ int orphanResultsWithoutParticipationCount = resultCleanupRepository.countResultWithoutParticipationAndSubmission();
+
+ return new OrphanCleanupCountDTO(orphanFeedbackCount, orphanLongFeedbackTextCount, orphanTextBlockCount, orphanStudentScoreCount, orphanTeamScoreCount,
+ orphanFeedbackForOrphanResultsCount, orphanLongFeedbackTextForOrphanResultsCount, orphanTextBlockForOrphanResultsCount, orphanRatingCount,
+ orphanResultsWithoutParticipationCount);
+ }
+
+ /**
+ * Counts plagiarism comparisons with a status of "None" that belong to courses within the specified date range.
+ * It retrieves the IDs of the plagiarism comparisons matching the criteria and counts the related data,
+ * including plagiarism elements, submissions, and matches.
+ *
+ * @param deleteFrom the start date for selecting plagiarism comparisons
+ * @param deleteTo the end date for selecting plagiarism comparisons
+ * @return a {@link PlagiarismComparisonCleanupCountDTO} representing the counts of entities related to plagiarism comparisons
+ */
+ public PlagiarismComparisonCleanupCountDTO countPlagiarismComparisons(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) {
+ var pcIds = plagiarismComparisonCleanupRepository.findPlagiarismComparisonIdWithStatusNoneThatBelongToCourseWithDates(deleteFrom, deleteTo);
+ int plagiarismComparisonCount = pcIds.size();
+ int plagiarismElementsCount = plagiarismComparisonCleanupRepository.countPlagiarismSubmissionElementsByComparisonIdsIn(pcIds);
+ int plagiarismSubmissionsCount = plagiarismComparisonCleanupRepository.countPlagiarismSubmissionsByComparisonIdsIn(pcIds);
+ int plagiarismMatchesCount = plagiarismComparisonCleanupRepository.countPlagiarismComparisonMatchesByComparisonIdsIn(pcIds);
+
+ return new PlagiarismComparisonCleanupCountDTO(plagiarismComparisonCount, plagiarismElementsCount, plagiarismSubmissionsCount, plagiarismMatchesCount);
+ }
+
+ /**
+ * Counts non-rated results that are not the latest non-rated result for each participation, within the specified date range.
+ * This includes associated long feedback texts, text blocks, and feedback items that would be affected.
+ *
+ * @param deleteFrom The start of the date range for counting non-rated results.
+ * @param deleteTo The end of the date range for counting non-rated results.
+ * @return a {@link NonLatestNonRatedResultsCleanupCountDTO} representing the counts of entities related to non-latest non-rated results
+ */
+ public NonLatestNonRatedResultsCleanupCountDTO countNonLatestNonRatedResults(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) {
+ int longFeedbackTextCount = longFeedbackTextCleanupRepository.countLongFeedbackTextForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
+ int textBlockCount = textBlockCleanupRepository.countTextBlockForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
+ int feedbackCount = feedbackCleanupRepository.countOldNonRatedFeedbackWhereCourseDateBetween(deleteFrom, deleteTo);
+
+ return new NonLatestNonRatedResultsCleanupCountDTO(longFeedbackTextCount, textBlockCount, feedbackCount);
+ }
+
+ /**
+ * Counts rated results that are not the latest rated result for each participation, for courses conducted within the specified date range.
+ * This includes associated long feedback texts, text blocks, and feedback items that would be affected.
+ *
+ * @param deleteFrom The start of the date range for counting rated results.
+ * @param deleteTo The end of the date range for counting rated results.
+ * @return a {@link NonLatestRatedResultsCleanupCountDTO} representing the counts of entities related to non-latest rated results
+ */
+ public NonLatestRatedResultsCleanupCountDTO countNonLatestRatedResults(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) {
+ int longFeedbackTextCount = longFeedbackTextCleanupRepository.countLongFeedbackTextForRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
+ int textBlockCount = textBlockCleanupRepository.countTextBlockForRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
+ int feedbackCount = feedbackCleanupRepository.countOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo);
+
+ return new NonLatestRatedResultsCleanupCountDTO(longFeedbackTextCount, textBlockCount, feedbackCount);
+ }
+
+ public SubmissionVersionsCleanupCountDTO countSubmissionVersions(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) {
+ int submissionVersionsCount = this.submissionVersionCleanupRepository.countSubmissionVersionsByCreatedDateRange(deleteFrom.toInstant(), deleteTo.toInstant());
+ return new SubmissionVersionsCleanupCountDTO(submissionVersionsCount);
}
/**
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java
index 88f7bb7302e6..124430b9386d 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java
@@ -73,7 +73,7 @@ public enum ColumnMapping {
)),
FEEDBACK_ANALYSIS(Map.of(
"count", "COUNT(f.id)",
- "detailText", "f.detailText",
+ "detailTexts", "f.detailText",
"testCaseName", "f.testCase.testName",
"taskName", """
COALESCE((
@@ -82,9 +82,6 @@ SELECT MAX(t.taskName)
JOIN t.testCases tct
WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName
), '')"""
- )),
- AFFECTED_STUDENTS(Map.of(
- "participationId", "p.id"
));
// @formatter:on
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java
index 60d865abdb0d..5a2e4c8c9dc8 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java
@@ -16,6 +16,11 @@
import org.springframework.web.bind.annotation.RestController;
import de.tum.cit.aet.artemis.core.dto.CleanupServiceExecutionRecordDTO;
+import de.tum.cit.aet.artemis.core.dto.NonLatestNonRatedResultsCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.NonLatestRatedResultsCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.OrphanCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.PlagiarismComparisonCleanupCountDTO;
+import de.tum.cit.aet.artemis.core.dto.SubmissionVersionsCleanupCountDTO;
import de.tum.cit.aet.artemis.core.security.annotations.EnforceAdmin;
import de.tum.cit.aet.artemis.core.service.cleanup.DataCleanupService;
@@ -50,6 +55,19 @@ public ResponseEntity deleteOrphans() {
return ResponseEntity.ok().body(result);
}
+ /**
+ * GET admin/cleanup/orphans/count
+ * Counts the number of orphaned data entries that would be deleted.
+ *
+ * @return a {@link ResponseEntity} containing the count of orphaned entries
+ */
+ @GetMapping("orphans/count")
+ public ResponseEntity countOrphans() {
+ log.info("REST request to count orphaned data in Artemis database");
+ OrphanCleanupCountDTO result = dataCleanupService.countOrphans();
+ return ResponseEntity.ok().body(result);
+ }
+
/**
* DELETE admin/cleanup/plagiarism-comparisons
* Deletes plagiarism comparisons within the specified date range.
@@ -66,6 +84,22 @@ public ResponseEntity deletePlagiarismComparis
return ResponseEntity.ok().body(result);
}
+ /**
+ * GET admin/cleanup/plagiarism-comparisons/count
+ * Counts the number of plagiarism comparisons and related entries that would be deleted within the specified date range.
+ *
+ * @param deleteFrom the start date of the counting range
+ * @param deleteTo the end date of the counting range
+ * @return a {@link ResponseEntity} containing the count of affected entries
+ */
+ @GetMapping("plagiarism-comparisons/count")
+ public ResponseEntity countPlagiarismComparisons(@RequestParam("deleteFrom") ZonedDateTime deleteFrom,
+ @RequestParam("deleteTo") ZonedDateTime deleteTo) {
+ log.info("REST request to count plagiarism comparisons between {} and {}", deleteFrom, deleteTo);
+ PlagiarismComparisonCleanupCountDTO result = dataCleanupService.countPlagiarismComparisons(deleteFrom, deleteTo);
+ return ResponseEntity.ok().body(result);
+ }
+
/**
* DELETE admin/cleanup/non-rated-results
* Deletes non-rated results within the specified date range.
@@ -82,6 +116,22 @@ public ResponseEntity deleteNonRatedResults(@R
return ResponseEntity.ok().body(result);
}
+ /**
+ * GET admin/cleanup/non-rated-results/count
+ * Counts the number of non-rated results and related entries that would be deleted within the specified date range.
+ *
+ * @param deleteFrom the start date of the counting range
+ * @param deleteTo the end date of the counting range
+ * @return a {@link ResponseEntity} containing the count of affected entries
+ */
+ @GetMapping("non-rated-results/count")
+ public ResponseEntity countNonRatedResults(@RequestParam("deleteFrom") ZonedDateTime deleteFrom,
+ @RequestParam("deleteTo") ZonedDateTime deleteTo) {
+ log.info("REST request to count non-rated results between {} and {}", deleteFrom, deleteTo);
+ NonLatestNonRatedResultsCleanupCountDTO result = dataCleanupService.countNonLatestNonRatedResults(deleteFrom, deleteTo);
+ return ResponseEntity.ok().body(result);
+ }
+
/**
* DELETE admin/cleanup/old-rated-results
* Deletes old rated results within the specified date range.
@@ -98,6 +148,54 @@ public ResponseEntity deleteOldRatedResults(@R
return ResponseEntity.ok().body(result);
}
+ /**
+ * GET admin/cleanup/old-rated-results/count
+ * Counts the number of old rated results and related entries that would be deleted within the specified date range.
+ *
+ * @param deleteFrom the start date of the counting range
+ * @param deleteTo the end date of the counting range
+ * @return a {@link ResponseEntity} containing the count of affected entries
+ */
+ @GetMapping("old-rated-results/count")
+ public ResponseEntity countOldRatedResults(@RequestParam("deleteFrom") ZonedDateTime deleteFrom,
+ @RequestParam("deleteTo") ZonedDateTime deleteTo) {
+ log.info("REST request to count old rated results between {} and {}", deleteFrom, deleteTo);
+ NonLatestRatedResultsCleanupCountDTO result = dataCleanupService.countNonLatestRatedResults(deleteFrom, deleteTo);
+ return ResponseEntity.ok().body(result);
+ }
+
+ /**
+ * DELETE admin/cleanup/old-submission-versions
+ * Deletes old submission versions within the specified date range.
+ *
+ * @param deleteFrom the start date of the deletion range
+ * @param deleteTo the end date of the deletion range
+ * @return a {@link ResponseEntity} containing the result of the cleanup operation
+ */
+ @DeleteMapping("old-submission-versions")
+ public ResponseEntity deleteOldSubmissionVersions(@RequestParam("deleteFrom") ZonedDateTime deleteFrom,
+ @RequestParam("deleteTo") ZonedDateTime deleteTo) {
+ log.info("REST request to delete old submission versions between {} and {}", deleteFrom, deleteTo);
+ CleanupServiceExecutionRecordDTO result = dataCleanupService.deleteSubmissionVersions(deleteFrom, deleteTo);
+ return ResponseEntity.ok().body(result);
+ }
+
+ /**
+ * GET admin/cleanup/old-submission-versions/count
+ * Counts the number of submission versions entries that would be deleted within the specified date range.
+ *
+ * @param deleteFrom the start date of the counting range
+ * @param deleteTo the end date of the counting range
+ * @return a {@link ResponseEntity} containing the count of affected entries
+ */
+ @GetMapping("old-submission-versions/count")
+ public ResponseEntity countOldSubmissionVersions(@RequestParam("deleteFrom") ZonedDateTime deleteFrom,
+ @RequestParam("deleteTo") ZonedDateTime deleteTo) {
+ log.info("REST request to count old submission versions between {} and {}", deleteFrom, deleteTo);
+ SubmissionVersionsCleanupCountDTO result = dataCleanupService.countSubmissionVersions(deleteFrom, deleteTo);
+ return ResponseEntity.ok().body(result);
+ }
+
/**
* GET admin/cleanup/last-executions
* Retrieves the last execution records of the data cleanup operations.
diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java
index d9cb4a6e205a..26deb8669fdc 100644
--- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java
+++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java
@@ -1277,13 +1277,13 @@ SELECT MAX(t.taskName)
ELSE 'Student Error'
END
)
- FROM StudentParticipation p
- LEFT JOIN p.results r ON r.id = (
+ FROM ProgrammingExerciseStudentParticipation p
+ INNER JOIN p.results r ON r.id = (
SELECT MAX(pr.id)
FROM p.results pr
WHERE pr.participation.id = p.id
)
- LEFT JOIN r.feedbacks f
+ INNER JOIN r.feedbacks f
WHERE p.exercise.id = :exerciseId
AND p.testRun = FALSE
AND f.positive = FALSE
@@ -1305,7 +1305,7 @@ HAVING COUNT(f.id) BETWEEN :minOccurrence AND :maxOccurrence
""")
Page findFilteredFeedbackByExerciseId(@Param("exerciseId") long exerciseId, @Param("searchTerm") String searchTerm,
@Param("filterTestCases") List filterTestCases, @Param("filterTaskNames") List filterTaskNames, @Param("minOccurrence") long minOccurrence,
- @Param("maxOccurrence") long maxOccurrence, @Param("filterErrorCategories") List filterErrorCategories, Pageable pageable);
+ @Param("maxOccurrence") long maxOccurrence, @Param("filterErrorCategories") List filterErrorCategories, @Param("pageable") Pageable pageable);
/**
* Counts the distinct number of latest results for a given exercise, excluding those in practice mode.
@@ -1317,8 +1317,8 @@ Page findFilteredFeedbackByExerciseId(@Param("exerciseId") lo
*/
@Query("""
SELECT COUNT(DISTINCT r.id)
- FROM StudentParticipation p
- LEFT JOIN p.results r ON r.id = (
+ FROM ProgrammingExerciseStudentParticipation p
+ INNER JOIN p.results r ON r.id = (
SELECT MAX(pr.id)
FROM p.results pr
WHERE pr.participation.id = p.id
@@ -1344,13 +1344,13 @@ SELECT MAX(pr.id)
SELECT MAX(feedbackCounts.feedbackCount)
FROM (
SELECT COUNT(f.id) AS feedbackCount
- FROM StudentParticipation p
- LEFT JOIN p.results r ON r.id = (
+ FROM ProgrammingExerciseStudentParticipation p
+ INNER JOIN p.results r ON r.id = (
SELECT MAX(pr.id)
FROM p.results pr
WHERE pr.participation.id = p.id
)
- LEFT JOIN r.feedbacks f
+ INNER JOIN r.feedbacks f
WHERE p.exercise.id = :exerciseId
AND p.testRun = FALSE
AND f.positive = FALSE
@@ -1365,12 +1365,10 @@ SELECT MAX(pr.id)
*
* @param exerciseId for which the affected student participation data is requested.
* @param feedbackIds used to filter the participation to only those affected by specific feedback entries.
- * @param pageable A {@link Pageable} object to control pagination and sorting of the results, specifying page number, page size, and sort order.
* @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback.
*/
@Query("""
- SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO(
- p.exercise.course.id,
+ SELECT DISTINCT new de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO(
p.id,
p.student.firstName,
p.student.lastName,
@@ -1378,63 +1376,56 @@ SELECT MAX(pr.id)
p.repositoryUri
)
FROM ProgrammingExerciseStudentParticipation p
- LEFT JOIN p.submissions s
- LEFT JOIN s.results r
- LEFT JOIN r.feedbacks f
+ INNER JOIN p.results r ON r.id = (
+ SELECT MAX(pr.id)
+ FROM p.results pr
+ WHERE pr.participation.id = p.id
+ )
+ INNER JOIN r.feedbacks f
WHERE p.exercise.id = :exerciseId
AND f.id IN :feedbackIds
AND p.testRun = FALSE
ORDER BY p.student.firstName ASC
""")
- Page findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds, Pageable pageable);
+ List findAffectedStudentsByFeedbackIds(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds);
- /**
- * Retrieves the logins of students affected by a specific feedback detail text in a given exercise.
- *
- * @param exerciseId The ID of the exercise for which affected students are requested.
- * @param detailText The feedback detail text to filter by.
- * @return A list of student logins affected by the given feedback detail text in the specified exercise.
- */
@Query("""
- SELECT DISTINCT p.student.login
+ SELECT f.id
FROM ProgrammingExerciseStudentParticipation p
- INNER JOIN p.submissions s
- INNER JOIN s.results r ON r.id = (
+ INNER JOIN p.results r ON r.id = (
SELECT MAX(pr.id)
- FROM s.results pr
+ FROM p.results pr
WHERE pr.participation.id = p.id
)
INNER JOIN r.feedbacks f
WHERE p.exercise.id = :exerciseId
- AND f.detailText = :detailText
- AND p.testRun = FALSE
+ AND p.testRun = FALSE
+ ORDER BY p.student.firstName ASC
""")
- List findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText);
+ List findAffectedStudentsByFeedbackIds2(@Param("exerciseId") long exerciseId);
/**
- * Counts the number of distinct students affected by a specific feedback detail text for a given programming exercise.
- *
- * This query identifies students whose submissions were impacted by feedback entries matching the provided detail text
- * within the specified exercise. Only students with non-test run submissions and negative feedback entries are considered.
- *
+ * Retrieves the logins of students affected by a specific feedback detail text in a given exercise.
*
- * @param exerciseId the ID of the programming exercise for which the count is calculated.
- * @param detailText the feedback detail text used to filter the affected students.
- * @return the total number of distinct students affected by the feedback detail text.
+ * @param exerciseId The ID of the exercise for which affected students are requested.
+ * @param detailTexts The feedback detail text to filter by.
+ * @param testCaseName The name of the test case for which the feedback is given.
+ * @return A list of student logins affected by the given feedback detail text in the specified exercise.
*/
@Query("""
- SELECT COUNT(DISTINCT p.student.id)
+ SELECT DISTINCT p.student.login
FROM ProgrammingExerciseStudentParticipation p
- INNER JOIN p.submissions s
- INNER JOIN s.results r ON r.id = (
+ INNER JOIN p.results r ON r.id = (
SELECT MAX(pr.id)
- FROM s.results pr
+ FROM p.results pr
WHERE pr.participation.id = p.id
)
INNER JOIN r.feedbacks f
WHERE p.exercise.id = :exerciseId
- AND f.detailText = :detailText
+ AND f.detailText IN :detailTexts
+ AND f.testCase.testName = :testCaseName
AND p.testRun = FALSE
""")
- long countAffectedStudentsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText);
+ List findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailTexts") List detailTexts,
+ @Param("testCaseName") String testCaseName);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java
index dd3ffcbea67a..d902dc90d2c8 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/StaticCodeAnalysisTool.java
@@ -18,6 +18,7 @@ public enum StaticCodeAnalysisTool {
PMD_CPD("cpd.xml"),
SWIFTLINT("swiftlint-result.xml"),
GCC("gcc.xml"),
+ RUFF("ruff.sarif"),
OTHER(null),
;
// @formatter:on
@@ -26,7 +27,8 @@ public enum StaticCodeAnalysisTool {
private static final Map> TOOLS_OF_PROGRAMMING_LANGUAGE = new EnumMap<>(Map.of(
ProgrammingLanguage.JAVA, List.of(SPOTBUGS, CHECKSTYLE, PMD, PMD_CPD),
ProgrammingLanguage.SWIFT, List.of(SWIFTLINT),
- ProgrammingLanguage.C, List.of(GCC)
+ ProgrammingLanguage.C, List.of(GCC),
+ ProgrammingLanguage.PYTHON, List.of(RUFF)
));
// @formatter:on
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java
index 3d8beec05006..9ee663c55c96 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java
@@ -107,7 +107,7 @@ private ProgrammingExerciseGitDiffReport createReport(Repository repository, Rev
diffs.append(out.toString(StandardCharsets.UTF_8));
}
- var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diffs.toString(), false);
+ var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diffs.toString(), false, false);
var report = new ProgrammingExerciseGitDiffReport();
for (ProgrammingExerciseGitDiffEntry gitDiffEntry : programmingExerciseGitDiffEntries) {
gitDiffEntry.setGitDiffReport(report);
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java
index c302c5f147e5..978508ce5c95 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java
@@ -49,6 +49,8 @@ public class ProgrammingExerciseRepositoryService {
private static final String TEST_DIR = "test";
+ private static final String STATIC_CODE_ANALYSIS_DIR = "staticCodeAnalysis";
+
private static final String POM_XML = "pom.xml";
private static final String BUILD_GRADLE = "build.gradle";
@@ -114,7 +116,8 @@ void setupExerciseTemplate(final ProgrammingExercise programmingExercise, final
setupRepositories(programmingExercise, exerciseCreator, exerciseResources, solutionResources, testResources);
}
- private record RepositoryResources(Repository repository, Resource[] resources, Path prefix, Resource[] projectTypeResources, Path projectTypePrefix) {
+ private record RepositoryResources(Repository repository, Resource[] resources, Path prefix, Resource[] projectTypeResources, Path projectTypePrefix,
+ Resource[] staticCodeAnalysisResources, Path staticCodeAnalysisPrefix) {
}
/**
@@ -128,17 +131,17 @@ private record RepositoryResources(Repository repository, Resource[] resources,
private RepositoryResources getRepositoryResources(final ProgrammingExercise programmingExercise, final RepositoryType repositoryType) throws GitAPIException {
final String programmingLanguage = programmingExercise.getProgrammingLanguage().toString().toLowerCase(Locale.ROOT);
final ProjectType projectType = programmingExercise.getProjectType();
- final Path projectTypeTemplateDir = getTemplateDirectoryForRepositoryType(repositoryType);
+ final Path repositoryTypeTemplateDir = getTemplateDirectoryForRepositoryType(repositoryType);
final VcsRepositoryUri repoUri = programmingExercise.getRepositoryURL(repositoryType);
final Repository repo = gitService.getOrCheckoutRepository(repoUri, true);
// Get path, files and prefix for the programming-language dependent files. They are copied first.
final Path generalTemplatePath = ProgrammingExerciseService.getProgrammingLanguageTemplatePath(programmingExercise.getProgrammingLanguage())
- .resolve(projectTypeTemplateDir);
+ .resolve(repositoryTypeTemplateDir);
Resource[] resources = resourceLoaderService.getFileResources(generalTemplatePath);
- Path prefix = Path.of(programmingLanguage).resolve(projectTypeTemplateDir);
+ Path prefix = Path.of(programmingLanguage).resolve(repositoryTypeTemplateDir);
Resource[] projectTypeResources = null;
Path projectTypePrefix = null;
@@ -149,8 +152,8 @@ private RepositoryResources getRepositoryResources(final ProgrammingExercise pro
projectType);
final String projectTypePath = projectType.name().toLowerCase();
final Path generalProjectTypePrefix = Path.of(programmingLanguage, projectTypePath);
- final Path projectTypeSpecificPrefix = generalProjectTypePrefix.resolve(projectTypeTemplateDir);
- final Path projectTypeTemplatePath = programmingLanguageProjectTypePath.resolve(projectTypeTemplateDir);
+ final Path projectTypeSpecificPrefix = generalProjectTypePrefix.resolve(repositoryTypeTemplateDir);
+ final Path projectTypeTemplatePath = programmingLanguageProjectTypePath.resolve(repositoryTypeTemplateDir);
final Resource[] projectTypeSpecificResources = resourceLoaderService.getFileResources(projectTypeTemplatePath);
@@ -165,7 +168,19 @@ private RepositoryResources getRepositoryResources(final ProgrammingExercise pro
}
}
- return new RepositoryResources(repo, resources, prefix, projectTypeResources, projectTypePrefix);
+ Resource[] staticCodeAnalysisResources = null;
+ Path staticCodeAnalysisPrefix = null;
+
+ if (programmingExercise.isStaticCodeAnalysisEnabled()) {
+ Path programmingLanguageStaticCodeAnalysisPath = ProgrammingExerciseService.getProgrammingLanguageTemplatePath(programmingExercise.getProgrammingLanguage())
+ .resolve(STATIC_CODE_ANALYSIS_DIR);
+ final Path staticCodeAnalysisTemplatePath = programmingLanguageStaticCodeAnalysisPath.resolve(repositoryTypeTemplateDir);
+
+ staticCodeAnalysisResources = resourceLoaderService.getFileResources(staticCodeAnalysisTemplatePath);
+ staticCodeAnalysisPrefix = Path.of(programmingLanguage, STATIC_CODE_ANALYSIS_DIR).resolve(repositoryTypeTemplateDir);
+ }
+
+ return new RepositoryResources(repo, resources, prefix, projectTypeResources, projectTypePrefix, staticCodeAnalysisResources, staticCodeAnalysisPrefix);
}
private Path getTemplateDirectoryForRepositoryType(final RepositoryType repositoryType) {
@@ -316,10 +331,13 @@ private void setupTemplateAndPush(final RepositoryResources repositoryResources,
final Path repoLocalPath = getRepoAbsoluteLocalPath(repositoryResources.repository);
fileService.copyResources(repositoryResources.resources, repositoryResources.prefix, repoLocalPath, true);
- // Also copy project type specific files AFTERWARDS (so that they might overwrite the default files)
+ // Also copy project type and static code analysis specific files AFTERWARDS (so that they might overwrite the default files)
if (repositoryResources.projectTypeResources != null) {
fileService.copyResources(repositoryResources.projectTypeResources, repositoryResources.projectTypePrefix, repoLocalPath, true);
}
+ if (repositoryResources.staticCodeAnalysisResources != null) {
+ fileService.copyResources(repositoryResources.staticCodeAnalysisResources, repositoryResources.staticCodeAnalysisPrefix, repoLocalPath, true);
+ }
replacePlaceholders(programmingExercise, repositoryResources.repository);
commitAndPushRepository(repositoryResources.repository, templateName + "-Template pushed by Artemis", true, user);
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java
index 7ace2505cf1c..6950e1216df5 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java
@@ -109,7 +109,7 @@ public ProgrammingExerciseGitDiffReport updateReport(ProgrammingExercise program
var templateHash = templateSubmission.getCommitHash();
var solutionHash = solutionSubmission.getCommitHash();
- var existingReport = this.getReportOfExercise(programmingExercise);
+ var existingReport = getReportOfExercise(programmingExercise);
if (existingReport != null && canUseExistingReport(existingReport, templateHash, solutionHash)) {
return existingReport;
}
@@ -164,7 +164,7 @@ else if (reports.size() == 1) {
* @return The report or null if none can be generated
*/
public ProgrammingExerciseGitDiffReport getOrCreateReportOfExercise(ProgrammingExercise programmingExercise) {
- var report = this.getReportOfExercise(programmingExercise);
+ var report = getReportOfExercise(programmingExercise);
if (report == null) {
return updateReport(programmingExercise);
}
@@ -215,7 +215,7 @@ public int calculateNumberOfDiffLinesBetweenRepos(VcsRepositoryUri urlRepoA, Pat
try (var diffOutputStream = new ByteArrayOutputStream(); var git = Git.wrap(repoB)) {
git.diff().setOldTree(treeParserRepoB).setNewTree(treeParserRepoA).setOutputStream(diffOutputStream).call();
var diff = diffOutputStream.toString();
- return gitDiffReportParserService.extractDiffEntries(diff, true).stream().mapToInt(ProgrammingExerciseGitDiffEntry::getLineCount).sum();
+ return gitDiffReportParserService.extractDiffEntries(diff, true, false).stream().mapToInt(ProgrammingExerciseGitDiffEntry::getLineCount).sum();
}
catch (IOException | GitAPIException e) {
log.error("Error calculating number of diff lines between repositories: urlRepoA={}, urlRepoB={}.", urlRepoA, urlRepoB, e);
@@ -234,6 +234,7 @@ public int calculateNumberOfDiffLinesBetweenRepos(VcsRepositoryUri urlRepoA, Pat
*/
private ProgrammingExerciseGitDiffReport generateReport(TemplateProgrammingExerciseParticipation templateParticipation,
SolutionProgrammingExerciseParticipation solutionParticipation) throws GitAPIException, IOException {
+ // TODO: in case of LocalVC, we should calculate the diff in the bare origin repository
Repository templateRepo = prepareTemplateRepository(templateParticipation);
var solutionRepo = gitService.getOrCheckoutRepository(solutionParticipation.getVcsRepositoryUri(), true);
gitService.resetToOriginHead(solutionRepo);
@@ -306,19 +307,20 @@ private ProgrammingExerciseGitDiffReport parseFilesAndCreateReport(Repository re
* It parses all files of the repositories in their directories on the file system and creates a report containing the changes.
* Both repositories have to be checked out at the commit that should be compared and be in different directories
*
- * @param repo1 The first repository
- * @param oldTreeParser The tree parser for the first repository
- * @param newTreeParser The tree parser for the second repository
+ * @param firstRepo The first repository
+ * @param firstRepoTreeParser The tree parser for the first repository
+ * @param secondRepoTreeParser The tree parser for the second repository
* @return The report with the changes between the two repositories at their checked out state
* @throws IOException If an error occurs while accessing the file system
* @throws GitAPIException If an error occurs while accessing the git repository
*/
@NotNull
- private ProgrammingExerciseGitDiffReport createReport(Repository repo1, FileTreeIterator oldTreeParser, FileTreeIterator newTreeParser) throws IOException, GitAPIException {
- try (ByteArrayOutputStream diffOutputStream = new ByteArrayOutputStream(); Git git = Git.wrap(repo1)) {
- git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).setOutputStream(diffOutputStream).call();
+ private ProgrammingExerciseGitDiffReport createReport(Repository firstRepo, FileTreeIterator firstRepoTreeParser, FileTreeIterator secondRepoTreeParser)
+ throws IOException, GitAPIException {
+ try (ByteArrayOutputStream diffOutputStream = new ByteArrayOutputStream(); Git git = Git.wrap(firstRepo)) {
+ git.diff().setOldTree(firstRepoTreeParser).setNewTree(secondRepoTreeParser).setOutputStream(diffOutputStream).call();
var diff = diffOutputStream.toString();
- var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diff, false);
+ var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diff, false, true);
var report = new ProgrammingExerciseGitDiffReport();
for (ProgrammingExerciseGitDiffEntry gitDiffEntry : programmingExerciseGitDiffEntries) {
gitDiffEntry.setGitDiffReport(report);
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java
index e0486750422f..54126903a6d0 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java
@@ -53,7 +53,7 @@ public LocalCIProgrammingLanguageFeatureService() {
programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, true));
programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, false, false, true, true, false, List.of(), false, true));
programmingLanguageFeatures.put(OCAML, new ProgrammingLanguageFeature(OCAML, false, false, false, false, true, List.of(), false, true));
- programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, true));
+ programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, true, true, false, false, List.of(), false, true));
programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false, true));
programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, true));
programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true));
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserPolicy.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserPolicy.java
index fa90c31c7c9d..43b263c7d282 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserPolicy.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/ParserPolicy.java
@@ -4,6 +4,8 @@
import de.tum.cit.aet.artemis.programming.domain.StaticCodeAnalysisTool;
import de.tum.cit.aet.artemis.programming.service.localci.scaparser.exception.UnsupportedToolException;
+import de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif.RuffCategorizer;
+import de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif.SarifParser;
/**
* Policy class for the parser strategies.
@@ -27,7 +29,7 @@ public ParserStrategy configure(String fileName) {
case CHECKSTYLE -> new CheckstyleParser();
case PMD -> new PMDParser();
case PMD_CPD -> new PMDCPDParser();
- // so far, we do not support swiftlint and gcc only SCA for Java
+ case RUFF -> new SarifParser(StaticCodeAnalysisTool.RUFF, new RuffCategorizer());
default -> throw new UnsupportedToolException("Tool " + tool + " is not supported");
};
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuffCategorizer.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuffCategorizer.java
new file mode 100644
index 000000000000..705611acd5ba
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/scaparser/strategy/sarif/RuffCategorizer.java
@@ -0,0 +1,15 @@
+package de.tum.cit.aet.artemis.programming.service.localci.scaparser.strategy.sarif;
+
+import java.util.Map;
+
+import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.PropertyBag;
+import de.tum.cit.aet.artemis.programming.service.localci.scaparser.format.sarif.ReportingDescriptor;
+
+public class RuffCategorizer implements RuleCategorizer {
+
+ @Override
+ public String categorizeRule(ReportingDescriptor rule) {
+ Map properties = rule.getOptionalProperties().map(PropertyBag::additionalProperties).orElseGet(Map::of);
+ return properties.getOrDefault("kind", "Unknown").toString();
+ }
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/HashUtils.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/HashUtils.java
index 4312a3b45789..0ca141ed9f60 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/HashUtils.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/HashUtils.java
@@ -10,4 +10,8 @@ public class HashUtils {
public static String getSha512Fingerprint(PublicKey key) {
return KeyUtils.getFingerPrint(BuiltinDigests.sha512.create(), key);
}
+
+ public static String getSha256Fingerprint(PublicKey key) {
+ return KeyUtils.getFingerPrint(BuiltinDigests.sha256.create(), key);
+ }
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java
new file mode 100644
index 000000000000..77c386aa95b6
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java
@@ -0,0 +1,57 @@
+package de.tum.cit.aet.artemis.programming.service.localvc.ssh;
+
+import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.ws.rs.BadRequestException;
+
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.server.SshServer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+/**
+ * Service responsible for providing SSH fingerprints of the SSH server running in Artemis.
+ */
+@Profile(PROFILE_LOCALVC)
+@Service
+public class SshFingerprintsProviderService {
+
+ private static final Logger log = LoggerFactory.getLogger(SshFingerprintsProviderService.class);
+
+ private final SshServer sshServer;
+
+ public SshFingerprintsProviderService(SshServer sshServer) {
+ this.sshServer = sshServer;
+ }
+
+ /**
+ * Retrieves the SSH key fingerprints from the stored SSH keys
+ *
+ * @return a map containing the SSH key fingerprints, where the key is the algorithm
+ * of the public key and the value is its SHA-256 fingerprint.
+ * @throws BadRequestException if there is an error loading keys from the SSH server.
+ */
+ public Map getSshFingerPrints() {
+ Map fingerprints = new HashMap<>();
+ KeyPairProvider keyPairProvider = sshServer.getKeyPairProvider();
+ if (keyPairProvider != null) {
+ try {
+ keyPairProvider.loadKeys(null).iterator()
+ .forEachRemaining(keyPair -> fingerprints.put(keyPair.getPublic().getAlgorithm(), HashUtils.getSha256Fingerprint(keyPair.getPublic())));
+
+ }
+ catch (IOException | GeneralSecurityException e) {
+ log.info("Could not load keys from the ssh server while trying to get SSH key fingerprints", e);
+ throw new BadRequestException("Could not load keys from the ssh server");
+ }
+ }
+ return fingerprints;
+ }
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java
index a12dff5575d6..37042b8cdb06 100644
--- a/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java
@@ -23,16 +23,17 @@ public class GitDiffReportParserService {
private static final String PREFIX_RENAME_TO = "rename to ";
- private final Pattern gitDiffLinePattern = Pattern.compile("@@ -(?\\d+)(,(?\\d+))? \\+(?\\d+)(,(?\\d+))? @@");
+ private final Pattern gitDiffLinePattern = Pattern.compile("@@ -(?\\d+)(,(?\\d+))? \\+(?\\d+)(,(?\\d+))? @@.*");
/**
* Extracts the ProgrammingExerciseGitDiffEntry from the raw git-diff output
*
* @param diff The raw git-diff output
* @param useAbsoluteLineCount Whether to use absolute line count or previous line count
+ * @param ignoreWhitespace Whether to ignore entries where only leading and trailing whitespace differ
* @return The extracted ProgrammingExerciseGitDiffEntries
*/
- public List extractDiffEntries(String diff, boolean useAbsoluteLineCount) {
+ public List extractDiffEntries(String diff, boolean useAbsoluteLineCount, boolean ignoreWhitespace) {
var lines = diff.split("\n");
var parserState = new ParserState();
Map renamedFilePaths = new HashMap<>();
@@ -44,8 +45,7 @@ public List extractDiffEntries(String diff, boo
continue;
}
- // Files may be renamed without changes, in which case the lineMatcher will never match the entry
- // We store this information separately so it is not lost
+ // Check for renamed files
if (line.startsWith(PREFIX_RENAME_FROM) && i + 1 < lines.length) {
var nextLine = lines[i + 1];
if (nextLine.startsWith(PREFIX_RENAME_TO)) {
@@ -57,34 +57,35 @@ public List extractDiffEntries(String diff, boo
var lineMatcher = gitDiffLinePattern.matcher(line);
if (lineMatcher.matches()) {
- handleNewDiffBlock(lines, i, parserState, lineMatcher);
+ handleNewDiffBlock(lines, i, parserState, lineMatcher, ignoreWhitespace);
}
- else if (!parserState.deactivateCodeReading) {
+ else if (!parserState.deactivateCodeReading && !line.isEmpty()) {
switch (line.charAt(0)) {
- case '+' -> handleAddition(parserState);
- case '-' -> handleRemoval(parserState, useAbsoluteLineCount);
- case ' ' -> handleUnchanged(parserState);
+ case '+' -> handleAddition(parserState, line);
+ case '-' -> handleRemoval(parserState, useAbsoluteLineCount, line);
+ case ' ' -> handleUnchanged(parserState, ignoreWhitespace);
default -> parserState.deactivateCodeReading = true;
}
}
}
- if (!parserState.currentEntry.isEmpty()) {
- parserState.entries.add(parserState.currentEntry);
- }
- // Add an empty diff entry for renamed files without changes
+
+ // Check the last entry
+ finalizeEntry(parserState, ignoreWhitespace);
+
+ // Add empty entries for renamed files without changes
for (var entry : renamedFilePaths.entrySet()) {
var diffEntry = new ProgrammingExerciseGitDiffEntry();
diffEntry.setFilePath(entry.getValue());
diffEntry.setPreviousFilePath(entry.getKey());
parserState.entries.add(diffEntry);
}
+
return parserState.entries;
}
- private void handleNewDiffBlock(String[] lines, int currentLine, ParserState parserState, Matcher lineMatcher) {
- if (!parserState.currentEntry.isEmpty()) {
- parserState.entries.add(parserState.currentEntry);
- }
+ private void handleNewDiffBlock(String[] lines, int currentLine, ParserState parserState, Matcher lineMatcher, boolean ignoreWhitespace) {
+ finalizeEntry(parserState, ignoreWhitespace);
+
// Start of a new file
var newFilePath = getFilePath(lines, currentLine);
var newPreviousFilePath = getPreviousFilePath(lines, currentLine);
@@ -92,40 +93,45 @@ private void handleNewDiffBlock(String[] lines, int currentLine, ParserState par
parserState.currentFilePath = newFilePath;
parserState.currentPreviousFilePath = newPreviousFilePath;
}
+
parserState.currentEntry = new ProgrammingExerciseGitDiffEntry();
parserState.currentEntry.setFilePath(parserState.currentFilePath);
parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath);
parserState.currentLineCount = Integer.parseInt(lineMatcher.group("newLine"));
parserState.currentPreviousLineCount = Integer.parseInt(lineMatcher.group("previousLine"));
parserState.deactivateCodeReading = false;
+ parserState.addedLines.clear();
+ parserState.removedLines.clear();
}
- private void handleUnchanged(ParserState parserState) {
- var entry = parserState.currentEntry;
- if (!entry.isEmpty()) {
- parserState.entries.add(entry);
- }
- entry = new ProgrammingExerciseGitDiffEntry();
- entry.setFilePath(parserState.currentFilePath);
- entry.setPreviousFilePath(parserState.currentPreviousFilePath);
+ private void handleUnchanged(ParserState parserState, boolean ignoreWhitespace) {
+ finalizeEntry(parserState, ignoreWhitespace);
+ parserState.currentEntry = new ProgrammingExerciseGitDiffEntry();
+ parserState.currentEntry.setFilePath(parserState.currentFilePath);
+ parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath);
- parserState.currentEntry = entry;
parserState.lastLineRemoveOperation = false;
parserState.currentLineCount++;
parserState.currentPreviousLineCount++;
+ parserState.addedLines.clear();
+ parserState.removedLines.clear();
}
- private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount) {
+ private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount, String line) {
var entry = parserState.currentEntry;
if (!parserState.lastLineRemoveOperation && !entry.isEmpty()) {
- parserState.entries.add(entry);
- entry = new ProgrammingExerciseGitDiffEntry();
- entry.setFilePath(parserState.currentFilePath);
- entry.setPreviousFilePath(parserState.currentPreviousFilePath);
+ finalizeEntry(parserState, false);
+ parserState.currentEntry = new ProgrammingExerciseGitDiffEntry();
+ parserState.currentEntry.setFilePath(parserState.currentFilePath);
+ parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath);
}
- if (entry.getPreviousLineCount() == null) {
- entry.setPreviousLineCount(0);
- entry.setPreviousStartLine(parserState.currentPreviousLineCount);
+
+ // Store removed line
+ parserState.removedLines.add(line.substring(1));
+
+ if (parserState.currentEntry.getPreviousLineCount() == null) {
+ parserState.currentEntry.setPreviousLineCount(0);
+ parserState.currentEntry.setPreviousStartLine(parserState.currentPreviousLineCount);
}
if (useAbsoluteLineCount) {
if (parserState.currentEntry.getLineCount() == null) {
@@ -135,15 +141,17 @@ private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount
parserState.currentEntry.setLineCount(parserState.currentEntry.getLineCount() + 1);
}
else {
- entry.setPreviousLineCount(entry.getPreviousLineCount() + 1);
+ parserState.currentEntry.setPreviousLineCount(parserState.currentEntry.getPreviousLineCount() + 1);
}
- parserState.currentEntry = entry;
parserState.lastLineRemoveOperation = true;
parserState.currentPreviousLineCount++;
}
- private void handleAddition(ParserState parserState) {
+ private void handleAddition(ParserState parserState, String line) {
+ // Store added line
+ parserState.addedLines.add(line.substring(1));
+
if (parserState.currentEntry.getLineCount() == null) {
parserState.currentEntry.setLineCount(0);
parserState.currentEntry.setStartLine(parserState.currentLineCount);
@@ -154,6 +162,29 @@ private void handleAddition(ParserState parserState) {
parserState.currentLineCount++;
}
+ private void finalizeEntry(ParserState parserState, boolean ignoreWhitespace) {
+ if (!parserState.currentEntry.isEmpty()) {
+ if (!ignoreWhitespace || !isWhitespaceOnlyChange(parserState.addedLines, parserState.removedLines)) {
+ parserState.entries.add(parserState.currentEntry);
+ }
+ }
+ }
+
+ private boolean isWhitespaceOnlyChange(List addedLines, List removedLines) {
+ if (addedLines.size() != removedLines.size()) {
+ return false; // Different number of lines changed, definitely not whitespace only
+ }
+
+ for (int i = 0; i < addedLines.size(); i++) {
+ String added = addedLines.get(i).trim();
+ String removed = removedLines.get(i).trim();
+ if (!added.equals(removed)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* Extracts the file path from the raw git-diff for a specified diff block
*
@@ -219,6 +250,10 @@ private static class ParserState {
private int currentPreviousLineCount;
+ private final List addedLines;
+
+ private final List removedLines;
+
public ParserState() {
entries = new ArrayList<>();
currentEntry = new ProgrammingExerciseGitDiffEntry();
@@ -226,6 +261,8 @@ public ParserState() {
lastLineRemoveOperation = false;
currentLineCount = 0;
currentPreviousLineCount = 0;
+ addedLines = new ArrayList<>();
+ removedLines = new ArrayList<>();
}
}
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java
new file mode 100644
index 000000000000..d882d82d3c3d
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java
@@ -0,0 +1,41 @@
+package de.tum.cit.aet.artemis.programming.web.localvc.ssh;
+
+import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC;
+
+import java.util.Map;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent;
+import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshFingerprintsProviderService;
+
+/**
+ * REST controller for managing.
+ */
+@Profile(PROFILE_LOCALVC)
+@RestController
+@RequestMapping("api/")
+public class SshFingerprintsProviderResource {
+
+ SshFingerprintsProviderService sshFingerprintsProviderService;
+
+ public SshFingerprintsProviderResource(SshFingerprintsProviderService sshFingerprintsProviderService) {
+ this.sshFingerprintsProviderService = sshFingerprintsProviderService;
+ }
+
+ /**
+ * GET /ssh-fingerprints
+ *
+ * @return the SSH fingerprints for the keys a user uses
+ */
+ @GetMapping(value = "ssh-fingerprints", produces = MediaType.APPLICATION_JSON_VALUE)
+ @EnforceAtLeastStudent
+ public ResponseEntity