diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..3b41682a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/.github/actions/create-branches/action.yml b/.github/actions/create-branches/action.yml
index 8b735fe6..6fbbafa1 100644
--- a/.github/actions/create-branches/action.yml
+++ b/.github/actions/create-branches/action.yml
@@ -11,23 +11,23 @@ runs:
- name: Create branches
shell: bash
run: |
- git checkout ${{ env.BRANCH_NAME }}
-
- if [[ ${{ env.BRANCH_NAME }} == "master" ]]; then
- git checkout -b "patch/${{ env.PATCH_VERSION }}"
- ./.github/scripts/update-project-version.sh "patch/${{ env.PATCH_VERSION }}" "${{ env.PATCH_VERSION }}-patch" ${{ inputs.github-token }} ${{ github.repository }}
-
- git checkout -b "release/${{ env.VERSION }}" "patch/${{ env.PATCH_VERSION }}"
-
- ./.github/scripts/update-project-version.sh "release/${{ env.VERSION }}" "${{ env.VERSION }}-release" ${{ inputs.github-token }} ${{ github.repository }}
- ./.github/scripts/update-project-version.sh "patch/${{ env.PATCH_VERSION }}" "${{ env.NEXT_PATCH_VERSION }}-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
- ./.github/scripts/update-project-version.sh ${{ env.BRANCH_NAME }} "${{ env.NEXT_MINOR_VERSION }}-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
+ git checkout "$BRANCH_NAME"
+
+ if [[ "$BRANCH_NAME" == "master" ]]; then
+ git checkout -b "patch/$PATCH_VERSION"
+ ./.github/scripts/update-project-version.sh "patch/$PATCH_VERSION" "$PATCH_VERSION-patch" ${{ inputs.github-token }} ${{ github.repository }}
+
+ git checkout -b "release/$VERSION" "patch/$PATCH_VERSION"
+
+ ./.github/scripts/update-project-version.sh "release/$VERSION" "$VERSION-release" ${{ inputs.github-token }} ${{ github.repository }}
+ ./.github/scripts/update-project-version.sh "patch/$PATCH_VERSION" "$NEXT_PATCH_VERSION-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
+ ./.github/scripts/update-project-version.sh "$BRANCH_NAME" "$NEXT_MINOR_VERSION-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
else
- git checkout -b "release/${{ env.VERSION }}"
-
- ./.github/scripts/update-project-version.sh "release/${{ env.VERSION }}" "${{ env.VERSION }}-release" ${{ inputs.github-token }} ${{ github.repository }}
- ./.github/scripts/update-project-version.sh ${{ env.BRANCH_NAME }} "${{ env.NEXT_PATCH_VERSION }}-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
+ git checkout -b "release/$VERSION"
+
+ ./.github/scripts/update-project-version.sh "release/$VERSION" "$VERSION-release" ${{ inputs.github-token }} ${{ github.repository }}
+ ./.github/scripts/update-project-version.sh "$BRANCH_NAME" "$NEXT_PATCH_VERSION-snapshot" ${{ inputs.github-token }} ${{ github.repository }}
fi
- git tag -f ${{ github.event.release.tag_name }} "release/${{ env.VERSION }}"
+ git tag -f ${{ github.event.release.tag_name }} "release/$VERSION"
git push --tags -f
diff --git a/.github/actions/execute-gradle/action.yml b/.github/actions/execute-gradle/action.yml
deleted file mode 100644
index 7c342b46..00000000
--- a/.github/actions/execute-gradle/action.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: Execute Gradle
-description: Executes Gradle commands
-inputs:
- distribution:
- description: 'The distribution of Java to use'
- required: false
- default: 'adopt-openj9'
- java-version:
- description: 'The version of Java to use'
- required: false
- default: '17'
- architecture:
- description: 'The architecture of the Java version'
- required: false
- default: 'x64'
- gradle-commands:
- description: 'The Gradle commands to run'
- required: false
- default: 'build test'
-
-runs:
- using: 'composite'
- steps:
- - name: Setup Java
- uses: actions/setup-java@v4
- with:
- distribution: '${{ inputs.distribution }}'
- java-version: '${{ inputs.java-version }}'
- architecture: '${{ inputs.architecture }}'
-
- - name: Cache Gradle packages
- uses: actions/cache@v3
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle
-
- - name: Execute Gradle commands
- shell: bash
- run: ./gradlew ${{ inputs.gradle-commands }}
-
- - name: Cleanup Gradle Cache
- shell: bash
- run: |
- rm -f ~/.gradle/caches/modules-2/modules-2.lock
- rm -f ~/.gradle/caches/modules-2/gc.properties
diff --git a/.github/actions/execute-maven/action.yml b/.github/actions/execute-maven/action.yml
new file mode 100644
index 00000000..f8e0159f
--- /dev/null
+++ b/.github/actions/execute-maven/action.yml
@@ -0,0 +1,41 @@
+name: Execute Maven
+description: Executes Maven commands
+inputs:
+ distribution:
+ description: 'The distribution of Java to use'
+ required: false
+ default: 'semeru'
+ java-version:
+ description: 'The version of Java to use'
+ required: false
+ default: '17'
+ architecture:
+ description: 'The architecture of the Java version'
+ required: false
+ default: 'x64'
+ maven-commands:
+ description: 'The Maven commands to run'
+ required: false
+ default: 'verify'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: '${{ inputs.distribution }}'
+ java-version: '${{ inputs.java-version }}'
+ architecture: '${{ inputs.architecture }}'
+
+ - name: Cache Maven packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven
+
+ - name: Execute Maven commands
+ shell: bash
+ run: ./mvnw --no-transfer-progress ${{ inputs.maven-commands }}
diff --git a/.github/actions/extract-branch/action.yml b/.github/actions/extract-branch/action.yml
index d1343e27..bbeb9e92 100644
--- a/.github/actions/extract-branch/action.yml
+++ b/.github/actions/extract-branch/action.yml
@@ -7,10 +7,10 @@ runs:
- name: Extract branch name
shell: bash
run: |
- if [[ ${{ env.VERSION }} =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
+ if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
BRANCH_NAME=master
else
- BRANCH_NAME="patch/${{ env.PATCH_VERSION }}"
+ BRANCH_NAME="patch/$PATCH_VERSION"
fi
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
diff --git a/.github/actions/extract-version/action.yml b/.github/actions/extract-version/action.yml
index e573dd0b..0480341b 100644
--- a/.github/actions/extract-version/action.yml
+++ b/.github/actions/extract-version/action.yml
@@ -1,5 +1,5 @@
-name: Configure Git
-description: Adds the necessary configurations to Git
+name: Extract Version
+description: Extracts version information from the release tag
runs:
using: 'composite'
diff --git a/.github/actions/publish-package/action.yml b/.github/actions/publish-package/action.yml
index 540a043d..a321716e 100644
--- a/.github/actions/publish-package/action.yml
+++ b/.github/actions/publish-package/action.yml
@@ -36,7 +36,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ inputs.registry }}
- uses: docker/login-action@v3.0.0
+ uses: docker/login-action@v3
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
@@ -63,17 +63,18 @@ runs:
# tags:
# sep-tags: ','
- - name: Build bootJar
- uses: ./.github/actions/execute-gradle
+ - name: Build package
+ uses: ./.github/actions/execute-maven
with:
- gradle-commands: ':bootJar'
+ maven-commands: 'clean package -DskipTests'
- name: Build and push Docker image
id: build-and-push
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v6
with:
context: .
push: 'true'
+ build-args: VERSION=${{ env.VERSION }}-release
tags: |
${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.VERSION }}
${{ inputs.registry }}/${{ inputs.image-name }}:${{ env.LATEST_TAG }}
diff --git a/.github/actions/update-versions/action.yml b/.github/actions/update-versions/action.yml
index 97b42c4f..5f552fb1 100644
--- a/.github/actions/update-versions/action.yml
+++ b/.github/actions/update-versions/action.yml
@@ -26,16 +26,16 @@ runs:
- name: Replace version
if: steps.get-files.outputs.exit_code == 0
shell: bash
- run: find ./src -type f -exec sed -i "s/%CURRENT_VERSION%/${{ env.VERSION }}/g" {} +
+ run: find ./src -type f -exec sed -i "s/%CURRENT_VERSION%/$VERSION/g" {} +
- name: Commit and push changes
if: steps.get-files.outputs.exit_code == 0
shell: bash
run: |
- git checkout ${{ env.BRANCH_NAME }}
+ git checkout "$BRANCH_NAME"
git add ./src
- git commit -m "release: Update version to ${{ env.VERSION }}"
- git push https://x-access-token:${{ inputs.github-token }}@github.com/${{ github.repository }}.git ${{ env.BRANCH_NAME }}
+ git commit -m "release: Update version to $VERSION"
+ git push https://x-access-token:${{ inputs.github-token }}@github.com/${{ github.repository }}.git "$BRANCH_NAME"
- name: Log change files
shell: bash
diff --git a/.github/mergify.yml b/.github/mergify.yml
deleted file mode 100644
index 1c97d053..00000000
--- a/.github/mergify.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-pull_request_rules:
-# - name: Automatic merge to master on successful build if it's not WIP
-# conditions:
-# - base=master
-# - check-success=build
-# - check-success=test
-# - -title~=(?i)wip
-# actions:
-# merge:
-# method: merge
-# assign:
-# add_users:
-# - 5h15h4k1n9
- - name: Add `kotlin` label, if files associated with Kotlin were changed
- conditions:
- - or:
- - files~=\.kt$
- - files~=\.kts$
- actions:
- label:
- add:
- - kotlin
- - name: Add `Dockerfile` label, if Dockerfile was changed
- conditions:
- - files~=\Dockerfile$
- actions:
- label:
- add:
- - Dockerfile
diff --git a/.github/scripts/update-project-version.sh b/.github/scripts/update-project-version.sh
index b995e287..5e398096 100755
--- a/.github/scripts/update-project-version.sh
+++ b/.github/scripts/update-project-version.sh
@@ -7,9 +7,8 @@ echo -e "\n\e[32mUpdating project version to $2 in branch $1\e[0m\n"
git checkout "$1"
-sed -i "s/version = \"[0-9.]*-[a-z]*\"/version = \"$2\"/" build.gradle.kts
-sed -i "s/ARG VERSION=[0-9.]*-[a-z]*/ARG VERSION=$2/" Dockerfile
+./mvnw --no-transfer-progress versions:set -DnewVersion="$2" -DgenerateBackupPoms=false
-git add build.gradle.kts Dockerfile
+git add pom.xml
git commit -m "release: Update project version to $2"
-git push https://x-access-token:"$3"@github.com/"$4".git "$1"
\ No newline at end of file
+git push https://x-access-token:"$3"@github.com/"$4".git "$1"
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index d4037770..408250a3 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -15,9 +15,9 @@ jobs:
submodules: recursive
- name: Build
- uses: ./.github/actions/execute-gradle
+ uses: ./.github/actions/execute-maven
with:
- gradle-commands: ':clean :assemble'
+ maven-commands: 'clean package -DskipTests'
test:
runs-on: ubuntu-latest
@@ -28,17 +28,17 @@ jobs:
submodules: recursive
- name: Test
- uses: ./.github/actions/execute-gradle
+ uses: ./.github/actions/execute-maven
with:
- gradle-commands: ':clean :test --stacktrace'
+ maven-commands: 'clean test'
- name: JaCoCo Coverage Report
env:
- report_path: build/jacoco/report.csv
+ report_path: target/site/jacoco/jacoco.csv
run: |
awk -F"," '{ instructions += $4 + $5; covered += $5; branches += $6 + $7; branches_covered +=$7 } END { print "Instructions covered:", covered"/"instructions, "--", 100*covered/instructions"%"; print "Branches covered:", branches_covered"/"branches, "--", 100*branches_covered/branches"%" }' $report_path
- uses: actions/upload-artifact@v4
with:
name: test-and-coverage-reports
- path: build/reports
\ No newline at end of file
+ path: target/surefire-reports
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 91d95c9d..0afa1696 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,9 +19,9 @@ jobs:
submodules: recursive
- name: Build
- uses: ./.github/actions/execute-gradle
+ uses: ./.github/actions/execute-maven
with:
- gradle-commands: 'build'
+ maven-commands: 'clean package -DskipTests'
- name: Rollback release
if: ${{ failure() || cancelled() }}
@@ -38,9 +38,9 @@ jobs:
submodules: recursive
- name: Test
- uses: ./.github/actions/execute-gradle
+ uses: ./.github/actions/execute-maven
with:
- gradle-commands: 'test'
+ maven-commands: 'test'
- name: Rollback release
if: ${{ failure() || cancelled() }}
diff --git a/.gitignore b/.gitignore
index 5b1c6d81..8c9a032e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -181,4 +181,17 @@ gradle-app.setting
*.gz
# Demo application data
-demo/
\ No newline at end of file
+demo/
+
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+*.iml
+
+data/**
+
+amplicode.xml
+.env
+/.vscode/settings.json
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 26904466..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "protos"]
- path = libs/trik-testsys/protos
- url = https://github.com/trik-testsys/protos.git
diff --git a/.idea/runConfigurations/WebApp.xml b/.idea/runConfigurations/WebApp.xml
deleted file mode 100644
index a219eb24..00000000
--- a/.idea/runConfigurations/WebApp.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 00000000..12fbe1e9
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/Dockerfile b/Dockerfile
index f0f27cba..fac5aa4e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,16 +1,16 @@
-FROM openjdk:17-alpine
+FROM eclipse-temurin:17
MAINTAINER Roman Shishkin
#Setting directories args
ARG APP_DIR=web-app
-ARG VERSION=2.7.0-snapshot
+ARG VERSION
+ARG JAR_FILE=target/web-app-${VERSION}.jar
+ARG APP=app.jar
#Copying application
WORKDIR /$APP_DIR
-ARG JAR_FILE=build/libs/web-app-$VERSION.jar
-ARG APP=app.jar
COPY $JAR_FILE $APP
#Running application
EXPOSE 8888
-ENTRYPOINT java $JAVA_OPTIONS -jar app.jar
\ No newline at end of file
+ENTRYPOINT java $JAVA_OPTIONS -jar app.jar
diff --git a/build.gradle.kts b/build.gradle.kts
deleted file mode 100644
index 5b853b95..00000000
--- a/build.gradle.kts
+++ /dev/null
@@ -1,152 +0,0 @@
-import com.google.protobuf.gradle.id
-
-plugins {
- jacoco
- id("org.springframework.boot") version "2.7.0"
- id("io.spring.dependency-management") version "1.0.11.RELEASE"
- id("com.google.protobuf") version "0.9.4"
- kotlin("jvm") version "1.8.10"
- kotlin("plugin.spring") version "1.6.21"
- kotlin("plugin.jpa") version "1.6.21"
-}
-
-group = "TestSys"
-version = "2.7.0-snapshot"
-java.sourceCompatibility = JavaVersion.VERSION_17
-
-repositories {
- mavenCentral()
-}
-
-sourceSets {
- main {
- proto {
- srcDir("libs/trik-testsys/protos")
- }
- }
-}
-
-val protobufVersion = "4.27.3"
-val grpcVersion = "1.66.0"
-val grpcktVersion = "1.4.1"
-
-dependencies {
- implementation("org.jetbrains.kotlin:kotlin-reflect")
- implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
-
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC.2")
- implementation("com.google.protobuf:protobuf-kotlin:${protobufVersion}")
- implementation("io.grpc:grpc-okhttp:${grpcVersion}")
- api("io.grpc:grpc-protobuf:${grpcVersion}")
- api("com.google.protobuf:protobuf-java-util:${protobufVersion}")
- api("com.google.protobuf:protobuf-kotlin:${protobufVersion}")
- api("io.grpc:grpc-kotlin-stub:${grpcktVersion}")
- api("io.grpc:grpc-stub:${grpcVersion}")
-
- implementation("org.springframework.boot:spring-boot-starter")
- implementation("org.springframework.boot:spring-boot-starter-web")
- implementation("org.springframework.boot:spring-boot-starter-security")
- implementation("org.springframework.boot:spring-boot-starter-data-jpa")
- implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
- implementation("org.springframework.boot:spring-boot-devtools")
- implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
-
- implementation("org.thymeleaf.extras:thymeleaf-extras-java8time")
-
- implementation("org.springframework.session:spring-session-jdbc")
-
- implementation("org.zeroturnaround:zt-zip:1.17")
- implementation("org.yaml:snakeyaml:1.33")
- implementation("com.github.ua-parser:uap-java:1.5.4")
-
- implementation("io.springfox:springfox-swagger2:3.0.0")
- implementation("io.springfox:springfox-swagger-ui:3.0.0")
- implementation("io.springfox:springfox-boot-starter:3.0.0")
-
-// implementation("org.springframework.boot:spring-boot-starter-actuator")
-// implementation("io.micrometer:micrometer-core:1.6.6")
-// implementation("io.micrometer:micrometer-registry-prometheus:1.6.6")
-
- runtimeOnly("mysql:mysql-connector-java")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
-
- implementation("com.h2database:h2")
- testImplementation("org.springframework.security:spring-security-test")
- testImplementation("org.springframework.boot:spring-boot-starter-test")
- testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
- testImplementation("org.mockito:mockito-core:2.1.0")
- testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
- testImplementation(platform("org.junit:junit-bom:5.9.0"))
- testImplementation(kotlin("test"))
-}
-
-protobuf {
- protoc {
- artifact = "com.google.protobuf:protoc:${protobufVersion}"
- }
- plugins {
- id("grpc") {
- artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
- }
- id("grpckt") {
- artifact = "io.grpc:protoc-gen-grpc-kotlin:${grpcktVersion}:jdk8@jar"
- }
- }
- generateProtoTasks {
- all().forEach {
- it.plugins {
- id("grpc")
- id("grpckt")
- }
- it.builtins {
- id("kotlin")
- }
- }
- }
-}
-
-tasks.withType {
- kotlinOptions {
- freeCompilerArgs = listOf("-Xjsr305=strict")
- jvmTarget = "17"
- }
-}
-
-val jacocoExclude = listOf(
- "**/configuration/**",
- "**/entities/**",
- "**/enums/**",
- "**/repositories/**",
- "**/services/**",
- "**/*Application*"
-)
-
-tasks.test {
- finalizedBy(tasks.jacocoTestReport)
- useJUnitPlatform()
- maxHeapSize = "2G"
- extensions.configure {
- excludes = jacocoExclude
- }
- // Uncomment to run concurrent tests on your own PC
- /*jvmArgs(
- "-Xmx4096m",
- "--add-opens", "java.base/jdk.internal.misc=ALL-UNNAMED",
- "--add-exports", "java.base/jdk.internal.util=ALL-UNNAMED"
- )*/
-}
-
-tasks.jacocoTestReport {
- dependsOn(tasks.test)
- reports {
- xml.required.set(false)
- csv.required.set(true)
- csv.outputLocation.set(file("${buildDir}/jacoco/report.csv"))
- html.outputLocation.set(file("${buildDir}/reports/jacoco"))
- }
- classDirectories.setFrom(classDirectories.files.map {
- fileTree(it).matching {
- exclude(jacocoExclude)
- }
- })
-}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index cb0e38a4..954a7a4d 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,15 +19,18 @@ services:
volumes:
- ./data/mysql:/var/lib/mysql
+# ports:
+# - "3306:3306"
+# expose:
+# - 3306
+
web-app:
- image: ghcr.io/trik-testsys/web-app:2.2.0
+ image: 5h15h4k1n9/web-app:3.0.0-beta
restart: always
environment:
- JAVA_OPTIONS=-server -Xmx4g -Xms1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/web-app/dumps/ -XX:+CrashOnOutOfMemoryError -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=6 -XX:ConcGCThreads=3
- - TRIK_STUDIO_VERSION=latest
- - GRADING_NODE_ADDRESSES=1.1.1.1:8080,2.2.2.2:8080,3.3.3.3:8080
- - CONTEXT_PATH=/demo2025
+ - CONTEXT_PATH=/
- MYSQL_HOST=mysql_host
- MYSQL_PORT=3306
- MYSQL_USERNAME=testsys
diff --git a/gradle.properties b/gradle.properties
deleted file mode 100644
index 35164e2e..00000000
--- a/gradle.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-kotlin.code.style=official
-org.gradle.parallel=true
-org.gradle.daemon=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index 41d9927a..00000000
Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index aa991fce..00000000
--- a/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
deleted file mode 100755
index 1b6c7873..00000000
--- a/gradlew
+++ /dev/null
@@ -1,234 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015-2021 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-
-##############################################################################
-#
-# Gradle start up script for POSIX generated by Gradle.
-#
-# Important for running:
-#
-# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-# noncompliant, but you have some other compliant shell such as ksh or
-# bash, then to run this script, type that shell name before the whole
-# command line, like:
-#
-# ksh Gradle
-#
-# Busybox and similar reduced shells will NOT work, because this script
-# requires all of these POSIX shell features:
-# * functions;
-# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-# * compound commands having a testable exit status, especially «case»;
-# * various built-in commands including «command», «set», and «ulimit».
-#
-# Important for patching:
-#
-# (2) This script targets any POSIX shell, so it avoids extensions provided
-# by Bash, Ksh, etc; in particular arrays are avoided.
-#
-# The "traditional" practice of packing multiple parameters into a
-# space-separated string is a well documented source of bugs and security
-# problems, so this is (mostly) avoided, by progressively accumulating
-# options in "$@", and eventually passing that to Java.
-#
-# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-# see the in-line comments for details.
-#
-# There are tweaks for specific operating systems such as AIX, CygWin,
-# Darwin, MinGW, and NonStop.
-#
-# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-# within the Gradle project.
-#
-# You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
- APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
- [ -h "$app_path" ]
-do
- ls=$( ls -ld "$app_path" )
- link=${ls#*' -> '}
- case $link in #(
- /*) app_path=$link ;; #(
- *) app_path=$APP_HOME$link ;;
- esac
-done
-
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-APP_NAME="Gradle"
-APP_BASE_NAME=${0##*/}
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
- echo "$*"
-} >&2
-
-die () {
- echo
- echo "$*"
- echo
- exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in #(
- CYGWIN* ) cygwin=true ;; #(
- Darwin* ) darwin=true ;; #(
- MSYS* | MINGW* ) msys=true ;; #(
- NONSTOP* ) nonstop=true ;;
-esac
-
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
- if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD=$JAVA_HOME/jre/sh/java
- else
- JAVACMD=$JAVA_HOME/bin/java
- fi
- if [ ! -x "$JAVACMD" ] ; then
- die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-else
- JAVACMD=java
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
- case $MAX_FD in #(
- max*)
- MAX_FD=$( ulimit -H -n ) ||
- warn "Could not query maximum file descriptor limit"
- esac
- case $MAX_FD in #(
- '' | soft) :;; #(
- *)
- ulimit -n "$MAX_FD" ||
- warn "Could not set maximum file descriptor limit to $MAX_FD"
- esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-# * args from the command line
-# * the main class name
-# * -classpath
-# * -D...appname settings
-# * --module-path (only if needed)
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
- APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
-
- JAVACMD=$( cygpath --unix "$JAVACMD" )
-
- # Now convert the arguments - kludge to limit ourselves to /bin/sh
- for arg do
- if
- case $arg in #(
- -*) false ;; # don't mess with options #(
- /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
- [ -e "$t" ] ;; #(
- *) false ;;
- esac
- then
- arg=$( cygpath --path --ignore --mixed "$arg" )
- fi
- # Roll the args list around exactly as many times as the number of
- # args, so each arg winds up back in the position where it started, but
- # possibly modified.
- #
- # NB: a `for` loop captures its iteration list before it begins, so
- # changing the positional parameters here affects neither the number of
- # iterations, nor the values presented in `arg`.
- shift # remove old arg
- set -- "$@" "$arg" # push replacement arg
- done
-fi
-
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
-
-set -- \
- "-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
- "$@"
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-# set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
- printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
- xargs -n1 |
- sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
- tr '\n' ' '
- )" '"$@"'
-
-exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
deleted file mode 100644
index 107acd32..00000000
--- a/gradlew.bat
+++ /dev/null
@@ -1,89 +0,0 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
diff --git a/libs/trik-testsys/protos b/libs/trik-testsys/protos
deleted file mode 160000
index 66c2e085..00000000
--- a/libs/trik-testsys/protos
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 66c2e085e1686922c1d7fc1c05e0dee38ca1e896
diff --git a/mvnw b/mvnw
new file mode 100755
index 00000000..19529ddf
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,259 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 00000000..249bdf38
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 00000000..aac6e693
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,183 @@
+
+
+ 4.0.0
+
+ trik.testsys
+ spring-app-core
+ 1.3.0-SNAPSHOT
+
+
+
+ web-app
+ 3.0.0-SNAPSHOT
+
+ Web Application
+ TestSys Web Application
+
+
+ 1.1.0-SNAPSHOT
+ 1.56.1
+ 1.4.0
+
+
+
+
+ mavenCentral
+ https://repo1.maven.org/maven2/
+
+
+ testsys-releases
+ TestSys Releases
+ https://raw.githubusercontent.com/trik-testsys/maven-repo/releases/
+
+ true
+ daily
+ warn
+
+
+ false
+
+
+
+ testsys-snapshots
+ TestSys Snapshots
+ https://raw.githubusercontent.com/trik-testsys/maven-repo/snapshots/
+
+ false
+
+
+ true
+ always
+ warn
+
+
+
+
+
+
+ Apache License 2.0
+ https://github.com/trik-testsys/spring-app-core/blob/master/LICENSE
+
+
+
+
+
+ Roman Shishkin
+ romashkin.2001@yandex.ru
+ TestSys
+
+
+ Vyacheslav Buchin
+ slava.slava2003@mail.ru
+ TestSys
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ trik.testsys.grading
+ protos
+ ${protos.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+
+
+ com.sun.mail
+ javax.mail
+ 1.5.5
+
+
+ org.zeroturnaround
+ zt-zip
+ 1.17
+
+
+ io.grpc
+ grpc-okhttp
+ ${grpc.version}
+
+
+ io.grpc
+ grpc-core
+ ${grpc.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+
+ net.axay
+ simplekotlinmail-core
+ ${kotlin.mail.version}
+
+
+ net.axay
+ simplekotlinmail-client
+ ${kotlin.mail.version}
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ trik.testsys.webapp.backoffice.Application
+
+
+
+ kotlin-maven-plugin
+ org.jetbrains.kotlin
+ ${kotlin.version}
+
+
+ spring
+ jpa
+ all-open
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-noarg
+ ${kotlin.version}
+
+
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
deleted file mode 100644
index 325d9b9a..00000000
--- a/settings.gradle.kts
+++ /dev/null
@@ -1,3 +0,0 @@
-
-rootProject.name = "web-app"
-
diff --git a/src/main/kotlin/trik/testsys/Application.kt b/src/main/kotlin/trik/testsys/Application.kt
deleted file mode 100644
index 6395ec62..00000000
--- a/src/main/kotlin/trik/testsys/Application.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package trik.testsys
-
-import org.springframework.boot.SpringApplication
-import org.springframework.boot.autoconfigure.SpringBootApplication
-import org.springframework.boot.web.servlet.MultipartConfigFactory
-import org.springframework.context.annotation.Bean
-import org.springframework.context.annotation.Configuration
-import org.springframework.util.unit.DataSize
-import org.springframework.util.unit.DataUnit
-
-import javax.servlet.MultipartConfigElement
-
-@SpringBootApplication
-@Configuration
-class Application {
-
- @Bean
- fun multipartConfigElement(): MultipartConfigElement {
- val factory = MultipartConfigFactory()
- factory.setMaxFileSize(DataSize.of(4, DataUnit.MEGABYTES))
- factory.setMaxRequestSize(DataSize.of(4, DataUnit.MEGABYTES))
- return factory.createMultipartConfig()
- }
-
- companion object {
- @JvmStatic
- fun main(args: Array) {
- SpringApplication.run(Application::class.java, *args)
- }
- }
-}
diff --git a/src/main/kotlin/trik/testsys/core/controller/TrikRestController.kt b/src/main/kotlin/trik/testsys/core/controller/TrikRestController.kt
deleted file mode 100644
index f00b2660..00000000
--- a/src/main/kotlin/trik/testsys/core/controller/TrikRestController.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package trik.testsys.core.controller
-
-interface TrikRestController
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/AbstractEntity.kt b/src/main/kotlin/trik/testsys/core/entity/AbstractEntity.kt
deleted file mode 100644
index 4eccb69c..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/AbstractEntity.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-package trik.testsys.core.entity
-
-import java.time.LocalDateTime
-import java.time.ZoneId
-import java.util.TimeZone
-import javax.persistence.*
-
-/**
- * Simple abstract entity class. Describes basic entity behavior.
- *
- * @see Entity
- * @author Roman Shishkin
- * @since 2.0.0
- */
-@MappedSuperclass
-abstract class AbstractEntity : Entity {
-
- /**
- * Entity ID field.
- * May be null if an entity doesn't persist in a database.
- *
- * The default value is null.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- @Column(nullable = false, unique = true, updatable = false)
- private var id: Long? = null
-
- @Column(nullable = true, unique = false, updatable = false)
- override var creationDate: LocalDateTime? = null
-
- override fun setId(id: Long?) {
- this.id = id
- }
-
- override fun getId() = id
-
- override fun isNew() = id == null
-
- @Column(
- nullable = false, unique = false, updatable = true,
- length = ADDITIONAL_INFO_MAX_LENGTH
- )
- override var additionalInfo: String = ADDITIONAL_INFO_DEFAULT
-
- override fun toString() = when (isNew) {
- true -> "New entity of type ${javaClass.name} and with hashcode: ${hashCode().toString(16)}"
- false -> "Entity of type ${javaClass.name} and with ID: $id"
- }
-
- @PrePersist
- fun onCreate() {
- creationDate = LocalDateTime.now(DEFAULT_ZONE_ID)
- }
-
- companion object {
-
- /**
- * Default zone code for all entities in the system.
- *
- * @see DEFAULT_ZONE_ID
- * @see DEFAULT_TIME_ZONE
- * @author Roman Shishkin
- * @since 2.0.0
- */
- const val DEFAULT_ZONE_CODE = "UTC"
-
- /**
- * Default zone id for all entities in the system.
- *
- * @see DEFAULT_ZONE_CODE
- * @see DEFAULT_TIME_ZONE
- * @author Roman Shishkin
- * @since 2.0.0
- */
- val DEFAULT_ZONE_ID: ZoneId = ZoneId.of(DEFAULT_ZONE_CODE)
-
- /**
- * Default time zone for all entities in the system.
- *
- * @see DEFAULT_ZONE_CODE
- * @see DEFAULT_ZONE_ID
- * @see TimeZone
- * @since 2.0.0
- */
- val DEFAULT_TIME_ZONE: TimeZone = TimeZone.getTimeZone(DEFAULT_ZONE_ID)
-
- /**
- * Max length for [additionalInfo] field.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- private const val ADDITIONAL_INFO_MAX_LENGTH = 1000
-
- /**
- * Default value for [additionalInfo] field.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- private const val ADDITIONAL_INFO_DEFAULT = ""
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/Entity.kt b/src/main/kotlin/trik/testsys/core/entity/Entity.kt
deleted file mode 100644
index ba90dc48..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/Entity.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package trik.testsys.core.entity
-
-import org.springframework.data.domain.Persistable
-import java.time.LocalDateTime
-
-/**
- * Simple interface for entities. It inherits [getId] (val property id) and [isNew] (val property isNew) from [Persistable],
- * and extends it with [creationDate] field.
- *
- * @see Persistable
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface Entity : Persistable {
-
- /**
- * Property which contains entity id.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
- fun setId(id: Long?)
-
- /**
- * Property which says date and time of entity creation.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- val creationDate: LocalDateTime?
-
- /**
- * Property which contains any entity additional info.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- var additionalInfo: String
-
- companion object {
-
- /**
- * Prefix for all tables in database.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- const val TABLE_PREFIX = "TRIK"
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/named/AbstractNamedEntity.kt b/src/main/kotlin/trik/testsys/core/entity/named/AbstractNamedEntity.kt
deleted file mode 100644
index a518a974..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/named/AbstractNamedEntity.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package trik.testsys.core.entity.named
-
-import trik.testsys.core.entity.AbstractEntity
-import javax.persistence.Column
-import javax.persistence.MappedSuperclass
-
-/**
- * Simple abstract named entity class which extends [AbstractEntity] and implements [NamedEntity].
- *
- * @see NamedEntity
- * @see AbstractEntity
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-@MappedSuperclass
-abstract class AbstractNamedEntity(
- @Column(
- nullable = false, unique = false, updatable = true,
- length = NAME_MAX_LEN
- ) override var name: String
-) : NamedEntity, AbstractEntity() {
-
- companion object {
-
- /**
- * Maximum length of the [name] property.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- private const val NAME_MAX_LEN = 127
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/named/NamedEntity.kt b/src/main/kotlin/trik/testsys/core/entity/named/NamedEntity.kt
deleted file mode 100644
index 02afd0e6..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/named/NamedEntity.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package trik.testsys.core.entity.named
-
-import trik.testsys.core.entity.Entity
-
-/**
- * Interface for named entities. Extends [Entity] with [name] property.
- *
- * @see Entity
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-interface NamedEntity : Entity {
-
- /**
- * Property which contains name string. Must be initialized.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- var name: String
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/user/AbstractUser.kt b/src/main/kotlin/trik/testsys/core/entity/user/AbstractUser.kt
deleted file mode 100644
index ee57b099..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/user/AbstractUser.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package trik.testsys.core.entity.user
-
-import trik.testsys.core.entity.named.AbstractNamedEntity
-import java.time.LocalDateTime
-import javax.persistence.*
-
-/**
- * Simple abstract user entity class which extends [AbstractNamedEntity] and implements [UserEntity].
- * Describes basic behavior.
- *
- * @see UserEntity
- * @see AbstractNamedEntity
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-@MappedSuperclass
-abstract class AbstractUser(
- name: String,
-
- @Column(
- nullable = false, unique = true, updatable = false,
- length = ACCESS_TOKEN_MAX_LEN
- ) override var accessToken: AccessToken
-) : UserEntity, AbstractNamedEntity(name) {
-
- @Column(nullable = true, unique = false, updatable = true)
- final override var lastLoginDate: LocalDateTime? = null
-
- override fun updateLastLoginDate() {
- lastLoginDate = LocalDateTime.now(DEFAULT_ZONE_ID)
- this.name
- }
-
- companion object {
-
- private const val ACCESS_TOKEN_MAX_LEN = 255
-
- /**
- * Anonymous user entity for system usage.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- object System : AbstractUser(
- name = "System User",
- accessToken = "system-user-non-accessible-token"
- )
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/entity/user/UserEntity.kt b/src/main/kotlin/trik/testsys/core/entity/user/UserEntity.kt
deleted file mode 100644
index b6e10374..00000000
--- a/src/main/kotlin/trik/testsys/core/entity/user/UserEntity.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package trik.testsys.core.entity.user
-
-import trik.testsys.core.entity.Entity
-import trik.testsys.core.entity.named.NamedEntity
-import java.time.LocalDateTime
-
-typealias AccessToken = String
-
-/**
- * Simple interface for user entities. Extends [Entity] with [accessToken] properties.
- * In fact every entity that implements this can be identified by [accessToken].
- *
- * @see Entity
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface UserEntity : NamedEntity {
-
- /**
- * Property which contains unique access token.
- * It is used to identify user in a system.
- * Must be initialized.
- * In fact should be an entity identifier.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- var accessToken: AccessToken
-
- /**
- * Property which contains date time of last user login.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- var lastLoginDate: LocalDateTime?
-
- /**
- * Update [lastLoginDate] field. Should be used when a user is logging in.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun updateLastLoginDate()
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/repository/EntityRepository.kt b/src/main/kotlin/trik/testsys/core/repository/EntityRepository.kt
deleted file mode 100644
index 4d4a3fe8..00000000
--- a/src/main/kotlin/trik/testsys/core/repository/EntityRepository.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package trik.testsys.core.repository
-
-import org.springframework.data.jpa.repository.JpaRepository
-import org.springframework.data.repository.NoRepositoryBean
-import trik.testsys.core.entity.Entity
-import java.time.LocalDateTime
-
-/**
- * Simple repository interface for entities, extends [JpaRepository].
- * Contains methods that works with [Entity.creationDate]:
- *
- * 1. [findByCreationDateAfter]
- * 2. [findByCreationDateBefore]
- * 3. [findByCreationDateBetween]
- *
- * @see JpaRepository
- * @param E entity class, implements [Entity]
- * @author Roman Shishkin
- * @since 2.0.0
- */
-@NoRepositoryBean
-interface EntityRepository : JpaRepository {
-
- /**
- * Finds entities with [Entity.creationDate] greater than [creationDate].
- *
- * @param creationDate date by which entities will be found
- * @return [Collection] of entities with [Entity.creationDate] greater than [creationDate]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateAfter(creationDate: LocalDateTime): List
-
- /**
- * Finds entities with [Entity.creationDate] less than [creationDate].
- *
- * @param creationDate date by which entities will be found
- * @return [Collection] of entities with [Entity.creationDate] less than [creationDate]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateBefore(creationDate: LocalDateTime): List
-
- /**
- * Finds entities with [Entity.creationDate] greater than [creationDateFrom] and less than [creationDateTo].
- *
- * @param creationDateFrom bottom edge for finding entities
- * @param creationDateTo top edge for finding entities
- * @return [Collection] of entities with [Entity.creationDate] between [creationDateFrom] and [creationDateTo]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateBetween(creationDateFrom: LocalDateTime, creationDateTo: LocalDateTime): List
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/repository/named/NamedEntityRepository.kt b/src/main/kotlin/trik/testsys/core/repository/named/NamedEntityRepository.kt
deleted file mode 100644
index 2b750715..00000000
--- a/src/main/kotlin/trik/testsys/core/repository/named/NamedEntityRepository.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package trik.testsys.core.repository.named
-
-import org.springframework.data.repository.NoRepositoryBean
-import trik.testsys.core.entity.named.NamedEntity
-import trik.testsys.core.repository.EntityRepository
-
-/**
- * Repository interface for named entities. Extends [EntityRepository] with methods:
- *
- * 1. [findByName]
- *
- * @see NamedEntity
- * @see EntityRepository
- * @param E entity class, implements [NamedEntity]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-@NoRepositoryBean
-interface NamedEntityRepository : EntityRepository {
-
- /**
- * Finds all entities by [NamedEntity.name].
- *
- * @param name name by which entities will be found
- * @return [Collection] with all found entities with [NamedEntity.name] equals to [name]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByName(name: String): Collection
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/repository/user/UserRepository.kt b/src/main/kotlin/trik/testsys/core/repository/user/UserRepository.kt
deleted file mode 100644
index 93aef204..00000000
--- a/src/main/kotlin/trik/testsys/core/repository/user/UserRepository.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package trik.testsys.core.repository.user
-
-import org.springframework.data.repository.NoRepositoryBean
-import trik.testsys.core.entity.user.AccessToken
-import trik.testsys.core.entity.user.UserEntity
-import trik.testsys.core.repository.named.NamedEntityRepository
-
-/**
- * Repository interface for [UserEntity] typed entities, extends [NamedEntityRepository].
- * Contains methods that work with [UserEntity.accessToken].
- *
- * 1. [findByAccessToken]
- * 2. [findAllByAccessTokenIn]
- * 3. [findByNameAndAccessToken]
- *
- * @param E user entity class, implements [UserEntity]
- *
- * @see NamedEntityRepository
- * @see UserEntity
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-@NoRepositoryBean
-interface UserRepository : NamedEntityRepository {
-
- /**
- * Finds entity by [UserEntity.accessToken].
- *
- * @param accessToken access token by which entity will be found.
- * @return entity with [UserEntity.accessToken] equals to [accessToken]. If nothing was found - `null`
- *
- * @see [UserEntity.accessToken]
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByAccessToken(accessToken: AccessToken): E?
-
- /**
- * Find all entities by [UserEntity.accessToken].
- *
- * @param accessTokens access tokens by which entities will be found
- * @return [Collection] with all found entities which [UserEntity.accessToken] contained in [accessTokens]
- *
- * @see [UserEntity.accessToken]
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findAllByAccessTokenIn(accessTokens: Collection): List
-
- /**
- * Finds entity by [UserEntity.name] and [UserEntity.accessToken].
- *
- * @param name name by which entity will be found
- * @param accessToken access token by which entity will be found
- * @return entity with [UserEntity.name] equals to [name] and [UserEntity.accessToken] equals to [accessToken]. If nothing was found - `null`
- *
- * @see [UserEntity.name]
- * @see [UserEntity.accessToken]
- * @since 2.0.0
- */
- fun findByNameAndAccessToken(name: String, accessToken: AccessToken): E?
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/AbstractService.kt b/src/main/kotlin/trik/testsys/core/service/AbstractService.kt
deleted file mode 100644
index 90a5b3ae..00000000
--- a/src/main/kotlin/trik/testsys/core/service/AbstractService.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-package trik.testsys.core.service
-
-import org.springframework.beans.factory.annotation.Autowired
-import trik.testsys.core.entity.AbstractEntity
-import trik.testsys.core.repository.EntityRepository
-import java.time.LocalDateTime
-
-/**
- * Abstract service class that implements [EntityService] basic CRUD operations.
- *
- * @param E Entity type. Extends [AbstractEntity].
- * @param R Repository type. Extends [EntityRepository].
- *
- * @see EntityService
- * @see AbstractEntity
- * @see EntityRepository
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-abstract class AbstractService> : EntityService {
-
- /**
- * The repository associated with the service.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- @Autowired
- protected lateinit var repository: R
-
- //region Save methods
-
- override fun save(entity: E) = repository.save(entity)
-
- override fun saveAll(entities: Iterable): List = repository.saveAll(entities)
-
- //endregion
-
- //region Find methods
-
- override fun find(id: Long?) = id?.let {
- val entity = repository.findById(id)
- entity.orElse(null)
- }
-
- override fun findAll(ids: Iterable): List = repository.findAllById(ids)
-
- override fun findAll(): List {
- val entities = repository.findAll()
- return entities
- }
-
- override fun findByCreationDateAfter(creationDate: LocalDateTime): List {
- val entities = repository.findByCreationDateAfter(creationDate)
- return entities
- }
-
- override fun findByCreationDateBefore(creationDate: LocalDateTime): List {
- val entities = repository.findByCreationDateBefore(creationDate)
- return entities
- }
-
- override fun findByCreationDateBetween(
- creationDateFrom: LocalDateTime,
- creationDateTo: LocalDateTime
- ): List {
- val entities = repository.findByCreationDateBetween(creationDateFrom, creationDateTo)
- return entities
- }
-
- //endregion
-
- //region Count methods
-
- override fun count(): Long {
- val count = repository.count()
- return count
- }
-
- //endregion
-
- //region Exist methods
-
- override fun exists(id: Long): Boolean {
- val isExist = repository.existsById(id)
- return isExist
- }
-
- //endregion
-
- //region Delete methods
-
- override fun delete(id: Long): Boolean {
- val isDeleted = tryDeletion { repository.deleteById(id) }
- return isDeleted
- }
-
- override fun delete(entity: E): Boolean {
- val isDeleted = tryDeletion { repository.delete(entity) }
- return isDeleted
- }
-
- override fun deleteAll(entities: Iterable): Boolean {
- val isDeleted = tryDeletion { repository.deleteAll(entities) }
- return isDeleted
- }
-
- override fun deleteAllById(ids: Iterable): Boolean {
- val isDeleted = tryDeletion { repository.deleteAllById(ids) }
- return isDeleted
- }
-
- override fun deleteAll(): Boolean {
- val isDeleted = tryDeletion { repository.deleteAll() }
- return isDeleted
- }
-
- //endregion
-
- companion object {
-
- /**
- * Tries to delete entity or entities using [block] function and returns result.
- *
- * @param block Deletion block.
- * @return `true` if deletion was successful and there were no exceptions, `false` otherwise.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- private inline fun tryDeletion(block: () -> Unit): Boolean {
- try {
- block()
- return true
- } catch (e: IllegalArgumentException) {
- return false
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/EntityService.kt b/src/main/kotlin/trik/testsys/core/service/EntityService.kt
deleted file mode 100644
index 1c85dd26..00000000
--- a/src/main/kotlin/trik/testsys/core/service/EntityService.kt
+++ /dev/null
@@ -1,235 +0,0 @@
-package trik.testsys.core.service
-
-import trik.testsys.core.entity.Entity
-import trik.testsys.core.repository.EntityRepository
-import java.time.LocalDateTime
-
-/**
- * This is a generic service interface that defines common operations for a service.
- * It works with any type of `Entity`.
- *
- * @param E The type of the entity.
- *
- * @see Entity
- * @see EntityRepository
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface EntityService {
-
- //region Save methods
-
- /**
- * Saves the entity.
- *
- * @param entity The entity to save.
- * @return The saved entity.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun save(entity: E): E
-
- /**
- * Saves all entities.
- *
- * @param entities The entities to save.
- * @return The saved entities.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun saveAll(entities: Iterable): List
-
- //endregion
-
- //region Find methods
-
- /**
- * Finds the entity by its ID.
- *
- * @param id The ID of the entity to find.
- * @return The entity with the given ID. If nothing was found - null.
- *
- * @see Entity.getId
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun find(id: Long?): E?
-
- /**
- * Finds all entities by their IDs.
- *
- * @param ids The IDs of the entities to find.
- * @return The entities with the given IDs.
- *
- * @see Entity.getId
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findAll(ids: Iterable): List
-
- /**
- * Finds all entities.
- *
- * @return All entities.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findAll(): List
-
- /**
- * Finds entities with creation date after the given date.
- *
- * @param creationDate The date by which entities will be found.
- * @return The entities with creation date after the given date.
- *
- * @see Entity.creationDate
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateAfter(creationDate: LocalDateTime): List
-
- /**
- * Finds entities with creation date before the given date.
- *
- * @param creationDate The date by which entities will be found.
- * @return The entities with creation date before the given date.
- *
- * @see Entity.creationDate
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateBefore(creationDate: LocalDateTime): List
-
- /**
- * Finds entities with creation date between the given dates.
- *
- * @param creationDateFrom The bottom edge for finding entities.
- * @param creationDateTo The top edge for finding entities.
- * @return The entities with creation date between the given dates.
- *
- * @see Entity.creationDate
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByCreationDateBetween(creationDateFrom: LocalDateTime, creationDateTo: LocalDateTime): List
-
- //endregion
-
- //region Count methods
-
- /**
- * Counts all entities.
- *
- * @return The number of entities.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun count(): Long
-
- //endregion
-
- //region Exist methods
-
- /**
- * Checks if the entity with the given ID exists.
- *
- * @param id The ID of the entity to check.
- * @return `true` if the entity exists, `false` otherwise.
- *
- * @see Entity.getId
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun exists(id: Long): Boolean
-
- //endregion
-
- //region Delete methods
-
- /**
- * Deletes the entity with the given ID.
- *
- * @param id The ID of the entity to delete.
- * @return `true` if the entity was deleted, `false` otherwise.
- *
- * @see Entity.getId
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun delete(id: Long): Boolean
-
- /**
- * Deletes the entity.
- *
- * @param entity The entity to delete.
- * @return `true` if the entity was deleted, `false` otherwise.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun delete(entity: E): Boolean
-
- /**
- * Deletes all entities.
- *
- * @param entities The entities to delete.
- * @return `true` if the entities were deleted, `false` otherwise.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun deleteAll(entities: Iterable): Boolean
-
- /**
- * Deletes all entities by their IDs.
- *
- * @param ids The IDs of the entities to delete.
- * @return `true` if the entities were deleted, `false` otherwise.
- *
- * @see Entity.getId
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun deleteAllById(ids: Iterable): Boolean
-
- /**
- * Deletes all entities. Use with caution.
- *
- * @return `true` if the entities were deleted, `false` otherwise.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun deleteAll(): Boolean
-
- //endregion
-
- //region Validation methods
-
- /**
- * Validates the [Entity.additionalInfo] field.
- *
- * @param entity entity to validate
- * @return `true` if the entity is valid, `false` otherwise.
- *
- * @see Entity
- *
- * @since 2.0.0
- */
- fun validateAdditionalInfo(entity: E): Boolean = true
-
- //endregion
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/named/AbstractNamedEntityService.kt b/src/main/kotlin/trik/testsys/core/service/named/AbstractNamedEntityService.kt
deleted file mode 100644
index 8790582d..00000000
--- a/src/main/kotlin/trik/testsys/core/service/named/AbstractNamedEntityService.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package trik.testsys.core.service.named
-
-import trik.testsys.core.entity.named.AbstractNamedEntity
-import trik.testsys.core.repository.named.NamedEntityRepository
-import trik.testsys.core.service.AbstractService
-
-/**
- * Abstract implementation of [NamedEntityService] interface. Contains common methods for named entity services.
- *
- * @param E named entity class, extends [AbstractNamedEntity]
- *
- * @see NamedEntityService
- * @see AbstractNamedEntity
- * @see NamedEntityRepository
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-abstract class AbstractNamedEntityService> :
- NamedEntityService,
- AbstractService() {
-
- override fun findByName(name: String): Collection {
- val entity = repository.findByName(name)
- return entity
- }
-
- override fun validateName(entity: E) = entity.nameIsNotEmpty()
-
- companion object {
-
- fun E.nameIsNotEmpty() = name.isNotEmpty()
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/named/NamedEntityService.kt b/src/main/kotlin/trik/testsys/core/service/named/NamedEntityService.kt
deleted file mode 100644
index 647bfa7b..00000000
--- a/src/main/kotlin/trik/testsys/core/service/named/NamedEntityService.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package trik.testsys.core.service.named
-
-import trik.testsys.core.entity.named.NamedEntity
-import trik.testsys.core.service.EntityService
-
-/**
- * Simple interface for named entity services extends [EntityService]. Contains methods that work with [NamedEntity.name]:
- *
- * 1. [findByName]
- *
- * @param E entity class, implements [NamedEntity]
- *
- * @see EntityService
- * @see NamedEntity
- *
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-interface NamedEntityService : EntityService {
-
- /**
- * Finds all entities by [NamedEntity.name].
- *
- * @param name name by which entities will be found
- * @return [Collection] with all found entities with [NamedEntity.name] equals to [name]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByName(name: String): Collection
-
- /**
- * Validates the [NamedEntity.name] of the [entity].
- *
- * @param entity entity to validate
- * @return `true` if the [NamedEntity.name] is valid, `false` otherwise.
- *
- * @see NamedEntity.name
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun validateName(entity: E): Boolean
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/user/AbstractUserService.kt b/src/main/kotlin/trik/testsys/core/service/user/AbstractUserService.kt
deleted file mode 100644
index 033e0b92..00000000
--- a/src/main/kotlin/trik/testsys/core/service/user/AbstractUserService.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package trik.testsys.core.service.user
-
-import trik.testsys.core.entity.user.AbstractUser
-import trik.testsys.core.entity.user.AccessToken
-import trik.testsys.core.repository.user.UserRepository
-import trik.testsys.core.service.named.AbstractNamedEntityService
-
-/**
- * Abstract implementation of [UserService] interface. Contains common methods for user services.
- *
- * @param E user entity class, extends [AbstractUser]
- * @param R user repository class, extends [UserRepository]
- *
- * @see UserService
- * @see AbstractUser
- * @see UserRepository
- * @see AbstractNamedEntityService
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-abstract class AbstractUserService> :
- UserService,
- AbstractNamedEntityService() {
-
- override fun findByAccessToken(accessToken: String): E? {
- val entity = repository.findByAccessToken(accessToken)
- return entity
- }
-
- override fun findAllByAccessTokenIn(accessTokens: Collection): Collection {
- val entities = repository.findAllByAccessTokenIn(accessTokens)
- return entities
- }
-
- override fun validateName(entity: E) = super.validateName(entity) &&
- !entity.name.containsAccessToken(entity.accessToken)
-
- override fun validateAdditionalInfo(entity: E) = super.validateAdditionalInfo(entity) &&
- !entity.additionalInfo.containsAccessToken(entity.accessToken)
-
- companion object {
-
- fun String.containsAccessToken(accessToken: AccessToken) = contains(accessToken, ignoreCase = true)
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/service/user/UserService.kt b/src/main/kotlin/trik/testsys/core/service/user/UserService.kt
deleted file mode 100644
index c982620f..00000000
--- a/src/main/kotlin/trik/testsys/core/service/user/UserService.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package trik.testsys.core.service.user
-
-import trik.testsys.core.entity.user.UserEntity
-import trik.testsys.core.repository.user.UserRepository
-import trik.testsys.core.service.EntityService
-import trik.testsys.core.service.named.NamedEntityService
-
-/**
- * Simple interface for user services extends [NamedEntityService].
- * Contains methods that work with [UserEntity.accessToken]:
- *
- * 1. [findByAccessToken]
- * 2. [findAllByAccessTokenIn]
- *
- * @param E user entity class, implements [UserEntity]
- * @param R user repository class, implements [UserRepository]
- *
- * @see EntityService
- * @see UserEntity
- * @see UserRepository
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface UserService : NamedEntityService {
-
- /**
- * Finds entity by [UserEntity.accessToken].
- *
- * @param accessToken access token by which entity will be found.
- * @return entity with [UserEntity.accessToken] equals to [accessToken]. If nothing was found - `null`
- *
- * @see [UserEntity.accessToken]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findByAccessToken(accessToken: String): E?
-
- /**
- * Find all entities by [UserEntity.accessToken].
- *
- * @param accessTokens access tokens by which entities will be found
- * @return [Collection] with all found entities which [UserEntity.accessToken] contained in [accessTokens]
- *
- * @see [UserEntity.accessToken]
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
- fun findAllByAccessTokenIn(accessTokens: Collection): Collection
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/utils/enums/Enum.kt b/src/main/kotlin/trik/testsys/core/utils/enums/Enum.kt
deleted file mode 100644
index 64ab3fa6..00000000
--- a/src/main/kotlin/trik/testsys/core/utils/enums/Enum.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package trik.testsys.core.utils.enums
-
-/**
- * Interface for any enum class.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface Enum {
-
- val dbkey: String
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/utils/enums/converter/AbstractEnumConverter.kt b/src/main/kotlin/trik/testsys/core/utils/enums/converter/AbstractEnumConverter.kt
deleted file mode 100644
index c2a9b264..00000000
--- a/src/main/kotlin/trik/testsys/core/utils/enums/converter/AbstractEnumConverter.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package trik.testsys.core.utils.enums.converter
-
-import trik.testsys.core.utils.enums.Enum
-import java.lang.reflect.ParameterizedType
-import javax.persistence.AttributeConverter
-
-/**
- * Simple enum jpa converter.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-abstract class AbstractEnumConverter : AttributeConverter {
-
- private val enumClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class
-
- override fun convertToDatabaseColumn(attribute: T?) = attribute?.dbkey
-
- override fun convertToEntityAttribute(dbData: String?): T? {
- dbData ?: return null
- enumClass.enumConstants.forEach { value ->
- if (value.dbkey == dbData) return value
- }
-
- throw IllegalArgumentException("Invalid value $dbData for enum class $${enumClass.simpleName}.")
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/utils/l10n/L10nCode.kt b/src/main/kotlin/trik/testsys/core/utils/l10n/L10nCode.kt
deleted file mode 100644
index e03760bc..00000000
--- a/src/main/kotlin/trik/testsys/core/utils/l10n/L10nCode.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package trik.testsys.core.utils.l10n
-
-class L10nCode(val code: String)
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/DefaultLocalizer.kt b/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/DefaultLocalizer.kt
deleted file mode 100644
index 5ea3bd0f..00000000
--- a/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/DefaultLocalizer.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package trik.testsys.core.utils.l10n.localizer
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import org.springframework.beans.factory.annotation.Value
-import org.springframework.stereotype.Service
-import trik.testsys.core.utils.l10n.L10nCode
-import java.io.File
-
-/**
- * Default implementation of localizer.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-@Service
-class DefaultLocalizer(@Value("\${path.l10n}") l10nDirPath: String) : Localizer {
-
- private var l10nDir: File = File(l10nDirPath)
-
- private lateinit var l10nMap: Map
-
- init {
- val map = mutableMapOf()
-
- if (l10nDir.exists() && l10nDir.isDirectory) {
- l10nDir.listFiles()?.map {
- val objectMapper = ObjectMapper()
- val jsonNode = objectMapper.readTree(it.reader())
-
- jsonNode.fields().forEach { field -> map[field.key] = field.value.asText() }
- }
- }
-
- l10nMap = map.toSortedMap()
- }
-
- override fun localize(l10nCode: L10nCode): String {
- val code = l10nCode.code
- val l10n = l10nMap[code] ?: return code
-
- return l10n
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/Localizer.kt b/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/Localizer.kt
deleted file mode 100644
index b2fa3a8e..00000000
--- a/src/main/kotlin/trik/testsys/core/utils/l10n/localizer/Localizer.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package trik.testsys.core.utils.l10n.localizer
-
-import trik.testsys.core.utils.l10n.L10nCode
-
-
-/**
- * Contract for implementation of localizer.
- *
- * @author Roman Shishkin
- * @since 2.0.0
- */
-interface Localizer {
-
- /**
- * Gets a localized message by [l10nCode].
- */
- fun localize(l10nCode: L10nCode): String
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/view/View.kt b/src/main/kotlin/trik/testsys/core/view/View.kt
deleted file mode 100644
index 831498b3..00000000
--- a/src/main/kotlin/trik/testsys/core/view/View.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package trik.testsys.core.view
-
-import trik.testsys.core.entity.Entity
-import java.time.LocalDateTime
-
-/**
- * @author Roman Shishkin
- * @since 2.0.0
-**/
-interface View {
-
- val id: Long?
-
- val creationDate: LocalDateTime?
-
- val additionalInfo: String
-
- fun toEntity(timeZoneId: String?): T
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/view/named/NamedEntityView.kt b/src/main/kotlin/trik/testsys/core/view/named/NamedEntityView.kt
deleted file mode 100644
index 7d9343a6..00000000
--- a/src/main/kotlin/trik/testsys/core/view/named/NamedEntityView.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package trik.testsys.core.view.named
-
-import trik.testsys.core.entity.named.NamedEntity
-import trik.testsys.core.view.View
-
-/**
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-interface NamedEntityView : View {
-
- val name: String
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/core/view/user/UserView.kt b/src/main/kotlin/trik/testsys/core/view/user/UserView.kt
deleted file mode 100644
index a146fd36..00000000
--- a/src/main/kotlin/trik/testsys/core/view/user/UserView.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package trik.testsys.core.view.user
-
-import trik.testsys.core.entity.user.AccessToken
-import trik.testsys.core.entity.user.UserEntity
-import trik.testsys.core.view.named.NamedEntityView
-import java.time.LocalDateTime
-
-/**
- * @author Roman Shishkin
- * @since 2.0.0
- **/
-interface UserView : NamedEntityView {
-
- val accessToken: AccessToken
-
- val lastLoginDate: LocalDateTime?
-}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/grading/BalancingGraderService.kt b/src/main/kotlin/trik/testsys/grading/BalancingGraderService.kt
deleted file mode 100644
index cb3f1c51..00000000
--- a/src/main/kotlin/trik/testsys/grading/BalancingGraderService.kt
+++ /dev/null
@@ -1,238 +0,0 @@
-package trik.testsys.grading
-
-import com.google.protobuf.Empty
-import io.grpc.ManagedChannelBuilder
-import io.grpc.StatusException
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.*
-import org.slf4j.LoggerFactory
-import org.springframework.stereotype.Service
-import trik.testsys.webclient.entity.impl.Solution
-import trik.testsys.webclient.service.FileManager
-import trik.testsys.webclient.service.Grader
-import trik.testsys.webclient.service.Grader.*
-import trik.testsys.grading.GradingNodeOuterClass.Submission
-import trik.testsys.grading.GradingNodeOuterClass.Result
-import trik.testsys.grading.converter.FieldResultConverter
-import trik.testsys.grading.converter.FileConverter
-import trik.testsys.grading.converter.ResultConverter
-import trik.testsys.grading.converter.SubmissionBuilder
-import trik.testsys.webclient.service.entity.impl.SolutionService
-import java.time.Duration
-import java.time.LocalDateTime
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.LinkedBlockingQueue
-
-@Service
-@Suppress("unused")
-class BalancingGraderService(
- private val fileManager: FileManager,
- private val solutionService: SolutionService
-): Grader {
-
- private val replayCount = 1
- // TODO: Use proper type for intervals and timeouts
- private val statusRequestTimeout = 2000L
- private val nodePollingInterval = 1000L
- private val resendHangingSubmissionsInterval = 60000L
- // TODO: get from status per each grading node
- private val hangTimeout = 2 * 5 * 60 * 1000L
- private val log = LoggerFactory.getLogger(this.javaClass)
- private data class SubmissionInfo(
- val solution: Solution,
- val submission: Submission,
- val sentTime: LocalDateTime,
- )
- private data class NodeInfo (
- val node: GradingNodeGrpcKt.GradingNodeCoroutineStub,
- val submissions: MutableSharedFlow,
- val results: Flow,
- val sentSubmissions: LinkedBlockingQueue = LinkedBlockingQueue(),
- )
- private val nodes = ConcurrentHashMap()
- private val submissionQueue = LinkedBlockingQueue()
-
- private val subscriptions = mutableListOf<(GradingInfo) -> Unit>()
- private val converter = ResultConverter(FieldResultConverter(FileConverter()))
-
- private val resultProcessingScope = CoroutineScope(Dispatchers.Default)
-
- private val submissionSendScope = CoroutineScope(Dispatchers.IO)
- private val submissionSendJob = submissionSendScope.launch {
- while (isActive) {
- try {
- val submissionInfo = submissionQueue.take()
- var nodeInfo = findFreeNode()
- while (nodeInfo == null) {
- delay(nodePollingInterval)
- nodeInfo = findFreeNode()
- }
- val submission = submissionInfo.submission
- val solution = submissionInfo.solution
- nodeInfo.sentSubmissions.add(submissionInfo)
- nodeInfo.submissions.emit(submission)
- log.info("Submission[id=${submission.id}] emitted")
- resultProcessingScope.launch {
- processResults(nodeInfo.results, nodeInfo.sentSubmissions)
- }
-
- solution.status = Solution.SolutionStatus.IN_PROGRESS
- solutionService.save(solution)
- } catch (e: Exception) {
- log.error("Submission send job iteration end up with error:", e)
- }
- }
- }
-
- private val resendHangingSubmissionsJob = submissionSendScope.launch {
- while (isActive) {
- try {
- delay(resendHangingSubmissionsInterval)
-
- val currentTime = LocalDateTime.now()
-
- for ((ipPort, nodeInfo) in nodes) {
- for (submission in nodeInfo.sentSubmissions) {
- if (Duration.between(currentTime, submission.sentTime) > Duration.ofMillis(hangTimeout)) {
- log.warn("Resend hanging submission[${submission.submission.id}] on node[${ipPort}]")
- submissionQueue.add(submission)
- nodeInfo.sentSubmissions.remove(submission)
- }
- }
- }
- } catch (e: Exception) {
- log.error("Submission resend job iteration end up with error:", e)
- }
- }
- }
-
- private fun resendSubmissions(sentSubmissions: LinkedBlockingQueue) {
- if (sentSubmissions.isNotEmpty()) {
- log.warn("${sentSubmissions.size} submissions are ungraded. Resending them")
- sentSubmissions.drainTo(submissionQueue)
- }
- }
-
- private suspend fun processResults(
- results: Flow,
- sentSubmissions: LinkedBlockingQueue
- ) = withContext(Dispatchers.IO) {
- try {
- results.collect { result ->
- val gradingInfo = converter.convert(result)
- log.info("Got result for submission[id=${gradingInfo.submissionId}]")
- sentSubmissions.removeIf { (_, submission) -> submission.id == gradingInfo.submissionId }
- subscriptions.forEach { it.invoke(gradingInfo) }
- }
- log.debug("Finished processing results")
- } catch (se: StatusException) {
- val status = se.status
- log.warn("RPC is finished with status code ${status.code.value()}, description ${status.description}, cause ${status.cause}")
- resendSubmissions(sentSubmissions)
- } catch (e: Exception) {
- log.error("Unexpected error while processing results", e)
- resendSubmissions(sentSubmissions)
- }
- }
-
- private fun findFreeNode(): NodeInfo? =
- getAllNodeStatuses()
- .mapNotNull {
- val aliveStatus = (it.value as? NodeStatus.Alive) ?: return@mapNotNull null
- log.debug("Get status for node[id=${aliveStatus.id}, queued=${aliveStatus.queued}, capacity=${aliveStatus.capacity}]")
- val nodeInfo = nodes[it.key]
- if (nodeInfo != null && nodeInfo.sentSubmissions.size < aliveStatus.capacity)
- nodeInfo to aliveStatus
- else null
- }
- .minByOrNull { (nodeInfo, status) ->
- nodeInfo.sentSubmissions.size.toDouble() / status.capacity
- }
- ?.first
-
- override fun sendToGrade(solution: Solution, gradingOptions: GradingOptions) {
- val taskFiles = solution.task.polygons.mapNotNull { fileManager.getTaskFile(it) }
- val solutionFile = fileManager.getSolutionFile(solution) ?: throw IllegalArgumentException("Cannot find solution file")
-
- val submission = SubmissionBuilder.build {
- this.solution = solution
- this.solutionFile = solutionFile
- this.task = solution.task
- this.taskFiles = taskFiles
- this.gradingOptions = gradingOptions
- }
-
- submissionQueue.put(SubmissionInfo(solution, submission, LocalDateTime.now()))
- }
-
- override fun subscribeOnGraded(onGraded: (GradingInfo) -> Unit) {
- subscriptions.add(onGraded)
- }
-
- override fun addNode(address: String) {
- log.info("Adding node with address '$address'")
-
- try {
- val channel = ManagedChannelBuilder.forTarget(address)
- .usePlaintext() // TODO: Make proper channel initialization
- .maxInboundMessageSize(400_000_000)
- .maxInboundMetadataSize(400_000_000)
- .build()
-
- val node = GradingNodeGrpcKt.GradingNodeCoroutineStub(channel)
- // grpc backend becomes listener of submissions only after the solution is sent, thus need to replay submission for it
- val submissions = MutableSharedFlow(replayCount)
- val results = node.grade(submissions)
- nodes[address] = NodeInfo(node, submissions, results)
-
- log.info("Added node with address '$address'")
- } catch (se: StatusException) {
- log.error("The grade request end up with status error (code ${se.status.code})")
- } catch (e: Exception) {
- log.error("The grade request end up with error:", e)
- }
- }
-
- override fun removeNode(address: String) {
- TODO("Need discussion. Docs say that node's channel should be closed by server with Status.OK code for proper termination")
- }
-
- private suspend fun getNodeStatus(nodeInfo: NodeInfo): NodeStatus = coroutineScope {
- val node = nodeInfo.node
- try {
- withTimeout(statusRequestTimeout) {
- val status = node.getStatus(Empty.getDefaultInstance())
- NodeStatus.Alive(status.id, status.queued, status.capacity)
- }
- } catch (se: StatusException) {
- NodeStatus.Unreachable("The request end up with status error (code ${se.status.code})")
- } catch (tce: TimeoutCancellationException) {
- NodeStatus.Unreachable("Status request timeout reached")
- }
- }
-
- /**
- * @param address address of the node
- * @return [NodeStatus] instance or null if no node with given [address] is tracked
- */
- override fun getNodeStatus(address: String): NodeStatus? {
- val nodeInfo = nodes[address] ?: return null
- return runBlocking { getNodeStatus(nodeInfo) }
- }
-
- override fun getAllNodeStatuses(): Map {
- val nodeStatuses = runBlocking {
- nodes.mapValues { async { getNodeStatus(it.value) } }
- .mapValues { it.value.await() }
- }
-
- nodeStatuses.forEach { (address, nodeStatus) ->
- when (nodeStatus) {
- is NodeStatus.Alive -> log.debug("Node with ID ${nodeStatus.id} available by address $address (${nodeStatus.queued}/${nodeStatus.capacity})")
- is NodeStatus.Unreachable -> log.warn("Node is not available by address $address: ${nodeStatus.reason}")
- }
- }
-
- return nodeStatuses
- }
-}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/Application.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/Application.kt
new file mode 100644
index 00000000..837d23e7
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/Application.kt
@@ -0,0 +1,26 @@
+package trik.testsys.webapp.backoffice
+
+import org.springframework.boot.SpringApplication
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.scheduling.annotation.EnableScheduling
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@EnableScheduling
+@SpringBootApplication(scanBasePackages = ["trik.testsys.webapp.**"])
+class Application {
+
+ companion object {
+
+ /**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+ @JvmStatic
+ fun main(args: Array) {
+ SpringApplication.run(Application::class.java, *args)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/AbstractUserController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/AbstractUserController.kt
new file mode 100644
index 00000000..9ecdb77a
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/AbstractUserController.kt
@@ -0,0 +1,89 @@
+package trik.testsys.webapp.backoffice.controller
+
+import jakarta.annotation.PostConstruct
+import jakarta.servlet.http.HttpSession
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.ui.Model
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.data.entity.impl.AccessToken
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.UserService
+import trik.testsys.webapp.backoffice.data.service.impl.AccessTokenService
+import trik.testsys.webapp.backoffice.service.menu.MenuBuilder
+import trik.testsys.webapp.backoffice.utils.addMessage
+import trik.testsys.webapp.backoffice.utils.addHasActiveSession
+import trik.testsys.webapp.backoffice.utils.addSections
+import trik.testsys.webapp.backoffice.utils.addUser
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+abstract class AbstractUserController {
+
+ @Autowired
+ protected lateinit var accessTokenService: AccessTokenService
+
+ @Autowired
+ protected lateinit var userService: UserService
+
+ @Autowired
+ protected lateinit var menuBuilder: MenuBuilder
+
+ @Value("\${trik.testsys.display-app-name}")
+ private lateinit var displayAppName: String
+
+ protected val logger: Logger = LoggerFactory.getLogger(AbstractUserController::class.java)
+
+ @PostConstruct
+ fun postConstruct() {
+ logger.debug("Display application name = '$displayAppName'")
+ }
+
+ protected fun getAccessToken(session: HttpSession, redirectAttributes: RedirectAttributes): AccessToken? {
+ val accessToken = (session.getAttribute(ACCESS_TOKEN) as? String)?.let {
+ accessTokenService.findByValue(it)
+ } ?: run {
+ redirectAttributes.addMessage("Пожалуйста, войдите в систему.")
+ return null
+ }
+
+ return accessToken
+ }
+
+ protected fun getUser(accessToken: AccessToken, redirectAttributes: RedirectAttributes): User? {
+ accessToken.user ?: run {
+ redirectAttributes.addMessage("Пользователь не найден.")
+ return null
+ }
+
+ val user = accessToken.user ?: error("UNDEFINED")
+ if (user.isRemoved) {
+ redirectAttributes.addMessage("Доступ запрещён.")
+ return null
+ }
+
+ return user
+ }
+
+ /**
+ * Populates common model attributes for authenticated pages.
+ */
+ protected fun setupModel(model: Model, session: HttpSession, user: User) {
+ model.apply {
+ addAttribute(APPLICATION_NAME_ATTR, displayAppName.ifBlank { null })
+ addHasActiveSession(session)
+ addUser(user)
+ addSections(menuBuilder.buildFor(user))
+ }
+ }
+
+ companion object {
+
+ const val ACCESS_TOKEN = "accessToken"
+ const val APPLICATION_NAME_ATTR = "appName"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/MainController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/MainController.kt
new file mode 100644
index 00000000..05bd5f17
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/MainController.kt
@@ -0,0 +1,148 @@
+package trik.testsys.webapp.backoffice.controller.impl
+
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.data.service.UserService
+import trik.testsys.webapp.backoffice.data.service.ViewerService
+import trik.testsys.webapp.backoffice.data.service.impl.AccessTokenService
+import trik.testsys.webapp.backoffice.data.service.impl.RegTokenService
+import trik.testsys.webapp.backoffice.data.service.impl.StudentGroupTokenService
+import trik.testsys.webapp.backoffice.data.service.StudentGroupService
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.service.SponsorshipService
+import trik.testsys.webapp.backoffice.utils.SESSION_ACCESS_TOKEN
+import trik.testsys.webapp.backoffice.utils.addHasActiveSession
+import trik.testsys.webapp.backoffice.utils.addMessage
+
+@Controller
+class MainController(
+ private val accessTokenService: AccessTokenService,
+ private val regTokenService: RegTokenService,
+ private val viewerService: ViewerService,
+ private val userService: UserService,
+ private val studentGroupTokenService: StudentGroupTokenService,
+ private val studentGroupService: StudentGroupService,
+ private val sponsorshipService: SponsorshipService
+) {
+
+ @GetMapping("/")
+ fun mainPage(model: Model, session: HttpSession): String {
+ model.addHasActiveSession(session)
+ model.addAttribute("sponsorshipImages", sponsorshipService.getImageNames())
+ return "main"
+ }
+
+ @GetMapping("/login")
+ fun loginPage(model: Model, session: HttpSession): String {
+ model.addHasActiveSession(session)
+ model.addAttribute("sponsorshipImages", sponsorshipService.getImageNames())
+ return "login"
+ }
+
+ @GetMapping("/reg")
+ fun regPage(model: Model, session: HttpSession): String {
+ model.addHasActiveSession(session)
+ model.addAttribute("sponsorshipImages", sponsorshipService.getImageNames())
+ return "reg"
+ }
+
+ @PostMapping("/login")
+ fun login(
+ @RequestParam("accessToken", required = false) accessTokenValue: String?,
+ request: HttpServletRequest,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val provided = (accessTokenValue ?: (request.getAttribute("accessToken") as? String) ?: "").trim()
+ val token = accessTokenService.findByValue(provided)
+
+ val user = token?.user ?: run {
+ redirectAttributes.addMessage("Неверный Код-доступа.")
+ return "redirect:/login"
+ }
+
+ if (user.isRemoved) {
+ redirectAttributes.addMessage("Доступ запрещён.")
+ return "redirect:/login"
+ }
+
+ userService.updateLastLoginAt(user)
+
+ session.setAttribute(SESSION_USER_ID, user.id)
+ session.setAttribute(SESSION_ACCESS_TOKEN, token.value)
+ return "redirect:/user"
+ }
+
+ @PostMapping("/logout")
+ fun logout(session: HttpSession): String {
+ session.invalidate()
+ return "redirect:/"
+ }
+
+ @PostMapping("/reg")
+ fun register(
+ @RequestParam("regToken") regTokenValue: String,
+ @RequestParam("name") name: String,
+ request: HttpServletRequest,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val provided = regTokenValue.trim()
+ // Try Admin registration via viewer RegToken first
+ regTokenService.findByValue(provided)?.let { token ->
+ val viewer = token.viewer ?: run {
+ redirectAttributes.addMessage("Неверный Код-доступа.")
+ return "redirect:/login"
+ }
+
+ val newUser = viewerService.createAdmin(viewer, name) ?: run {
+ redirectAttributes.addMessage("Ошибка")
+ return "redirect:/login"
+ }
+
+ request.setAttribute("accessToken", newUser.accessToken?.value)
+ return "forward:/login"
+ }
+
+ // Try Student registration via StudentGroupToken
+ val stgToken = studentGroupTokenService.findByValue(provided) ?: run {
+ redirectAttributes.addMessage("Неверный Код-доступа.")
+ return "redirect:/login"
+ }
+
+ val group = stgToken.studentGroup ?: run {
+ redirectAttributes.addMessage("Неверный Код-доступа.")
+ return "redirect:/login"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Псевдоним не может быть пустым.")
+ return "redirect:/login"
+ }
+
+ val accessToken = accessTokenService.generate()
+ val student = User().also {
+ it.accessToken = accessToken
+ it.name = trimmedName
+ it.privileges.add(User.Privilege.STUDENT)
+ }
+
+ val persisted = userService.save(student)
+ accessToken.user = persisted
+ // Add student to the StudentGroup; service will also add to admin owner's user groups
+ studentGroupService.addMember(group, persisted)
+
+ request.setAttribute("accessToken", persisted.accessToken?.value)
+ return "forward:/login"
+ }
+
+ companion object {
+ private const val SESSION_USER_ID = "userId"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/rest/StudentExportControllerImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/rest/StudentExportControllerImpl.kt
new file mode 100644
index 00000000..391c131b
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/rest/StudentExportControllerImpl.kt
@@ -0,0 +1,55 @@
+package trik.testsys.webapp.backoffice.controller.impl.rest
+
+import org.springframework.http.HttpHeaders
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import jakarta.servlet.http.HttpSession
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.StudentGroupService
+import trik.testsys.webapp.backoffice.data.service.impl.AccessTokenService
+
+/**
+ * Export endpoints for viewer-related data.
+ */
+@RestController
+@RequestMapping("/rest/export")
+class StudentExportControllerImpl(
+ private val accessTokenService: AccessTokenService,
+ private val studentGroupService: StudentGroupService
+) {
+
+ /**
+ * Returns a CSV of all students belonging to admins managed by the given viewer.
+ * For MVP, emits only headers and one demo row if data not yet modeled.
+ */
+ @GetMapping("/admin-students")
+ fun exportAdminStudents(session: HttpSession): ResponseEntity {
+ val tokenValue = session.getAttribute("accessToken") as? String
+ ?: return ResponseEntity.status(401).build()
+
+ val token = accessTokenService.findByValue(tokenValue)
+ ?: return ResponseEntity.status(401).build()
+
+ val viewer = token.user ?: return ResponseEntity.status(401).build()
+ if (!viewer.privileges.contains(User.Privilege.VIEWER)) {
+ return ResponseEntity.status(403).build()
+ }
+
+ val groups = viewer.managedAdmins
+ .asSequence()
+ .flatMap { it.ownedStudentGroups.asSequence() }
+ .distinct()
+ .sortedBy { it.id }
+ .toList()
+
+ val csv = studentGroupService.generateResultsCsv(groups)
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=viewer_results.csv")
+ .contentType(MediaType.TEXT_PLAIN)
+ .body(csv)
+ }
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/AdminController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/AdminController.kt
new file mode 100644
index 00000000..c1f424a7
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/AdminController.kt
@@ -0,0 +1,247 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpStatus
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.StudentGroupService
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import trik.testsys.webapp.backoffice.utils.PrivilegeI18n
+
+@Controller
+@RequestMapping("/user/admin")
+class AdminController(
+ private val studentGroupService: StudentGroupService,
+ private val contestService: ContestService,
+) : AbstractUserController() {
+
+ @GetMapping("/groups")
+ fun groupsList(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val groups = studentGroupService.findByOwner(admin).sortedBy { it.id }
+ val groupSizes = groups.associate { group ->
+ group.id!! to group.members.filterNot { it.isRemoved }.size
+ }
+
+ setupModel(model, session, admin)
+ model.addAttribute("groups", groups)
+ model.addAttribute("groupSizes", groupSizes)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+
+ return "admin/groups"
+ }
+
+ @GetMapping("/groups/{id}")
+ fun viewGroup(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = studentGroupService.findById(id) ?: run {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/admin/groups"
+ }
+ if (group.owner?.id != admin.id) {
+ redirectAttributes.addMessage("Нет доступа к группе.")
+ return "redirect:/user/admin/groups"
+ }
+
+ val memberPrivilegesRuByUserId = group.members.associate { it.id!! to PrivilegeI18n.listRu(it.privileges) }
+
+ setupModel(model, session, admin)
+ model.addAttribute("group", group)
+ model.addAttribute("activeMembers", group.members.filter { !it.isRemoved }.sortedBy { it.id })
+ model.addAttribute("memberPrivilegesRuByUserId", memberPrivilegesRuByUserId)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+ model.addAttribute("attachedContests", group.contests.sortedBy { it.id })
+ model.addAttribute(
+ "availableContests",
+ contestService.findForUser(admin)
+ .filterNot { c -> group.contests.any { it.id == c.id } }
+ .sortedBy { it.id }
+ )
+
+ return "admin/group"
+ }
+
+ @GetMapping("/groups/create")
+ fun createForm(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ setupModel(model, session, admin)
+
+ return "admin/group-create"
+ }
+
+ @PostMapping("/groups/create")
+ fun create(
+ @RequestParam name: String,
+ @RequestParam info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = studentGroupService.create(admin, name, info) ?: run {
+ redirectAttributes.addMessage("Ошибка при создании Группы.")
+ return "redirect:/user/admin/groups/create"
+ }
+
+ redirectAttributes.addMessage("Группа создана (id=${group.id}).")
+ return "redirect:/user/admin/groups"
+ }
+
+ @PostMapping("/groups/{id}/generate")
+ fun generateStudents(
+ @PathVariable id: Long,
+ @RequestParam(name = "count", defaultValue = "1") count: Int,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = studentGroupService.findById(id) ?: return "redirect:/user/admin/groups"
+ if (group.owner?.id != admin.id) return "redirect:/user/admin/groups"
+
+ if (count < 1 || count > 200) {
+ redirectAttributes.addMessage("Количество должно быть от 1 до 200.")
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ val created = studentGroupService.generateStudents(admin, group, count)
+ redirectAttributes.addMessage("Сгенерировано ${created.size} участников.")
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ @GetMapping("/groups/{id}/export")
+ fun exportStudents(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): ResponseEntity {
+ val redirection: ResponseEntity = ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .header(HttpHeaders.LOCATION, "/login")
+ .build()
+
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return redirection
+ val admin = getUser(accessToken, redirectAttributes) ?: return redirection
+
+ val group = studentGroupService.findById(id) ?: return ResponseEntity.badRequest().build()
+ if (group.owner?.id != admin.id) return ResponseEntity.status(403).build()
+
+ val csv = studentGroupService.generateMembersCsv(group)
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=group_${group.id}_students.csv")
+ .contentType(MediaType.TEXT_PLAIN)
+ .body(csv)
+ }
+
+ @GetMapping("/groups/{id}/export-results")
+ fun exportResults(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): ResponseEntity {
+ val redirection: ResponseEntity = ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .header(HttpHeaders.LOCATION, "/login")
+ .build()
+
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return redirection
+ val admin = getUser(accessToken, redirectAttributes) ?: return redirection
+
+ val group = studentGroupService.findById(id) ?: return ResponseEntity.badRequest().build()
+ if (group.owner?.id != admin.id) return ResponseEntity.status(403).build()
+
+ val csv = studentGroupService.generateResultsCsv(group)
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=group_${group.id}_results.csv")
+ .contentType(MediaType.TEXT_PLAIN)
+ .body(csv)
+ }
+
+ @PostMapping("/groups/{id}/attach-contest")
+ fun attachContest(
+ @PathVariable id: Long,
+ @RequestParam("contestId") contestId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = studentGroupService.findById(id) ?: return "redirect:/user/admin/groups"
+ if (group.owner?.id != admin.id) return "redirect:/user/admin/groups"
+
+ val contest = contestService.findById(contestId)
+ if (contest == null) {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ val available = contestService.findForUser(admin)
+ val canAttach = available.any { it.id == contest.id }
+ if (!canAttach) {
+ redirectAttributes.addMessage("Вы не имеете доступа к этому Туру.")
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ val added = group.contests.add(contest)
+ studentGroupService.save(group)
+ if (added) {
+ redirectAttributes.addMessage("Тур прикреплён к группе.")
+ } else {
+ redirectAttributes.addMessage("Тур уже прикреплён к группе.")
+ }
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ @PostMapping("/groups/{id}/detach-contest")
+ fun detachContest(
+ @PathVariable id: Long,
+ @RequestParam("contestId") contestId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val admin = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = studentGroupService.findById(id) ?: return "redirect:/user/admin/groups"
+ if (group.owner?.id != admin.id) return "redirect:/user/admin/groups"
+
+ val contest = contestService.findById(contestId)
+ if (contest == null) {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/admin/groups/$id"
+ }
+
+ val removed = group.contests.removeIf { it.id == contest.id }
+ studentGroupService.save(group)
+ if (removed) {
+ redirectAttributes.addMessage("Тур откреплён от группы.")
+ } else {
+ redirectAttributes.addMessage("Тур не был прикреплён к группе.")
+ }
+ return "redirect:/user/admin/groups/$id"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/GroupAdminController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/GroupAdminController.kt
new file mode 100644
index 00000000..278259d6
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/GroupAdminController.kt
@@ -0,0 +1,234 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.ModelAttribute
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.UserGroupService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import trik.testsys.webapp.backoffice.utils.PrivilegeI18n
+
+@Controller
+@RequestMapping("/user/group-admin")
+class GroupAdminController(
+ private val userGroupService: UserGroupService,
+) : AbstractUserController() {
+
+ @GetMapping("/groups")
+ fun groupsList(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!current.privileges.contains(User.Privilege.GROUP_ADMIN)) {
+ redirectAttributes.addMessage("Нет прав GROUP_ADMIN.")
+ return "redirect:/user"
+ }
+
+ val groups = userGroupService.findByOwner(current).sortedBy { it.id }
+
+ setupModel(model, session, current)
+ model.addAttribute("groups", groups)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+
+ return "group-admin/groups"
+ }
+
+ @GetMapping("/groups/{id}")
+ fun viewGroup(@PathVariable id: Long, model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = userGroupService.findById(id) ?: run {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/group-admin/groups"
+ }
+ if (group.owner?.id != current.id) {
+ redirectAttributes.addMessage("Нет доступа к группе.")
+ return "redirect:/user/group-admin/groups"
+ }
+
+ val memberPrivilegesRuByUserId = group.activeMembers.associate { it.id!! to PrivilegeI18n.listRu(it.privileges) }
+
+ // Build a list of candidate users to add: exclude owner and already added members
+ val candidateUsers = userService.findCandidatesFor(group)
+ .sortedBy { it.name?.lowercase() ?: "" }
+ val candidatePrivilegesRuByUserId = candidateUsers.associate { it.id!! to PrivilegeI18n.listRu(it.privileges) }
+
+ setupModel(model, session, current)
+ model.addAttribute("group", group)
+ model.addAttribute("memberPrivilegesRuByUserId", memberPrivilegesRuByUserId)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+ model.addAttribute("candidateUsers", candidateUsers)
+ model.addAttribute("candidatePrivilegesRuByUserId", candidatePrivilegesRuByUserId)
+
+ return "group-admin/group"
+ }
+
+ @ModelAttribute("allowedPrivileges")
+ fun allowedPrivileges() = setOf(
+ User.Privilege.DEVELOPER,
+ User.Privilege.JUDGE,
+ User.Privilege.VIEWER,
+ )
+
+ @GetMapping("/groups/{id}/users/create")
+ fun createUserForm(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!current.privileges.contains(User.Privilege.GROUP_ADMIN)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val group = userGroupService.findById(id) ?: run {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/group-admin/groups"
+ }
+ if (group.owner?.id != current.id) {
+ redirectAttributes.addMessage("Нет доступа к группе.")
+ return "redirect:/user/group-admin/groups"
+ }
+
+ val allowed = allowedPrivileges()
+
+ val privilegeOptions = PrivilegeI18n.listOptions()
+ .filter { (name, _) -> allowed.contains(User.Privilege.valueOf(name)) }
+
+ setupModel(model, session, current)
+ model.addAttribute("group", group)
+ model.addAttribute("privilegeOptions", privilegeOptions)
+ return "group-admin/user-create"
+ }
+
+ @GetMapping("/groups/create")
+ fun createForm(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ setupModel(model, session, current)
+
+ return "group-admin/group-create"
+ }
+
+ @PostMapping("/groups/create")
+ fun create(@RequestParam name: String, @RequestParam info: String?, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = userGroupService.create(current, name, info) ?: run {
+ redirectAttributes.addMessage("Ошибка при создании Группы.")
+ return "redirect:/user/group-admin/groups/create"
+ }
+
+ redirectAttributes.addMessage("Группа создана (id=${group.id}).")
+ return "redirect:/user/group-admin/groups"
+ }
+
+ @PostMapping("/groups/{id}/add-member")
+ fun addMember(@PathVariable id: Long, @RequestParam userId: Long, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = userGroupService.findById(id) ?: return "redirect:/user/group-admin/groups"
+ if (group.owner?.id != current.id) return "redirect:/user/group-admin/groups"
+
+ val user = userService.findById(userId)
+ if (user == null) {
+ redirectAttributes.addMessage("Пользователь не найден.")
+ } else {
+ if (group.activeMembers.contains(user)) {
+ redirectAttributes.addMessage("Пользователь уже в группе.")
+ } else {
+ val ok = userGroupService.addMember(group, user)
+ if (ok) redirectAttributes.addMessage("Пользователь добавлен.") else redirectAttributes.addMessage("Не удалось добавить пользователя.")
+ }
+ }
+ return "redirect:/user/group-admin/groups/$id"
+ }
+
+ @PostMapping("/groups/{id}/remove-member")
+ fun removeMember(@PathVariable id: Long, @RequestParam userId: Long, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = userGroupService.findById(id) ?: return "redirect:/user/group-admin/groups"
+ if (group.owner?.id != current.id) return "redirect:/user/group-admin/groups"
+
+ // Prevent removing the owner from the group
+ if (group.owner?.id == userId) {
+ redirectAttributes.addMessage("Нельзя удалить владельца группы.")
+ return "redirect:/user/group-admin/groups/$id"
+ }
+
+ val user = userService.findById(userId)
+ if (user == null) {
+ redirectAttributes.addMessage("Пользователь не найден.")
+ } else {
+ val ok = userGroupService.removeMember(group, user)
+ if (ok) redirectAttributes.addMessage("Пользователь удален.") else redirectAttributes.addMessage("Не удалось удалить пользователя.")
+ }
+ return "redirect:/user/group-admin/groups/$id"
+ }
+
+ @PostMapping("/groups/{id}/create-user")
+ fun createUserInGroup(
+ @PathVariable id: Long,
+ @RequestParam name: String?,
+ @RequestParam(required = false) privileges: List?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val current = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!current.privileges.contains(User.Privilege.GROUP_ADMIN)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val group = userGroupService.findById(id) ?: run {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/group-admin/groups"
+ }
+ if (group.owner?.id != current.id) {
+ redirectAttributes.addMessage("Нет доступа к группе.")
+ return "redirect:/user/group-admin/groups"
+ }
+
+ val trimmed = (name ?: "").trim()
+ if (trimmed.isEmpty()) {
+ redirectAttributes.addMessage("Имя не может быть пустым.")
+ return "redirect:/user/group-admin/groups/$id"
+ }
+
+ val allowed = allowedPrivileges()
+ val requested = privileges?.toSet()?.intersect(allowed) ?: emptySet()
+
+ val newUser = userService.createUserByGroupAdmin(current, trimmed, requested)
+ if (newUser == null) {
+ redirectAttributes.addMessage("Не удалось создать пользователя.")
+ return "redirect:/user/group-admin/groups/$id"
+ }
+
+ val added = if (group.defaultGroup) true else userGroupService.addMember(group, newUser)
+ if (added) redirectAttributes.addMessage("Пользователь создан и добавлен в группу.") else redirectAttributes.addMessage("Пользователь создан, но не удалось добавить в группу.")
+
+ return "redirect:/user/group-admin/groups/$id"
+ }
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/SuperUserController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/SuperUserController.kt
new file mode 100644
index 00000000..e50e9f1c
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/SuperUserController.kt
@@ -0,0 +1,167 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import java.time.Instant
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.SuperUserService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import trik.testsys.webapp.backoffice.utils.PrivilegeI18n
+
+@Controller
+@RequestMapping("/user/superuser")
+class SuperUserController(
+ private val superUserService: SuperUserService,
+) : AbstractUserController() {
+
+ private data class UserRow(
+ val id: Long,
+ val name: String?,
+ val createdAt: Instant?,
+ val accessToken: String?,
+ val privilegesRu: List,
+ val canRemove: Boolean
+ )
+
+ @GetMapping("/users")
+ fun usersPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val currentUser = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!currentUser.privileges.contains(User.Privilege.SUPER_USER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val privilegeOptions = PrivilegeI18n.listOptions()
+ val createdUsers = userService.findAllBySuperUser(currentUser)
+
+ val userRows = createdUsers.filter { !it.isRemoved }.map { u ->
+ val privsRu = PrivilegeI18n.listRu(u.privileges)
+ UserRow(id = u.id!!, name = u.name, createdAt = u.createdAt, accessToken = u.accessToken?.value, privilegesRu = privsRu, canRemove = !u.hasLoggedIn)
+ }
+
+ val allUsers = if (currentUser.isAllUserSuperUser) userService.findAll() else emptyList()
+ val (nonRemovedUsers, removedUsers) = allUsers.partition { !it.isRemoved }
+
+ val allUserRows = nonRemovedUsers.map { u ->
+ val privsRu = PrivilegeI18n.listRu(u.privileges)
+ UserRow(id = u.id!!, name = u.name, createdAt = u.createdAt, accessToken = u.accessToken?.value, privilegesRu = privsRu, canRemove = !u.hasLoggedIn)
+ }
+ val removedUsersRow = removedUsers.map { u ->
+ val privsRu = PrivilegeI18n.listRu(u.privileges)
+ UserRow(id = u.id!!, name = u.name, createdAt = u.createdAt, accessToken = u.accessToken?.value, privilegesRu = privsRu, canRemove = false)
+ }
+
+ setupModel(model, session, currentUser)
+ model.addAttribute("userRows", userRows)
+ model.addAttribute("allUserRows", allUserRows)
+ model.addAttribute("removedUsersRow", removedUsersRow)
+ model.addAttribute("isAllUserSuperUser", currentUser.isAllUserSuperUser)
+ model.addAttribute("privileges", User.Privilege.entries)
+ model.addAttribute("privilegeOptions", privilegeOptions)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+ return "superuser/users"
+ }
+
+ @GetMapping("/users/create")
+ fun createUserForm(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val currentUser = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!currentUser.privileges.contains(User.Privilege.SUPER_USER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val privilegeOptions = PrivilegeI18n.listOptions()
+ setupModel(model, session, currentUser)
+ model.addAttribute("privilegeOptions", privilegeOptions)
+ return "superuser/user-create"
+ }
+
+ @PostMapping("/users/create")
+ fun createUser(
+ @RequestParam name: String?,
+ @RequestParam privileges: List?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val currentUser = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!currentUser.privileges.contains(User.Privilege.SUPER_USER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmed = (name ?: "").trim()
+ if (trimmed.isEmpty()) {
+ redirectAttributes.addMessage("Имя не может быть пустым.")
+ return "redirect:/user/superuser/users"
+ }
+
+ val privs = privileges?.toSet() ?: emptySet()
+ val ok = superUserService.createUser(currentUser, trimmed, privs)
+ if (ok) redirectAttributes.addMessage("Пользователь создан.") else redirectAttributes.addMessage("Не удалось создать пользователя.")
+ return "redirect:/user/superuser/users"
+ }
+
+ @PostMapping("/users/privilege")
+ fun addPrivilege(
+ @RequestParam userId: Long,
+ @RequestParam privilege: User.Privilege,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val currentUser = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!currentUser.privileges.contains(User.Privilege.SUPER_USER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val target = userService.findById(userId)
+ if (target == null) {
+ redirectAttributes.addMessage("Пользователь не найден.")
+ return "redirect:/user/superuser/users"
+ }
+
+ val ok = superUserService.addPrivilege(currentUser, target, privilege)
+ if (ok) redirectAttributes.addMessage("Роль добавлена.") else redirectAttributes.addMessage("Не удалось добавить роль.")
+ return "redirect:/user/superuser/users"
+ }
+
+ @PostMapping("/users/remove")
+ fun removeUser(
+ @RequestParam userId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val currentUser = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!currentUser.privileges.contains(User.Privilege.SUPER_USER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val target = userService.findById(userId)
+ if (target == null) {
+ redirectAttributes.addMessage("Пользователь не найден.")
+ return "redirect:/user/superuser/users"
+ }
+
+ val ok = superUserService.removeUser(currentUser, target)
+ if (ok) redirectAttributes.addMessage("Пользователь удалён.") else redirectAttributes.addMessage("Не удалось удалить пользователя.")
+ return "redirect:/user/superuser/users"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserController.kt
new file mode 100644
index 00000000..9767933f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserController.kt
@@ -0,0 +1,103 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.UserGroupService
+import trik.testsys.webapp.backoffice.utils.addHasActiveSession
+import trik.testsys.webapp.backoffice.utils.addMessage
+import trik.testsys.webapp.backoffice.utils.addSections
+import trik.testsys.webapp.backoffice.utils.addUser
+import trik.testsys.webapp.backoffice.utils.PrivilegeI18n
+
+@Controller
+@RequestMapping("/user")
+class UserController(
+ private val userGroupService: UserGroupService,
+) : AbstractUserController() {
+
+ @GetMapping
+ fun getUserPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ // Build dynamic menu from privileges
+ val memberedGroups = user.memberedGroups.sortedBy { it.id }
+ val privilegesRu = PrivilegeI18n.listRu(user.privileges)
+
+ setupModel(model, session, user)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+ model.addAttribute("privilegesRu", privilegesRu)
+ model.addAttribute("groups", memberedGroups)
+ return "user"
+ }
+
+ @GetMapping("/groups")
+ fun getUserGroupsPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ // Route kept for backward compatibility; now the groups are shown on the overview page
+ // Redirect to the overview which now contains the groups subsection
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+ setupModel(model, session, user)
+ return "redirect:/user"
+ }
+
+ @GetMapping("/privileges")
+ fun getUserPrivilegesPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+ setupModel(model, session, user)
+ return "redirect:/user"
+ }
+
+ @PostMapping("/name")
+ fun updateName(
+ @RequestParam("name") newName: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val trimmed = (newName ?: "").trim()
+ if (trimmed.isEmpty()) {
+ redirectAttributes.addMessage("Псевдоним не может быть пустым.")
+ return "redirect:/user"
+ }
+
+ userService.updateName(user, trimmed)
+ redirectAttributes.addMessage("Псевдоним успешно обновлен.")
+ return "redirect:/user"
+ }
+
+ @PostMapping("/groups/{id}/leave")
+ fun leaveUserGroup(@PathVariable("id") groupId: Long, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val group = userGroupService.findById(groupId)
+ if (group == null) {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user"
+ }
+ if (!group.activeMembers.contains(user)) {
+ redirectAttributes.addMessage("Вы не состоите в этой группе.")
+ return "redirect:/user"
+ }
+ if (group.owner?.id == user.id) {
+ redirectAttributes.addMessage("Владелец не может покинуть свою группу.")
+ return "redirect:/user"
+ }
+ val ok = userGroupService.removeMember(group, user)
+ if (ok) redirectAttributes.addMessage("Вы покинули группу.") else redirectAttributes.addMessage("Не удалось покинуть группу.")
+ return "redirect:/user"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserMailController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserMailController.kt
new file mode 100644
index 00000000..285afb3b
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/UserMailController.kt
@@ -0,0 +1,145 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.service.UserEmailService
+import trik.testsys.webapp.backoffice.utils.addHasActiveSession
+import trik.testsys.webapp.backoffice.utils.addMessage
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Controller
+@RequestMapping("/user/mail")
+class UserMailController(
+ private val userEmailService: UserEmailService
+) : AbstractUserController() {
+
+ @PostMapping("/update")
+ fun update(
+ @RequestParam("email") newEmail: String,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val trimmed = newEmail.trim()
+ if (trimmed.isEmpty()) {
+ redirectAttributes.addMessage("Почта не может быть пустой.")
+ return "redirect:/user"
+ }
+
+ val existingUser = userService.findByEmail(newEmail)
+ if (existingUser != null) {
+ redirectAttributes.addMessage("Почта уже используется в системе")
+ return "redirect:/user"
+ }
+
+ userEmailService.sendVerificationToken(user, trimmed)
+ userService.updateEmail(user, trimmed)
+ redirectAttributes.addMessage("Почта успешно обновлена. Для подтверждения скопируйте код, отправленный на указанную почту, и вставьте его в соседнее поле.")
+ return "redirect:/user"
+ }
+
+ @PostMapping("/remove")
+ fun remove(
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (user.email == null) {
+ redirectAttributes.addMessage("К пользователю не привязана почта")
+ return "redirect:/user"
+ }
+
+ userEmailService.sendVerificationToken(user, null)
+
+ user.requestedEmailDetach = true
+ userService.save(user)
+
+ redirectAttributes.addMessage("Для открепления почты скопируйте код, отправленный на нее, и вставьте его в соседнее поле.")
+ return "redirect:/user"
+ }
+
+ @PostMapping("/resend")
+ fun resendToken(
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (user.email == null) {
+ redirectAttributes.addMessage("К пользователю не привязана почта")
+ return "redirect:/user"
+ }
+
+ if (user.requestedEmailDetach) {
+ userEmailService.sendVerificationToken(user, null)
+ } else {
+ userEmailService.sendVerificationToken(user, user.email)
+ }
+
+ redirectAttributes.addMessage("Код отправлен повторно")
+ return "redirect:/user"
+ }
+
+ @PostMapping("/verify")
+ fun verify(
+ @RequestParam verificationToken: String,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ model: Model,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val verified = userEmailService.verify(user, verificationToken.trim())
+ val message = if (verified) {
+ if (user.email == null) "Почта откреплена"
+ else "Почта подтверждена"
+ } else {
+ redirectAttributes.addFlashAttribute("isWarning", true)
+ "Неверный код подтверждения"
+ }
+
+ redirectAttributes.addMessage(message)
+ model.addHasActiveSession(session)
+
+ return "redirect:/user"
+ }
+
+ @GetMapping("/restore")
+ fun getRestorePage(session: HttpSession,model: Model): String {
+ model.addHasActiveSession(session)
+ model.addAttribute("tokenSent", null)
+
+
+ return "token-restore"
+ }
+
+ @PostMapping("/restore")
+ fun restoreEmail(
+ @RequestParam("email") email: String,
+ session: HttpSession,
+ model: Model,
+ ): String {
+ val tokenSent = userEmailService.sendAccessToken(email.trim())
+
+ model.addHasActiveSession(session)
+ model.addAttribute("tokenSent", tokenSent)
+
+ return "token-restore"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/ViewerController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/ViewerController.kt
new file mode 100644
index 00000000..4defb27c
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/ViewerController.kt
@@ -0,0 +1,43 @@
+package trik.testsys.webapp.backoffice.controller.impl.user
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.utils.addHasActiveSession
+import trik.testsys.webapp.backoffice.utils.addSections
+import trik.testsys.webapp.backoffice.utils.addUser
+import trik.testsys.webapp.backoffice.utils.PrivilegeI18n
+
+@Controller
+@RequestMapping("/user/viewer")
+class ViewerController() : AbstractUserController() {
+
+ @GetMapping("/admins")
+ fun listAdmins(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val viewer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ val admins = viewer.managedAdmins.sortedBy { it.id }
+ setupModel(model, session, viewer)
+ model.addAttribute("admins", admins)
+ model.addAttribute("privilegeToRu", PrivilegeI18n.asMap())
+ return "viewer/admins"
+ }
+
+ @GetMapping("/token")
+ fun viewTokenRedirect(): String = "redirect:/user/viewer/admins"
+
+ @GetMapping("/export")
+ fun exportView(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val viewer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ setupModel(model, session, viewer)
+ return "viewer/export"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperContestController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperContestController.kt
new file mode 100644
index 00000000..77f65125
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperContestController.kt
@@ -0,0 +1,543 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.developer
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.Task
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.data.service.StudentGroupService
+import trik.testsys.webapp.backoffice.data.service.TaskService
+import trik.testsys.webapp.backoffice.data.service.UserGroupService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import java.time.LocalDateTime
+import java.time.ZoneId
+
+@Controller
+@RequestMapping("/user/developer")
+class DeveloperContestController(
+ private val contestService: ContestService,
+ private val userGroupService: UserGroupService,
+ private val studentGroupService: StudentGroupService,
+ private val taskService: TaskService,
+) : AbstractUserController() {
+
+ @GetMapping("/contests/{id}")
+ fun view(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+
+ val isOwner = contest.developer?.id == developer.id
+ if (!isOwner) {
+ val canView = developer.memberedGroups.any { mg -> contest.userGroups.any { ug -> ug.id == mg.id } }
+ if (!canView) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Туру.")
+ return "redirect:/user/developer/contests"
+ }
+ }
+
+ setupModel(model, session, developer)
+ model.addAttribute("contest", contest)
+ model.addAttribute("isOwner", isOwner)
+ model.addAttribute("hasAnySolutions", contest.solutions.isNotEmpty())
+ model.addAttribute("isAttachedToAnyStudentGroup", studentGroupService.existsByContestId(contest.id!!))
+ val taskOrders = contest.getOrders()
+ model.addAttribute(
+ "availableUserGroups",
+ userGroupService.findByMember(developer)
+ .filterNot { g -> contest.userGroups.any { it.id == g.id } }
+ .sortedBy { it.id }
+ )
+ model.addAttribute("attachedTasks", contest.tasks.sortedBy { t -> taskOrders[t.id!!] ?: Long.MAX_VALUE })
+ model.addAttribute("taskOrders", taskOrders)
+ val availableTasks = taskService.findForUser(developer)
+ .filterNot { t -> contest.tasks.any { it.id == t.id } }
+ .filter { it.testingStatus == Task.TestingStatus.PASSED }
+ .sortedBy { it.id }
+ model.addAttribute("availableTasks", availableTasks)
+
+ // Build availability info: groups intersection and ownership flag
+ val developerGroupIds = developer.memberedGroups.mapNotNull { it.id }.toSet()
+ val availableGroupsByTaskId: Map> = availableTasks
+ .filter { it.id != null }
+ .associate { task ->
+ val groups = task.userGroups
+ .filter { ug -> ug.id != null && developerGroupIds.contains(ug.id) }
+ .sortedBy { it.id }
+ task.id!! to groups
+ }
+ val ownedTaskIds: Set = availableTasks
+ .filter { it.developer?.id == developer.id }
+ .mapNotNull { it.id }
+ .toSet()
+
+ model.addAttribute("availableGroupsByTaskId", availableGroupsByTaskId)
+ model.addAttribute("ownedTaskIds", ownedTaskIds)
+
+ return "developer/contest"
+ }
+
+ @PostMapping("/contests/{id}/tasks/attach")
+ fun attachTaskToContest(
+ @PathVariable id: Long,
+ @RequestParam("taskId") taskId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val task = taskService.findById(taskId)
+ if (task == null || (task.developer?.id != developer.id && task.userGroups.none { developer.memberedGroups.contains(it) })) {
+ redirectAttributes.addMessage("Задача не найдена или недоступна.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ if (task.testingStatus != Task.TestingStatus.PASSED) {
+ redirectAttributes.addMessage("Можно прикреплять только Задачи со статусом тестирования Пройдено.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val hasPolygon = task.data.polygonFileIds.isNotEmpty()
+ val hasSolution = task.data.solutionFileDataById.isNotEmpty()
+ val hasExercise = task.data.exerciseFileIds.isNotEmpty()
+ if (!hasPolygon || !hasSolution || !hasExercise) {
+ redirectAttributes.addMessage("Для прикрепления к Туру требуется минимум один Полигон, одно Эталонное Решение и одно Упражнение.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val added = contest.tasks.add(task)
+ if (added && contest.data.orderByTaskId.isNotEmpty()) {
+ // Only assign explicit order if ordering was already customized
+ val currentOrders = contest.getOrders()
+ val nextOrder = (currentOrders.values.maxOrNull() ?: 0L) + 1L
+ contest.data.orderByTaskId[task.id!!] = nextOrder
+ }
+ contestService.save(contest)
+ if (added) redirectAttributes.addMessage("Задача прикреплена к Туру.") else redirectAttributes.addMessage("Задача уже была прикреплена.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ @PostMapping("/contests/{id}/groups/add")
+ fun addUserGroup(
+ @PathVariable id: Long,
+ @RequestParam("groupId") groupId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val group = userGroupService.findById(groupId)
+ if (group == null) {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val added = contest.userGroups.add(group)
+ contestService.save(contest)
+ if (added) redirectAttributes.addMessage("Группа добавлена к доступу.") else redirectAttributes.addMessage("Группа уже имеет доступ.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ @PostMapping("/contests/{contestId}/tasks/{taskId}/detach")
+ fun detachTaskFromContest(
+ @PathVariable contestId: Long,
+ @PathVariable taskId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/contests/$contestId"
+ }
+
+ val removed = contest.tasks.removeIf { it.id == taskId }
+ if (removed) {
+ contest.data.orderByTaskId.remove(taskId)
+ }
+ contestService.save(contest)
+ if (removed) redirectAttributes.addMessage("Задача откреплена от Тура.") else redirectAttributes.addMessage("Задача не была прикреплена.")
+ return "redirect:/user/developer/contests/$contestId"
+ }
+
+ @PostMapping("/contests/{id}/tasks/order")
+ fun updateTaskOrder(
+ @PathVariable id: Long,
+ @RequestParam("taskId") taskIds: List,
+ @RequestParam("order") orderValues: List,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ if (taskIds.size != orderValues.size) {
+ redirectAttributes.addMessage("Некорректные параметры порядка.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val attachedIds = contest.tasks.mapNotNull { it.id }.toSet()
+ val pairs = taskIds.zip(orderValues)
+ .filter { (tId, _) -> tId in attachedIds }
+ .sortedBy { it.second }
+
+ // normalize to 1..N to avoid duplicates and gaps
+ contest.data.orderByTaskId.clear()
+ var pos = 1L
+ for ((tId, _) in pairs) {
+ contest.data.orderByTaskId[tId] = pos
+ pos += 1
+ }
+
+ contestService.save(contest)
+ redirectAttributes.addMessage("Порядок задач обновлён.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ @PostMapping("/contests/{id}/rename")
+ fun rename(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ contest.name = trimmed
+ contestService.save(contest)
+ redirectAttributes.addMessage("Название Тура обновлено.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ @PostMapping("/contests/{id}/update")
+ fun update(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam startsAt: String,
+ @RequestParam(required = false) endsAt: String?,
+ @RequestParam(required = false) duration: Long?,
+ @RequestParam(required = false) timezone: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ if (duration != null && duration <= 0) {
+ redirectAttributes.addMessage("Время на прохождение должно быть положительной.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val startsInstant = try {
+ val ldt = LocalDateTime.parse(startsAt)
+ val zone = try {
+ if (timezone.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(timezone)
+ } catch (e: Exception) {
+ ZoneId.systemDefault()
+ }
+ ldt.atZone(zone).toInstant()
+ } catch (e: Exception) {
+ redirectAttributes.addMessage("Некорректная дата начала. Формат: yyyy-MM-dd'T'HH:mm")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val endsInstant = if (!endsAt.isNullOrBlank()) {
+ try {
+ val ldt = LocalDateTime.parse(endsAt)
+ val zone = try {
+ if (timezone.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(timezone)
+ } catch (e: Exception) {
+ ZoneId.systemDefault()
+ }
+ ldt.atZone(zone).toInstant()
+ } catch (e: Exception) {
+ redirectAttributes.addMessage("Некорректная дата окончания. Формат: yyyy-MM-dd'T'HH:mm")
+ return "redirect:/user/developer/contests/$id"
+ }
+ } else null
+
+ if (endsInstant != null && endsInstant.isBefore(startsInstant)) {
+ redirectAttributes.addMessage("Окончание не может быть раньше начала.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ contest.name = trimmedName
+ contest.info = info?.takeIf { it.isNotBlank() }
+ contest.startsAt = startsInstant
+ contest.endsAt = endsInstant
+ contest.duration = duration
+ contestService.save(contest)
+ redirectAttributes.addMessage("Данные Тура обновлены.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ @GetMapping("/contests/create")
+ fun createForm(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+ return "developer/contest-create"
+ }
+
+ @GetMapping("/contests")
+ fun contestsPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val createdContests = contestService.findForOwner(developer).sortedBy { it.id }
+ val sharedContests = contestService.findForUser(developer).sortedBy { it.id }
+
+ setupModel(model, session, developer)
+ model.addAttribute("createdContests", createdContests)
+ model.addAttribute("sharedContests", sharedContests)
+
+ return "developer/contests"
+ }
+
+ @PostMapping("/contests/create")
+ fun createContest(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam startsAt: String,
+ @RequestParam(required = false) endsAt: String?,
+ @RequestParam(required = false) duration: Long?,
+ @RequestParam(required = false) timezone: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/contests"
+ }
+
+ if (duration != null && duration <= 0) {
+ redirectAttributes.addMessage("Время на прохождение должно быть положительной.")
+ return "redirect:/user/developer/contests"
+ }
+
+ val startsInstant = try {
+ val ldt = LocalDateTime.parse(startsAt)
+ val zone = try {
+ if (timezone.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(timezone)
+ } catch (e: Exception) {
+ ZoneId.systemDefault()
+ }
+ ldt.atZone(zone).toInstant()
+ } catch (e: Exception) {
+ redirectAttributes.addMessage("Некорректная дата начала. Формат: yyyy-MM-dd'T'HH:mm")
+ return "redirect:/user/developer/contests"
+ }
+
+ val endsInstant = if (!endsAt.isNullOrBlank()) {
+ try {
+ val ldt = LocalDateTime.parse(endsAt)
+ val zone = try {
+ if (timezone.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(timezone)
+ } catch (e: Exception) {
+ ZoneId.systemDefault()
+ }
+ ldt.atZone(zone).toInstant()
+ } catch (e: Exception) {
+ redirectAttributes.addMessage("Некорректная дата окончания. Формат: yyyy-MM-dd'T'HH:mm")
+ return "redirect:/user/developer/contests"
+ }
+ } else null
+
+ if (endsInstant != null && endsInstant.isBefore(startsInstant)) {
+ redirectAttributes.addMessage("Окончание не может быть раньше начала.")
+ return "redirect:/user/developer/contests"
+ }
+
+ val contest = Contest().also {
+ it.name = trimmedName
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ it.startsAt = startsInstant
+ it.endsAt = endsInstant
+ it.duration = duration
+ it.developer = developer
+ }
+ contestService.save(contest)
+ redirectAttributes.addMessage("Тур создан (id=${contest.id}).")
+ return "redirect:/user/developer/contests"
+ }
+
+ @PostMapping("/contests/{id}/delete")
+ fun deleteContest(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/developer/contests"
+ }
+ if (contest.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ val attachedToStudentGroups = studentGroupService.existsByContestId(contest.id!!)
+ if (attachedToStudentGroups) {
+ redirectAttributes.addMessage("Нельзя удалить Тур, прикреплённый к студенческим группам.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ if (contest.solutions.isNotEmpty()) {
+ redirectAttributes.addMessage("Нельзя удалить Тур, по которому есть Решения.")
+ return "redirect:/user/developer/contests/$id"
+ }
+
+ contestService.delete(contest)
+ redirectAttributes.addMessage("Тур удалён.")
+ return "redirect:/user/developer/contests"
+ }
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskController.kt
new file mode 100644
index 00000000..0afeab7f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskController.kt
@@ -0,0 +1,991 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.developer
+
+import jakarta.servlet.http.HttpSession
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.stereotype.Controller
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.Task
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.data.service.SolutionService
+import trik.testsys.webapp.backoffice.data.service.TaskService
+import trik.testsys.webapp.backoffice.data.service.VerdictService
+import trik.testsys.webapp.backoffice.data.service.UserGroupService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ConditionFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ExerciseFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.PolygonFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.SolutionFileService
+import trik.testsys.webapp.backoffice.service.FileManager
+import trik.testsys.webapp.backoffice.service.Grader
+import trik.testsys.webapp.backoffice.utils.addMessage
+
+@Controller
+@RequestMapping("/user/developer")
+class DeveloperTaskController(
+ private val contestService: ContestService,
+ private val taskService: TaskService,
+ private val solutionService: SolutionService,
+ private val fileManager: FileManager,
+ private val grader: Grader,
+ @Value("\${trik.testsys.trik-studio.container.name}")
+ private val trikStudioContainerName: String,
+ private val verdictService: VerdictService,
+ private val userGroupService: UserGroupService,
+
+ private val conditionFileService: ConditionFileService,
+ private val exerciseFileService: ExerciseFileService,
+ private val polygonFileService: PolygonFileService,
+ private val solutionFileService: SolutionFileService,
+) : AbstractUserController() {
+
+ @GetMapping("/tasks")
+ fun tasksPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val createdTasks = taskService.findByDeveloper(developer).sortedBy { it.id }
+ val sharedTasks = taskService
+ .findForUser(developer)
+ .filter { it.developer?.id != developer.id }
+ .sortedBy { it.id }
+
+ setupModel(model, session, developer)
+ model.addAttribute("tasks", createdTasks)
+ model.addAttribute("sharedTasks", sharedTasks)
+
+ return "developer/tasks"
+ }
+
+ @GetMapping("/tasks/create")
+ fun taskCreateForm(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+
+ return "developer/task-create"
+ }
+
+ @PostMapping("/tasks/create")
+ fun createTask(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ val task = Task().also {
+ it.name = trimmedName
+ it.developer = developer
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ }
+ taskService.save(task)
+ redirectAttributes.addMessage("Задача создана (id=${task.id}).")
+ return "redirect:/user/developer/tasks"
+ }
+
+ @GetMapping("/tasks/{id}")
+ fun viewTask(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этой Задаче.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ val attachedConditions = task.data.conditionFileIds.let { conditionFileService.findAllById(it) }
+ val attachedExercises = task.data.exerciseFileIds.let { exerciseFileService.findAllById(it) }
+ val attachedPolygons = task.data.polygonFileIds.let { polygonFileService.findAllById(it) }
+ val attachedSolutions = task.data.solutionFileDataById.keys.let { solutionFileService.findAllById(it) }
+
+ val ownedConditions = conditionFileService.findByDeveloper(developer.id!!).filterNot { task.data.conditionFileIds.contains(it.id) }
+ val ownedExercises = exerciseFileService.findByDeveloper(developer.id!!).filterNot { task.data.exerciseFileIds.contains(it.id) }
+ val ownedPolygons = polygonFileService.findByDeveloper(developer.id!!).filterNot { task.data.polygonFileIds.contains(it.id) }
+ val ownedSolutions = solutionFileService.findByDeveloper(developer.id!!).filterNot { task.data.solutionFileDataById.keys.contains(it.id) }
+
+ val hasPolygon = attachedPolygons.isNotEmpty()
+ val hasSolution = attachedSolutions.isNotEmpty()
+
+ val allContests = contestService.findAll()
+
+ val isUsedInAnyContest = allContests.asSequence()
+ .any { c -> c.tasks.any { it.id == task.id } }
+
+ val attachedContests = allContests
+ .filter { c -> c.tasks.any { it.id == task.id } }
+ .sortedBy { it.id }
+
+ val lastTestStatus = when (task.testingStatus) {
+ Task.TestingStatus.NOT_TESTED -> "Не запускалось"
+ Task.TestingStatus.TESTING -> "В процессе"
+ Task.TestingStatus.PASSED -> "Пройдено"
+ Task.TestingStatus.FAILED -> "Не пройдено"
+ }
+
+ val testSolutions = task.solutions.filter { it.contest == null }
+ .sortedByDescending { it.id }
+
+ val resultsAvailability = testSolutions.associate { s ->
+ val hasVerdicts = fileManager.getVerdicts(s).isNotEmpty()
+ val hasRecordings = fileManager.getRecording(s).isNotEmpty()
+ (s.id!!) to (hasVerdicts || hasRecordings)
+ }
+
+ setupModel(model, session, developer)
+
+ model.addAttribute("attachedConditions", attachedConditions)
+ model.addAttribute("attachedExercises", attachedExercises)
+ model.addAttribute("attachedPolygons", attachedPolygons)
+ model.addAttribute("attachedSolutions", attachedSolutions)
+
+ model.addAttribute("availableConditions", ownedConditions)
+ model.addAttribute("availableExercises", ownedExercises)
+ model.addAttribute("availablePolygons", ownedPolygons)
+ model.addAttribute("availableSolutions", ownedSolutions)
+
+ model.addAttribute("task", task)
+ model.addAttribute("isOwner", true)
+ model.addAttribute("isTesting", task.testingStatus == Task.TestingStatus.TESTING)
+ model.addAttribute("testReady", hasPolygon && hasSolution)
+ model.addAttribute("numPolygons", attachedPolygons.size)
+ model.addAttribute("numSolutions", attachedSolutions.size)
+ model.addAttribute("testStatus", lastTestStatus)
+ model.addAttribute("isUsedInAnyContest", isUsedInAnyContest)
+ model.addAttribute("attachedContests", attachedContests)
+ model.addAttribute("hasAnySolutions", testSolutions.isNotEmpty())
+ model.addAttribute("resultsAvailable", resultsAvailability)
+ model.addAttribute(
+ "availableUserGroups",
+ userGroupService.findByMember(developer)
+ .filterNot { g -> task.userGroups.any { it.id == g.id } }
+ .sortedBy { it.id }
+ )
+
+ return "developer/task"
+ }
+
+ @GetMapping("/tasks/{id}/tests")
+ fun taskTests(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этой Задаче.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ val testSolutions = task.solutions.filter { it.contest == null }
+ .sortedByDescending { it.id }
+
+ val verdicts = verdictService.findAllBySolutions(testSolutions)
+ val verdictsBySolutionIds = verdicts.associateBy { it.solutionId }
+
+ val resultsAvailability = testSolutions.associate { s ->
+ val hasVerdicts = fileManager.getVerdicts(s).isNotEmpty()
+ val hasRecordings = fileManager.getRecording(s).isNotEmpty()
+ (s.id!!) to (hasVerdicts || hasRecordings)
+ }
+
+ setupModel(model, session, developer)
+ model.addAttribute("task", task)
+ model.addAttribute("solutions", testSolutions)
+ model.addAttribute("verdicts", verdictsBySolutionIds)
+ model.addAttribute("resultsAvailable", resultsAvailability)
+
+ return "developer/task-tests"
+ }
+
+ @GetMapping("/tasks/{taskId}/tests/{solutionId}/download")
+ fun downloadTestResults(
+ @PathVariable("taskId") taskId: Long,
+ @PathVariable("solutionId") solutionId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этой Задаче.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ val solution = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/developer/tasks/$taskId/tests"
+ }
+ if (solution.task.id != task.id || solution.contest != null) {
+ redirectAttributes.addMessage("Решение не относится к данному тестированию Задачи.")
+ return "redirect:/user/developer/tasks/$taskId/tests"
+ }
+ if (solution.createdBy.id != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к данному Решению.")
+ return "redirect:/user/developer/tasks/$taskId/tests"
+ }
+
+ val hasAnyResults = fileManager.getVerdicts(solution).isNotEmpty() || fileManager.getRecording(solution).isNotEmpty()
+ if (!hasAnyResults) {
+ redirectAttributes.addMessage("Результаты для данного Решения отсутствуют.")
+ return "redirect:/user/developer/tasks/$taskId/tests"
+ }
+
+ val results = fileManager.getSolutionResultFilesCompressed(solution)
+ val bytes = results.readBytes()
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"${results.name}\"")
+ .header("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header("Content-Transfer-Encoding", "binary")
+ .header("Content-Length", bytes.size.toString())
+ .body(bytes)
+ }
+
+ @PostMapping("/tasks/{id}/groups/add")
+ fun addUserGroup(
+ @PathVariable id: Long,
+ @RequestParam("groupId") groupId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val group = userGroupService.findById(groupId)
+ if (group == null) {
+ redirectAttributes.addMessage("Группа не найдена.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val added = task.userGroups.add(group)
+ taskService.save(task)
+ if (added) redirectAttributes.addMessage("Группа добавлена к доступу.") else redirectAttributes.addMessage("Группа уже имеет доступ.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/update")
+ fun updateTask(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ task.name = trimmedName
+ task.info = info?.takeIf { it.isNotBlank() }
+ taskService.save(task)
+ redirectAttributes.addMessage("Данные Задачи обновлены.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/files/condition/attach")
+ fun attachConditionFile(
+ @PathVariable id: Long,
+ @RequestParam("conditionId") conditionFileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val conditionFile = conditionFileService.findById(conditionFileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Можно прикреплять только свои файлы.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ task.data.conditionFileIds.clear()
+ task.data.conditionFileIds.add(conditionFileId)
+ taskService.save(task)
+
+ conditionFile.data.attachedTaskIds.add(task.id!!)
+ conditionFileService.save(conditionFile)
+
+ redirectAttributes.addMessage("Файл прикреплён к Задаче.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/files/exercise/attach")
+ fun attachExerciseFile(
+ @PathVariable id: Long,
+ @RequestParam("exerciseId") exerciseFileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val exerciseFile = exerciseFileService.findById(exerciseFileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Можно прикреплять только свои файлы.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ task.data.exerciseFileIds.clear()
+ task.data.exerciseFileIds.add(exerciseFileId)
+ taskService.save(task)
+
+ exerciseFile.data.attachedTaskIds.add(task.id!!)
+ exerciseFileService.save(exerciseFile)
+
+ redirectAttributes.addMessage("Файл прикреплён к Задаче.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/files/polygon/attach")
+ fun attachPolygonFile(
+ @PathVariable id: Long,
+ @RequestParam("polygonId") polygonFileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val polygonFile = polygonFileService.findById(polygonFileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Можно прикреплять только свои файлы.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+ if (polygonFile.analysisStatus != PolygonFile.AnalysisStatus.SUCCESS) {
+ redirectAttributes.addMessage("Нельзя прикреплять полигоны, которые не прошли диагностику успешно")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Нельзя изменять Полигоны во время тестирования.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val usedInContest = contestService.findAll().asSequence().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя изменять Полигоны Задачи, прикреплённой к Туру.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ task.testingStatus = Task.TestingStatus.NOT_TESTED
+ task.data.polygonFileIds.add(polygonFileId)
+ taskService.save(task)
+
+ polygonFile.data.attachedTaskIds.add(task.id!!)
+ polygonFileService.save(polygonFile)
+
+ redirectAttributes.addMessage("Файл прикреплён к Задаче.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/files/solution/attach")
+ fun attachSolutionFile(
+ @PathVariable id: Long,
+ @RequestParam("solutionId") solutionFileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val solutionFile = solutionFileService.findById(solutionFileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Можно прикреплять только свои файлы.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Нельзя изменять файлы во время тестирования.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val usedInContest = contestService.findAll().asSequence().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя изменять Полигоны Задачи, прикреплённой к Туру.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ task.testingStatus = Task.TestingStatus.NOT_TESTED
+
+ val solutionFileData = Task.SolutionFileData(
+ solutionFile.solutionType,
+ null,
+ 0L
+ )
+ task.data.solutionFileDataById[solutionFileId] = solutionFileData
+ taskService.save(task)
+
+ solutionFile.data.attachedTaskIds.add(task.id!!)
+ solutionFileService.save(solutionFile)
+
+ redirectAttributes.addMessage("Файл прикреплён к Задаче.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{taskId}/files/condition/{fileId}/detach")
+ fun detachConditionFile(
+ @PathVariable taskId: Long,
+ @PathVariable fileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val conditionFile = conditionFileService.findById(fileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ task.data.conditionFileIds.remove(fileId)
+ taskService.save(task)
+
+ conditionFile.data.attachedTaskIds.remove(taskId)
+ conditionFileService.save(conditionFile)
+
+ redirectAttributes.addMessage("Файл откреплён от Задачи.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ @PostMapping("/tasks/{taskId}/files/exercise/{fileId}/detach")
+ fun detachExerciseFile(
+ @PathVariable taskId: Long,
+ @PathVariable fileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val exerciseFile = exerciseFileService.findById(fileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ task.data.exerciseFileIds.remove(fileId)
+ taskService.save(task)
+
+ exerciseFile.data.attachedTaskIds.remove(taskId)
+ exerciseFileService.save(exerciseFile)
+
+ redirectAttributes.addMessage("Файл откреплён от Задачи.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ @PostMapping("/tasks/{taskId}/files/polygon/{fileId}/detach")
+ fun detachPolygonFile(
+ @PathVariable taskId: Long,
+ @PathVariable fileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Нельзя откреплять Полигоны во время тестирования.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val usedInContest = contestService.findAll().asSequence().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя откреплять Полигоны от Задачи, прикреплённой к Туру.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val polygonFile = polygonFileService.findById(fileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ task.testingStatus = Task.TestingStatus.NOT_TESTED
+ task.data.polygonFileIds.remove(fileId)
+ taskService.save(task)
+
+ polygonFile.data.attachedTaskIds.remove(taskId)
+ polygonFileService.save(polygonFile)
+
+ redirectAttributes.addMessage("Файл откреплён от Задачи.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ @PostMapping("/tasks/{taskId}/files/solution/{fileId}/detach")
+ fun detachSolutionFile(
+ @PathVariable taskId: Long,
+ @PathVariable fileId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Изменение доступов доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Нельзя откреплять Эталонные решения во время тестирования.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val usedInContest = contestService.findAll().asSequence().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя откреплять Эталонные решения от Задачи, прикреплённой к Туру.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ val solutionFile = solutionFileService.findById(fileId) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ task.testingStatus = Task.TestingStatus.NOT_TESTED
+ task.data.solutionFileDataById.remove(fileId)
+ taskService.save(task)
+
+ solutionFile.data.attachedTaskIds.remove(taskId)
+ solutionFileService.save(solutionFile)
+
+ redirectAttributes.addMessage("Файл откреплён от Задачи.")
+ return "redirect:/user/developer/tasks/$taskId"
+ }
+
+ @PostMapping("/tasks/{id}/test")
+ fun testTask(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val usedInContest = contestService.findAll().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя запускать тестирование для Задачи, прикреплённой к Туру.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Задача уже тестируется.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val hasPolygons = task.data.polygonFileIds.isNotEmpty()
+ val hasSolutions = task.data.solutionFileDataById.isNotEmpty()
+ if (!hasPolygons || !hasSolutions) {
+ redirectAttributes.addMessage("Для тестирования требуется минимум один Файл типа Полигон и один Файл типа Эталонное Решение.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val solutionFiles = solutionFileService.findAllById(task.data.solutionFileDataById.keys)
+ val polygonFiles = polygonFileService.findAllById(task.data.polygonFileIds)
+
+ if (solutionFiles.isEmpty() || polygonFiles.isEmpty()) {
+ redirectAttributes.addMessage("Для тестирования требуется минимум один Полигон и один Эталонное Решение.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ // Collect all solution file updates first
+ val solutionFileUpdates = mutableMapOf>()
+ val solutionsToGrade = mutableListOf()
+
+ // For each solution file, create a synthetic Solution and prepare for grading
+ solutionFiles.forEach { solutionTf ->
+ val solution = Solution().also {
+ it.createdBy = developer
+ it.contest = null
+ it.task = task
+ it.type = solutionTf.solutionType
+ it.info = "Тестирование Эталонного решения (id=${solutionTf.id}, type=${it.type}) для Задачи (id=${task.id})."
+ }
+
+ logger.debug("Calling solutionService.save(id=${solution.id}) in DeveloperTaskController.testTask")
+ val saved = solutionService.save(solution)
+
+ val solutionSource = fileManager.getSolutionFile(solutionTf)
+ ?: run {
+ solutionService.delete(saved)
+ redirectAttributes.addMessage("Файл эталонного решения недоступен на сервере.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val ok = fileManager.saveSolution(saved, solutionSource)
+ if (!ok) {
+ solutionService.delete(saved)
+ redirectAttributes.addMessage("Не удалось подготовить файл решения для тестирования.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ solutionFileUpdates[solutionTf.id!!] = Pair(saved.id!!, 0L)
+ solutionsToGrade.add(saved)
+ }
+
+ // Mark testing in progress and update task data in a single save
+ task.testingStatus = Task.TestingStatus.TESTING
+ solutionFileUpdates.forEach { entry ->
+ val solutionFileId = entry.key
+ val (solutionId, score) = entry.value
+ task.data.solutionFileDataById[solutionFileId]?.lastSolutionId = solutionId
+ task.data.solutionFileDataById[solutionFileId]?.lastTestScore = score
+ }
+ taskService.save(task)
+
+ // Send all solutions for grading after task is saved
+ solutionsToGrade.forEach { solution ->
+ grader.sendToGrade(solution, Grader.GradingOptions(shouldRecordRun = true, trikStudioVersion = trikStudioContainerName))
+ }
+
+ redirectAttributes.addMessage("Задача отправлена на тестирование.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ @PostMapping("/tasks/{id}/delete")
+ fun deleteTask(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val usedInContest = contestService.findAll().any { c -> c.tasks.any { it.id == task.id } }
+ if (usedInContest) {
+ redirectAttributes.addMessage("Нельзя удалить Задачу, прикреплённую к Туру.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ if (task.solutions.any { it.contest != null }) {
+ redirectAttributes.addMessage("Нельзя удалить Задачу, по которой есть Решения.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val attachedConditions = task.data.conditionFileIds.let { conditionFileService.findAllById(it) }
+ val attachedExercises = task.data.exerciseFileIds.let { exerciseFileService.findAllById(it) }
+ val attachedPolygons = task.data.polygonFileIds.let { polygonFileService.findAllById(it) }
+ val attachedSolutions = task.data.solutionFileDataById.keys.let { solutionFileService.findAllById(it) }
+
+ attachedConditions.forEach { it.data.attachedTaskIds.remove(task.id) }
+ attachedExercises.forEach { it.data.attachedTaskIds.remove(task.id) }
+ attachedPolygons.forEach { it.data.attachedTaskIds.remove(task.id) }
+ attachedSolutions.forEach { it.data.attachedTaskIds.remove(task.id) }
+
+ conditionFileService.saveAll(attachedConditions)
+ exerciseFileService.saveAll(attachedExercises)
+ polygonFileService.saveAll(attachedPolygons)
+ solutionFileService.saveAll(attachedSolutions)
+
+ taskService.delete(task)
+ redirectAttributes.addMessage("Задача удалена.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ @PostMapping("/tasks/{id}/solution-score")
+ fun updateSolutionScore(
+ @PathVariable id: Long,
+ @RequestParam solutionFileId: Long,
+ @RequestParam score: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val task = taskService.findById(id) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/developer/tasks"
+ }
+ if (task.developer?.id != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этой Задаче.")
+ return "redirect:/user/developer/tasks"
+ }
+
+ if (task.testingStatus == Task.TestingStatus.TESTING) {
+ redirectAttributes.addMessage("Нельзя изменять баллы во время тестирования.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val allContests = contestService.findAll()
+ val isUsedInAnyContest = allContests.asSequence()
+ .any { c -> c.tasks.any { it.id == task.id } }
+
+ if (isUsedInAnyContest) {
+ redirectAttributes.addMessage("Нельзя изменять баллы для задачи, используемой в соревновании.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ val solutionFileData = task.data.solutionFileDataById[solutionFileId]
+ if (solutionFileData == null) {
+ redirectAttributes.addMessage("Решение не найдено в задаче.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ solutionFileData.score = score
+ taskService.save(task)
+
+ redirectAttributes.addMessage("Балл для решения обновлён.")
+ return "redirect:/user/developer/tasks/$id"
+ }
+
+ companion object {
+
+ private val logger = LoggerFactory.getLogger(DeveloperTaskController::class.java)
+ }
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskFileController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskFileController.kt
new file mode 100644
index 00000000..f0afa95e
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/developer/DeveloperTaskFileController.kt
@@ -0,0 +1,1186 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.developer
+
+import jakarta.servlet.http.HttpSession
+import org.slf4j.LoggerFactory
+import org.springframework.http.MediaType
+import org.springframework.http.ContentDisposition
+import org.springframework.http.HttpHeaders
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.multipart.MultipartFile
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ConditionFile
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ExerciseFile
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.SolutionFile
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.backoffice.data.service.PolygonDiagnosticReportEntityService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ConditionFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ExerciseFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.PolygonFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.SolutionFileService
+import trik.testsys.webapp.backoffice.service.FileManager
+import trik.testsys.webapp.backoffice.utils.addMessage
+import java.nio.charset.StandardCharsets
+
+@Controller
+@RequestMapping("/user/developer")
+class DeveloperTaskFileController(
+ private val fileManager: FileManager,
+
+ private val conditionFileService: ConditionFileService,
+ private val exerciseFileService: ExerciseFileService,
+ private val polygonFileService: PolygonFileService,
+ private val solutionFileService: SolutionFileService,
+ private val polygonDiagnosticReportEntityService: PolygonDiagnosticReportEntityService
+) : AbstractUserController() {
+
+ @GetMapping("/task-files")
+ fun taskFilesPage(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val developerId = developer.id!!
+
+ val conditionFiles = conditionFileService.findByDeveloper(developerId)
+ val exerciseFiles = exerciseFileService.findByDeveloper(developerId)
+ val polygonFiles = polygonFileService.findByDeveloper(developerId)
+ val solutionFiles = solutionFileService.findByDeveloper(developerId)
+
+ setupModel(model, session, developer)
+ model.addAttribute("polygonFiles", polygonFiles)
+ model.addAttribute("exerciseFiles", exerciseFiles)
+ model.addAttribute("solutionFiles", solutionFiles)
+ model.addAttribute("conditionFiles", conditionFiles)
+
+ return "developer/task-files"
+ }
+
+ @GetMapping("/task-files/condition")
+ fun conditionFileCreateForm(
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+
+ return "developer/condition-create"
+ }
+
+ @GetMapping("/task-files/exercise")
+ fun exerciseFileCreateForm(
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+
+ return "developer/exercise-create"
+ }
+
+ @GetMapping("/task-files/polygon")
+ fun polygonFileCreateForm(
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+
+ return "developer/polygon-create"
+ }
+
+ @GetMapping("/task-files/solution")
+ fun solutionFileCreateForm(
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ setupModel(model, session, developer)
+
+ return "developer/solution-create"
+ }
+
+ @PostMapping("/task-files/condition/create")
+ fun conditionFileCreate(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam type: FileType,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (!ConditionFile.allowedTypes.contains(type)) {
+ redirectAttributes.addMessage("Некорректный тип файла.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val conditionFile = ConditionFile().also {
+ it.name = trimmedName
+ it.data.originalFileNameByVersion[it.fileVersion] = file.originalFilename?.substringBeforeLast(".") ?: trimmedName
+ it.developerId = developer.id
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ it.type = type
+ }
+
+ val saved = fileManager.saveConditionFile(conditionFile, file) ?: run {
+ conditionFileService.delete(conditionFile)
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ redirectAttributes.addMessage("Файл создан (id=${saved.id}).")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/exercise/create")
+ fun exerciseFileCreate(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam type: FileType,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (!ExerciseFile.allowedTypes.contains(type)) {
+ redirectAttributes.addMessage("Некорректный тип файла.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val exerciseFile = ExerciseFile().also {
+ it.name = trimmedName
+ it.data.originalFileNameByVersion[it.fileVersion] = file.originalFilename?.substringBeforeLast(".") ?: trimmedName
+ it.developerId = developer.id
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ it.type = type
+ }
+
+ val saved = fileManager.saveExerciseFile(exerciseFile, file) ?: run {
+ exerciseFileService.delete(exerciseFile)
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ redirectAttributes.addMessage("Файл создан (id=${saved.id}).")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/polygon/create")
+ fun polygonFileCreate(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam type: FileType,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (!PolygonFile.allowedTypes.contains(type)) {
+ redirectAttributes.addMessage("Некорректный тип файла.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val polygonFile = PolygonFile().also {
+ it.name = trimmedName
+ it.data.originalFileNameByVersion[it.fileVersion] = file.originalFilename?.substringBeforeLast(".") ?: trimmedName
+ it.developerId = developer.id
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ it.type = type
+ }
+
+ val saved = fileManager.savePolygonFile(polygonFile, file) ?: run {
+ polygonFileService.delete(polygonFile)
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ redirectAttributes.addMessage("Файл создан (id=${saved.id}).")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/solution/create")
+ fun solutionFileCreate(
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ @RequestParam type: FileType,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (!SolutionFile.allowedTypes.contains(type)) {
+ redirectAttributes.addMessage("Некорректный тип файла.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val solutionFile = SolutionFile().also {
+ it.name = trimmedName
+ it.data.originalFileNameByVersion[it.fileVersion] = file.originalFilename?.substringBeforeLast(".") ?: trimmedName
+ it.developerId = developer.id
+ it.info = info?.takeIf { s -> s.isNotBlank() }
+ it.type = type
+ it.solutionType = type.toSolutionType()
+ }
+
+ val saved = fileManager.saveSolutionFile(solutionFile, file) ?: run {
+ solutionFileService.delete(solutionFile)
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ redirectAttributes.addMessage("Файл создан (id=${saved.id}).")
+ return "redirect:/user/developer/task-files"
+ }
+
+ private fun FileType.toSolutionType() = when (this) {
+ FileType.PYTHON -> Solution.SolutionType.PYTHON
+ FileType.QRS -> Solution.SolutionType.QRS
+ FileType.JAVASCRIPT -> Solution.SolutionType.JAVA_SCRIPT
+ else -> error("NOT ALLOWED FILE TYPE")
+ }
+
+ @GetMapping("/task-files/condition/{id}")
+ fun viewConditionFile(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val conditionFile = conditionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val versions = fileManager.listFileVersions(conditionFile)
+
+ setupModel(model, session, developer)
+ model.addAttribute("taskFile", conditionFile)
+ model.addAttribute("versions", versions)
+
+ return "developer/condition-file"
+ }
+
+ @GetMapping("/task-files/exercise/{id}")
+ fun viewExerciseFile(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val exerciseFile = exerciseFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val versions = fileManager.listFileVersions(exerciseFile)
+
+ setupModel(model, session, developer)
+ model.addAttribute("taskFile", exerciseFile)
+ model.addAttribute("versions", versions)
+
+ return "developer/exercise-file"
+ }
+
+ @GetMapping("/task-files/polygon/{id}")
+ fun viewPolygonFile(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val polygonFile = polygonFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val versions = fileManager.listFileVersions(polygonFile)
+
+ val activeReports = polygonDiagnosticReportEntityService.findActiveByPolygonFileId(id)
+
+ setupModel(model, session, developer)
+ model.addAttribute("taskFile", polygonFile)
+ model.addAttribute("versions", versions)
+ model.addAttribute("reports", activeReports)
+
+ return "developer/polygon-file"
+ }
+
+ @GetMapping("/task-files/solution/{id}")
+ fun viewSolutionFile(
+ @PathVariable id: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solutionFile = solutionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val versions = fileManager.listFileVersions(solutionFile)
+
+ setupModel(model, session, developer)
+ model.addAttribute("taskFile", solutionFile)
+ model.addAttribute("versions", versions)
+
+ return "developer/solution-file"
+ }
+
+ @PostMapping("/task-files/condition/{id}/upload")
+ fun updateConditionFile(
+ @PathVariable id: Long,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val conditionFile = conditionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+ if (conditionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ conditionFile.fileVersion++
+ conditionFile.data.originalFileNameByVersion[conditionFile.fileVersion] = file.originalFilename?.substringBeforeLast('.')
+ ?: conditionFile.name!!
+
+ val saved = fileManager.saveConditionFile(conditionFile, file) ?: run {
+ conditionFile.fileVersion--
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ conditionFileService.save(saved)
+ redirectAttributes.addMessage("Файл обновлён.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ @PostMapping("/task-files/exercise/{id}/upload")
+ fun updateExerciseFile(
+ @PathVariable id: Long,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val exerciseFile = exerciseFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+ if (exerciseFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ exerciseFile.fileVersion++
+ exerciseFile.data.originalFileNameByVersion[exerciseFile.fileVersion] = file.originalFilename?.substringBeforeLast('.')
+ ?: exerciseFile.name!!
+
+ val saved = fileManager.saveExerciseFile(exerciseFile, file) ?: run {
+ exerciseFile.fileVersion--
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ exerciseFileService.save(saved)
+ redirectAttributes.addMessage("Файл обновлён.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ @PostMapping("/task-files/polygon/{id}/upload")
+ fun updatePolygonFile(
+ @PathVariable id: Long,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val polygonFile = polygonFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.analysisStatus == PolygonFile.AnalysisStatus.ANALYZING) {
+ redirectAttributes.addMessage("Полигон нельзя изменять, пока производится его диагностика.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ polygonFile.fileVersion++
+ polygonFile.data.originalFileNameByVersion[polygonFile.fileVersion] = file.originalFilename?.substringBeforeLast('.')
+ ?: polygonFile.name!!
+
+ val saved = fileManager.savePolygonFile(polygonFile, file) ?: run {
+ polygonFile.fileVersion--
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ polygonFile.analysisStatus = PolygonFile.AnalysisStatus.NOT_ANALYZED
+ polygonFileService.save(saved)
+ redirectAttributes.addMessage("Файл обновлён.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ @PostMapping("/task-files/solution/{id}/upload")
+ fun updateSolutionFile(
+ @PathVariable id: Long,
+ @RequestParam("file") file: MultipartFile,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solutionFile = solutionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+ if (solutionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ solutionFile.fileVersion++
+ solutionFile.data.originalFileNameByVersion[solutionFile.fileVersion] = file.originalFilename?.substringBeforeLast('.')
+ ?: solutionFile.name!!
+
+ val saved = fileManager.saveSolutionFile(solutionFile, file) ?: run {
+ solutionFile.fileVersion--
+ redirectAttributes.addMessage("Не удалось сохранить файл.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ solutionFileService.save(saved)
+ redirectAttributes.addMessage("Файл обновлён.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ @PostMapping("/task-files/condition/{id}/update")
+ fun updateConditionFileMeta(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val conditionFile = conditionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+ if (conditionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ conditionFile.name = trimmedName
+ conditionFile.info = info?.takeIf { it.isNotBlank() }
+ conditionFileService.save(conditionFile)
+ redirectAttributes.addMessage("Данные Файла обновлены.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ @PostMapping("/task-files/exercise/{id}/update")
+ fun updateExerciseFileMeta(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val exerciseFile = exerciseFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+ if (exerciseFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ exerciseFile.name = trimmedName
+ exerciseFile.info = info?.takeIf { it.isNotBlank() }
+ exerciseFileService.save(exerciseFile)
+ redirectAttributes.addMessage("Данные Файла обновлены.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ @PostMapping("/task-files/polygon/{id}/update")
+ fun updatePolygonFileMeta(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val polygonFile = polygonFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+ if (polygonFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ polygonFile.name = trimmedName
+ polygonFile.info = info?.takeIf { it.isNotBlank() }
+ polygonFileService.save(polygonFile)
+ redirectAttributes.addMessage("Данные Файла обновлены.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ @PostMapping("/task-files/solution/{id}/update")
+ fun updateSolutionFileMeta(
+ @PathVariable id: Long,
+ @RequestParam name: String,
+ @RequestParam(required = false) info: String?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solutionFile = solutionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Редактирование доступно только владельцу.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+ if (solutionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён и недоступен для обновления.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) {
+ redirectAttributes.addMessage("Название не может быть пустым.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ solutionFile.name = trimmedName
+ solutionFile.info = info?.takeIf { it.isNotBlank() }
+ solutionFileService.save(solutionFile)
+ redirectAttributes.addMessage("Данные Файла обновлены.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ @PostMapping("/task-files/condition/{id}/delete")
+ fun deleteConditionFile(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val conditionFile = conditionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ if (conditionFile.taskIds.isNotEmpty()) {
+ redirectAttributes.addMessage("Нельзя удалить Файл, прикреплённый к Задаче.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ conditionFile.isRemoved = true
+ conditionFileService.save(conditionFile)
+
+ logger.info("ConditionFile(id=${conditionFile.id}) was marked as removed.")
+
+ redirectAttributes.addMessage("Файл удален.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/exercise/{id}/delete")
+ fun deleteExerciseFile(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val exerciseFile = exerciseFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ if (exerciseFile.taskIds.isNotEmpty()) {
+ redirectAttributes.addMessage("Нельзя удалить Файл, прикреплённый к Задаче.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ exerciseFile.isRemoved = true
+ exerciseFileService.save(exerciseFile)
+
+ logger.info("ExerciseFile(id=${exerciseFile.id}) was marked as removed.")
+
+ redirectAttributes.addMessage("Файл удален.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/polygon/{id}/delete")
+ fun deletePolygonFile(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val polygonFile = polygonFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ if (polygonFile.taskIds.isNotEmpty()) {
+ redirectAttributes.addMessage("Нельзя удалить Файл, прикреплённый к Задаче.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ if (polygonFile.analysisStatus == PolygonFile.AnalysisStatus.ANALYZING) {
+ redirectAttributes.addMessage("Полигон нельзя удалять, пока производится его диагностика.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ polygonFile.isRemoved = true
+ polygonFileService.save(polygonFile)
+
+ logger.info("PolygonFile(id=${polygonFile.id}) was marked as removed.")
+
+ redirectAttributes.addMessage("Файл удален.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @PostMapping("/task-files/solution/{id}/delete")
+ fun deleteSolutionFile(
+ @PathVariable id: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solutionFile = solutionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("Удаление доступно только владельцу.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ if (solutionFile.taskIds.isNotEmpty()) {
+ redirectAttributes.addMessage("Нельзя удалить Файл, прикреплённый к Задаче.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ solutionFile.isRemoved = true
+ solutionFileService.save(solutionFile)
+
+ logger.info("SolutionFile(id=${solutionFile.id}) was marked as removed.")
+
+ redirectAttributes.addMessage("Файл удален.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ @GetMapping("/task-files/condition/{id}/download/{version}")
+ fun downloadConditionFileVersion(
+ @PathVariable id: Long,
+ @PathVariable version: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val conditionFile = conditionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (conditionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val file = fileManager.getConditionFile(conditionFile, version) ?: run {
+ redirectAttributes.addMessage("Версия не найдена.")
+ return "redirect:/user/developer/task-files/condition/$id"
+ }
+
+ val bytes = file.readBytes()
+ val name = conditionFile.data.originalFileNameByVersion[version] ?: conditionFile.name
+ val filename = "${name}${conditionFile.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/task-files/exercise/{id}/download/{version}")
+ fun downloadExerciseFileVersion(
+ @PathVariable id: Long,
+ @PathVariable version: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val exerciseFile = exerciseFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (exerciseFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val file = fileManager.getExerciseFile(exerciseFile, version) ?: run {
+ redirectAttributes.addMessage("Версия не найдена.")
+ return "redirect:/user/developer/task-files/exercise/$id"
+ }
+
+ val bytes = file.readBytes()
+ val name = exerciseFile.data.originalFileNameByVersion[version] ?: exerciseFile.name
+ val filename = "${name}${exerciseFile.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/task-files/polygon/{id}/download/{version}")
+ fun downloadPolygonFileVersion(
+ @PathVariable id: Long,
+ @PathVariable version: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val polygonFile = polygonFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (polygonFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val file = fileManager.getPolygonFile(polygonFile, version) ?: run {
+ redirectAttributes.addMessage("Версия не найдена.")
+ return "redirect:/user/developer/task-files/polygon/$id"
+ }
+
+ val bytes = file.readBytes()
+ val name = polygonFile.data.originalFileNameByVersion[version] ?: polygonFile.name
+ val filename = "${name}${polygonFile.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/task-files/solution/{id}/download/{version}")
+ fun downloadSolutionFileVersion(
+ @PathVariable id: Long,
+ @PathVariable version: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val developer = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!developer.privileges.contains(User.Privilege.DEVELOPER)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solutionFile = solutionFileService.findById(id) ?: run {
+ redirectAttributes.addMessage("Файл не найден.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.developerId != developer.id) {
+ redirectAttributes.addMessage("У вас нет доступа к этому Файлу.")
+ return "redirect:/user/developer/task-files"
+ }
+ if (solutionFile.isRemoved) {
+ redirectAttributes.addMessage("Файл удалён.")
+ return "redirect:/user/developer/task-files"
+ }
+
+ val file = fileManager.getSolutionFile(solutionFile, version) ?: run {
+ redirectAttributes.addMessage("Версия не найдена.")
+ return "redirect:/user/developer/task-files/solution/$id"
+ }
+
+ val bytes = file.readBytes()
+ val name = solutionFile.data.originalFileNameByVersion[version] ?: solutionFile.name
+ val filename = "${name}${solutionFile.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ companion object {
+
+ private val logger = LoggerFactory.getLogger(DeveloperTaskFileController::class.java)
+ }
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/judge/JudgeController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/judge/JudgeController.kt
new file mode 100644
index 00000000..cefd2088
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/judge/JudgeController.kt
@@ -0,0 +1,372 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.judge
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.PageRequest
+import org.springframework.data.domain.Sort
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import java.time.LocalDate
+import java.time.ZoneOffset
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.SolutionService
+import trik.testsys.webapp.backoffice.data.service.VerdictService
+import trik.testsys.webapp.backoffice.service.FileManager
+import trik.testsys.webapp.backoffice.service.Grader
+import trik.testsys.webapp.backoffice.data.repository.support.SolutionSpecifications
+import trik.testsys.webapp.backoffice.utils.addMessage
+
+@Controller
+@RequestMapping("/user/judge")
+class JudgeController(
+ private val solutionService: SolutionService,
+ private val verdictService: VerdictService,
+ private val fileManager: FileManager,
+ private val grader: Grader,
+ @Value("\${trik.testsys.trik-studio.container.name}") private val trikStudioContainerName: String,
+) : AbstractUserController() {
+
+ @GetMapping("/solutions")
+ fun solutionsPage(
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ @RequestParam("studentId", required = false) studentId: Long?,
+ @RequestParam("groupId", required = false) groupId: Long?,
+ @RequestParam("adminId", required = false) adminId: Long?,
+ @RequestParam("viewerId", required = false) viewerId: Long?,
+ @RequestParam("fromDate", required = false) fromDate: String?,
+ @RequestParam("toDate", required = false) toDate: String?,
+ @RequestParam("page", defaultValue = "0") page: Int,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ // Judge's own resubmits — always shown (small set, no pagination needed)
+ val judgeSolutions = solutionService.findAll(SolutionSpecifications.createdBy(judge.id!!))
+ .sortedByDescending { it.id }
+ val judgeVerdicts = verdictService.findAllBySolutions(judgeSolutions)
+ val judgeVerdictsBySolutionId = judgeVerdicts.associateBy { it.solutionId }
+ val judgeResultsAvailable = judgeSolutions.associate { s ->
+ (s.id!!) to (fileManager.hasAnyVerdict(s) || fileManager.hasAnyRecording(s))
+ }
+ val judgeSolutionFileAvailable = judgeSolutions.associate { s ->
+ (s.id!!) to fileManager.hasSolution(s)
+ }
+
+ setupModel(model, session, judge)
+ model.addAttribute("judgeSolutions", judgeSolutions)
+ model.addAttribute("judgeVerdicts", judgeVerdictsBySolutionId)
+ model.addAttribute("judgeResultsAvailable", judgeResultsAvailable)
+ model.addAttribute("judgeSolutionFileAvailable", judgeSolutionFileAvailable)
+
+ // Filter params — passed back to template to repopulate the form
+ model.addAttribute("filterStudentId", studentId)
+ model.addAttribute("filterGroupId", groupId)
+ model.addAttribute("filterAdminId", adminId)
+ model.addAttribute("filterViewerId", viewerId)
+ model.addAttribute("filterFromDate", fromDate)
+ model.addAttribute("filterToDate", toDate)
+
+ val hasFilters = listOf(studentId, groupId, adminId, viewerId, fromDate, toDate).any { it != null }
+ if (!hasFilters) {
+ model.addAttribute("hasSearched", false)
+ model.addAttribute("studentSolutionsPage", null)
+ model.addAttribute("studentSolutions", emptyList())
+ model.addAttribute("verdicts", emptyMap())
+ model.addAttribute("viewerBySolutionId", emptyMap())
+ model.addAttribute("adminBySolutionId", emptyMap())
+ model.addAttribute("groupBySolutionId", emptyMap())
+ model.addAttribute("resultsAvailable", emptyMap())
+ model.addAttribute("solutionFileAvailable", emptyMap())
+ model.addAttribute("paginationPages", emptyList())
+ return "judge/solutions"
+ }
+
+ val fromInstant = fromDate?.takeIf { it.isNotBlank() }
+ ?.let { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant() }
+ val toInstant = toDate?.takeIf { it.isNotBlank() }
+ ?.let { LocalDate.parse(it).atTime(23, 59, 59).atOffset(ZoneOffset.UTC).toInstant() }
+
+ val pageable = PageRequest.of(page, PAGE_SIZE, Sort.by("id").descending())
+ val studentSolutionsPage: Page = solutionService.findStudentSolutionsPage(
+ studentId, groupId, adminId, viewerId, fromInstant, toInstant, pageable
+ )
+ val studentSolutions = studentSolutionsPage.content
+
+ val verdicts = verdictService.findAllBySolutions(studentSolutions)
+ val verdictsBySolutionId = verdicts.associateBy { it.solutionId }
+
+ val viewerBySolutionId = mutableMapOf()
+ val adminBySolutionId = mutableMapOf()
+ val groupBySolutionId = mutableMapOf()
+ studentSolutions.forEach { s ->
+ val contest = s.contest
+ val student = s.createdBy
+ val group = if (contest != null) {
+ student.memberedStudentGroups.firstOrNull { sg -> sg.contests.any { it.id == contest.id } }
+ } else null
+ val admin = group?.owner
+ val viewer = admin?.viewer
+ val sid = s.id ?: return@forEach
+ groupBySolutionId[sid] = group?.id
+ adminBySolutionId[sid] = admin?.id
+ viewerBySolutionId[sid] = viewer?.id
+ }
+
+ val resultsAvailability = studentSolutions.associate { s ->
+ (s.id!!) to (fileManager.hasAnyVerdict(s) || fileManager.hasAnyRecording(s))
+ }
+ val solutionFileAvailability = studentSolutions.associate { s ->
+ (s.id!!) to fileManager.hasSolution(s)
+ }
+
+ model.addAttribute("hasSearched", true)
+ model.addAttribute("studentSolutionsPage", studentSolutionsPage)
+ model.addAttribute("paginationPages", buildPaginationPages(studentSolutionsPage.number, studentSolutionsPage.totalPages))
+ model.addAttribute("studentSolutions", studentSolutions)
+ model.addAttribute("verdicts", verdictsBySolutionId)
+ model.addAttribute("viewerBySolutionId", viewerBySolutionId)
+ model.addAttribute("adminBySolutionId", adminBySolutionId)
+ model.addAttribute("groupBySolutionId", groupBySolutionId)
+ model.addAttribute("resultsAvailable", resultsAvailability)
+ model.addAttribute("solutionFileAvailable", solutionFileAvailability)
+
+ return "judge/solutions"
+ }
+
+ @GetMapping("/solutions/by-name")
+ fun redirectByName(
+ @RequestParam("name") name: String,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val id = name.toLongOrNull()
+ if (id == null) {
+ redirectAttributes.addMessage("Некорректный идентификатор решения.")
+ return "redirect:/user/judge/solutions"
+ }
+ return "redirect:/user/judge/solutions/$id"
+ }
+
+ @GetMapping("/solutions/{solutionId}")
+ fun solutionDetails(
+ @PathVariable("solutionId") solutionId: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solution = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ val verdicts = verdictService.findAllForSolution(solutionId).sortedByDescending { it.id }
+
+ setupModel(model, session, judge)
+ model.addAttribute("solution", solution)
+ model.addAttribute("verdicts", verdicts)
+ model.addAttribute("resultsAvailable", (fileManager.getVerdicts(solution).isNotEmpty() || fileManager.getRecording(solution).isNotEmpty()))
+ model.addAttribute("solutionFileAvailable", (fileManager.getSolution(solution) != null))
+ return "judge/solution"
+ }
+
+ @PostMapping("/solutions/{solutionId}/relevant")
+ fun createRelevantVerdict(
+ @PathVariable("solutionId") solutionId: Long,
+ @RequestParam("score") score: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solution = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ verdictService.createNewForSolution(solution, score)
+ solutionService.save(solution)
+ redirectAttributes.addMessage("Релевантный вердикт обновлён.")
+ return "redirect:/user/judge/solutions/$solutionId"
+ }
+
+ @GetMapping("/solutions/{solutionId}/download")
+ fun downloadResults(
+ @PathVariable("solutionId") solutionId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solution = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ val hasAnyResults = fileManager.getVerdicts(solution).isNotEmpty() || fileManager.getRecording(solution).isNotEmpty()
+ if (!hasAnyResults) {
+ redirectAttributes.addMessage("Результаты для данного Решения отсутствуют.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ val results = fileManager.getSolutionResultFilesCompressed(solution)
+ val bytes = results.readBytes()
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"${results.name}\"")
+ .header("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header("Content-Transfer-Encoding", "binary")
+ .header("Content-Length", bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/solutions/{solutionId}/file")
+ fun downloadSolutionFile(
+ @PathVariable("solutionId") solutionId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val solution = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ val file = fileManager.getSolution(solution) ?: run {
+ redirectAttributes.addMessage("Файл посылки отсутствует на сервере.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ val bytes = file.readBytes()
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"${file.name}\"")
+ .header("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header("Content-Length", bytes.size.toString())
+ .body(bytes)
+ }
+
+ @PostMapping("/solutions/{solutionId}/resubmit")
+ fun resubmitSolution(
+ @PathVariable("solutionId") solutionId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val judge = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!judge.privileges.contains(User.Privilege.JUDGE)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val original = solutionService.findById(solutionId) ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ // Create a cloned solution owned by judge
+ val cloned = Solution().also {
+ it.createdBy = judge
+ it.contest = original.contest
+ it.task = original.task
+ it.type = original.type
+ }
+ val saved = solutionService.save(cloned)
+
+ // Copy original solution file to the cloned one
+ val sourceFile = fileManager.getSolution(original)
+ if (sourceFile == null) {
+ redirectAttributes.addMessage("Файл исходного решения отсутствует на сервере.")
+ return "redirect:/user/judge/solutions"
+ }
+ val copied = fileManager.saveSolution(saved, sourceFile)
+ if (!copied) {
+ redirectAttributes.addMessage("Не удалось подготовить файл решения для перезапуска.")
+ return "redirect:/user/judge/solutions"
+ }
+
+ return try {
+ grader.sendToGrade(saved, Grader.GradingOptions(shouldRecordRun = true, trikStudioVersion = trikStudioContainerName))
+ redirectAttributes.addMessage("Создана новая посылка и отправлена на проверку.")
+ "redirect:/user/judge/solutions"
+ } catch (e: Exception) {
+ redirectAttributes.addMessage("Не удалось отправить на проверку: ${e.message}")
+ "redirect:/user/judge/solutions"
+ }
+ }
+
+ private fun buildPaginationPages(current: Int, total: Int): List {
+ if (total <= 1) return emptyList()
+ val shown = sortedSetOf()
+ shown.add(0)
+ if (1 < total) shown.add(1)
+ if (total - 2 >= 0) shown.add(total - 2)
+ shown.add(total - 1)
+ if (current - 1 >= 0) shown.add(current - 1)
+ shown.add(current)
+ if (current + 1 < total) shown.add(current + 1)
+ val result = mutableListOf()
+ val sorted = shown.toList()
+ for (i in sorted.indices) {
+ if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.add(-1)
+ result.add(sorted[i])
+ }
+ return result
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 20
+ }
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestController.kt
new file mode 100644
index 00000000..e03d5de0
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestController.kt
@@ -0,0 +1,128 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.student
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.data.service.VerdictService
+import trik.testsys.webapp.backoffice.data.service.ContestRunService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import java.time.Duration
+import java.time.Instant
+
+@Controller
+@RequestMapping("/user/student/contests/{id}")
+class StudentContestController(
+ private val contestService: ContestService,
+ private val verdictService: VerdictService,
+ private val contestRunService: ContestRunService,
+) : AbstractUserController() {
+
+ @GetMapping
+ fun view(
+ @PathVariable("id") id: Long,
+ @RequestParam(name = "start", required = false, defaultValue = "false") start: Boolean,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(id) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+
+ // Verify accessibility via student's groups
+ val inStudentGroups = user.memberedStudentGroups.any { g -> g.contests.any { it.id == contest.id } }
+ if (!inStudentGroups) {
+ redirectAttributes.addMessage("Тур недоступен.")
+ return "redirect:/user/student/contests"
+ }
+
+ // Start on demand (after user confirmation on UI)
+ if (start) {
+ startContestIfNeeded(user, contest)
+ }
+
+ setupModel(model, session, user)
+ model.addAttribute("contest", contest)
+ val orders = contest.getOrders()
+ val tasks = contest.tasks.sortedBy { t -> orders[t.id!!] ?: Long.MAX_VALUE }
+ model.addAttribute("tasks", tasks)
+ model.addAttribute("remainingTime", remainingTimeString(user, Instant.now(), contest))
+
+ val userSolutions = contest.solutions.filter { it.createdBy.id == user.id }
+ val verdicts = verdictService.findAllBySolutions(userSolutions)
+
+ val taskScores = mutableMapOf()
+ userSolutions.forEach { sol ->
+ val solVerdicts = verdicts.filter { it.solutionId == sol.id }
+ val maxScore = solVerdicts.maxOfOrNull { it.value }
+ if (maxScore != null) {
+ val taskId = sol.task.id!!
+ val current = taskScores[taskId]
+ if (current == null || maxScore > current) {
+ taskScores[taskId] = maxScore
+ }
+ }
+ }
+ model.addAttribute("taskScores", taskScores)
+ return "student/contest"
+ }
+
+ private fun startContestIfNeeded(user: User, contest: Contest) {
+ contestRunService.startIfAbsent(user, contest)
+ }
+
+ private fun remainingTimeString(user: User, now: Instant, contest: Contest): String {
+ val durationMinutes = contest.duration
+ val endsAt = contest.endsAt
+
+ if (endsAt == null && durationMinutes == null) return "—"
+
+ val startInstant = contestRunService.findByUserAndContest(user, contest)?.startedAt
+
+ var timeByEnds: Duration? = null
+ if (endsAt != null) {
+ timeByEnds = Duration.between(now, endsAt)
+ }
+
+ var timeByDuration: Duration? = null
+ if (durationMinutes != null && startInstant != null) {
+ val elapsed = Duration.between(startInstant, now)
+ val total = Duration.ofMinutes(durationMinutes)
+ timeByDuration = total.minus(elapsed)
+ }
+
+ val effective = when {
+ endsAt == null -> timeByDuration
+ durationMinutes == null -> timeByEnds
+ else -> listOfNotNull(timeByEnds, timeByDuration).minByOrNull { it }
+ }
+
+ if (effective == null) return "—"
+ if (effective.isNegative || effective.isZero) return "00:00:00"
+ val totalSeconds = effective.seconds
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds)
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestsController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestsController.kt
new file mode 100644
index 00000000..7604fa94
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentContestsController.kt
@@ -0,0 +1,144 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.student
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.data.service.ContestRunService
+import trik.testsys.webapp.backoffice.utils.addMessage
+import java.time.Instant
+import java.time.Duration
+
+@Controller
+@RequestMapping("/user/student/contests")
+class StudentContestsController(
+ private val contestService: ContestService,
+ private val contestRunService: ContestRunService,
+) : AbstractUserController() {
+
+ @GetMapping
+ fun page(model: Model, session: HttpSession, redirectAttributes: RedirectAttributes): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ // Available/Ended split with personal duration respected
+ val now = Instant.now()
+ val studentGroupContests = user.memberedStudentGroups.flatMap { it.contests }.toSet()
+
+ val upcoming = studentGroupContests
+ .filter { c ->
+ val starts = c.startsAt
+ starts != null && now.isBefore(starts)
+ }
+ .sortedBy { it.id }
+
+ val available = studentGroupContests
+ .filter { c ->
+ val starts = c.startsAt
+ val ends = c.endsAt
+ val windowOpen = when {
+ starts == null -> true
+ ends == null -> now.isAfter(starts)
+ else -> now.isAfter(starts) && now.isBefore(ends)
+ }
+ if (!windowOpen) return@filter false
+
+ val started = contestRunService.findByUserAndContest(user, c) != null
+ if (!started) return@filter true
+
+ val rd = remainingDuration(user, now, c)
+ rd == null || rd.seconds > 0
+ }
+ .sortedBy { it.id }
+
+ val ended = studentGroupContests
+ .filter { c ->
+ val started = contestRunService.findByUserAndContest(user, c) != null
+ if (!started) return@filter false
+ val ends = c.endsAt
+ val endedByDate = ends != null && now.isAfter(ends)
+ val rd = remainingDuration(user, now, c)
+ val endedByDuration = rd != null && (rd.isZero || rd.isNegative)
+ endedByDate || endedByDuration
+ }
+ .sortedBy { it.id }
+
+ setupModel(model, session, user)
+ model.addAttribute("upcomingContests", upcoming)
+ model.addAttribute("availableContests", available)
+ model.addAttribute("endedContests", ended)
+ // Remaining time per contest using session-based start moment
+ val remainingByContestId = (available + ended).associate { it.id!! to remainingTimeString(user, now, it) }
+ model.addAttribute("remainingByContestId", remainingByContestId)
+ val startedByContestId = (available + ended).associate { it.id!! to (contestRunService.findByUserAndContest(user, it) != null) }
+ model.addAttribute("startedByContestId", startedByContestId)
+ return "student/contests"
+ }
+
+ private fun remainingTimeString(user: User, now: Instant, contest: Contest): String {
+ val durationMinutes = contest.duration
+ val endsAt = contest.endsAt
+
+ if (endsAt == null && durationMinutes == null) return "—"
+
+ val startInstant = contestRunService.findByUserAndContest(user, contest)?.startedAt
+
+ var timeByEnds: Duration? = null
+ if (endsAt != null) {
+ timeByEnds = Duration.between(now, endsAt)
+ }
+
+ var timeByDuration: Duration? = null
+ if (durationMinutes != null && startInstant != null) {
+ val elapsed = Duration.between(startInstant, now)
+ val total = Duration.ofMinutes(durationMinutes)
+ timeByDuration = total.minus(elapsed)
+ }
+
+ val effective = when {
+ endsAt == null -> timeByDuration
+ durationMinutes == null -> timeByEnds
+ else -> listOfNotNull(timeByEnds, timeByDuration).minByOrNull { it }
+ }
+
+ if (effective == null) return "—"
+ if (effective.isNegative || effective.isZero) return "00:00:00"
+ val totalSeconds = effective.seconds
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds)
+ }
+
+ private fun remainingDuration(user: User, now: Instant, contest: Contest): Duration? {
+ val durationMinutes = contest.duration
+ val endsAt = contest.endsAt
+ if (endsAt == null && durationMinutes == null) return null
+ val startInstant = contestRunService.findByUserAndContest(user, contest)?.startedAt
+ var timeByEnds: Duration? = null
+ if (endsAt != null) timeByEnds = Duration.between(now, endsAt)
+ var timeByDuration: Duration? = null
+ if (durationMinutes != null && startInstant != null) {
+ val elapsed = Duration.between(startInstant, now)
+ val total = Duration.ofMinutes(durationMinutes)
+ timeByDuration = total.minus(elapsed)
+ }
+ return when {
+ endsAt == null -> timeByDuration
+ durationMinutes == null -> timeByEnds
+ else -> listOfNotNull(timeByEnds, timeByDuration).minByOrNull { it }
+ }
+ }
+
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentTaskController.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentTaskController.kt
new file mode 100644
index 00000000..b820f0f1
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/controller/impl/user/student/StudentTaskController.kt
@@ -0,0 +1,317 @@
+package trik.testsys.webapp.backoffice.controller.impl.user.student
+
+import jakarta.servlet.http.HttpSession
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.stereotype.Controller
+import org.springframework.ui.Model
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.mvc.support.RedirectAttributes
+import org.springframework.http.MediaType
+import org.springframework.http.ContentDisposition
+import org.springframework.http.HttpHeaders
+import org.springframework.http.ResponseEntity
+import trik.testsys.webapp.backoffice.controller.AbstractUserController
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.service.ContestService
+import trik.testsys.webapp.backoffice.data.service.SolutionService
+import trik.testsys.webapp.backoffice.data.service.TaskService
+import trik.testsys.webapp.backoffice.service.FileManager
+import trik.testsys.webapp.backoffice.service.Grader
+import trik.testsys.webapp.backoffice.utils.addMessage
+import org.springframework.web.multipart.MultipartFile
+import trik.testsys.webapp.backoffice.data.service.VerdictService
+import java.nio.charset.StandardCharsets
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ConditionFileService
+import trik.testsys.webapp.backoffice.data.service.impl.taskFile.ExerciseFileService
+
+@Controller
+@RequestMapping("/user/student/contests/{contestId}/tasks/{taskId}")
+class StudentTaskController(
+ private val contestService: ContestService,
+ private val taskService: TaskService,
+ private val solutionService: SolutionService,
+ private val fileManager: FileManager,
+ private val grader: Grader,
+ private val verdictService: VerdictService,
+
+ @Value("\${trik.testsys.trik-studio.container.name}")
+ private val trikStudioContainerName: String,
+ private val exerciseFileService: ExerciseFileService, private val conditionFileService: ConditionFileService,
+) : AbstractUserController() {
+
+ @GetMapping
+ fun view(
+ @PathVariable("contestId") contestId: Long,
+ @PathVariable("taskId") taskId: Long,
+ model: Model,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ // Ensure task belongs to contest
+ if (contest.tasks.none { it.id == task.id }) {
+ redirectAttributes.addMessage("Задача не принадлежит данному Туру.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ setupModel(model, session, user)
+ model.addAttribute("contest", contest)
+ model.addAttribute("task", task)
+ val solutions = contest.solutions.filter { it.task.id == task.id }
+ .filter { it.createdBy.id == user.id }
+ .sortedByDescending { it.id }
+ val verdicts = verdictService.findAllBySolutions(solutions)
+ val verdictsBySolutionId = verdicts.associateBy { it.solutionId }
+
+ model.addAttribute("solutions", solutions)
+ model.addAttribute("verdicts", verdictsBySolutionId)
+ val resultsAvailability = solutions.associate { s ->
+ val hasVerdicts = fileManager.getVerdicts(s).isNotEmpty()
+ val hasRecordings = fileManager.getRecording(s).isNotEmpty()
+ (s.id!!) to (hasVerdicts || hasRecordings)
+ }
+ model.addAttribute("resultsAvailable", resultsAvailability)
+ val maxScore = verdicts.maxOfOrNull { it.value } ?: 0
+ model.addAttribute("maxScore", maxScore)
+ val hasExercise = task.data.exerciseFileIds.isNotEmpty()
+ val hasCondition = task.data.conditionFileIds.isNotEmpty()
+ model.addAttribute("hasExercise", hasExercise)
+ model.addAttribute("hasCondition", hasCondition)
+ return "student/task"
+ }
+
+ @PostMapping
+ fun upload(
+ @PathVariable("contestId") contestId: Long,
+ @PathVariable("taskId") taskId: Long,
+ @RequestParam("file") file: MultipartFile,
+ @RequestParam("solutionType", required = false) type: Solution.SolutionType?,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): String {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+ if (contest.tasks.none { it.id == task.id }) {
+ redirectAttributes.addMessage("Задача не принадлежит данному Туру.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ val solution = Solution().also {
+ it.createdBy = user
+ it.contest = contest
+ it.task = task
+ it.type = type ?: Solution.SolutionType.QRS
+ }
+ val saved = solutionService.save(solution)
+ val ok = fileManager.saveSolution(saved, file)
+ if (!ok) {
+ redirectAttributes.addMessage("Не удалось сохранить файл решения.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ grader.sendToGrade(saved, Grader.GradingOptions(shouldRecordRun = true, trikStudioVersion = trikStudioContainerName))
+ redirectAttributes.addMessage("Решение загружено и отправлено на проверку.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ @GetMapping("/results/{solutionId}/download")
+ fun downloadResults(
+ @PathVariable("contestId") contestId: Long,
+ @PathVariable("taskId") taskId: Long,
+ @PathVariable("solutionId") solutionId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+ if (contest.tasks.none { it.id == task.id }) {
+ redirectAttributes.addMessage("Задача не принадлежит данному Туру.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ val solution = contest.solutions.firstOrNull { it.id == solutionId } ?: run {
+ redirectAttributes.addMessage("Решение не найдено.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+ if (solution.createdBy.id != user.id) {
+ redirectAttributes.addMessage("У вас нет доступа к данному Решению.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ val hasAnyResults = fileManager.getVerdicts(solution).isNotEmpty() || fileManager.getRecording(solution).isNotEmpty()
+ if (!hasAnyResults) {
+ redirectAttributes.addMessage("Результаты для данного Решения отсутствуют.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ val results = fileManager.getSolutionResultFilesCompressed(solution)
+ val bytes = results.readBytes()
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(results.name, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header("Content-Transfer-Encoding", "binary")
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/download/exercise")
+ fun downloadExercise(
+ @PathVariable("contestId") contestId: Long,
+ @PathVariable("taskId") taskId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+ if (contest.tasks.none { it.id == task.id }) {
+ redirectAttributes.addMessage("Задача не принадлежит данному Туру.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ val exerciseTf = task.data.exerciseFileIds.firstOrNull()?.let {
+ exerciseFileService.findById(it)
+ } ?: run {
+ redirectAttributes.addMessage("Упражнение не найдено.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+ val file = fileManager.getExerciseFile(exerciseTf) ?: run {
+ redirectAttributes.addMessage("Файл упражнения отсутствует на сервере.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ val bytes = file.readBytes()
+ val originalName = "${task.name} (Упражнение)"
+ val filename = "${originalName}${exerciseTf.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+
+ @GetMapping("/download/condition")
+ fun downloadCondition(
+ @PathVariable("contestId") contestId: Long,
+ @PathVariable("taskId") taskId: Long,
+ session: HttpSession,
+ redirectAttributes: RedirectAttributes,
+ ): Any {
+ val accessToken = getAccessToken(session, redirectAttributes) ?: return "redirect:/login"
+ val user = getUser(accessToken, redirectAttributes) ?: return "redirect:/login"
+
+ if (!user.privileges.contains(User.Privilege.STUDENT)) {
+ redirectAttributes.addMessage("Недостаточно прав.")
+ return "redirect:/user"
+ }
+
+ val contest = contestService.findById(contestId) ?: run {
+ redirectAttributes.addMessage("Тур не найден.")
+ return "redirect:/user/student/contests"
+ }
+ val task = taskService.findById(taskId) ?: run {
+ redirectAttributes.addMessage("Задача не найдена.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+ if (contest.tasks.none { it.id == task.id }) {
+ redirectAttributes.addMessage("Задача не принадлежит данному Туру.")
+ return "redirect:/user/student/contests/$contestId"
+ }
+
+ val conditionTf = task.data.conditionFileIds.firstOrNull()?.let {
+ conditionFileService.findById(it)
+ } ?: run {
+ redirectAttributes.addMessage("У задания нет Условия.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+ val file = fileManager.getConditionFile(conditionTf) ?: run {
+ redirectAttributes.addMessage("Файл условия отсутствует на сервере.")
+ return "redirect:/user/student/contests/$contestId/tasks/$taskId"
+ }
+
+ val bytes = file.readBytes()
+ val originalName = "${task.name} (Условие)"
+ val filename = "${originalName}${conditionTf.type?.extension}"
+ val disposition = ContentDisposition
+ .attachment()
+ .filename(filename, StandardCharsets.UTF_8)
+ .build()
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString())
+ .body(bytes)
+ }
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/AbstractFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/AbstractFile.kt
new file mode 100644
index 00000000..b19e7441
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/AbstractFile.kt
@@ -0,0 +1,54 @@
+package trik.testsys.webapp.backoffice.data.entity
+
+import jakarta.persistence.Basic
+import jakarta.persistence.Column
+import jakarta.persistence.Convert
+import jakarta.persistence.Enumerated
+import jakarta.persistence.ManyToMany
+import jakarta.persistence.MappedSuperclass
+import jakarta.persistence.Transient
+import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.type.SqlTypes
+import trik.testsys.webapp.backoffice.data.entity.impl.Task
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@MappedSuperclass
+abstract class AbstractFile : AbstractEntity() {
+
+ @Column(name = "name", nullable = false)
+ var name: String? = null
+
+ @Column(name = "data", nullable = false)
+ @JdbcTypeCode(SqlTypes.JSON)
+ var data: Data = Data()
+
+ @Column(name = "developer_id", nullable = false)
+ var developerId: Long? = null
+
+ @Column(name = "file_version", nullable = false)
+ var fileVersion: Long = 0
+
+ @Column(name = "is_removed", nullable = false)
+ var isRemoved: Boolean = false
+
+ @Convert(converter = FileType.Companion.EnumConverter::class)
+ @Column(name = "type", nullable = false)
+ var type: FileType? = null
+
+ abstract fun getFileName(version: Long = fileVersion): String
+
+ @get:Transient
+ val taskIds: List
+ get() = data.attachedTaskIds
+
+ data class Data(
+ val originalFileNameByVersion: MutableMap = mutableMapOf(),
+ val attachedTaskIds: MutableList = mutableListOf(),
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Sharable.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Sharable.kt
new file mode 100644
index 00000000..4cddb66c
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Sharable.kt
@@ -0,0 +1,12 @@
+package trik.testsys.webapp.backoffice.data.entity
+
+import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+interface Sharable {
+
+ val userGroups: MutableSet
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Token.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Token.kt
new file mode 100644
index 00000000..258129b1
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/Token.kt
@@ -0,0 +1,43 @@
+package trik.testsys.webapp.backoffice.data.entity
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.MappedSuperclass
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@MappedSuperclass
+abstract class Token(
+ @Enumerated(EnumType.STRING)
+ @Column(name = "type", nullable = false, length = 20)
+ val type: Type
+) : AbstractEntity() {
+
+ @Column(name = "value", nullable = false)
+ var value: String? = null
+ set(value) {
+ val prefix = type.dbKey.lowercase()
+ field = "$prefix-$value"
+ }
+
+ @Suppress("unused")
+ enum class Type(override val dbKey: String) : PersistableEnum {
+
+ REGISTRATION("REG"),
+ ACCESS("ACS"),
+ STUDENT_GROUP("STG");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class JpaConverter : AbstractPersistableEnumConverter()
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/AccessToken.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/AccessToken.kt
new file mode 100644
index 00000000..f4cd72f8
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/AccessToken.kt
@@ -0,0 +1,22 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Entity
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}token", uniqueConstraints = [
+ UniqueConstraint(name = "${TABLE_PREFIX}uc_token_type_value", columnNames = ["type", "value"])
+])
+class AccessToken() : Token(Type.ACCESS) {
+
+ @OneToOne(mappedBy = "accessToken")
+ var user: User? = null
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Contest.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Contest.kt
new file mode 100644
index 00000000..e8303139
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Contest.kt
@@ -0,0 +1,99 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.*
+import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.type.SqlTypes
+import trik.testsys.webapp.backoffice.data.entity.Sharable
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+import java.time.Instant
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}contest")
+class Contest() :
+ AbstractEntity(),
+ Sharable {
+
+ @Column(name = "name", nullable = false)
+ var name: String? = null
+
+ @Column(name = "starts_at", nullable = false)
+ var startsAt: Instant? = null
+
+ @Column(name = "ends_at")
+ var endsAt: Instant? = null
+
+ @Column(name = "duration", nullable = true)
+ var duration: Long? = null
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "developer_id", nullable = false)
+ var developer: User? = null
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "ts_contest_userGroups",
+ joinColumns = [JoinColumn(name = "contest_id")],
+ inverseJoinColumns = [JoinColumn(name = "userGroups_id")]
+ )
+ override var userGroups: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "contest", orphanRemoval = true)
+ var solutions: MutableSet = mutableSetOf()
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "ts_contest_tasks",
+ joinColumns = [JoinColumn(name = "contest_id")],
+ inverseJoinColumns = [JoinColumn(name = "tasks_id")]
+ )
+ var tasks: MutableSet = mutableSetOf()
+
+ @Column(name = "data", nullable = false)
+ @JdbcTypeCode(SqlTypes.JSON)
+ val data: Data = Data()
+
+ fun getOrders(): Map {
+ // If no explicit order is set, fall back to id order with 1..N labels
+ if (data.orderByTaskId.isEmpty()) {
+ var idx = 0L
+ return tasks
+ .sortedBy { it.id }
+ .associate { task -> task.id!! to (++idx) }
+ }
+
+ // There is some saved order: normalize to 1..N without mutating entity
+ val attachedIds = tasks.mapNotNull { it.id }.toSet()
+ val bySaved = data.orderByTaskId
+ .filterKeys { it in attachedIds }
+ .toList()
+ .sortedBy { it.second }
+
+ val result = LinkedHashMap(tasks.size)
+ var pos = 1L
+ for ((taskId, _) in bySaved) {
+ result[taskId] = pos
+ pos += 1
+ }
+
+ val remaining = tasks
+ .sortedBy { it.id }
+ .mapNotNull { it.id }
+ .filterNot { it in result.keys }
+ for (taskId in remaining) {
+ result[taskId] = pos
+ pos += 1
+ }
+
+ return result
+ }
+
+ data class Data(
+ val orderByTaskId: MutableMap = mutableMapOf()
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/ContestRun.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/ContestRun.kt
new file mode 100644
index 00000000..d7fa339e
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/ContestRun.kt
@@ -0,0 +1,24 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.*
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import java.time.Instant
+
+@Entity
+@Table(name = "${TABLE_PREFIX}contest_run")
+class ContestRun : AbstractEntity() {
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ lateinit var user: User
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "contest_id", nullable = false)
+ lateinit var contest: Contest
+
+ @Column(name = "started_at", nullable = false)
+ lateinit var startedAt: Instant
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/PolygonDiagnosticReportEntity.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/PolygonDiagnosticReportEntity.kt
new file mode 100644
index 00000000..f99ae8c0
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/PolygonDiagnosticReportEntity.kt
@@ -0,0 +1,88 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile
+import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReport
+import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReportSeverity
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}polygon_diagnostic_report")
+class PolygonDiagnosticReportEntity(
+ @Column(name = "polygon_file_id", nullable = false)
+ val polygonFileId: Long,
+
+ @Column(name = "diagnostic_name", nullable = false)
+ val diagnosticName: String,
+ @Column(name = "level", nullable = false)
+ val level: Level,
+ @Column(name = "description", nullable = false)
+ val description: String,
+ @Column(name = "location", nullable = true, length = 4000)
+ val location: String? = null
+) : AbstractEntity() {
+
+ @Column(name = "is_removed", nullable = false)
+ var isRemoved: Boolean = false
+
+ class Builder internal constructor() {
+
+ private var polygonFile: PolygonFile? = null
+ private var polygonDiagnosticReport: PolygonDiagnosticReport? = null
+
+ fun polygonFile(polygonFile: PolygonFile) = apply {
+ this.polygonFile = polygonFile
+ }
+
+ fun polygonDiagnosticReport(polygonDiagnosticReport: PolygonDiagnosticReport) = apply {
+ this.polygonDiagnosticReport = polygonDiagnosticReport
+ }
+
+ fun with(polygonFile: PolygonFile) = this
+ .polygonFile(polygonFile)
+ .build()
+
+ fun build() = PolygonDiagnosticReportEntity(
+ polygonFileId = requireNotNull(polygonFile?.id),
+
+ diagnosticName = requireNotNull(polygonDiagnosticReport).diagnosticName,
+ level = when (requireNotNull(polygonDiagnosticReport).severity) {
+ PolygonDiagnosticReportSeverity.Error -> Level.ERROR
+ PolygonDiagnosticReportSeverity.Warning -> Level.WARNING
+ },
+ description = requireNotNull(polygonDiagnosticReport).description,
+ location = requireNotNull(polygonDiagnosticReport).location
+ )
+ }
+
+ enum class Level(override val dbKey: String, val locale: String) : PersistableEnum {
+ WARNING("W", "Предупреждение"),
+ ERROR("E", "Ошибка");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class EnumConverter : AbstractPersistableEnumConverter()
+ }
+ }
+
+ companion object {
+
+ @JvmStatic
+ fun from(polygonDiagnosticReport: PolygonDiagnosticReport): Builder {
+ val builder = Builder()
+ return builder.polygonDiagnosticReport(polygonDiagnosticReport)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/RegToken.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/RegToken.kt
new file mode 100644
index 00000000..edca7a15
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/RegToken.kt
@@ -0,0 +1,22 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Entity
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}token", uniqueConstraints = [
+ UniqueConstraint(name = "${TABLE_PREFIX}uc_token_type_value", columnNames = ["type", "value"])
+])
+class RegToken() : Token(Type.REGISTRATION) {
+
+ @OneToOne(mappedBy = "adminRegToken")
+ var viewer: User? = null
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Solution.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Solution.kt
new file mode 100644
index 00000000..61e486dc
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Solution.kt
@@ -0,0 +1,85 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import jakarta.persistence.*
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+@Entity
+@Table(name = "${TABLE_PREFIX}solution")
+class Solution() : AbstractEntity() {
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "created_by_id", nullable = false)
+ lateinit var createdBy: User
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ var status: Status = Status.IN_PROGRESS
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = true)
+ @JoinColumn(name = "contest_id", nullable = true)
+ var contest: Contest? = null
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "task_id", nullable = false)
+ lateinit var task: Task
+
+ @get:Transient
+ val isTest: Boolean
+ get() = contest == null
+
+ @Column(name = "relevant_verdict_id", nullable = true)
+ var relevantVerdictId: Long? = null
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "type", nullable = false)
+ var type: SolutionType = SolutionType.QRS
+
+ enum class Status(override val dbKey: String) : PersistableEnum {
+
+ NOT_STARTED("NST"),
+ IN_PROGRESS("INP"),
+ TIMEOUT("TMO"),
+ PASSED("PAS"),
+ ERROR("ERR");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class StatusConverter : AbstractPersistableEnumConverter() {
+ override fun convertToEntityAttribute(dbData: String?): Status? {
+ // Map legacy FAILED (FLD) to PASSED
+ if (dbData == "FLD") return PASSED
+ return super.convertToEntityAttribute(dbData)
+ }
+ }
+ }
+ }
+
+ enum class SolutionType(override val dbKey: String, val extension: String) : PersistableEnum {
+
+ QRS("QRS", "qrs"),
+ PYTHON("PY", "py"),
+ JAVA_SCRIPT("JS", "js");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class SolutionTypeConverter : AbstractPersistableEnumConverter()
+ }
+ }
+
+ @get:Transient
+ val fileName: String
+ get() = "solution-$id.$extension"
+
+ @get:Transient
+ val extension: String
+ get() = type.extension
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroup.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroup.kt
new file mode 100644
index 00000000..73cbab3f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroup.kt
@@ -0,0 +1,41 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.*
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}student_group")
+class StudentGroup() : AbstractEntity() {
+
+ @Column(name = "name")
+ var name: String? = null
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "owner_id", nullable = false)
+ var owner: User? = null
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "${TABLE_PREFIX}student_group_users",
+ joinColumns = [JoinColumn(name = "studentGroup_id")],
+ inverseJoinColumns = [JoinColumn(name = "members_id")]
+ )
+ var members: MutableSet = mutableSetOf()
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "${TABLE_PREFIX}student_group_contests",
+ joinColumns = [JoinColumn(name = "studentGroup_id")],
+ inverseJoinColumns = [JoinColumn(name = "contest_id")]
+ )
+ var contests: MutableSet = mutableSetOf()
+
+ @OneToOne(fetch = FetchType.EAGER, optional = true, orphanRemoval = true)
+ @JoinColumn(name = "student_group_token_id", nullable = true, unique = true)
+ var studentGroupToken: StudentGroupToken? = null
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroupToken.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroupToken.kt
new file mode 100644
index 00000000..dd093cb3
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/StudentGroupToken.kt
@@ -0,0 +1,22 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Entity
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}token", uniqueConstraints = [
+ UniqueConstraint(name = "${TABLE_PREFIX}uc_token_type_value", columnNames = ["type", "value"])
+])
+class StudentGroupToken() : Token(Type.STUDENT_GROUP) {
+
+ @OneToOne(mappedBy = "studentGroupToken")
+ var studentGroup: StudentGroup? = null
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Task.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Task.kt
new file mode 100644
index 00000000..48991f49
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Task.kt
@@ -0,0 +1,99 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.FetchType
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.JoinTable
+import jakarta.persistence.ManyToMany
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.OneToMany
+import jakarta.persistence.Table
+import jakarta.persistence.Transient
+import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.type.SqlTypes
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+import trik.testsys.webapp.backoffice.data.entity.Sharable
+
+
+@Entity
+@Table(name = "${TABLE_PREFIX}task")
+class Task() : AbstractEntity(), Sharable {
+
+ @Column(name = "name")
+ var name: String? = null
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "developer_id", nullable = false)
+ var developer: User? = null
+
+ @Deprecated("")
+ @ManyToMany
+ @JoinTable(
+ name = "${TABLE_PREFIX}task_taskFiles",
+ joinColumns = [JoinColumn(name = "task_id")],
+ inverseJoinColumns = [JoinColumn(name = "taskFiles_id")],
+ )
+ var taskFiles: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "task", orphanRemoval = true, fetch = FetchType.EAGER)
+ var solutions: MutableSet = mutableSetOf()
+
+ @get:Transient
+ val tests: Set
+ get() = solutions.filter { it.isTest }.toSet()
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "${TABLE_PREFIX}task_userGroups",
+ joinColumns = [JoinColumn(name = "task_id")],
+ inverseJoinColumns = [JoinColumn(name = "userGroups_id")]
+ )
+ override var userGroups: MutableSet = mutableSetOf()
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "testing_status", nullable = false)
+ var testingStatus: TestingStatus = TestingStatus.NOT_TESTED
+
+ @Column(name = "data", nullable = false)
+ @JdbcTypeCode(SqlTypes.JSON)
+ var data: Data = Data()
+
+ @get:Transient
+ val allowedSolutionTypes: Set
+ get() = data.solutionFileDataById.values.map { it.type }.toSet()
+
+ enum class TestingStatus(override val dbKey: String) : PersistableEnum {
+
+ NOT_TESTED("NTR"),
+ TESTING("TST"),
+ PASSED("PSD"),
+ FAILED("FLD");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class EnumConverter : AbstractPersistableEnumConverter()
+ }
+ }
+
+ data class Data(
+ val conditionFileIds: MutableList = mutableListOf(),
+ val exerciseFileIds: MutableList = mutableListOf(),
+ val polygonFileIds: MutableList = mutableListOf(),
+ val solutionFileDataById: MutableMap = mutableMapOf(),
+ )
+
+ data class SolutionFileData(
+ val type: Solution.SolutionType = Solution.SolutionType.QRS,
+ var lastSolutionId: Long? = null,
+ var score: Long = 0,
+ var lastTestScore: Long? = null
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/TaskFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/TaskFile.kt
new file mode 100644
index 00000000..7d3eb1b4
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/TaskFile.kt
@@ -0,0 +1,97 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.FetchType
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToMany
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.Table
+import jakarta.persistence.Transient
+import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.type.SqlTypes
+import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile.TaskFileType.Companion.extension
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+@Deprecated(
+ message = "Useless",
+ replaceWith = ReplaceWith(
+ "AbstractFile",
+ "trik.testsys.webapp.backoffice.data.entity.AbstractFile"
+ )
+)
+@Entity
+@Table(name = "${TABLE_PREFIX}task_file")
+class TaskFile() : AbstractEntity() {
+
+ @Column(name = "name", nullable = false)
+ var name: String? = null
+
+ @Column(name = "data", nullable = false)
+ @JdbcTypeCode(SqlTypes.JSON)
+ var data: Data = Data()
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "developer_id", nullable = false)
+ var developer: User? = null
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "type", nullable = false)
+ var type: TaskFileType? = null
+
+ @Column(name = "file_version", nullable = false)
+ var fileVersion: Long = 0
+
+ @Column(name = "is_removed", nullable = false)
+ var isRemoved: Boolean = false
+
+ @get:Transient
+ val fileName: String
+ get() = "$id-$fileVersion${type?.extension()}"
+
+ @ManyToMany(mappedBy = "taskFiles")
+ var tasks: MutableSet = mutableSetOf()
+
+ @Deprecated(message = "Useless enum class")
+ enum class TaskFileType(override val dbKey: String) : PersistableEnum {
+
+ POLYGON("PLG"),
+ EXERCISE("EXR"),
+ SOLUTION("SLN"),
+ CONDITION("CND");
+
+ fun canBeRemovedOnTaskTesting() = this == CONDITION || this == EXERCISE
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class TaskFileTypeConverter : AbstractPersistableEnumConverter()
+
+ @JvmStatic
+ fun TaskFileType.localized() = when(this) {
+ POLYGON -> "Полигон"
+ EXERCISE -> "Упражнение"
+ SOLUTION -> "Эталонное Решение"
+ CONDITION -> "Условие"
+ }
+
+ @JvmStatic
+ fun TaskFileType.extension() = when(this) {
+ POLYGON -> ".xml"
+ EXERCISE -> ".qrs"
+ SOLUTION -> ".qrs"
+ CONDITION -> ".pdf"
+ }
+ }
+ }
+
+ data class Data(
+ val originalFileNameByVersion: MutableMap = mutableMapOf()
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/User.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/User.kt
new file mode 100644
index 00000000..98452c0f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/User.kt
@@ -0,0 +1,143 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.CollectionTable
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.ElementCollection
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.FetchType
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToMany
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.OneToMany
+import jakarta.persistence.OneToOne
+import jakarta.persistence.Table
+import jakarta.persistence.Transient
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+import java.time.Instant
+
+@Entity
+@Table(name = "${TABLE_PREFIX}user")
+class User() : AbstractEntity() {
+
+ @Column(name = "name", nullable = false)
+ var name: String? = null
+
+ @OneToOne(fetch = FetchType.EAGER, optional = false, orphanRemoval = true)
+ @JoinColumn(name = "access_token_id", nullable = false, unique = true)
+ var accessToken: AccessToken? = null
+
+ @Column(name = "last_login_at")
+ var lastLoginAt: Instant? = null
+
+ @Column(name = "is_removed", nullable = false)
+ var isRemoved: Boolean = false
+
+ @get:Transient
+ val hasLoggedIn: Boolean
+ get() = lastLoginAt != null
+
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "${TABLE_PREFIX}privilege", joinColumns = [JoinColumn(name = "user_id")])
+ @Column(name = "${TABLE_PREFIX}privilege")
+ @Enumerated(EnumType.STRING)
+ val privileges: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "owner")
+ var ownedGroups: MutableSet = mutableSetOf()
+
+ @ManyToMany(mappedBy = "members")
+ var memberedGroups: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "owner")
+ var ownedStudentGroups: MutableSet = mutableSetOf()
+
+ @ManyToMany(mappedBy = "members")
+ var memberedStudentGroups: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "viewer")
+ var managedAdmins: MutableSet = mutableSetOf()
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "viewer_id")
+ var viewer: User? = null
+
+ @OneToOne(fetch = FetchType.EAGER)
+ @JoinColumn(name = "admin_reg_token_id", unique = true)
+ var adminRegToken: RegToken? = null
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "super_user_id")
+ var superUser: User? = null
+
+ @OneToMany(mappedBy = "superUser")
+ var createdUsers: MutableSet = mutableSetOf()
+
+ @Column(name = "all_user_super_user", nullable = false)
+ var isAllUserSuperUser: Boolean = false
+
+ @OneToMany(mappedBy = "developer", orphanRemoval = true)
+ var contests: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "developer", orphanRemoval = true)
+ var taskFiles: MutableSet = mutableSetOf()
+
+ @OneToMany(mappedBy = "createdBy", orphanRemoval = true)
+ var solutions: MutableSet = mutableSetOf()
+
+ @Column(name = "email", nullable = true, unique = true)
+ var email: String? = null
+
+ @Column(name = "email_verified_at", nullable = true)
+ var emailVerifiedAt: Instant? = null
+
+ @Column(name = "requested_email_detach", nullable = false)
+ var requestedEmailDetach: Boolean = false
+
+ fun hasAnyOf(privileges: Collection): Boolean {
+ privileges.forEach { privilege ->
+ if (this.privileges.contains(privilege)) return true
+ }
+
+ return false
+ }
+
+ fun hasAllOf(privileges: Collection): Boolean {
+ return this.privileges.containsAll(privileges)
+ }
+
+ fun hasOnly(privileges: Collection): Boolean {
+ return privileges.containsAll(this.privileges)
+ }
+
+ @Suppress("unused")
+ enum class Privilege(override val dbKey: String) : PersistableEnum {
+
+ ADMIN("ADM"),
+ DEVELOPER("DEV"),
+ JUDGE("JDG"),
+ STUDENT("ST"),
+ SUPER_USER("SU"),
+ VIEWER("VWR"),
+ GROUP_ADMIN("GA");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class JpaConverter : AbstractPersistableEnumConverter()
+ }
+ }
+
+ companion object {
+
+ const val ACCESS_TOKEN = "accessToken"
+ const val PRIVILEGES = "privileges"
+ const val IS_ALL_USER_SUPER_USER = "isAllUserSuperUser"
+ const val IS_REMOVED = "isRemoved"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/UserGroup.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/UserGroup.kt
new file mode 100644
index 00000000..aa5fa49f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/UserGroup.kt
@@ -0,0 +1,66 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.FetchType
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.JoinTable
+import jakarta.persistence.ManyToMany
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.PrePersist
+import jakarta.persistence.Table
+import jakarta.persistence.Transient
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "user_group")
+class UserGroup : AbstractEntity() {
+
+ @Column(name = "name")
+ var name: String? = null
+
+ /**
+ * Flag for default group for each user. Better be only one default group in system.
+ */
+ @Column(name = "is_default", nullable = false)
+ var defaultGroup: Boolean = false
+
+ @PrePersist
+ fun prePersist() {
+ owner?.let { addMember(it) } ?: error("'owner' field must be initialized")
+ }
+
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
+ @JoinColumn(name = "owner_id", nullable = false)
+ var owner: User? = null
+
+ @ManyToMany
+ @JoinTable(
+ name = "${TABLE_PREFIX}user_group_members",
+ joinColumns = [JoinColumn(name = "userGroup_id")],
+ inverseJoinColumns = [JoinColumn(name = "member_id")]
+ )
+ var members: MutableSet = mutableSetOf()
+
+ @get:Transient
+ val activeMembers: Set
+ get() = members.filter { !it.isRemoved }.toSet()
+
+ fun addMember(member: User) = addMembers(listOf(member))
+
+ fun addMembers(members: Collection) = this.members.addAll(members)
+
+ fun removeMember(member: User) = removeMembers(listOf(member))
+
+ fun removeMembers(members: Collection) = this.members.removeAll(members)
+
+ @ManyToMany(mappedBy = "userGroups")
+ var contests: MutableSet = mutableSetOf()
+
+// @ManyToMany(mappedBy = "userGroups")
+// var taskTemplates: MutableSet = mutableSetOf()
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Verdict.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Verdict.kt
new file mode 100644
index 00000000..6421d31d
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/Verdict.kt
@@ -0,0 +1,19 @@
+package trik.testsys.webapp.backoffice.data.entity.impl
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+@Entity
+@Table(name = "${TABLE_PREFIX}verdict")
+class Verdict() : AbstractEntity() {
+
+ @Column(name = "value", nullable = false)
+ var value: Long = -1
+
+
+ @Column(name = "solution_id", nullable = false)
+ var solutionId: Long = -1
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ConditionFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ConditionFile.kt
new file mode 100644
index 00000000..b46f6a09
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ConditionFile.kt
@@ -0,0 +1,25 @@
+package trik.testsys.webapp.backoffice.data.entity.impl.taskFile
+
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.backoffice.data.entity.AbstractFile
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}condition_file")
+class ConditionFile : AbstractFile() {
+
+ override fun getFileName(version: Long) = "$FILE_NAME_PREFIX-$id-$version${type?.extension}"
+
+ companion object {
+
+ const val FILE_NAME_PREFIX = "cond"
+
+ val allowedTypes = setOf(FileType.PDF, FileType.TXT)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ExerciseFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ExerciseFile.kt
new file mode 100644
index 00000000..4c7e54d8
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/ExerciseFile.kt
@@ -0,0 +1,25 @@
+package trik.testsys.webapp.backoffice.data.entity.impl.taskFile
+
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.backoffice.data.entity.AbstractFile
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}exercise_file")
+class ExerciseFile : AbstractFile() {
+
+ override fun getFileName(version: Long) = "$FILE_NAME_PREFIX-$id-$version${type?.extension}"
+
+ companion object {
+
+ const val FILE_NAME_PREFIX = "ex"
+
+ val allowedTypes = setOf(FileType.QRS, FileType.ZIP)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/PolygonFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/PolygonFile.kt
new file mode 100644
index 00000000..55f6fc42
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/PolygonFile.kt
@@ -0,0 +1,50 @@
+package trik.testsys.webapp.backoffice.data.entity.impl.taskFile
+
+import jakarta.persistence.Column
+import jakarta.persistence.Converter
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.backoffice.data.entity.AbstractFile
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}polygon_file")
+class PolygonFile : AbstractFile() {
+
+ @Column(name = "timeLimit", nullable = false)
+ var timeLimit: Long = -1
+
+ @Column(name = "analysis_status", nullable = false)
+ var analysisStatus: AnalysisStatus = AnalysisStatus.NOT_ANALYZED
+
+ override var type: FileType? = FileType.XML
+
+ override fun getFileName(version: Long) = "$FILE_NAME_PREFIX-$id-$version${type?.extension}"
+
+ enum class AnalysisStatus(override val dbKey: String, val locale: String) : PersistableEnum {
+ NOT_ANALYZED("NA", "Ожидает"),
+ ANALYZING("A", "В процессе"),
+ SUCCESS("S", "Ошибки отсутствуют"),
+ FAILED("F", "Имеются ошибки");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class EnumConverter : AbstractPersistableEnumConverter()
+ }
+ }
+
+ companion object {
+
+ const val FILE_NAME_PREFIX = "pol"
+
+ val allowedTypes = setOf(FileType.XML)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/SolutionFile.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/SolutionFile.kt
new file mode 100644
index 00000000..e0df268e
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/entity/impl/taskFile/SolutionFile.kt
@@ -0,0 +1,32 @@
+package trik.testsys.webapp.backoffice.data.entity.impl.taskFile
+
+import jakarta.persistence.Column
+import jakarta.persistence.Convert
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import trik.testsys.webapp.backoffice.data.entity.AbstractFile
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.enums.FileType
+import trik.testsys.webapp.core.data.entity.AbstractEntity.Companion.TABLE_PREFIX
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Entity
+@Table(name = "${TABLE_PREFIX}solution_file")
+class SolutionFile : AbstractFile() {
+
+ override fun getFileName(version: Long) = "$FILE_NAME_PREFIX-$id-$version${type?.extension}"
+
+ @Convert(converter = Solution.SolutionType.Companion.SolutionTypeConverter::class)
+ @Column(name = "solution_type", nullable = false)
+ var solutionType: Solution.SolutionType = Solution.SolutionType.QRS
+
+ companion object {
+
+ const val FILE_NAME_PREFIX = "sol"
+
+ val allowedTypes = setOf(FileType.QRS, FileType.PYTHON, FileType.JAVASCRIPT)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/enums/FileType.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/enums/FileType.kt
new file mode 100644
index 00000000..7ca85687
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/enums/FileType.kt
@@ -0,0 +1,29 @@
+package trik.testsys.webapp.backoffice.data.enums
+
+import jakarta.persistence.Converter
+import trik.testsys.webapp.core.utils.enums.PersistableEnum
+import trik.testsys.webapp.core.utils.enums.converter.AbstractPersistableEnumConverter
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+enum class FileType(
+ override val dbKey: String,
+ val extension: String,
+) : PersistableEnum {
+
+ QRS("QRS", ".qrs"),
+ PYTHON("PY", ".py"),
+ JAVASCRIPT("JS", ".js"),
+ XML("XML", ".xml"),
+ ZIP("ZIP", ".zip"),
+ PDF("PDF", ".pdf"),
+ TXT("TXT", ".txt");
+
+ companion object {
+
+ @Converter(autoApply = true)
+ class EnumConverter : AbstractPersistableEnumConverter()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/AccessTokenRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/AccessTokenRepository.kt
new file mode 100644
index 00000000..2891d3f7
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/AccessTokenRepository.kt
@@ -0,0 +1,18 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.backoffice.data.entity.impl.AccessToken
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface AccessTokenRepository : EntityRepository {
+
+ fun findByValueAndType(value: String?, type: Token.Type): AccessToken?
+
+ fun findByValueIn(values: Collection): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRepository.kt
new file mode 100644
index 00000000..c6ba2c6d
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRepository.kt
@@ -0,0 +1,19 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ **/
+@Repository
+interface ContestRepository : EntityRepository {
+
+ fun findByDeveloper(developer: User): Set
+
+ fun findDistinctByUserGroupsIn(userGroups: Collection): Set
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRunRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRunRepository.kt
new file mode 100644
index 00000000..c4a3a115
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/ContestRunRepository.kt
@@ -0,0 +1,17 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.ContestRun
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+@Repository
+interface ContestRunRepository : EntityRepository {
+
+ fun findByUserAndContest(user: User, contest: Contest): ContestRun?
+
+ fun findAllByUser(user: User): Set
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/PolygonDiagnosticReportEntityRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/PolygonDiagnosticReportEntityRepository.kt
new file mode 100644
index 00000000..b66ad3bc
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/PolygonDiagnosticReportEntityRepository.kt
@@ -0,0 +1,15 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.PolygonDiagnosticReportEntity
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface PolygonDiagnosticReportEntityRepository : EntityRepository {
+
+ fun findByPolygonFileIdAndIsRemoved(polygonFileId: Long?, isRemoved: Boolean = false): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/RegTokenRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/RegTokenRepository.kt
new file mode 100644
index 00000000..2747e2fe
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/RegTokenRepository.kt
@@ -0,0 +1,12 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.backoffice.data.entity.impl.RegToken
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+@Repository
+interface RegTokenRepository : EntityRepository {
+
+ fun findByValueAndType(value: String?, type: Token.Type): RegToken?
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/SolutionRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/SolutionRepository.kt
new file mode 100644
index 00000000..54f326a9
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/SolutionRepository.kt
@@ -0,0 +1,16 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.Task
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface SolutionRepository : EntityRepository {
+
+ fun findAllByTaskInAndContestNull(tasks: List): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupRepository.kt
new file mode 100644
index 00000000..121326ec
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupRepository.kt
@@ -0,0 +1,14 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroup
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+@Repository
+interface StudentGroupRepository : EntityRepository {
+
+ fun findByOwner(owner: User): Set
+
+ fun existsByContests_Id(contestId: Long): Boolean
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupTokenRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupTokenRepository.kt
new file mode 100644
index 00000000..12548a6f
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/StudentGroupTokenRepository.kt
@@ -0,0 +1,14 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.Token
+import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroupToken
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+@Repository
+interface StudentGroupTokenRepository : EntityRepository {
+
+ fun findByValueAndType(value: String?, type: Token.Type): StudentGroupToken?
+}
+
+
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskFileRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskFileRepository.kt
new file mode 100644
index 00000000..d3d922d5
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskFileRepository.kt
@@ -0,0 +1,18 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ **/
+@Repository
+interface TaskFileRepository : EntityRepository {
+
+ fun findByDeveloper(developer: User): Set
+
+ fun findByDeveloperAndType(developer: User, type: TaskFile.TaskFileType): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskRepository.kt
new file mode 100644
index 00000000..37c747c4
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/TaskRepository.kt
@@ -0,0 +1,18 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.Task
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+
+@Repository
+interface TaskRepository : EntityRepository {
+
+ fun findByDeveloper(developer: User): Set
+
+ fun findDistinctByUserGroupsIn(userGroups: Collection): Set
+
+ fun findByTestingStatus(testingStatus: Task.TestingStatus): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserGroupRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserGroupRepository.kt
new file mode 100644
index 00000000..4a36f476
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserGroupRepository.kt
@@ -0,0 +1,20 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface UserGroupRepository: EntityRepository {
+
+ fun findByOwner(owner: User): Set
+
+ fun findByMembersContaining(member: User): Set
+
+ fun findByDefaultGroupTrue(): UserGroup?
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserRepository.kt
new file mode 100644
index 00000000..d5e156fa
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/UserRepository.kt
@@ -0,0 +1,19 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface UserRepository: EntityRepository {
+
+ fun findAllBySuperUser(superUser: User): Set
+
+ fun findByViewer(viewer: User): Set
+
+ fun findByEmailAndEmailVerifiedAtNotNull(email: String): User?
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/VerdictRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/VerdictRepository.kt
new file mode 100644
index 00000000..560e62af
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/VerdictRepository.kt
@@ -0,0 +1,11 @@
+package trik.testsys.webapp.backoffice.data.repository
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.Verdict
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+@Repository
+interface VerdictRepository : EntityRepository {
+
+ fun findAllBySolutionId(solutionId: Long): Set
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecifications.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecifications.kt
new file mode 100644
index 00000000..b7779412
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecifications.kt
@@ -0,0 +1,97 @@
+package trik.testsys.webapp.backoffice.data.repository.support
+
+import jakarta.persistence.criteria.JoinType
+import org.springframework.data.jpa.domain.Specification
+import trik.testsys.webapp.backoffice.data.entity.impl.Contest
+import trik.testsys.webapp.backoffice.data.entity.impl.Solution
+import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroup
+import trik.testsys.webapp.backoffice.data.entity.impl.User
+import trik.testsys.webapp.core.data.entity.AbstractEntity
+import java.time.Instant
+
+object SolutionSpecifications {
+
+ /** Solutions whose creator has the STUDENT privilege. */
+ fun hasStudentPrivilege(): Specification = Specification { root, query, cb ->
+ query?.distinct(true)
+ val userJoin = root.join("createdBy", JoinType.INNER)
+ val privJoin = userJoin.join(User.PRIVILEGES, JoinType.INNER)
+ cb.equal(privJoin, User.Privilege.STUDENT)
+ }
+
+ /** Solutions created by a specific student user id. */
+ fun createdBy(studentId: Long): Specification = Specification { root, _, cb ->
+ cb.equal(root.get("createdBy").get(AbstractEntity.ID), studentId)
+ }
+
+ /**
+ * Solutions where the student belongs to a StudentGroup with [groupId]
+ * that also contains the solution's contest.
+ *
+ * Note: solutions with a null contest (test/non-contest submissions) are excluded from
+ * results because the subquery predicate on `Solution.contest` evaluates to NULL.
+ */
+ fun inGroup(groupId: Long): Specification = Specification { root, query, cb ->
+ query ?: return@Specification cb.conjunction()
+ val sub = query.subquery(Long::class.java)
+ val sg = sub.from(StudentGroup::class.java)
+ val member = sg.join("members", JoinType.INNER)
+ val contest = sg.join("contests", JoinType.INNER)
+ sub.select(sg.get(AbstractEntity.ID)).where(
+ cb.equal(sg.get(AbstractEntity.ID), groupId),
+ cb.equal(member.get(AbstractEntity.ID), root.get("createdBy").get(AbstractEntity.ID)),
+ cb.equal(contest.get(AbstractEntity.ID), root.get("contest").get(AbstractEntity.ID))
+ )
+ cb.exists(sub)
+ }
+
+ /**
+ * Solutions where the student's contest group is owned by [adminId].
+ *
+ * Note: solutions with a null contest (test/non-contest submissions) are excluded from
+ * results because the subquery predicate on `Solution.contest` evaluates to NULL.
+ */
+ fun underAdmin(adminId: Long): Specification = Specification { root, query, cb ->
+ query ?: return@Specification cb.conjunction()
+ val sub = query.subquery(Long::class.java)
+ val sg = sub.from(StudentGroup::class.java)
+ val member = sg.join("members", JoinType.INNER)
+ val contest = sg.join("contests", JoinType.INNER)
+ sub.select(sg.get(AbstractEntity.ID)).where(
+ cb.equal(sg.get("owner").get(AbstractEntity.ID), adminId),
+ cb.equal(member.get(AbstractEntity.ID), root.get("createdBy").get(AbstractEntity.ID)),
+ cb.equal(contest.get(AbstractEntity.ID), root.get("contest").get(AbstractEntity.ID))
+ )
+ cb.exists(sub)
+ }
+
+ /**
+ * Solutions where the group admin's viewer is [viewerId].
+ *
+ * Note: solutions with a null contest (test/non-contest submissions) are excluded from
+ * results because the subquery predicate on `Solution.contest` evaluates to NULL.
+ */
+ fun underViewer(viewerId: Long): Specification = Specification { root, query, cb ->
+ query ?: return@Specification cb.conjunction()
+ val sub = query.subquery(Long::class.java)
+ val sg = sub.from(StudentGroup::class.java)
+ val member = sg.join("members", JoinType.INNER)
+ val contest = sg.join("contests", JoinType.INNER)
+ sub.select(sg.get(AbstractEntity.ID)).where(
+ cb.equal(sg.get("owner").get("viewer").get(AbstractEntity.ID), viewerId),
+ cb.equal(member.get(AbstractEntity.ID), root.get("createdBy").get(AbstractEntity.ID)),
+ cb.equal(contest.get(AbstractEntity.ID), root.get("contest").get(AbstractEntity.ID))
+ )
+ cb.exists(sub)
+ }
+
+ /** Solutions created at or after [from]. */
+ fun createdAfter(from: Instant): Specification = Specification { root, _, cb ->
+ cb.greaterThanOrEqualTo(root.get(AbstractEntity.CREATED_AT), from)
+ }
+
+ /** Solutions created at or before [to]. */
+ fun createdBefore(to: Instant): Specification = Specification { root, _, cb ->
+ cb.lessThanOrEqualTo(root.get(AbstractEntity.CREATED_AT), to)
+ }
+}
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ConditionFileRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ConditionFileRepository.kt
new file mode 100644
index 00000000..8f858a32
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ConditionFileRepository.kt
@@ -0,0 +1,15 @@
+package trik.testsys.webapp.backoffice.data.repository.taskFile
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ConditionFile
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface ConditionFileRepository : EntityRepository {
+
+ fun findByDeveloperId(developerId: Long): Set
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ExerciseFileRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ExerciseFileRepository.kt
new file mode 100644
index 00000000..77356f71
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/ExerciseFileRepository.kt
@@ -0,0 +1,15 @@
+package trik.testsys.webapp.backoffice.data.repository.taskFile
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ExerciseFile
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface ExerciseFileRepository : EntityRepository {
+
+ fun findByDeveloperId(developerId: Long): Set
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/PolygonFileRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/PolygonFileRepository.kt
new file mode 100644
index 00000000..ff1069b0
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/PolygonFileRepository.kt
@@ -0,0 +1,17 @@
+package trik.testsys.webapp.backoffice.data.repository.taskFile
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface PolygonFileRepository : EntityRepository {
+
+ fun findByDeveloperId(developerId: Long): Set
+
+ fun findByAnalysisStatus(analysisStatus: PolygonFile.AnalysisStatus): List
+}
\ No newline at end of file
diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/SolutionFileRepository.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/SolutionFileRepository.kt
new file mode 100644
index 00000000..e46f3ded
--- /dev/null
+++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/repository/taskFile/SolutionFileRepository.kt
@@ -0,0 +1,17 @@
+package trik.testsys.webapp.backoffice.data.repository.taskFile
+
+import org.springframework.stereotype.Repository
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ExerciseFile
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile
+import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.SolutionFile
+import trik.testsys.webapp.core.data.repository.EntityRepository
+
+/**
+ * @author Roman Shishkin
+ * @since %CURRENT_VERSION%
+ */
+@Repository
+interface SolutionFileRepository : EntityRepository {
+
+ fun findByDeveloperId(developerId: Long): Set