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 +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestRunService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestRunService.kt new file mode 100644 index 00000000..fbfd2026 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestRunService.kt @@ -0,0 +1,14 @@ +package trik.testsys.webapp.backoffice.data.service + +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.service.EntityService + +interface ContestRunService : EntityService { + fun findByUserAndContest(user: User, contest: Contest): ContestRun? + fun findAllByUser(user: User): Set + fun startIfAbsent(user: User, contest: Contest): ContestRun +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestService.kt new file mode 100644 index 00000000..11d799c4 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ContestService.kt @@ -0,0 +1,16 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.impl.Contest +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface ContestService : EntityService { + + fun findForOwner(user: User): Set + + fun findForUser(user: User): Set +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/PolygonDiagnosticReportEntityService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/PolygonDiagnosticReportEntityService.kt new file mode 100644 index 00000000..d82c6650 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/PolygonDiagnosticReportEntityService.kt @@ -0,0 +1,13 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.impl.PolygonDiagnosticReportEntity +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface PolygonDiagnosticReportEntityService : EntityService { + + fun findActiveByPolygonFileId(polygonFileId: Long): List +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SharableEntityService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SharableEntityService.kt new file mode 100644 index 00000000..79a30f43 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SharableEntityService.kt @@ -0,0 +1,13 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.Sharable +import trik.testsys.webapp.core.data.entity.AbstractEntity +import trik.testsys.webapp.core.data.service.EntityService + +interface SharableEntityService : EntityService + where E : AbstractEntity, + E : Sharable { + + fun a() + +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SolutionService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SolutionService.kt new file mode 100644 index 00000000..3aeb04b5 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SolutionService.kt @@ -0,0 +1,31 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Task +import trik.testsys.webapp.core.data.service.EntityService +import java.time.Instant + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface SolutionService : EntityService { + + fun findAllTestingSolutions(task: Task): List = findAllTestingSolutions(listOf(task)) + + fun findAllTestingSolutions(tasks: List): List + + fun updateStatus(solutionId: Long, newStatus: Solution.Status) + + fun findStudentSolutionsPage( + studentId: Long?, + groupId: Long?, + adminId: Long?, + viewerId: Long?, + fromDate: Instant?, + toDate: Instant?, + pageable: Pageable, + ): Page +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/StudentGroupService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/StudentGroupService.kt new file mode 100644 index 00000000..f7065e54 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/StudentGroupService.kt @@ -0,0 +1,38 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroup +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface StudentGroupService : EntityService { + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = false) + fun addMember(studentGroup: StudentGroup, member: User): Boolean + + // Unused; removing misleading bulk add with singular param. Add later if needed. + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = false) + fun removeMember(studentGroup: StudentGroup, member: User): Boolean + + fun findByOwner(owner: User): Set + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun create(owner: User, name: String, info: String?): StudentGroup? + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = false) + fun generateStudents(owner: User, group: StudentGroup, count: Int): Set + + fun generateMembersCsv(group: StudentGroup): ByteArray + + fun generateResultsCsv(group: StudentGroup): ByteArray + + fun generateResultsCsv(groups: Collection): ByteArray + + fun existsByContestId(contestId: Long): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SuperUserService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SuperUserService.kt new file mode 100644 index 00000000..c6b6ad9e --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/SuperUserService.kt @@ -0,0 +1,28 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.User + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface SuperUserService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createUser(superUser: User, name: String, privileges: Collection): Boolean + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun addPrivilege(superUser: User, user: User, privilege: User.Privilege): Boolean + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun addPrivileges(superUser: User, user: User, privileges: Collection): Boolean = + privileges.forEach { addPrivilege(superUser, user, it) }.let { true } + + @Transactional(propagation = Propagation.REQUIRED, readOnly = true) + fun findAllSuperUser(isAllUserSuperUser: Boolean? = null): Set + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun removeUser(superUser: User, user: User): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskFileService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskFileService.kt new file mode 100644 index 00000000..e5505f8a --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskFileService.kt @@ -0,0 +1,14 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface TaskFileService : EntityService { + + fun findByDeveloper(developer: User): Set +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskService.kt new file mode 100644 index 00000000..46a7c81a --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TaskService.kt @@ -0,0 +1,18 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.impl.Task +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface TaskService : EntityService { + + fun findByDeveloper(developer: User): Set + + fun findForUser(user: User): Set + + fun findAllTesting(): List +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TokenService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TokenService.kt new file mode 100644 index 00000000..844340c4 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/TokenService.kt @@ -0,0 +1,15 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.Token +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface TokenService : EntityService { + + fun generate(): T + + fun findByValue(value: String): T? +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserGroupService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserGroupService.kt new file mode 100644 index 00000000..7ee11159 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserGroupService.kt @@ -0,0 +1,33 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface UserGroupService : EntityService { + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + fun addMember(userGroup: UserGroup, user: User): Boolean + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + fun removeMember(userGroup: UserGroup, user: User): Boolean + + fun findByOwner(owner: User): Set + + fun findByMember(member: User): Set + + @Transactional(propagation = Propagation.REQUIRED) + fun create(owner: User, name: String, info: String?): UserGroup? + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + fun getOrCreateDefaultGroup(owner: User): UserGroup + + @Transactional(propagation = Propagation.REQUIRED, readOnly = true) + fun getDefaultGroup(): UserGroup? +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserService.kt new file mode 100644 index 00000000..662df1ee --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/UserService.kt @@ -0,0 +1,42 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup +import trik.testsys.webapp.core.data.service.EntityService +import java.time.Instant + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface UserService : EntityService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun updateName(user: User, newName: String): User + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun updateEmail(user: User, newEmail: String?): User + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun updateLastLoginAt(user: User, lastLoginAt: Instant? = null): User + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + fun findAllGroupAdmin(): Set + + @Transactional(propagation = Propagation.REQUIRED, readOnly = true) + fun findAllBySuperUser(superUser: User): Set + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createUserByGroupAdmin( + groupAdmin: User, + name: String, + privileges: Collection + ): User? + + @Transactional(propagation = Propagation.REQUIRED, readOnly = true) + fun findCandidatesFor(userGroup: UserGroup): Set + + fun findByEmail(email: String): User? +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/VerdictService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/VerdictService.kt new file mode 100644 index 00000000..a6919533 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/VerdictService.kt @@ -0,0 +1,20 @@ +package trik.testsys.webapp.backoffice.data.service + +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Verdict +import trik.testsys.webapp.core.data.service.EntityService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface VerdictService : EntityService { + + fun createNewForSolution(solution: Solution, score: Long): Verdict + + fun findAllBySolutions(solutions: Collection): Set + + fun findAllBySolutionIds(solutionIds: Collection): Set + + fun findAllForSolution(solutionId: Long): Set +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ViewerService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ViewerService.kt new file mode 100644 index 00000000..3bb6e053 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/ViewerService.kt @@ -0,0 +1,15 @@ +package trik.testsys.webapp.backoffice.data.service + +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.User + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface ViewerService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createAdmin(viewer: User, name: String?): User? +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/AccessTokenService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/AccessTokenService.kt new file mode 100644 index 00000000..42970b2a --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/AccessTokenService.kt @@ -0,0 +1,78 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.Token +import java.security.SecureRandom +import trik.testsys.webapp.backoffice.data.entity.impl.AccessToken +import trik.testsys.webapp.backoffice.data.repository.AccessTokenRepository +import trik.testsys.webapp.backoffice.data.service.TokenService +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class AccessTokenService : AbstractService(), TokenService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun generate(): AccessToken { + val random = SecureRandom() + val token = AccessToken() + + fun nextChunk(): String = Integer.toHexString(random.nextInt(0x10000)).padStart(4, '0') + + do { + token.value = "${nextChunk()}-${nextChunk()}-${nextChunk()}-${nextChunk()}" + } while (findByValue(token.value!!) != null) + + return save(token) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun generateBatch(count: Int): List { + require(count >= 1) { "count must be >= 1" } + + val random = SecureRandom() + + fun nextChunk(): String = Integer.toHexString(random.nextInt(0x10000)).padStart(4, '0') + + val tokens = ArrayList(count) + val values = LinkedHashSet(count) + + // generate optimistic set of values (allow temporary dups, will re-fill later) + while (values.size < count) { + values.add("${nextChunk()}-${nextChunk()}-${nextChunk()}-${nextChunk()}") + } + + // ensure uniqueness against DB and within batch + val existing = repository.findByValueIn(values) + .mapNotNull { it.value } + .toHashSet() + + if (existing.isNotEmpty()) { + // remove existing values and regenerate until no collisions + values.removeAll(existing) + while (values.size < count) { + val candidate = "${nextChunk()}-${nextChunk()}-${nextChunk()}-${nextChunk()}" + if (candidate !in existing && candidate !in values) { + values.add(candidate) + } + } + } + + for (v in values) { + val t = AccessToken() + t.value = v + tokens.add(t) + } + + return saveAll(tokens) + } + + override fun findByValue(value: String): AccessToken? { + return repository.findByValueAndType(value, Token.Type.ACCESS) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestRunServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestRunServiceImpl.kt new file mode 100644 index 00000000..89e87080 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestRunServiceImpl.kt @@ -0,0 +1,33 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +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.backoffice.data.repository.ContestRunRepository +import trik.testsys.webapp.backoffice.data.service.ContestRunService +import trik.testsys.webapp.core.data.service.AbstractService +import java.time.Instant + +@Service +class ContestRunServiceImpl : + AbstractService(), + ContestRunService { + + override fun findByUserAndContest(user: User, contest: Contest): ContestRun? = + repository.findByUserAndContest(user, contest) + + override fun findAllByUser(user: User): Set = repository.findAllByUser(user) + + override fun startIfAbsent(user: User, contest: Contest): ContestRun { + val existing = repository.findByUserAndContest(user, contest) + if (existing != null) return existing + val run = ContestRun() + run.user = user + run.contest = contest + run.startedAt = Instant.now() + return save(run) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestServiceImpl.kt new file mode 100644 index 00000000..b6301d6f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/ContestServiceImpl.kt @@ -0,0 +1,30 @@ +package trik.testsys.webapp.backoffice.data.service.impl; + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.Contest +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.repository.ContestRepository +import trik.testsys.webapp.backoffice.data.service.ContestService +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class ContestServiceImpl : + AbstractService(), + ContestService { + + override fun findForOwner(user: User): Set { + val ownedByUser = repository.findByDeveloper(user) + + return ownedByUser + } + + override fun findForUser(user: User): Set { + val ownedByUser = findForOwner(user) + val viaGroups = if (user.memberedGroups.isEmpty()) emptySet() else repository.findDistinctByUserGroupsIn(user.memberedGroups) + return (ownedByUser + viaGroups).toSet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/PolygonDiagnosticReportEntityServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/PolygonDiagnosticReportEntityServiceImpl.kt new file mode 100644 index 00000000..f754cb50 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/PolygonDiagnosticReportEntityServiceImpl.kt @@ -0,0 +1,44 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.PolygonDiagnosticReportEntity +import trik.testsys.webapp.backoffice.data.repository.PolygonDiagnosticReportEntityRepository +import trik.testsys.webapp.backoffice.data.service.PolygonDiagnosticReportEntityService +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class PolygonDiagnosticReportEntityServiceImpl : + PolygonDiagnosticReportEntityService, + AbstractService() { + + override fun findActiveByPolygonFileId(polygonFileId: Long): List { + val active = repository.findByPolygonFileIdAndIsRemoved(polygonFileId) + return active + } + + override fun delete(entity: PolygonDiagnosticReportEntity) { + if (entity.isRemoved) { + return logger.debug("PolygonDiagnosticReportEntity(id=${entity.id}) is already removed.") + } + + entity.isRemoved = true + repository.save(entity) + } + + override fun deleteById(id: Long) { + repository.findByIdOrNull(id)?.let { toRemove -> + delete(toRemove) + } ?: return logger.warn("PolygonDiagnosticReportEntity(id=$id) not found.") + } + + companion object { + + private val logger = LoggerFactory.getLogger(PolygonDiagnosticReportEntityServiceImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/RegTokenService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/RegTokenService.kt new file mode 100644 index 00000000..64004d7d --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/RegTokenService.kt @@ -0,0 +1,39 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.Token +import trik.testsys.webapp.backoffice.data.entity.impl.RegToken +import trik.testsys.webapp.backoffice.data.repository.RegTokenRepository +import trik.testsys.webapp.backoffice.data.service.TokenService +import trik.testsys.webapp.core.data.service.AbstractService +import java.security.SecureRandom + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class RegTokenService : + AbstractService(), + TokenService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun generate(): RegToken { + val random = SecureRandom() + val token = RegToken() + + fun nextChunk(): String = Integer.toHexString(random.nextInt(0x10000)).padStart(5, '0') + + do { + token.value = "${nextChunk()}-${nextChunk()}-${nextChunk()}" + } while (findByValue(token.value!!) != null) + + return save(token) + } + + override fun findByValue(value: String): RegToken? { + return repository.findByValueAndType(value, Token.Type.REGISTRATION) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/SolutionServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/SolutionServiceImpl.kt new file mode 100644 index 00000000..20d1a7fb --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/SolutionServiceImpl.kt @@ -0,0 +1,63 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Task +import trik.testsys.webapp.backoffice.data.repository.SolutionRepository +import trik.testsys.webapp.backoffice.data.repository.support.SolutionSpecifications +import trik.testsys.webapp.backoffice.data.service.SolutionService +import trik.testsys.webapp.core.data.service.AbstractService +import java.time.Instant + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class SolutionServiceImpl : + AbstractService(), + SolutionService { + + override fun findAllTestingSolutions(tasks: List): List { + val allTesting = repository.findAllByTaskInAndContestNull(tasks) + return allTesting + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun updateStatus(solutionId: Long, newStatus: Solution.Status) { + logger.debug("Called SolutionService.updateStatus(solutionId=${solutionId}, newStatus=${newStatus})") + findById(solutionId)?.let { + it.status = newStatus + save(it) + } + } + + override fun findStudentSolutionsPage( + studentId: Long?, + groupId: Long?, + adminId: Long?, + viewerId: Long?, + fromDate: Instant?, + toDate: Instant?, + pageable: Pageable, + ): Page { + var spec = SolutionSpecifications.hasStudentPrivilege() + if (studentId != null) spec = spec.and(SolutionSpecifications.createdBy(studentId)) + if (groupId != null) spec = spec.and(SolutionSpecifications.inGroup(groupId)) + if (adminId != null) spec = spec.and(SolutionSpecifications.underAdmin(adminId)) + if (viewerId != null) spec = spec.and(SolutionSpecifications.underViewer(viewerId)) + if (fromDate != null) spec = spec.and(SolutionSpecifications.createdAfter(fromDate)) + if (toDate != null) spec = spec.and(SolutionSpecifications.createdBefore(toDate)) + return findAll(spec, pageable) + } + + companion object { + + private val logger = LoggerFactory.getLogger(SolutionServiceImpl::class.java) + } +} diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupServiceImpl.kt new file mode 100644 index 00000000..3d625bd3 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupServiceImpl.kt @@ -0,0 +1,235 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroup +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.repository.StudentGroupRepository +import trik.testsys.webapp.backoffice.data.service.StudentGroupService +import trik.testsys.webapp.backoffice.data.service.VerdictService +import trik.testsys.webapp.core.data.service.AbstractService +import trik.testsys.webapp.backoffice.data.service.UserService +import trik.testsys.webapp.backoffice.data.service.UserGroupService + +@Service +class StudentGroupServiceImpl( + private val userService: UserService, + private val accessTokenService: AccessTokenService, + private val studentGroupTokenService: StudentGroupTokenService, + private val userGroupService: UserGroupService, + private val verdictService: VerdictService, +) : + AbstractService(), + StudentGroupService { + + override fun addMember(studentGroup: StudentGroup, member: User) = when (member) { + studentGroup.owner -> { + logger.warn("Could not add user(id=${member.id}) to owned studentGroup(id=${studentGroup.id}).") + false + } + in studentGroup.members -> { + logger.warn("Could not add already membered user(id=${member.id}) to studentGroup(id=${studentGroup.id}).") + false + } + else -> { + logger.debug("Adding user(id=${member.id}) to studentGroup(id=${studentGroup.id})") + + studentGroup.members.add(member) + val savedGroup = save(studentGroup) + + // If a student is added to a student group, ensure they are membered in all groups of the admin-owner + if (member.privileges.contains(User.Privilege.STUDENT)) { + val adminOwner = savedGroup.owner + adminOwner?.memberedGroups?.forEach { adminGroup -> + userGroupService.addMember(adminGroup, member) + } + } + true + } + } + + override fun removeMember(studentGroup: StudentGroup, member: User) = when (member) { + studentGroup.owner -> { + logger.warn("Could not remove user(id=${member.id}) from owned studentGroup(id=${studentGroup.id}) members.") + false + } + !in studentGroup.members -> { + logger.warn("Could not remove user(id=${member.id}) from not membered studentGroup(id=${studentGroup.id}).") + false + } + else -> { + logger.debug("Removing member(id=${member.id}) from studentGroup(id=${studentGroup.id}).") + + studentGroup.members.remove(member) + save(studentGroup) + true + } + } + + override fun findByOwner(owner: User): Set { + return repository.findByOwner(owner) + } + + override fun create(owner: User, name: String, info: String?): StudentGroup? { + val newGroup = StudentGroup().also { + it.owner = owner + it.name = name.trim() + it.info = info?.trim() + it.studentGroupToken = studentGroupTokenService.generate() + } + + return save(newGroup) + } + + override fun generateStudents(owner: User, group: StudentGroup, count: Int): Set { + require(count >= 1) { "count must be >= 1" } + require(count <= 200) { "count must be <= 200" } + + val startMs = System.currentTimeMillis() + + // batch-generate tokens to avoid N+1 + val tokens = accessTokenService.generateBatch(count) + + val created = LinkedHashSet(count) + for ((idx, token) in tokens.withIndex()) { + val student = User().also { + it.name = "st-${group.id}-${startMs}-${idx + 1}" + it.accessToken = token + it.privileges.add(User.Privilege.STUDENT) + } + group.members.add(student) + created.add(student) + } + + // Persist students first to avoid transient references in many-to-many joins + val persistedStudents = userService.saveAll(created) + val savedGroup = save(group) + + // After persistence, add each student to all user groups where the admin-owner is a member + val adminOwner = savedGroup.owner + if (adminOwner != null) { + val adminGroups = adminOwner.memberedGroups + if (adminGroups.isNotEmpty()) { + persistedStudents.forEach { student -> + adminGroups.forEach { adminGroup -> + userGroupService.addMember(adminGroup, student) + } + } + } + } + + return persistedStudents.toSet() + } + + override fun generateMembersCsv(group: StudentGroup): ByteArray { + val header = "user_id,user_name,access_token\n" + val body = group.members.joinToString("\n") { + val id = (it.id ?: 0).toString() + val name = it.name ?: "" + val token = it.accessToken?.value ?: "" + "$id,$name,$token" + } + return (header + body).toByteArray() + } + + override fun generateResultsCsv(group: StudentGroup): ByteArray { + return generateResultsCsv(listOf(group)) + } + + override fun generateResultsCsv(groups: Collection): ByteArray { + val groupsList = groups.sortedBy { it.id } + + val contests = groupsList.asSequence() + .flatMap { it.contests.asSequence() } + .distinctBy { it.id } + .sortedBy { it.id } + .toList() + + val contestTaskPairs = contests.flatMap { contest -> + val orders = contest.getOrders() + contest.tasks.sortedBy { t -> orders[t.id!!] ?: Long.MAX_VALUE }.map { task -> contest to task } + } + + val fixedHeader = listOf( + "student_group_id", + "student_group_name", + "admin_id", + "admin_name", + "student_id", + "student_name" + ) + val dynamicHeader = contestTaskPairs.map { (contest, task) -> + val cId = contest.id ?: 0 + val cName = contest.name ?: "" + val tId = task.id ?: 0 + val tName = task.name ?: "" + "$cId $cName / $tId $tName" + } + val header = (fixedHeader + dynamicHeader).joinToString(",") + "\n" + + val membersByGroup = groupsList.associateWith { group -> + group.members.filter { it.privileges.contains(User.Privilege.STUDENT) }.sortedBy { it.id } + } + + val allMembers = membersByGroup.values.flatten() + + val contestIds = contests.mapNotNull { it.id }.toSet() + val taskIds = contestTaskPairs.mapNotNull { it.second.id }.toSet() + + val allRelevantSolutions = allMembers.asSequence() + .flatMap { it.solutions.asSequence() } + .filter { s -> + val cId = s.contest?.id + val tId = s.task.id + cId != null && cId in contestIds && tId in taskIds && s.relevantVerdictId != null + } + .toList() + + val verdicts = verdictService.findAllBySolutions(allRelevantSolutions) + val verdictById = verdicts.associateBy { it.id } + + val csv = StringBuilder() + for ((group, members) in groupsList.map { it to (membersByGroup[it] ?: emptyList()) }) { + val admin = group.owner + for (student in members) { + val fixed = listOf( + (group.id ?: 0).toString(), + group.name ?: "", + (admin?.id ?: 0).toString(), + (admin?.name ?: ""), + (student.id ?: 0).toString(), + student.name ?: "" + ) + + val perTask = contestTaskPairs.map { (contest, task) -> + val solution = student.solutions + .asSequence() + .filter { s -> + (s.contest?.id == contest.id) && (s.task.id == task.id) && s.relevantVerdictId != null + } + .maxByOrNull { s -> + verdictById[s.relevantVerdictId]?.value ?: Long.MIN_VALUE + } + + solution?.relevantVerdictId?.let { vid -> + verdictById[vid]?.value?.toString() + } ?: "" + } + + csv.append((fixed + perTask).joinToString(",")) + csv.append('\n') + } + } + + return (header + csv.toString()).toByteArray() + } + + override fun existsByContestId(contestId: Long): Boolean { + return repository.existsByContests_Id(contestId) + } + + companion object { + + private val logger = LoggerFactory.getLogger(StudentGroupServiceImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupTokenService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupTokenService.kt new file mode 100644 index 00000000..324a8127 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/StudentGroupTokenService.kt @@ -0,0 +1,37 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.Token +import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroupToken +import trik.testsys.webapp.backoffice.data.repository.StudentGroupTokenRepository +import trik.testsys.webapp.backoffice.data.service.TokenService +import trik.testsys.webapp.core.data.service.AbstractService +import java.security.SecureRandom + +@Service +class StudentGroupTokenService : + AbstractService(), + TokenService { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun generate(): StudentGroupToken { + val random = SecureRandom() + val token = StudentGroupToken() + + fun nextChunk(): String = Integer.toHexString(random.nextInt(0x10000)).padStart(6, '0') + + do { + token.value = "${nextChunk()}-${nextChunk()}" + } while (findByValue(token.value!!) != null) + + return save(token) + } + + override fun findByValue(value: String): StudentGroupToken? { + return repository.findByValueAndType(value, Token.Type.STUDENT_GROUP) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskFileServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskFileServiceImpl.kt new file mode 100644 index 00000000..46df4785 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskFileServiceImpl.kt @@ -0,0 +1,21 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.repository.TaskFileRepository +import trik.testsys.webapp.backoffice.data.service.TaskFileService +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + **/ +@Service +class TaskFileServiceImpl : + AbstractService(), + TaskFileService { + + override fun findByDeveloper(developer: User): Set = + repository.findByDeveloper(developer) +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskServiceImpl.kt new file mode 100644 index 00000000..08b98528 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/TaskServiceImpl.kt @@ -0,0 +1,33 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.Task +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.repository.TaskRepository +import trik.testsys.webapp.backoffice.data.service.TaskService +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class TaskServiceImpl : + AbstractService(), + TaskService { + + override fun findByDeveloper(developer: User): Set { + return repository.findByDeveloper(developer) + } + + override fun findForUser(user: User): Set { + val ownedByUser = repository.findByDeveloper(user) + val viaGroups = if (user.memberedGroups.isEmpty()) emptySet() else repository.findDistinctByUserGroupsIn(user.memberedGroups) + return (ownedByUser + viaGroups).toSet() + } + + override fun findAllTesting(): List { + val allTesting = repository.findByTestingStatus(Task.TestingStatus.TESTING) + return allTesting + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserGroupServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserGroupServiceImpl.kt new file mode 100644 index 00000000..61cfb42a --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserGroupServiceImpl.kt @@ -0,0 +1,124 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup +import trik.testsys.webapp.backoffice.data.repository.UserGroupRepository +import trik.testsys.webapp.backoffice.data.service.UserGroupService +import trik.testsys.webapp.core.data.service.AbstractService +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class UserGroupServiceImpl : + AbstractService(), + UserGroupService { + + override fun addMember(userGroup: UserGroup, user: User): Boolean { + val managed = try { + if (userGroup.isNew()) userGroup else getById(userGroup.id!!) + } catch (_: NoSuchElementException) { + // Fallback to resolving by default flag to avoid failures with detached/stale ids during startup + repository.findByDefaultGroupTrue() ?: userGroup + } + + when (user) { + managed.owner, + in managed.activeMembers -> { + logger.warn("Could not add already membered user(id=${user.id}) to userGroup(id=${managed.id}).") + return false + } + else -> { + logger.debug("Adding user(id=${user.id}) to userGroup(id=${managed.id})") + managed.addMember(user) + } + } + + if (user.privileges.contains(User.Privilege.VIEWER)) { + val allAdmins = user.managedAdmins + managed.addMembers(allAdmins) + + val allStudents = allAdmins + .flatMap { it.ownedStudentGroups } + .flatMap { it.members } + managed.addMembers(allStudents) + } else if (user.privileges.contains(User.Privilege.ADMIN)) { + val allStudents = user.ownedStudentGroups + .flatMap { it.members } + managed.addMembers(allStudents) + } + + save(managed) + return true + } + + override fun findByOwner(owner: User): Set = repository.findByOwner(owner) + + override fun findByMember(member: User): Set = repository.findByMembersContaining(member) + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun create(owner: User, name: String, info: String?): UserGroup? { + if (name.isBlank()) { + logger.warn("Could not create user group: empty name") + return null + } + val group = UserGroup().apply { + this.owner = owner + this.name = name + this.info = info + } + return save(group) + } + + override fun removeMember(userGroup: UserGroup, user: User): Boolean { + val managed = try { + if (userGroup.isNew()) userGroup else getById(userGroup.id!!) + } catch (_: NoSuchElementException) { + repository.findByDefaultGroupTrue() ?: userGroup + } + return when (user) { + managed.owner -> { + logger.warn("Could not remove owner(id=${user.id}) from userGroup(id=${managed.id}) members.") + false + } + in managed.activeMembers.takeIf { managed.defaultGroup } ?: emptySet() -> { + logger.warn("Could not remove user(id=${user.id}) from default userGroup(id=${managed.id}).") + false + } + !in managed.activeMembers -> { + logger.warn("Could not remove user(id=${user.id}) from not membered userGroup(id=${managed.id}).") + false + } + else -> { + logger.debug("Removing member(id=${user.id}) from userGroup(id=${managed.id}).") + managed.removeMember(user) + save(managed) + true + } + } + } + + override fun getOrCreateDefaultGroup(owner: User): UserGroup { + val existing = repository.findByDefaultGroupTrue() + if (existing != null) return existing + val group = UserGroup().apply { + this.owner = owner + this.name = "Публичная" + this.info = "Группа каждого пользователя по умолчанию." + this.defaultGroup = true + } + return save(group) + } + + override fun getDefaultGroup(): UserGroup? = repository.findByDefaultGroupTrue() + + companion object { + + private val logger = LoggerFactory.getLogger(UserGroupServiceImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserServiceImpl.kt new file mode 100644 index 00000000..45bdc546 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/UserServiceImpl.kt @@ -0,0 +1,297 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.slf4j.LoggerFactory +import jakarta.persistence.criteria.JoinType +import jakarta.persistence.criteria.Predicate +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.data.entity.impl.UserGroup +import trik.testsys.webapp.backoffice.data.repository.UserRepository +import trik.testsys.webapp.backoffice.data.service.UserService +import trik.testsys.webapp.backoffice.data.service.UserGroupService +import trik.testsys.webapp.backoffice.data.service.SuperUserService +import trik.testsys.webapp.backoffice.data.service.ViewerService +import trik.testsys.webapp.core.data.service.AbstractService +import trik.testsys.webapp.core.data.entity.AbstractEntity +import java.time.Instant +import kotlin.random.Random + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class UserServiceImpl( + private val accessTokenService: AccessTokenService, + private val regTokenService: RegTokenService, + private val userGroupService: UserGroupService +) : + AbstractService(), + UserService, ViewerService, SuperUserService { + + override fun updateName(user: User, newName: String): User { + user.name = newName + return save(user) + } + + override fun updateEmail(user: User, newEmail: String?): User { + user.email = newEmail + user.emailVerifiedAt = null + user.requestedEmailDetach = false + + return save(user) + } + + override fun updateLastLoginAt(user: User, lastLoginAt: Instant?): User { + lastLoginAt?.let { + user.lastLoginAt = it + } ?: run { user.lastLoginAt = Instant.now() } + + return save(user) + } + + override fun createAdmin(viewer: User, name: String?): User? { + viewer.adminRegToken ?: run { + logger.warn("Could not create user for VIEWER privileged user.") + return null + } + + val accessToken = accessTokenService.generate() + val admin = User().also { + it.accessToken = accessToken + + it.viewer = viewer + it.privileges.add(User.Privilege.ADMIN) + it.name = name ?: "New User ${Random.nextInt()}" + } + + // Persist the new admin first so that other persistent entities can safely reference it + val persistedAdmin = save(admin) + // set inverse side after user is persisted to avoid transient reference during flush + accessToken.user = persistedAdmin + // now update viewer relationships + viewer.managedAdmins.add(persistedAdmin) + save(viewer) + + // Ensure admin is a member of all groups the viewer is a member of (including viewer-owned groups) + // Use the owning side of the relation via service to persist join-table rows + viewer.memberedGroups.forEach { group -> + userGroupService.addMember(group, persistedAdmin) + } + + return persistedAdmin + } + + override fun createUser(superUser: User, name: String, privileges: Collection): Boolean { + if (!superUser.privileges.contains(User.Privilege.SUPER_USER)) { + logger.warn( + "Could not create new user(name=${name}, privileges=${privileges}) for user(id=${superUser.id}), " + + "it has no SuperUser privileges." + ) + return false + } + + val accessToken = accessTokenService.generate() + val newUser = User().also { + it.superUser = superUser + it.accessToken = accessToken + it.name = name + } + + // Persist the new user before assigning it to collections or creating tokens referencing it + val persistedUser = save(newUser) + // set inverse side after user is persisted to avoid transient reference during flush + accessToken.user = persistedUser + // assign privileges after the user is persistent (may create RegToken referencing user) + addPrivileges(superUser, persistedUser, privileges) + // link to creator after persistence + superUser.createdUsers.add(persistedUser) + save(superUser) + + return true + } + + override fun createUserByGroupAdmin( + groupAdmin: User, + name: String, + privileges: Collection + ): User? { + if (!groupAdmin.privileges.contains(User.Privilege.GROUP_ADMIN)) { + logger.warn( + "Could not create new user(name=$name, privileges=$privileges) for user(id=${groupAdmin.id}), it has no GROUP_ADMIN privileges." + ) + return null + } + + // Restrict privileges to allowed set for group admin + val allowed = setOf( + User.Privilege.DEVELOPER, + User.Privilege.JUDGE, + User.Privilege.VIEWER, + ) + val requested = privileges.toSet().intersect(allowed) + + val trimmed = name.trim() + if (trimmed.isEmpty()) return null + + val accessToken = accessTokenService.generate() + val newUser = User().also { + it.accessToken = accessToken + it.name = trimmed + } + + val persisted = save(newUser) + accessToken.user = persisted + + // assign only allowed privileges + requested.forEach { p -> + // mimic addPrivilege logic for VIEWER special case + persisted.privileges.add(p) + if (p == User.Privilege.VIEWER && persisted.adminRegToken == null) { + val regToken = regTokenService.generate() + persisted.adminRegToken = regToken + regToken.viewer = persisted + } + } + save(persisted) + + return persisted + } + + override fun addPrivilege(superUser: User, user: User, privilege: User.Privilege): Boolean { + if (!superUser.privileges.contains(User.Privilege.SUPER_USER)) { + logger.warn( + "Could not add privilege(privilege=$privilege) to user(id=${user.id}) by user(id=${superUser.id}), " + + "it has no SuperUser privileges." + ) + return false + } + + if (user.privileges.contains(privilege)) { + logger.warn("Could not add already present privilege(privilege=$privilege) to user(id=${user.id}).") + return false + } + + logger.debug("Adding privilege(privilege=$privilege) to user(id=${user.id}).") + user.privileges.add(privilege) + + if (privilege == User.Privilege.VIEWER && user.adminRegToken == null) { + logger.info("User(id=${user.id}) granted VIEWER. Generating adminRegToken.") + + val regToken = regTokenService.generate() + user.adminRegToken = regToken + regToken.viewer = user + } + + save(user) + return true + } + + override fun removeUser(superUser: User, user: User): Boolean { + if (!superUser.privileges.contains(User.Privilege.SUPER_USER)) { + logger.warn( + "Could not remove user(id=${user.id}) by user(id=${superUser.id}), it has no SuperUser privileges." + ) + return false + } + + if (!superUser.isAllUserSuperUser && user.superUser?.id != superUser.id) { + logger.warn("Could not remove user(id=${user.id}) not created by current SuperUser(id=${superUser.id}).") + return false + } + + if (user.lastLoginAt != null) { + logger.warn("Could not remove user(id=${user.id}) that already logged in.") + return false + } + + if (user.isRemoved) { + logger.info("User(id=${user.id}) already marked as removed.") + return true + } + + user.isRemoved = true + save(user) + return true + } + + override fun findAllSuperUser(isAllUserSuperUser: Boolean?) = repository.findAll { root, q, cb -> + val predicates = mutableListOf() + + root.fetch(User.ACCESS_TOKEN, JoinType.LEFT) + q?.distinct(true) + + val privilegesPath = root.get>(User.PRIVILEGES) + val hasSuperUserPrivilege = cb.isMember(User.Privilege.SUPER_USER, privilegesPath) + predicates.add(hasSuperUserPrivilege) + + isAllUserSuperUser?.let { + val allUserFlagMatches = cb.equal(root.get(User.IS_ALL_USER_SUPER_USER), isAllUserSuperUser) + predicates.add(allUserFlagMatches) + } + + cb.and(*predicates.toTypedArray()) + }.toSet() + + override fun findAllGroupAdmin() = repository.findAll { root, q, cb -> + root.fetch(User.ACCESS_TOKEN, JoinType.LEFT) + q?.distinct(true) + val privilegesPath = root.get>(User.PRIVILEGES) + cb.isMember(User.Privilege.GROUP_ADMIN, privilegesPath) + }.toSet() + + override fun findAllBySuperUser(superUser: User): Set { + return repository.findAllBySuperUser(superUser) + } + + override fun save(entity: User): User { + val isNewEntity = entity.isNew + val persisted = super.save(entity) + + if (isNewEntity) { + // Avoid creating the default group during arbitrary user creation to prevent FK/order issues. + // If the default group already exists, just add the member; otherwise startup runner will create it. + userGroupService.getDefaultGroup()?.let { defaultGroup -> + userGroupService.addMember(defaultGroup, persisted) + } + } + + return persisted + } + + override fun findCandidatesFor(userGroup: UserGroup): Set { + val ownerId = userGroup.owner?.id + val memberIds = userGroup.activeMembers.mapNotNull { it.id }.toSet() + + return repository.findAll { root, q, cb -> + val predicates = mutableListOf() + + q?.distinct(true) + + // Exclude removed users + predicates.add(cb.isFalse(root.get(User.IS_REMOVED))) + + // Exclude group owner if present + ownerId?.let { predicates.add(cb.notEqual(root.get(AbstractEntity.ID), it)) } + + // Exclude already membered users + if (memberIds.isNotEmpty()) { + val idPath = root.get(AbstractEntity.ID) + predicates.add(idPath.`in`(memberIds).not()) + } + + cb.and(*predicates.toTypedArray()) + }.toSet() + } + + override fun findByEmail(email: String): User? { + val user = repository.findByEmailAndEmailVerifiedAtNotNull(email) + + return user + } + + companion object { + + private val logger = LoggerFactory.getLogger(UserServiceImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/VerdictServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/VerdictServiceImpl.kt new file mode 100644 index 00000000..d8cc1d0c --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/VerdictServiceImpl.kt @@ -0,0 +1,47 @@ +package trik.testsys.webapp.backoffice.data.service.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Verdict +import trik.testsys.webapp.backoffice.data.repository.SolutionRepository +import trik.testsys.webapp.backoffice.data.repository.VerdictRepository +import trik.testsys.webapp.backoffice.data.service.VerdictService +import trik.testsys.webapp.core.data.service.AbstractService + +@Service +class VerdictServiceImpl( + private val solutionRepository: SolutionRepository, +) : AbstractService(), VerdictService { + + @Transactional(propagation = Propagation.REQUIRED) + override fun createNewForSolution(solution: Solution, score: Long): Verdict { + val newVerdict = Verdict().also { + it.solutionId = solution.id ?: error("Solution id must be initialized") + it.value = score + } + + val persisted = repository.save(newVerdict) + // Let caller persist the Solution update to avoid double-merge/version conflict + solution.relevantVerdictId = persisted.id + + return persisted + } + + override fun findAllBySolutionIds(solutionIds: Collection): Set { + val solutions = solutionRepository.findAllById(solutionIds) + return findAllBySolutions(solutions) + } + + override fun findAllBySolutions(solutions: Collection): Set { + val verdictIds = solutions.mapNotNull { it.relevantVerdictId } + val verdicts = repository.findAllById(verdictIds) + + return verdicts.toSet() + } + + override fun findAllForSolution(solutionId: Long): Set { + return repository.findAllBySolutionId(solutionId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ConditionFileService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ConditionFileService.kt new file mode 100644 index 00000000..498d9231 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ConditionFileService.kt @@ -0,0 +1,19 @@ +package trik.testsys.webapp.backoffice.data.service.impl.taskFile + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ConditionFile +import trik.testsys.webapp.backoffice.data.repository.taskFile.ConditionFileRepository +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class ConditionFileService : AbstractService() { + + fun findByDeveloper(developerId: Long, isRemoved: Boolean = false): Set { + val result = repository.findByDeveloperId(developerId).filter { it.isRemoved == isRemoved } + return result.toSet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ExerciseFileService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ExerciseFileService.kt new file mode 100644 index 00000000..c8abe6c9 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/ExerciseFileService.kt @@ -0,0 +1,19 @@ +package trik.testsys.webapp.backoffice.data.service.impl.taskFile + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.ExerciseFile +import trik.testsys.webapp.backoffice.data.repository.taskFile.ExerciseFileRepository +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class ExerciseFileService : AbstractService() { + + fun findByDeveloper(developerId: Long, isRemoved: Boolean = false): Set { + val result = repository.findByDeveloperId(developerId).filter { it.isRemoved == isRemoved } + return result.toSet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/PolygonFileService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/PolygonFileService.kt new file mode 100644 index 00000000..a8435ce3 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/PolygonFileService.kt @@ -0,0 +1,29 @@ +package trik.testsys.webapp.backoffice.data.service.impl.taskFile + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile +import trik.testsys.webapp.backoffice.data.repository.taskFile.PolygonFileRepository +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class PolygonFileService : AbstractService() { + + fun findByDeveloper(developerId: Long, isRemoved: Boolean = false): Set { + val result = repository.findByDeveloperId(developerId).filter { it.isRemoved == isRemoved } + return result.toSet() + } + + fun findNotAnalyzed(): List { + val result = repository.findByAnalysisStatus(PolygonFile.AnalysisStatus.NOT_ANALYZED) + return result + } + + fun findAnalyzing(): List { + val result = repository.findByAnalysisStatus(PolygonFile.AnalysisStatus.ANALYZING) + return result + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/SolutionFileService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/SolutionFileService.kt new file mode 100644 index 00000000..ab4b6488 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/impl/taskFile/SolutionFileService.kt @@ -0,0 +1,19 @@ +package trik.testsys.webapp.backoffice.data.service.impl.taskFile + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.SolutionFile +import trik.testsys.webapp.backoffice.data.repository.taskFile.SolutionFileRepository +import trik.testsys.webapp.core.data.service.AbstractService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class SolutionFileService : AbstractService() { + + fun findByDeveloper(developerId: Long, isRemoved: Boolean = false): Set { + val result = repository.findByDeveloperId(developerId).filter { it.isRemoved == isRemoved } + return result.toSet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/user/impl/StudentService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/user/impl/StudentService.kt new file mode 100644 index 00000000..e5553a78 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/data/service/user/impl/StudentService.kt @@ -0,0 +1,126 @@ +//package trik.testsys.webapp.backoffice.data.service.user.impl +// +//import org.springframework.beans.factory.annotation.Qualifier +//import org.springframework.stereotype.Service +//import trik.testsys.backoffice.entity.impl.StudentGroup +//import trik.testsys.backoffice.entity.impl.Solution +//import trik.testsys.backoffice.entity.impl.Task +//import trik.testsys.backoffice.entity.user.impl.Student +//import trik.testsys.backoffice.repository.user.StudentRepository +//import trik.testsys.backoffice.service.entity.impl.SolutionVerdictService +//import trik.testsys.backoffice.service.entity.user.WebUserService +//import trik.testsys.backoffice.service.token.access.AccessTokenGenerator +//import java.util.* +// +///** +// * @author Roman Shishkin +// * @since 1.0.0 +// */ +//@Service +//class StudentService( +// @Qualifier("studentAccessTokenGenerator") private val accessTokenGenerator: AccessTokenGenerator, +// +// private val solutionVerdictService: SolutionVerdictService +//): WebUserService() { +// +// override fun validateName(entity: Student) = +// !entity.name.contains(entity.group.regToken) && super.validateName(entity) +// +// override fun validateAdditionalInfo(entity: Student) = +// !entity.additionalInfo.contains(entity.group.regToken) && super.validateAdditionalInfo(entity) +// +// fun generate(count: Long, group: StudentGroup): List { +// val students = mutableListOf() +// +// for (i in 1..count) { +// val number = i +// val accessToken = accessTokenGenerator.generate(number.toString() + group.regToken) +// val name = "st-${group.id}-${UUID.randomUUID().toString().substring(4, 18)}-$number" +// +// val student = Student(name, accessToken) +// student.group = group +// +// students.add(student) +// } +// +// repository.saveAll(students) +// +// return students +// } +// +// fun export(groups: List): String { +// val students = groups.asSequence() +// .map { it.students }.flatten() +// .toSet() +// .sortedWith( +// compareBy( +// { it.group.admin.id }, +// { it.group.id }, +// { it.id } +// ) +// ) +// +// val tasks = students.asSequence() +// .map { it.solutions }.flatten() +// .map { it.task } +// .distinct() +// .toSet() +// .sortedBy { it.id } +// +// val bestScoresByStudents: Map> = students.associateWith { student -> +// tasks.map { task -> +// val solution = student.getBestSolutionFor(task) ?: return@map "–" +// +// val solutionVerdict = solutionVerdictService.findByStudentAndTask(student, task).firstOrNull() +// solutionVerdict?.score?.toString() ?: solution.score.toString() +// } +// } +// +// val csvHeader = listOf("ID Организатора", "Псевдоним Организатора", "ID Группы", "Псевдоним Группы", "ID Участника", "Псевдоним Участника", *tasks.map { "${it.id}: ${it.name}" }.toTypedArray()) +// .joinToString(separator = ";") +// .plus("\n") +// +// val csvData = students.map { student -> +// listOf( +// student.group.admin.id.toString(), +// student.group.admin.name, +// student.group.id.toString(), +// student.group.name, +// student.id.toString(), +// student.name, +// *bestScoresByStudents[student]!!.toTypedArray() +// ) +// } +// +// val csvDataString = csvData.joinToString(separator = "\n") { it.joinToString(separator = ";") } +// val csv = csvHeader.plus(csvDataString) +// +// return csv +// } +// +// fun generate(additionalInfos: List, group: StudentGroup): List { +// val students = mutableListOf() +// +// for (additionalInfo in additionalInfos) { +// val accessToken = accessTokenGenerator.generate(additionalInfo) +// val name = "st-${UUID.randomUUID().toString().substring(4, 18)}" +// +// val student = Student(name, accessToken).also { it.group = group } +// student.additionalInfo = additionalInfo +// +// students.add(student) +// } +// +// repository.saveAll(students) +// +// return students +// } +// +// companion object { +// fun Student.getBestSolutionFor(task: Task): Solution? { +// return solutions +// .filter { it.task.id == task.id && it.status == Solution.SolutionStatus.PASSED } +// .maxByOrNull { it.score } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/FileManager.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/FileManager.kt new file mode 100644 index 00000000..b49bb26c --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/FileManager.kt @@ -0,0 +1,83 @@ +package trik.testsys.webapp.backoffice.service + +import jakarta.transaction.Transactional +import org.springframework.web.multipart.MultipartFile +import trik.testsys.webapp.backoffice.data.entity.AbstractFile +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile +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 java.io.File +import java.time.Instant + +/** + * File manager for handling TaskFile persistence on disk. + */ +interface FileManager { + + @Deprecated("Remove after migration") + fun getTaskFileVersion(taskFile: TaskFile, version: Long): File? + + fun saveSolution(solution: Solution, fileData: MultipartFile): Boolean + + fun saveSolution(solution: Solution, sourceFile: File): Boolean + + fun getSolution(solution: Solution): File? + + fun hasSolution(solution: Solution): Boolean + + fun saveSuccessfulGradingInfo(fieldResult: Grader.GradingInfo.Ok) + + fun getVerdicts(solution: Solution): List + + fun hasAnyVerdict(solution: Solution): Boolean + + fun getRecording(solution: Solution): List + + fun hasAnyRecording(solution: Solution): Boolean + + fun getSolutionResultFilesCompressed(solution: Solution): File + + @Transactional + fun saveConditionFile(conditionFile: ConditionFile, fileData: MultipartFile): ConditionFile? + + @Transactional + fun saveExerciseFile(exerciseFile: ExerciseFile, fileData: MultipartFile): ExerciseFile? + + @Transactional + fun savePolygonFile(polygonFile: PolygonFile, fileData: MultipartFile): PolygonFile? + + @Transactional + fun saveSolutionFile(solutionFile: SolutionFile, fileData: MultipartFile): SolutionFile? + + @Transactional + fun saveConditionFile(conditionFile: ConditionFile, file: File): ConditionFile? + + @Transactional + fun saveExerciseFile(exerciseFile: ExerciseFile, file: File): ExerciseFile? + + @Transactional + fun savePolygonFile(polygonFile: PolygonFile, file: File): PolygonFile? + + @Transactional + fun saveSolutionFile(solutionFile: SolutionFile, file: File): SolutionFile? + + fun getConditionFile(conditionFile: ConditionFile, version: Long = conditionFile.fileVersion): File? + + fun getExerciseFile(exerciseFile: ExerciseFile, version: Long = exerciseFile.fileVersion): File? + + fun getPolygonFile(polygonFile: PolygonFile, version: Long = polygonFile.fileVersion): File? + + fun getSolutionFile(solutionFile: SolutionFile, version: Long = solutionFile.fileVersion): File? + + fun listFileVersions(file: AbstractFile): List +} + +data class TaskFileVersionInfo( + val version: Long, + val fileName: String, + val lastModifiedAt: Instant, + val originalName: String +) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/Grader.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/Grader.kt similarity index 57% rename from src/main/kotlin/trik/testsys/webclient/service/Grader.kt rename to src/main/kotlin/trik/testsys/webapp/backoffice/service/Grader.kt index 06d6beae..1dc5fdee 100644 --- a/src/main/kotlin/trik/testsys/webclient/service/Grader.kt +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/Grader.kt @@ -1,6 +1,6 @@ -package trik.testsys.webclient.service +package trik.testsys.webapp.backoffice.service -import trik.testsys.webclient.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Solution interface Grader { @@ -16,9 +16,25 @@ interface Grader { fun getAllNodeStatuses(): Map + sealed interface ErrorKind { + val description: String + + data class UnexpectedException(override val description: String) : ErrorKind + + data class NonZeroExitCode(val code: Int, override val description: String) : ErrorKind + + data class MismatchedFiles(override val description: String) : ErrorKind + + data class InnerTimeoutExceed(override val description: String) : ErrorKind + + data class UnsupportedImageVersion(override val description: String) : ErrorKind + + data class Unknown(override val description: String) : ErrorKind + } + sealed class GradingInfo(open val submissionId: Int) { - data class Error(override val submissionId: Int, val kind: Int, val description: String): GradingInfo(submissionId) // kind == 4 - timeout error (score 0) + data class Error(override val submissionId: Int, val kind: ErrorKind): GradingInfo(submissionId) // kind == 4 - timeout error (score 0) data class File(val name: String, val content: ByteArray) diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipService.kt new file mode 100644 index 00000000..f9cec628 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipService.kt @@ -0,0 +1,23 @@ +package trik.testsys.webapp.backoffice.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.io.File + +@Service +class SponsorshipService( + @Value("\${trik.testsys.paths.sponsorship}") private val sponsorshipDirPath: String +) { + + private val imageExtensions = setOf("png", "jpg", "jpeg", "svg", "gif", "webp") + + fun getImageNames(): List { + val dir = File(sponsorshipDirPath) + if (!dir.isDirectory) return emptyList() + return dir.listFiles() + ?.filter { it.isFile && it.extension.lowercase() in imageExtensions } + ?.map { it.name } + ?.sorted() + ?: emptyList() + } +} diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/UserEmailService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/UserEmailService.kt new file mode 100644 index 00000000..e0d2bbc2 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/UserEmailService.kt @@ -0,0 +1,16 @@ +package trik.testsys.webapp.backoffice.service + +import trik.testsys.webapp.backoffice.data.entity.impl.User + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface UserEmailService { + + fun sendVerificationToken(user: User, newEmail: String?) + + fun verify(user: User, verificationToken: String): Boolean + + fun sendAccessToken(email: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/AbstractPolygonDiagnostic.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/AbstractPolygonDiagnostic.kt new file mode 100644 index 00000000..fec02c80 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/AbstractPolygonDiagnostic.kt @@ -0,0 +1,115 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +abstract class AbstractPolygonDiagnostic : PolygonDiagnostic { + + private val name = this.javaClass.simpleName + protected val logger: Logger = LoggerFactory.getLogger(this::class.java) + + final override fun perform(polygon: Polygon): List { + logger.debug("Performing diagnostics(name=$name)") + + if (!isAvailable()) { + logger.debug("Diagnostic(name=$name) is unavailable.") + return emptyList() + } + + val reports = doPerform(polygon) + logger.debug("Diagnostic(name=$name) performed successfully. Diagnosed ${reports.size} reports: $reports.") + + return reports + } + + abstract fun isAvailable(): Boolean + + abstract fun doPerform(polygon: Polygon): List + + protected fun MutableList.addWarning(description: String) = add( + PolygonDiagnosticReport( + name, + null, + null, + PolygonDiagnosticReportSeverity.Warning, + description + ) + ) + + protected fun MutableList.addError(description: String) = add( + PolygonDiagnosticReport( + name, + null, + null, + PolygonDiagnosticReportSeverity.Error, + description + ) + ) + + protected fun MutableList.addWarning(element: PolygonElement, description: String) = add( + PolygonDiagnosticReport( + name, + null, + element, + PolygonDiagnosticReportSeverity.Warning, + description + ) + ) + + protected fun MutableList.addError(element: PolygonElement, description: String) = add( + PolygonDiagnosticReport( + name, + null, + element, + PolygonDiagnosticReportSeverity.Error, + description + ) + ) + + protected fun Polygon.atomicTriggers(): List { + val eventTriggers = + this.constraints.constraints + .filterIsInstance() + .flatMap { it.trigger.atomicTriggers } + val initTriggers = + this.constraints.constraints + .filterIsInstance() + .flatMap { it.triggers } + return eventTriggers + initTriggers + } + + protected fun Polygon.conditions(): List { + val eventConditions = + this.constraints.constraints + .filterIsInstance() + .flatMap { it.condition.conditions } + + val constraintConditions = + this.constraints.constraints + .filterIsInstance() + .flatMap { it.condition.conditions } + + return eventConditions + constraintConditions + } + + private fun Condition.atomicConditions(): List { + return when (this) { + is DroppedCondition -> listOf(this) + is InsideCondition -> listOf(this) + is SettedUpCondition -> listOf(this) + is TimerCondition -> listOf(this) + is TrueCondition -> listOf(this) + is BinaryCondition -> listOf(this) + is NotCondition -> this.condition.atomicConditions() + is PolygonCondition -> this.conditions.flatMap { it.atomicConditions() } + } + } + + protected fun Polygon.atomicConditions(): List { + return this.conditions().flatMap { it.atomicConditions() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/Polygon.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/Polygon.kt new file mode 100644 index 00000000..b0879a53 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/Polygon.kt @@ -0,0 +1,200 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon + +sealed interface PolygonElement + +//region World +// TODO: Add missed components +data class PolygonWorld( + val regions: List +): PolygonElement + +data class Region( + val identifier: String +): PolygonElement +//endregion + +//region Constraints +data class PolygonConstraints( + val constraints: List +): PolygonElement + +sealed interface PolygonConstraint: PolygonElement + +data class TimeLimitConstraint( + val milliseconds: Int +): PolygonConstraint + +data class InitConstraint( + val triggers: List, +): PolygonConstraint + +data class PlainConstraint( + val checkOnce: Boolean, + val failMessage: String, + val condition: PolygonCondition +): PolygonConstraint + +data class EventConstraint( + val dropsOnFire: Boolean, + val identifier: String?, + val settedUpInitially: Boolean, + val condition: PolygonCondition, + val trigger: PolygonTrigger, +): PolygonConstraint +//endregion + +//region Conditions +sealed interface Condition: PolygonElement +sealed interface AtomicCondition: Condition + +sealed interface ConditionGlue { + object GlueAnd: ConditionGlue + object GlueOr: ConditionGlue +} + +data class PolygonCondition( + val glue: ConditionGlue, + val conditions: List +): Condition + +sealed interface BinaryConditionKind { + object Equals: BinaryConditionKind + object NotEqual: BinaryConditionKind + object Greater: BinaryConditionKind + object NotGreater: BinaryConditionKind + object Less: BinaryConditionKind + object NotLess: BinaryConditionKind +} + +data class BinaryCondition( + val left: PolygonExpression, + val right: PolygonExpression, + val kind: BinaryConditionKind +): AtomicCondition + +object TrueCondition: AtomicCondition + +sealed interface ObjectPoints { + object Center: ObjectPoints + object Any: ObjectPoints + object All: ObjectPoints +} + +data class InsideCondition( + val objectId: String, + val regionId: String, + val points: ObjectPoints +): AtomicCondition + +data class SettedUpCondition( + val eventId: String +): AtomicCondition + +data class DroppedCondition( + val eventId: String +): AtomicCondition + +data class TimerCondition( + val timeout: Int, + val forceDropOnTimeout: Boolean +): AtomicCondition + +data class NotCondition( + val condition: Condition +): Condition + +//endregion + +//region Triggers +data class PolygonTrigger( + val atomicTriggers: List +): PolygonElement + +sealed interface PolygonAtomicTrigger: PolygonElement + +data class FailTrigger( + val failMessage: String +): PolygonAtomicTrigger + +data class SuccessTrigger( + val deferred: Boolean +): PolygonAtomicTrigger + +data class SetterTrigger( + val accessString: String, + val value: PolygonExpression +): PolygonAtomicTrigger + +data class SetUpTrigger( + val eventId: String +): PolygonAtomicTrigger + +data class DropTrigger( + val eventId: String +): PolygonAtomicTrigger + +data class MessageTrigger( + val message: String, + val replacements: List> +): PolygonAtomicTrigger + +data class LogTrigger( + val message: String, + val replacements: List> +): PolygonAtomicTrigger +//endregion + +//region Expressions +sealed interface PolygonExpression: PolygonElement + +data class VariableValueExpression( + val accessString: String +): PolygonExpression + +data class ObjectStateExpression( + val accessString: String +): PolygonExpression + +data class TypeOfExpression( + val accessString: String +): PolygonExpression + +sealed interface ConstExpression: PolygonExpression { + data class IntExpression(val value: Int): ConstExpression + data class DoubleExpression(val value: Double): ConstExpression + data class BooleanExpression(val value: Boolean): ConstExpression + data class StringExpression(val value: String): ConstExpression +} + +sealed interface UnaryExpressionKind { + object Minus: UnaryExpressionKind + object Abs: UnaryExpressionKind + object BoundingRect: UnaryExpressionKind +} + +data class UnaryExpression( + val expr: PolygonExpression, + val kind: UnaryExpressionKind +): PolygonExpression + +sealed interface BinaryExpressionKind { + object Sum: BinaryExpressionKind + object Diff: BinaryExpressionKind + object Min: BinaryExpressionKind + object Max: BinaryExpressionKind + object Mul: BinaryExpressionKind + object Distance: BinaryExpressionKind +} + +data class BinaryExpression( + val left: PolygonExpression, + val right: PolygonExpression, + val kind: BinaryExpressionKind +): PolygonExpression +//endregion + +// TODO: Add missed components +data class Polygon( + val world: PolygonWorld, + val constraints: PolygonConstraints +) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalysis.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalysis.kt new file mode 100644 index 00000000..5583ff20 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalysis.kt @@ -0,0 +1,48 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon + +import trik.testsys.webapp.backoffice.service.analysis.polygon.impl.PolygonParser + + +sealed interface PolygonDiagnosticReportSeverity { + object Error : PolygonDiagnosticReportSeverity { + override fun toString() = "Error" + } + + object Warning : PolygonDiagnosticReportSeverity { + override fun toString() = "Warning" + } +} + +data class PolygonDiagnosticReport( + val diagnosticName: String, + val location: String?, + val element: PolygonElement?, + val severity: PolygonDiagnosticReportSeverity, + val description: String +) { + + companion object { + + @JvmStatic + fun fromPolygonParsingException(exception: PolygonParser.PolygonParsingException) = + PolygonDiagnosticReport( + "Parsing", + null, + null, + PolygonDiagnosticReportSeverity.Error, + exception.message ?: "Синтаксическая ошибка в полигоне" + ) + + @JvmStatic + fun fromException(exception: Exception) = when (exception) { + is PolygonParser.PolygonParsingException -> fromPolygonParsingException(exception) + else -> PolygonDiagnosticReport( + "Unknown", + null, + null, + PolygonDiagnosticReportSeverity.Error, + exception.message ?: "Ошибка в полигоне" + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalyzer.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalyzer.kt new file mode 100644 index 00000000..e18d5c57 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonAnalyzer.kt @@ -0,0 +1,17 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon + +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile +import java.io.File + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface PolygonAnalyzer { + + fun analyze(file: File): List + + fun analyze(text: String): List + + fun analyze(polygonFile: PolygonFile): List +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonDiagnostic.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonDiagnostic.kt new file mode 100644 index 00000000..f672efc3 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/PolygonDiagnostic.kt @@ -0,0 +1,11 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon + + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +interface PolygonDiagnostic { + + fun perform(polygon: Polygon): List +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/InvalidEventIdDiagnostic.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/InvalidEventIdDiagnostic.kt new file mode 100644 index 00000000..0c774fd7 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/InvalidEventIdDiagnostic.kt @@ -0,0 +1,75 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon.impl + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.service.analysis.polygon.AbstractPolygonDiagnostic +import trik.testsys.webapp.backoffice.service.analysis.polygon.DropTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.DroppedCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.EventConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.Polygon +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReport +import trik.testsys.webapp.backoffice.service.analysis.polygon.SetUpTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.SettedUpCondition + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Service +class InvalidEventIdDiagnostic( + @Value("\${trik.testsys.diagnostics-availability.polygon.invalid-event-id}") + private val isAvailable: Boolean +) : AbstractPolygonDiagnostic() { + + override fun isAvailable() = isAvailable + + override fun doPerform(polygon: Polygon): List { + val results = mutableListOf() + + val droppedCondition = polygon + .atomicConditions() + .filterIsInstance() + + val settedUpCondition = polygon + .atomicConditions() + .filterIsInstance() + + val dropTriggers = polygon + .atomicTriggers() + .filterIsInstance() + + val setUpTriggers = polygon + .atomicTriggers() + .filterIsInstance() + + for (condition in droppedCondition) { + if (!eventExist(polygon, condition.eventId)) { + results.addError(condition, "Событие с id=${condition.eventId} не существует") + } + } + + for (condition in settedUpCondition) { + if (!eventExist(polygon, condition.eventId)) { + results.addError(condition, "Событие с id=${condition.eventId} не существует") + } + } + + for (trigger in dropTriggers) { + if (!eventExist(polygon, trigger.eventId)) { + results.addError(trigger, "Событие с id=${trigger.eventId} не существует") + } + } + + for (trigger in setUpTriggers) { + if (!eventExist(polygon, trigger.eventId)) { + results.addError(trigger, "Событие с id=${trigger.eventId} не существует") + } + } + + return results + } + + private fun eventExist(polygon: Polygon, eventId: String): Boolean { + return polygon.constraints.constraints.filterIsInstance().any { it.identifier == eventId } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonAnalyzerImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonAnalyzerImpl.kt new file mode 100644 index 00000000..a649f28f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonAnalyzerImpl.kt @@ -0,0 +1,132 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon.impl + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.getBeansOfType +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile +import trik.testsys.webapp.backoffice.service.FileManager +import trik.testsys.webapp.backoffice.service.analysis.polygon.Polygon +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonAnalyzer +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnostic +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReport +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonElement +import trik.testsys.webapp.notifier.CombinedIncidentNotifier +import java.io.File +import java.util.IdentityHashMap + +@Service +class PolygonAnalyzerImpl( + context: ApplicationContext, + + private val incidentNotifier: CombinedIncidentNotifier, + private val fileManager: FileManager +) : PolygonAnalyzer { + + private val diagnostics = context.getBeansOfType() + + override fun analyze(file: File): List { + logger.debug("Performing analyze(file=${file.name})") + + if (file.isDirectory || !file.exists()) { + logger.error("Cannot perform analyze: file(${file.name}) does not exist.") + return emptyList() + } + + val text = try { + file.readText() + } catch (e: Exception) { + logger.error("Cannot perform analyze: caught exception", e) + return emptyList() + } + + return analyze(text) + } + + override fun analyze(text: String): List { + logger.debug("Performing analyze(text.length=${text.length})") + + val parser = PolygonParser() + val polygon: Polygon + val locations: IdentityHashMap + + when (val parsedData = (parser.tryParse(text))) { + is ParsedData.SuccessData -> { + polygon = parsedData.polygon + locations = parsedData.locations + } + is ParsedData.PolygonErrorData -> { + val report = PolygonDiagnosticReport.fromPolygonParsingException(parsedData.exception) + return listOf(report) + } + is ParsedData.UnknownErrorData -> { + val report = PolygonDiagnosticReport.fromException(parsedData.exception) + return listOf(report) + } + } + + val allResults = mutableListOf() + diagnostics.forEach { (_, diagnostic) -> + val results = diagnostic.perform(polygon).map { + it.element ?: return@map it + it.copy(location = locations[it.element]) + } + + allResults.addAll(results) + } + + logger.debug("Analyze performed successful. Analyzed for ${allResults.size} diagnostic reports: $allResults") + return allResults + } + + override fun analyze(polygonFile: PolygonFile): List { + logger.debug("Performing analyse(polygonFile.id=${polygonFile.id}).") + + val file = fileManager.getPolygonFile(polygonFile) ?: error("Polygon file is not accessible") + val results = analyze(file) + + logger.debug("Analyze performed successful(polygonFile.id=${polygonFile.id}).") + return results + } + + private fun PolygonParser.tryParse(text: String): ParsedData { + val polygon = try { + this.parse(text) + } catch (e: PolygonParser.PolygonParsingException) { + logger.error("Cannot parse polygon: caught exception", e) +// incidentNotifier.notify("Caught PolygonParsingException(message=${e.message}) while parsing polygon: \n text") + + return ParsedData.PolygonErrorData(e) + } catch (e: Exception) { + logger.error("Cannot parse polygon: caught unknown exception", e) + incidentNotifier.notify("Caught unknown exception(className=${e::class.java}, message=${e.message}) while parsing polygon: \n text") + + return ParsedData.UnknownErrorData(e) + } + + val locations = this.getLocations() + + return ParsedData.SuccessData(polygon, locations) + } + + sealed interface ParsedData { + + class SuccessData( + val polygon: Polygon, + val locations: IdentityHashMap + ) : ParsedData + + class PolygonErrorData( + val exception: PolygonParser.PolygonParsingException + ) : ParsedData + + class UnknownErrorData( + val exception: Exception + ) : ParsedData + } + + companion object { + + private val logger = LoggerFactory.getLogger(PolygonAnalyzerImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonParser.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonParser.kt new file mode 100644 index 00000000..bae30313 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/PolygonParser.kt @@ -0,0 +1,581 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon.impl + +import org.w3c.dom.Element +import trik.testsys.webapp.backoffice.service.analysis.polygon.BinaryCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.BinaryConditionKind +import trik.testsys.webapp.backoffice.service.analysis.polygon.BinaryExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.BinaryExpressionKind +import trik.testsys.webapp.backoffice.service.analysis.polygon.Condition +import trik.testsys.webapp.backoffice.service.analysis.polygon.ConditionGlue +import trik.testsys.webapp.backoffice.service.analysis.polygon.ConstExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.DropTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.DroppedCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.EventConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.FailTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.InitConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.InsideCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.LogTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.MessageTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.NotCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.ObjectPoints +import trik.testsys.webapp.backoffice.service.analysis.polygon.ObjectStateExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.PlainConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.Polygon +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonAtomicTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonConstraints +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonElement +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonWorld +import trik.testsys.webapp.backoffice.service.analysis.polygon.Region +import trik.testsys.webapp.backoffice.service.analysis.polygon.SetUpTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.SettedUpCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.SetterTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.SuccessTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.TimeLimitConstraint +import trik.testsys.webapp.backoffice.service.analysis.polygon.TimerCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.TrueCondition +import trik.testsys.webapp.backoffice.service.analysis.polygon.TypeOfExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.UnaryExpression +import trik.testsys.webapp.backoffice.service.analysis.polygon.UnaryExpressionKind +import trik.testsys.webapp.backoffice.service.analysis.polygon.VariableValueExpression +import java.util.IdentityHashMap +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.reflect.typeOf + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +class PolygonParser { + + + //region Type safe xml + private data class XmlElement( + val path: String, + val tagName: String, + val attributes: Map, + val children: List + ) + + private fun Element.headerToString(): String { + val result = StringBuilder() + result.append("<${tagName}") + val attributesCount = this.attributes.length + for (i in 0 until attributesCount) { + val attribute = this.attributes.item(i) + result.append(" ${attribute.nodeName}=${attribute.nodeValue}") + } + result.append(">") + return result.toString() + } + + private fun Element.toTypeSafeElement(currentPath: String = ""): XmlElement { + val path = if (currentPath.isEmpty()) { + "/${this.headerToString()}" + } else { + "$currentPath/${this.headerToString()}" + } + + val tagName = this.tagName + + val attributes = hashMapOf() + val attributesCount = this.attributes.length + for (i in 0 until attributesCount) { + val attribute = this.attributes.item(i) + attributes[attribute.nodeName] = attribute.nodeValue + } + + val children = mutableListOf() + + val childrenCount = this.childNodes.length + for (i in 0 until childrenCount) { + val child = this.childNodes.item(i) + if (child is Element) { + children.add(child.toTypeSafeElement(path)) + } + } + + return XmlElement(path, tagName, attributes, children) + } + //endregion + + //region Locations + private val locations = IdentityHashMap() + + private fun recordLocation(e: XmlElement, value: T): T { + locations[value] = e.path + return value + } + //endregion + + //region Helpers + class PolygonParsingException( + message: String + ): Exception(message) + + private fun throwInvalidElement(e: XmlElement, message: String): Nothing { + throw PolygonParsingException("Error while parsing ${e.path}: $message") + } + + private fun assertChildrenCount(e: XmlElement, expected: Int) { + val actual = e.children.size + if (actual != expected) { + throwInvalidElement(e, + "${e.tagName} tag must have exactly $expected child tag(s), actual: $actual" + ) + } + } + + private fun XmlElement.getOptionalChild(tagName: String): XmlElement? { + return this.children.find { it.tagName == tagName } + } + + private fun XmlElement.getRequiredChild(tagName: String): XmlElement { + return this.getOptionalChild(tagName) ?: throwInvalidElement( + this, "Missed required child tag: $tagName" + ) + } + + private inline fun XmlElement.getRequiredAttribute(attributeName: String): T { + return this.getOptionalAttribute(attributeName) ?: throwInvalidElement( + this, "Missed required attribute: $attributeName" + ) + } + + private inline fun XmlElement.getOptionalAttribute(attributeName: String): T? { + val value = this.attributes[attributeName] ?: return null + val expectedType = typeOf() + + return when (expectedType) { + typeOf() -> value + typeOf() -> { + try { + value.toInt() + } catch (_: NumberFormatException) { + throwInvalidElement(this, "expected $attributeName is valid int, actual: $value") + } + } + typeOf() -> { + try { + value.toDouble() + } catch (_: NumberFormatException) { + throwInvalidElement(this, "expected $attributeName is valid double, actual: $value") + } + } + typeOf() -> { + when(value) { + "true" -> true + "false" -> false + else -> throwInvalidElement(this, + "expected $attributeName is true or false, actual: $value" + ) + } + } + else -> { + throw Exception("Unsupported attribute type: ${T::class.java.typeName}") + } + } as T + } + //endregion + + //region Expressions + private fun parseBool(e: XmlElement): ConstExpression.BooleanExpression { + val value = e.getRequiredAttribute("value") + return recordLocation(e, ConstExpression.BooleanExpression(value)) + } + + private fun parseInt(e: XmlElement): ConstExpression.IntExpression { + val value = e.getRequiredAttribute("value") + return recordLocation(e, ConstExpression.IntExpression(value)) + } + + private fun parseString(e: XmlElement): ConstExpression.StringExpression { + val value = e.getRequiredAttribute("value") + return recordLocation(e, ConstExpression.StringExpression(value)) + } + + private fun parseDouble(e: XmlElement): ConstExpression.DoubleExpression { + val value = e.getRequiredAttribute("value") + return recordLocation(e, ConstExpression.DoubleExpression(value)) + } + + private fun parseVariableValue(e: XmlElement): VariableValueExpression { + val value = e.getRequiredAttribute("name") + return recordLocation(e, VariableValueExpression(value)) + } + + private fun parseTypeOf(e: XmlElement): TypeOfExpression { + val value = e.getRequiredAttribute("objectId") + return recordLocation(e, TypeOfExpression(value)) + } + + private fun parseObjectState(e: XmlElement): ObjectStateExpression { + val value = e.getRequiredAttribute("object") + return recordLocation(e, ObjectStateExpression(value)) + } + + private fun parseBinaryExpression(e: XmlElement): BinaryExpression { + assertChildrenCount(e, 2) + + val operation = e.tagName + + val left = parseExpression(e.children[0]) + val right = parseExpression(e.children[1]) + + val kind = + when(operation) { + "mul" -> BinaryExpressionKind.Mul + "sum" -> BinaryExpressionKind.Sum + "difference" -> BinaryExpressionKind.Diff + "min" -> BinaryExpressionKind.Min + "max" -> BinaryExpressionKind.Max + "distance" -> BinaryExpressionKind.Distance + else -> throwInvalidElement(e, "Unexpected operation: $operation") + } + + return recordLocation(e, BinaryExpression(left, right, kind)) + } + + private fun parseUnaryExpression(e: XmlElement): UnaryExpression { + assertChildrenCount(e, 1) + + val operation = e.tagName + + val expr = parseExpression(e.children[0]) + + val kind = + when(operation) { + "minus" -> UnaryExpressionKind.Minus + "abs" -> UnaryExpressionKind.Abs + "boundingRect" -> UnaryExpressionKind.BoundingRect + else -> throwInvalidElement(e, "Unexpected operation: $operation") + } + + return recordLocation(e, UnaryExpression(expr, kind)) + } + + private fun parseExpression(e: XmlElement): PolygonExpression { + val tag = e.tagName + + return recordLocation(e, when(tag) { + "bool" -> parseBool(e) + "int" -> parseInt(e) + "string" -> parseString(e) + "double" -> parseDouble(e) + "variableValue" -> parseVariableValue(e) + "typeOf" -> parseTypeOf(e) + "objectState" -> parseObjectState(e) + in listOf("minus", "abs", "boundingRect") -> parseUnaryExpression(e) + in listOf("mul", "sum", "difference", "min", "max", "distance") -> parseBinaryExpression(e) + else -> throwInvalidElement(e, "unknown value") + }) + } + //endregion + + //region Triggers + private fun parseFailTrigger(e: XmlElement): FailTrigger { + val value = e.getRequiredAttribute("message") + return recordLocation(e, FailTrigger(value)) + } + + private fun parseSuccessTrigger(e: XmlElement): SuccessTrigger { + val value = e.getOptionalAttribute("deferred") ?: false + return recordLocation(e, SuccessTrigger(value)) + } + + private fun parseSetterTrigger(e: XmlElement): SetterTrigger { + assertChildrenCount(e, 1) + val name = e.getRequiredAttribute("name") + val expr = parseExpression(e.children[0]) + return recordLocation(e, SetterTrigger(name, expr)) + } + + private fun parseSetUpTrigger(e: XmlElement): SetUpTrigger { + val identifier = e.getRequiredAttribute("id") + return recordLocation(e, SetUpTrigger(identifier)) + } + + private fun parseDropTrigger(e: XmlElement): DropTrigger { + val identifier = e.getRequiredAttribute("id") + return recordLocation(e, DropTrigger(identifier)) + } + + private fun parseReplacements(e: XmlElement): List> { + return e.children.map { + if (it.tagName != "replace") { + throwInvalidElement(it, "invalid tag, expected ") + } + + val variable = it.getRequiredAttribute("var") + + assertChildrenCount(it, 1) + val replacement = parseExpression(it.children[0]) + variable to replacement + } + } + + private fun parseMessageTrigger(e: XmlElement): MessageTrigger { + val text = e.getRequiredAttribute("text") + val replacements = parseReplacements(e) + return recordLocation(e, MessageTrigger(text, replacements)) + } + + private fun parseLogTrigger(e: XmlElement): LogTrigger { + val text = e.getRequiredAttribute("text") + val replacements = parseReplacements(e) + return recordLocation(e, LogTrigger(text, replacements)) + } + + private fun parseAtomicTrigger(e: XmlElement): PolygonAtomicTrigger { + val tagName = e.tagName + + return recordLocation(e, when (tagName) { + "fail" -> parseFailTrigger(e) + "message" -> parseMessageTrigger(e) + "success" -> parseSuccessTrigger(e) + "setter" -> parseSetterTrigger(e) + "setUp" -> parseSetUpTrigger(e) + "drop" -> parseDropTrigger(e) + "log" -> parseLogTrigger(e) + "setState" -> throw Exception("TODO: Element not described in document") + else -> throwInvalidElement(e, "unknown trigger") + }) + } + + private fun parseSingleTrigger(e: XmlElement): PolygonAtomicTrigger { + assertChildrenCount(e, 1) + return recordLocation(e, parseAtomicTrigger(e.children[0])) + } + + private fun parseMultipleTriggers(e: XmlElement): List { + return e.children.map { parseAtomicTrigger(it) } + } + + private fun parseTrigger(e: XmlElement): PolygonTrigger { + val tagName = e.tagName + + val atomicTriggers = when (tagName) { + "trigger" -> listOf(parseSingleTrigger(e)) + "triggers" -> parseMultipleTriggers(e) + else -> throwInvalidElement(e, "unknown trigger") + } + + return recordLocation(e, PolygonTrigger(atomicTriggers)) + } + //endregion + + //region Conditions + private fun parseCondition(e: XmlElement): Condition { + val tagName = e.tagName + + return recordLocation(e, when (tagName) { + "true" -> TrueCondition + "not" -> parseNot(e) + "inside" -> parseInside(e) + "settedUp" -> parseSettedUp(e) + "dropped" -> parseDropped(e) + "timer" -> parseTimer(e) + "conditions" -> parseMultipleConditions(e) + "using" -> throw Exception("TODO: Element not described in document") + in listOf("equals", "notEqual", "greater", "notGreater", "less", "notLess") -> parseBinaryCondition(e) + else -> throwInvalidElement(e, "Unexpected tag") + }) + } + + private fun parseNot(e: XmlElement): NotCondition { + assertChildrenCount(e, 1) + val condition = parseCondition(e.children[0]) + return recordLocation(e, NotCondition(condition)) + } + + private fun parseBinaryCondition(e: XmlElement): BinaryCondition { + assertChildrenCount(e, 2) + + val tagName = e.tagName + + val left = parseExpression(e.children[0]) + val right = parseExpression(e.children[1]) + + val kind = + when (tagName) { + "equals" -> BinaryConditionKind.Equals + "notEqual" -> BinaryConditionKind.NotEqual + "greater" -> BinaryConditionKind.Greater + "notGreater" -> BinaryConditionKind.NotGreater + "less" -> BinaryConditionKind.Less + "notLess" -> BinaryConditionKind.NotLess + else -> throwInvalidElement(e, "Unexpected comparison: $tagName") + } + + return recordLocation(e, BinaryCondition(left, right, kind)) + } + + private fun parseInside(e: XmlElement): InsideCondition { + val objectId = e.getRequiredAttribute("objectId") + val regionId = e.getRequiredAttribute("regionId") + val stringPoints = e.getOptionalAttribute("objectPoint") + + val point = + when (stringPoints) { + "center" -> ObjectPoints.Center + "any" -> ObjectPoints.Any + "all" -> ObjectPoints.All + null -> ObjectPoints.Center + else -> throwInvalidElement(e, "Expected objetPoint=center|all|any, actual is $stringPoints") + } + + return recordLocation(e, InsideCondition(objectId, regionId, point)) + } + + private fun parseSettedUp(e: XmlElement): SettedUpCondition { + val identifier = e.getRequiredAttribute("id") + return recordLocation(e, SettedUpCondition(identifier)) + } + + private fun parseDropped(e: XmlElement): DroppedCondition { + val identifier = e.getRequiredAttribute("id") + return recordLocation(e, DroppedCondition(identifier)) + } + + private fun parseTimer(e: XmlElement): TimerCondition { + val timeout = e.getRequiredAttribute("timeout") + val forceDrop = e.getOptionalAttribute("forceDropOnTimeout") ?: true + return recordLocation(e, TimerCondition(timeout, forceDrop)) + } + + private fun parseSingleCondition(e: XmlElement): PolygonCondition { + assertChildrenCount(e, 1) + val condition = parseCondition(e.children[0]) + return recordLocation(e, PolygonCondition(ConditionGlue.GlueAnd, listOf(condition))) + } + + private fun parseMultipleConditions(e: XmlElement): PolygonCondition { + val conditions = e.children.map { parseCondition(it) } + val stringGlue = e.getRequiredAttribute("glue") + + val glue = + when(stringGlue) { + "and" -> ConditionGlue.GlueAnd + "or" -> ConditionGlue.GlueOr + else -> throwInvalidElement(e, "Expected glue=and|or, actual is $stringGlue") + } + + return recordLocation(e, PolygonCondition(glue, conditions)) + } + + private fun parsePolygonCondition(e: XmlElement): PolygonCondition { + val tagName = e.tagName + + return recordLocation(e, when (tagName) { + "condition" -> parseSingleCondition(e) + "conditions" -> parseMultipleConditions(e) + else -> throwInvalidElement(e, "unknown condition") + }) + } + //endregion + + //region Constraints + private fun parseTimeLimit(e: XmlElement): TimeLimitConstraint { + val value = e.getRequiredAttribute("value") + return recordLocation(e, TimeLimitConstraint(value)) + } + + private fun parseInitConstraint(e: XmlElement): InitConstraint { + val triggers = parseMultipleTriggers(e) + return recordLocation(e, InitConstraint(triggers)) + } + + private fun parsePlainConstraint(e: XmlElement): PlainConstraint { + assertChildrenCount(e, 1) + val failMessage = e.getRequiredAttribute("failMessage") + val checkOnce = e.getOptionalAttribute("checkOnce") ?: false + + val child = e.children[0] + val condition = + when(child.tagName) { + in listOf("condition", "conditions") -> parsePolygonCondition(child) + else -> parseSingleCondition(e) + } + return recordLocation(e, PlainConstraint(checkOnce, failMessage, condition)) + } + + private fun parseEventConstraint(e: XmlElement): EventConstraint { + assertChildrenCount(e, 2) + val identifier = e.getOptionalAttribute("id") + val dropsOnFire = e.getOptionalAttribute("dropsOnFire") ?: true + val settedUpInitially = e.getOptionalAttribute("settedUpInitially") ?: false + + val triggerChild = + e.getOptionalChild("trigger") + ?: e.getOptionalChild("triggers") + ?: throwInvalidElement(e, "Event tag must have \"trigger\" or \"triggers\" child tag.") + + val conditionChild = + e.getOptionalChild("condition") + ?: e.getOptionalChild("conditions") + ?: throwInvalidElement(e, "Event tag must have \"condition\" or \"conditions\" child tag.") + + val trigger = parseTrigger(triggerChild) + val condition = parsePolygonCondition(conditionChild) + + return recordLocation(e, EventConstraint( + dropsOnFire, + identifier, + settedUpInitially, + condition, + trigger + ) + ) + } + + private fun parseConstraint(e: XmlElement): PolygonConstraint { + return recordLocation(e, when(e.tagName) { + "event" -> parseEventConstraint(e) + "constraint" -> parsePlainConstraint(e) + "timelimit" -> parseTimeLimit(e) + in listOf("init", "initialization") -> parseInitConstraint(e) + else -> throwInvalidElement(e, "Unexpected tag: ${e.tagName}") + }) + } + + private fun parseConstraints(e: XmlElement): PolygonConstraints { + val constraints = e.children.map { parseConstraint(it) } + return recordLocation(e, PolygonConstraints(constraints)) + } + //endregion + + //region World + private fun parseRegion(e: XmlElement): Region { + val identifier = e.getRequiredAttribute("id") + return recordLocation(e, Region(identifier)) + } + + private fun parseWorld(e: XmlElement): PolygonWorld { + val regions = e.children + .filter { it.tagName == "region" } + .map { parseRegion(it) } + return recordLocation(e, PolygonWorld(regions)) + } + //endregion + + private fun parsePolygon(e: XmlElement): Polygon { + val world = parseWorld(e.getRequiredChild("world")) + val constraints = parseConstraints(e.getRequiredChild("constraints")) + return Polygon(world, constraints) + } + + fun parse(text: String): Polygon { + locations.clear() + val builder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val document = builder.parse(text.byteInputStream()) + val xmlElement = document.documentElement.toTypeSafeElement() + return parsePolygon(xmlElement) + } + + fun getLocations(): IdentityHashMap { + return locations + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/ScoreOutputDiagnostic.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/ScoreOutputDiagnostic.kt new file mode 100644 index 00000000..4a2d5e6b --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/ScoreOutputDiagnostic.kt @@ -0,0 +1,43 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon.impl + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.service.analysis.polygon.AbstractPolygonDiagnostic +import trik.testsys.webapp.backoffice.service.analysis.polygon.MessageTrigger +import trik.testsys.webapp.backoffice.service.analysis.polygon.Polygon +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReport + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Service +class ScoreOutputDiagnostic( + @Value("\${trik.testsys.diagnostics-availability.polygon.score-output}") + private val isAvailable: Boolean +) : AbstractPolygonDiagnostic() { + + override fun isAvailable() = isAvailable + + override fun doPerform(polygon: Polygon): List { + val results = mutableListOf() + + val prints = polygon + .atomicTriggers() + .filterIsInstance() + + val hasScoreOutput = prints + .any { it.message.contains(SCORE_OUTPUT_FORMAT) } + + if (!hasScoreOutput) { + results.addWarning("В полигоне отсутствует вывод баллов в формате \"$SCORE_OUTPUT_FORMAT\"") + } + + return results + } + + companion object { + + private const val SCORE_OUTPUT_FORMAT = "Набрано баллов:" + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/TimeLimitPolygonDiagnostic.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/TimeLimitPolygonDiagnostic.kt new file mode 100644 index 00000000..e188a89b --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/analysis/polygon/impl/TimeLimitPolygonDiagnostic.kt @@ -0,0 +1,42 @@ +package trik.testsys.webapp.backoffice.service.analysis.polygon.impl + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.service.analysis.polygon.AbstractPolygonDiagnostic +import trik.testsys.webapp.backoffice.service.analysis.polygon.Polygon +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonDiagnosticReport +import trik.testsys.webapp.backoffice.service.analysis.polygon.TimeLimitConstraint + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Service +class TimeLimitPolygonDiagnostic( + @Value("\${trik.testsys.diagnostics-availability.polygon.timelimit}") + private val isAvailable: Boolean +) : AbstractPolygonDiagnostic() { + + override fun isAvailable() = isAvailable + + override fun doPerform(polygon: Polygon): List { + val results = mutableListOf() + + val timeLimitConstraints = polygon.constraints.constraints.filterIsInstance().ifEmpty { + results.addError("Отсутствует ограничение по времени.") + return results + } + + if (timeLimitConstraints.size > 1) { + results.addWarning("Найдено ${timeLimitConstraints.size} ограничения по времени (timelimit), ожидалось 1") + } + + timeLimitConstraints.forEach { + if (it.milliseconds < 0 || it.milliseconds > 10 * 60 * 1000) { + results.addError(it, "Некорректное значение ограничения по времени") + } + } + + return results + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/EmailClient.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/EmailClient.kt new file mode 100644 index 00000000..906e6c2d --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/EmailClient.kt @@ -0,0 +1,92 @@ +package trik.testsys.webapp.backoffice.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.Properties +import javax.annotation.PostConstruct +import javax.mail.Authenticator +import javax.mail.Message +import javax.mail.PasswordAuthentication +import javax.mail.Session +import javax.mail.Transport +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Service +class EmailClient( + @Value("\${trik.testsys.email-client.smtp.host}") + private val smtpHost: String, + @Value("\${trik.testsys.email-client.smtp.port}") + private val smtpPort: Int, + @Value("\${trik.testsys.email-client.domain}") + private val emailDomain: String, + @Value("\${trik.testsys.email-client.credentials.username}") + private val username: String, + @Value("\${trik.testsys.email-client.credentials.password}") + private val password: String, +) { + + @PostConstruct + fun init() { + if (smtpHost.trim().isEmpty()) error("smtp host must be initialized") + if (smtpPort == null) error("smtp port must be initialized") + if (emailDomain.trim().isEmpty()) error("Email domain must be initialized") + if (username.trim().isEmpty()) error("Email username must be initialized") + if (password.trim().isEmpty()) error("Email password must be initialized") + } + + private val session: Session by lazy { + val properties = Properties().apply { + put("mail.smtp.host", smtpHost) + put("mail.smtp.port", smtpPort.toString()) + put("mail.smtp.auth", "true") + put("mail.smtp.starttls.enable", "true") + put("mail.smtp.socketFactory.port", smtpPort.toString()) + put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory") + + } + + Session.getInstance(properties, object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password) + } + }) + } + + data class Email( + val from: String, + val to: List, + val subject: String, + val body: String, + val cc: List = emptyList(), + val bcc: List = emptyList() + ) + + fun sendEmail(email: Email) { + return try { + val message = MimeMessage(session).apply { + setFrom(InternetAddress("${email.from}@$emailDomain")) + email.to.forEach { addRecipient(Message.RecipientType.TO, InternetAddress(it)) } + email.cc.forEach { addRecipient(Message.RecipientType.CC, InternetAddress(it)) } + email.bcc.forEach { addRecipient(Message.RecipientType.BCC, InternetAddress(it)) } + subject = email.subject + setText(email.body) + } + Transport.send(message) + + logger.info("Email message sent.") + } catch (e: Exception) { + logger.error("Failed to send email: ", e) + } + } + + companion object { + + private val logger = LoggerFactory.getLogger(EmailClient::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/FileManagerImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/FileManagerImpl.kt new file mode 100644 index 00000000..2f37fb0c --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/FileManagerImpl.kt @@ -0,0 +1,433 @@ +package trik.testsys.webapp.backoffice.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import org.zeroturnaround.zip.ZipUtil +import trik.testsys.webapp.backoffice.data.entity.AbstractFile +import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.TaskFile.TaskFileType.Companion.extension +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.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.service.TaskFileVersionInfo +import java.io.File +import java.nio.file.Files +import java.time.Instant +import javax.annotation.PostConstruct +import kotlin.streams.asSequence + +@Service +class FileManagerImpl( + @Value("\${trik.testsys.paths.taskFiles.conditions}") private val conditionFilesDirPath: String, + @Value("\${trik.testsys.paths.taskFiles.exercises}") private val exerciseFilesDirPath: String, + @Value("\${trik.testsys.paths.taskFiles.polygons}") private val polygonFilesDirPath: String, + @Value("\${trik.testsys.paths.taskFiles.solutions}") private val solutionFilesDirPath: String, + + @Value("\${trik.testsys.paths.files.solutions}") private val solutionsPath: String, + @Value("\${trik.testsys.paths.files.verdicts}") private val verdictsPath: String, + @Value("\${trik.testsys.paths.files.recordings}") private val recordingsPath: String, + @Value("\${trik.testsys.paths.files.results}") private val resultsPath: String, + + private val conditionFileService: ConditionFileService, + private val exerciseFileService: ExerciseFileService, + private val polygonFileService: PolygonFileService, + private val solutionFileService: SolutionFileService +) : FileManager { + + private val solutionFilesDir = File(solutionFilesDirPath) + private val exerciseFilesDir = File(exerciseFilesDirPath) + private val polygonFilesDir = File(polygonFilesDirPath) + private val conditionFilesDir = File(conditionFilesDirPath) + + private val solutionsDir = File(solutionsPath) + private val verdictsDir = File(verdictsPath) + private val recordingsDir = File(recordingsPath) + private val resultsDir = File(resultsPath) + + @Deprecated("") + private val dirByTaskFileType: Map by lazy { + mapOf( + TaskFile.TaskFileType.SOLUTION to solutionFilesDir, + TaskFile.TaskFileType.EXERCISE to exerciseFilesDir, + TaskFile.TaskFileType.POLYGON to polygonFilesDir, + TaskFile.TaskFileType.CONDITION to conditionFilesDir, + ) + } + + @PostConstruct + fun init() { + listOf( + solutionFilesDir, exerciseFilesDir, polygonFilesDir, conditionFilesDir, + solutionsDir, verdictsDir, recordingsDir, resultsDir + ).forEach { dir -> + if (!dir.exists()) dir.mkdirs() + } + } + + @Deprecated("") + override fun getTaskFileVersion(taskFile: TaskFile, version: Long): File? { + val dir = dirByTaskFileType[taskFile.type] ?: return null + val ext = taskFile.type?.extension() ?: return null + val file = File(dir, "${taskFile.id}-${version}$ext") + return if (file.exists()) file else null + } + + override fun saveSolution(solution: Solution, fileData: MultipartFile): Boolean { + // Persist original uploaded solution alongside task files under solutionsDir + val file = File(solution.dir, solution.fileName) + return try { + fileData.transferTo(file) + true + } catch (e: Exception) { + logger.error("Failed to save solution file(id=${solution.id})", e) + false + } + } + + override fun saveSolution(solution: Solution, sourceFile: File): Boolean { + val target = File(solution.dir, solution.fileName) + return try { + sourceFile.copyTo(target, overwrite = true) + true + } catch (e: Exception) { + logger.error("Failed to copy solution file(id=${solution.id}) from ${sourceFile.absolutePath}", e) + false + } + } + + override fun getSolution(solution: Solution): File? { + val file = File(solution.dir, solution.fileName) + return if (file.exists()) file else null + } + + override fun hasSolution(solution: Solution): Boolean { + val has = Files.list(solution.dir.toPath()).use { stream -> + stream.asSequence().firstOrNull { it.fileName.toString() == solution.fileName } + }?.let { true } ?: false + + return has + } + + // TODO: uncomment on major release +// private val Solution.dir: File +// get() = when (contest) { +// null -> solutionsDir +// else -> solutionFilesDir +// } + + private val Solution.dir: File + get() = solutionFilesDir + + override fun saveSuccessfulGradingInfo(fieldResult: Grader.GradingInfo.Ok) { + logger.info("Saving ok grading info") + + val (solutionId, fieldResults) = fieldResult + fieldResults.forEach { (fieldName, verdict, recording) -> + logger.info("Field $fieldName: verdict ${verdict.name}, recording ${recording?.name}") + + verdict.content.let { verdictContent -> + val verdictFile = File(verdictsDir, "${solutionId}_$fieldName.txt") + verdictFile.writeBytes(verdictContent) + + logger.info("Verdict saved to ${verdictFile.absolutePath}") + } + + recording?.content?.let { recordingContent -> + val recordingFile = File(recordingsDir, "${solutionId}_$fieldName.mp4") + recordingFile.writeBytes(recordingContent) + + logger.info("Recording saved to ${recordingFile.absolutePath}") + } + + } + } + + override fun getVerdicts(solution: Solution): List { + logger.info("Getting verdict files for solution with id ${solution.id}") + + val verdictFiles = Files.list(verdictsDir.toPath()).use { stream -> + stream.asSequence() + .filter { it.fileName.toString().startsWith("${solution.id}_") } + .map { it.toFile() } + .toList() + } + + return verdictFiles + } + + override fun hasAnyVerdict(solution: Solution): Boolean { + logger.debug("Finding verdict files for solution(id=${solution.id})") + + val hasAny = Files.list(verdictsDir.toPath()).use { stream -> + stream.asSequence() + .any { it.fileName.toString().startsWith("${solution.id}_") } + } + + return hasAny + } + + override fun getRecording(solution: Solution): List { + logger.info("Getting recording files for solution with id ${solution.id}") + + val recordingFiles = Files.list(recordingsDir.toPath()).use { stream -> + stream.asSequence() + .filter { it.fileName.toString().startsWith("${solution.id}_") } + .map { it.toFile() } + .toList() + } + + return recordingFiles + } + + override fun hasAnyRecording(solution: Solution): Boolean { + logger.debug("Finding recording files for solution(id=${solution.id})") + + val hasAny = Files.list(recordingsDir.toPath()).use { stream -> + stream.asSequence() + .any { it.fileName.toString().startsWith("${solution.id}_") } + } + + return hasAny + } + + override fun getSolutionResultFilesCompressed(solution: Solution): File { + logger.info("Getting compressed solution result files for solution with id ${solution.id}") + + val resultsFile = File(resultsDir, "${solution.id}_results.zip") + + if (resultsFile.exists()) { + logger.info("Compressed solution result files for solution with id ${solution.id} already exist") + + return resultsFile + } + + val verdicts = getVerdicts(solution) + val recordings = getRecording(solution) + val results = verdicts + recordings + + ZipUtil.packEntries(results.toTypedArray(), resultsFile) + + return resultsFile + } + + override fun saveConditionFile(conditionFile: ConditionFile, fileData: MultipartFile): ConditionFile? { + logger.debug("Saving conditionFile(id=${conditionFile.id}, type=${conditionFile.type})") + + val saved = conditionFileService.save(conditionFile) + val file = File(conditionFilesDir, saved.getFileName()) + + return try { + fileData.transferTo(file) + saved + } catch (e: Exception) { + logger.error("Failed to save condition file(id=${conditionFile.id})", e) + conditionFileService.delete(saved) + null + } + } + + override fun saveExerciseFile(exerciseFile: ExerciseFile, fileData: MultipartFile): ExerciseFile? { + logger.debug("Saving exerciseFile(id=${exerciseFile.id}, type=${exerciseFile.type})") + + val saved = exerciseFileService.save(exerciseFile) + val file = File(exerciseFilesDir, saved.getFileName()) + + return try { + fileData.transferTo(file) + saved + } catch (e: Exception) { + logger.error("Failed to save exercise file(id=${exerciseFile.id})", e) + exerciseFileService.delete(saved) + null + } + } + + override fun savePolygonFile(polygonFile: PolygonFile, fileData: MultipartFile): PolygonFile? { + logger.debug("Saving polygonFile(id=${polygonFile.id}, type=${polygonFile.type})") + + val saved = polygonFileService.save(polygonFile) + val file = File(polygonFilesDir, saved.getFileName()) + + return try { + fileData.transferTo(file) + saved + } catch (e: Exception) { + logger.error("Failed to save polygon file(id=${polygonFile.id})", e) + polygonFileService.delete(saved) + null + } + } + + override fun saveSolutionFile(solutionFile: SolutionFile, fileData: MultipartFile): SolutionFile? { + logger.debug("Saving solutionFile(id=${solutionFile.id}, type=${solutionFile.type})") + + val saved = solutionFileService.save(solutionFile) + val file = File(solutionFilesDir, saved.getFileName()) + + return try { + fileData.transferTo(file) + saved + } catch (e: Exception) { + logger.error("Failed to save solution file(id=${solutionFile.id})", e) + solutionFileService.delete(saved) + null + } + } + + override fun saveConditionFile(conditionFile: ConditionFile, file: File): ConditionFile? { + logger.debug("Saving conditionFile(id=${conditionFile.id}, type=${conditionFile.type}) from file ${file.absolutePath}") + + val saved = conditionFileService.save(conditionFile) + val target = File(conditionFilesDir, saved.getFileName()) + + return try { + file.copyTo(target, overwrite = true) + saved + } catch (e: Exception) { + logger.error("Failed to copy condition file(id=${conditionFile.id}) from ${file.absolutePath}", e) + conditionFileService.delete(saved) + null + } + } + + override fun saveExerciseFile(exerciseFile: ExerciseFile, file: File): ExerciseFile? { + logger.debug("Saving exerciseFile(id=${exerciseFile.id}, type=${exerciseFile.type}) from file ${file.absolutePath}") + + val saved = exerciseFileService.save(exerciseFile) + val target = File(exerciseFilesDir, saved.getFileName()) + + return try { + file.copyTo(target, overwrite = true) + saved + } catch (e: Exception) { + logger.error("Failed to copy exercise file(id=${exerciseFile.id}) from ${file.absolutePath}", e) + exerciseFileService.delete(saved) + null + } + } + + override fun savePolygonFile(polygonFile: PolygonFile, file: File): PolygonFile? { + logger.debug("Saving polygonFile(id=${polygonFile.id}, type=${polygonFile.type}) from file ${file.absolutePath}") + + val saved = polygonFileService.save(polygonFile) + val target = File(polygonFilesDir, saved.getFileName()) + + return try { + file.copyTo(target, overwrite = true) + saved + } catch (e: Exception) { + logger.error("Failed to copy polygon file(id=${polygonFile.id}) from ${file.absolutePath}", e) + polygonFileService.delete(saved) + null + } + } + + override fun saveSolutionFile(solutionFile: SolutionFile, file: File): SolutionFile? { + logger.debug("Saving solutionFile(id=${solutionFile.id}, type=${solutionFile.type}) from file ${file.absolutePath}") + + val saved = solutionFileService.save(solutionFile) + val target = File(solutionFilesDir, saved.getFileName()) + + return try { + file.copyTo(target, overwrite = true) + saved + } catch (e: Exception) { + logger.error("Failed to copy solution file(id=${solutionFile.id}) from ${file.absolutePath}", e) + solutionFileService.delete(saved) + null + } + } + + override fun getConditionFile(conditionFile: ConditionFile, version: Long): File? { + logger.debug("Getting conditionFile(id=${conditionFile.id}, version=$version)") + val file = File(conditionFilesDir, conditionFile.getFileName(version)) + + return if (file.exists()) file else { + logger.error("ConditionFile(id=${conditionFile.id}, version=$version) not found.") + null + } + } + + override fun getExerciseFile(exerciseFile: ExerciseFile, version: Long): File? { + logger.debug("Getting exerciseFile(id=${exerciseFile.id}, version=$version)") + val file = File(exerciseFilesDir, exerciseFile.getFileName(version)) + + return if (file.exists()) file else { + logger.error("ExerciseFile(id=${exerciseFile.id}, version=$version) not found.") + null + } + } + + override fun getPolygonFile(polygonFile: PolygonFile, version: Long): File? { + logger.debug("Getting polygonFile(id=${polygonFile.id}, version=$version)") + val file = File(polygonFilesDir, polygonFile.getFileName(version)) + + return if (file.exists()) file else { + logger.error("PolygonFile(id=${polygonFile.id}, version=$version) not found.") + null + } + } + + override fun getSolutionFile(solutionFile: SolutionFile, version: Long): File? { + logger.debug("Getting solutionFile(id=${solutionFile.id}, version=$version)") + val file = File(solutionFilesDir, solutionFile.getFileName(version)) + + return if (file.exists()) file else { + logger.error("SolutionFile(id=${solutionFile.id}, version=$version) not found.") + null + } + } + + override fun listFileVersions(file: AbstractFile): List { + val dir = when (file) { + is ConditionFile -> conditionFilesDir + is ExerciseFile -> exerciseFilesDir + is PolygonFile -> polygonFilesDir + is SolutionFile -> solutionFilesDir + else -> { + logger.error("Unsupported file type for listing versions: ${file::class.simpleName}") + return emptyList() + } + } + + val prefix = when (file) { + is ConditionFile -> "${ConditionFile.FILE_NAME_PREFIX}-${file.id}-" + is ExerciseFile -> "${ExerciseFile.FILE_NAME_PREFIX}-${file.id}-" + is PolygonFile -> "${PolygonFile.FILE_NAME_PREFIX}-${file.id}-" + is SolutionFile -> "${SolutionFile.FILE_NAME_PREFIX}-${file.id}-" + else -> { + logger.error("Unsupported file type for prefix generation: ${file::class.simpleName}") + return emptyList() + } + } + + val files = dir.listFiles { _, name -> name.startsWith(prefix) } ?: emptyArray() + + return files.mapNotNull { f -> + val versionStr = f.name.removePrefix(prefix).substringBeforeLast('.') + versionStr.toLongOrNull()?.let { version -> + TaskFileVersionInfo( + version = version, + fileName = f.name, + lastModifiedAt = Instant.ofEpochMilli(f.lastModified()), + originalName = file.data.originalFileNameByVersion[version] ?: f.name + ) + } + }.sortedByDescending { it.version } + } + + companion object { + + private val logger = LoggerFactory.getLogger(FileManagerImpl::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/MailSender.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/MailSender.kt new file mode 100644 index 00000000..4bacf4ce --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/MailSender.kt @@ -0,0 +1,81 @@ +package trik.testsys.webapp.backoffice.service.impl + +import net.axay.simplekotlinmail.delivery.MailerManager +import net.axay.simplekotlinmail.delivery.mailerBuilder +import net.axay.simplekotlinmail.delivery.sendSync +import net.axay.simplekotlinmail.email.emailBuilder +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class MailSender( + @Value("\${trik.testsys.email-client.smtp.host}") + private val smtpHost: String, + @Value("\${trik.testsys.email-client.smtp.port}") + private val smtpPort: Int, + @Value("\${trik.testsys.email-client.domain}") + private val emailDomain: String, + @Value("\${trik.testsys.email-client.credentials.username}") + private val username: String, + @Value("\${trik.testsys.email-client.credentials.password}") + private val password: String, +) { + + fun setUpMailer( + host: String = smtpHost, + port: Int = smtpPort, + username: String = this.username, + password: String = this.password + ) { + logger.info("Setting up mailer(host=$host, port=$port, username=$username, password=$password)") + + val mailer = mailerBuilder(host, port, username, password) + MailerManager.defaultMailer = mailer + + logger.info("Mailer set up") + } + + private suspend fun shutdownMailer() { + logger.info("Shutting down mailer") + MailerManager.shutdownMailers() + logger.info("Mailer shut down") + } + + + suspend fun sendMail( + from: String, + to: String, + subject: String, + text: String, + isHtml: Boolean = false + ) { + + val email = emailBuilder { + from("$from@$emailDomain") + to(to) + + withSubject(subject) + + if (isHtml) withHTMLText(text) + else withPlainText(text) + } + + try { + setUpMailer() + email.sendSync() + logger.debug("Success: Email sent to $to") + } catch (e: Exception) { + logger.debug("Failure: Failed to send email to $to. Cause: $e") + } finally { + shutdownMailer() + } + } + + companion object { + + private val logger = LoggerFactory.getLogger(MailSender::class.java) + + + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/PolygonFileScheduleAnalyzer.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/PolygonFileScheduleAnalyzer.kt new file mode 100644 index 00000000..d1ff9d8f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/PolygonFileScheduleAnalyzer.kt @@ -0,0 +1,79 @@ +package trik.testsys.webapp.backoffice.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import trik.testsys.webapp.backoffice.data.entity.impl.PolygonDiagnosticReportEntity +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile +import trik.testsys.webapp.backoffice.data.service.PolygonDiagnosticReportEntityService +import trik.testsys.webapp.backoffice.data.service.impl.taskFile.PolygonFileService +import trik.testsys.webapp.backoffice.service.analysis.polygon.PolygonAnalyzer + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class PolygonFileScheduleAnalyzer( + private val polygonFileService: PolygonFileService, + private val polygonDiagnosticReportEntityService: PolygonDiagnosticReportEntityService, + private val polygonAnalyzer: PolygonAnalyzer, + + private val transactionTemplate: TransactionTemplate +) { + + @Scheduled(fixedRate = 10_000) + fun analyzePolygonFiles() { + logger.debug("Started analyzePolygonFiles") + val notAnalyzed = polygonFileService.findNotAnalyzed() + + logger.debug("Found ${notAnalyzed.size} polygonFiles to analyze") + notAnalyzed.forEach { polygonFile -> + logger.debug("Started analyzePolygonFiles for polygonFile(id=${polygonFile.id})") + + polygonFile.analysisStatus = PolygonFile.AnalysisStatus.ANALYZING + polygonFileService.save(polygonFile) + + polygonDiagnosticReportEntityService.findActiveByPolygonFileId(requireNotNull(polygonFile.id)).forEach { result -> + polygonDiagnosticReportEntityService.delete(result) + } + + performAnalysis(requireNotNull(polygonFile.id)) + } + + logger.debug("Finished analyzePolygonFiles") + } + + private fun performAnalysis(polygonFileId: Long) = transactionTemplate.execute { + val polygonFile = polygonFileService.findById(polygonFileId) ?: run { + logger.warn("PolygonFile(id=$polygonFileId) not found inside transaction, skipping analysis") + return@execute + } + + val results = polygonAnalyzer.analyze(polygonFile).ifEmpty { + logger.debug("Finished analyzePolygonFiles for polygonFile(id=${polygonFile.id}), setting analysisStatus to 'SUCCESS'. No reports where generated.") + + polygonFile.analysisStatus = PolygonFile.AnalysisStatus.SUCCESS + polygonFileService.save(polygonFile) + return@execute + } + + logger.debug("Finished analyzePolygonFiles for polygonFile(id=${polygonFile.id}), setting analysisStatus to 'FAILED'. ${results.size} reports where generated: $results") + + val analyzeEntities = results.map { result -> + PolygonDiagnosticReportEntity + .from(result) + .with(polygonFile) + } + polygonDiagnosticReportEntityService.saveAll(analyzeEntities) + + polygonFile.analysisStatus = PolygonFile.AnalysisStatus.FAILED + polygonFileService.save(polygonFile) + } + + companion object { + + private val logger = LoggerFactory.getLogger(PolygonFileScheduleAnalyzer::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/TaskTestingService.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/TaskTestingService.kt new file mode 100644 index 00000000..24a1b83e --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/TaskTestingService.kt @@ -0,0 +1,101 @@ +package trik.testsys.webapp.backoffice.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +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.Verdict +import trik.testsys.webapp.backoffice.data.service.SolutionService +import trik.testsys.webapp.backoffice.data.service.TaskService +import trik.testsys.webapp.backoffice.data.service.VerdictService + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class TaskTestingService( + private val taskService: TaskService, + private val solutionService: SolutionService, + private val verdictService: VerdictService, +) { + + @Scheduled(fixedRate = 60_000) + fun updateTaskTestingStatus() { + logger.debug("Started updateTaskTestingStatus") + val allTestingTasks = taskService.findAllTesting() + + logger.debug("Found ${allTestingTasks.size} testing tasks.") + + for (task in allTestingTasks) { + logger.debug("Started updateTaskTestingStatus for task(id=${task.id})") + + val solutionIds = task.data.solutionFileDataById.values.mapNotNull { fileData -> + fileData.lastSolutionId + } + + val solutions = solutionService.findAllById(solutionIds) + + val allGraded = solutions.all { + it.status == Solution.Status.PASSED || + it.status == Solution.Status.ERROR || + it.status == Solution.Status.TIMEOUT + } + + if (!allGraded) { + logger.debug("Not all solution are graded. Skipping updateTaskTestingStatus for task(id=${task.id})") + continue + } + logger.debug("All solution are graded. Continuing updateTaskTestingStatus for task(id=${task.id})") + + val verdictBySolutionId = verdictService.findAllBySolutionIds(solutions.mapNotNull { it.id }) + .associateBy { it.solutionId } + updateTask(task, verdictBySolutionId) + + logger.debug("Finished updateTaskTestingStatus for task(id=${task.id}, newStatus=${task.testingStatus}, data=${task.data})") + } + + logger.debug("Finished updateTaskTestingStatus") + } + + private fun updateTask(task: Task, verdictBySolutionId: Map) { + val newStatus = getNewStatus(task, verdictBySolutionId) + task.testingStatus = newStatus + + updateTaskData(task, verdictBySolutionId) + + taskService.save(task) + } + + private fun getNewStatus(task: Task, verdictBySolutionId: Map): Task.TestingStatus { + val allWithExpectedScore = task.data.solutionFileDataById.values.all { fileData -> + val verdict = verdictBySolutionId[fileData.lastSolutionId] ?: run { + val message = "Missing verdict(solutionId=${fileData.lastSolutionId})" + logger.error(message) + throw IllegalStateException(message) + } + + verdict.value == fileData.score + } + + return if (allWithExpectedScore) Task.TestingStatus.PASSED else Task.TestingStatus.FAILED + } + + private fun updateTaskData(task: Task, verdictBySolutionId: Map) { + task.data.solutionFileDataById.values.forEach { fileData -> + val verdict = verdictBySolutionId[fileData.lastSolutionId] ?: run { + val message = "Missing verdict(solutionId=${fileData.lastSolutionId})" + logger.error(message) + throw IllegalStateException(message) + } + + fileData.lastTestScore = verdict.value + } + } + + companion object { + + private val logger = LoggerFactory.getLogger(TaskTestingService::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/UserEmailServiceImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/UserEmailServiceImpl.kt new file mode 100644 index 00000000..ee8a7525 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/impl/UserEmailServiceImpl.kt @@ -0,0 +1,167 @@ +package trik.testsys.webapp.backoffice.service.impl + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +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.UserEmailService +import java.time.Instant +import java.util.UUID + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class UserEmailServiceImpl( + private val emailClient: EmailClient, +// private val mailSender: MailSender, + private val userService: UserService, + private val accessTokenService: AccessTokenService +) : UserEmailService { + + private val dataByVerificationToken = mutableMapOf() + + override fun sendVerificationToken(user: User, newEmail: String?) { + val verificationToken = UUID.randomUUID().toString() + + val emailToSend = if (newEmail == null) { + val prevEmail = user.email + if (prevEmail == null) { + logger.error("Trying to remove null email from user(id=${user.id})") + return + } + + logger.info("Removing email $prevEmail from user(id=${user.id})") + + prevEmail + } else newEmail + + val data = EmailData(newEmail, requireNotNull(user.id)) + dataByVerificationToken[verificationToken] = data + + logger.debug("Sending verificationToken $verificationToken to email $emailToSend.") + val email = + if (newEmail == null) buildUnlinkVerificationEmail(emailToSend, verificationToken) + else buildVerificationEmail(emailToSend, verificationToken) + emailClient.sendEmail(email) + } + + private fun buildUnlinkVerificationEmail(emailTo: String, verificationToken: String) = EmailClient.Email( + FROM, + listOf(emailTo), + "Открепление почты на платформе TestSys", + """ + Вы получили данное письмо, потому что эта почта была указана для восстановления доступа в тестирующей системе TestSys. + Если это сделали не Вы – проигнорируйте данное сообщение. + + Код-подтверждения: $verificationToken + """.trimIndent() + ) + + private fun buildVerificationEmail(emailTo: String, verificationToken: String) = EmailClient.Email( + FROM, + listOf(emailTo), + "Подтверждение почты на платформе TestSys", + """ + Вы получили данное письмо, потому что эта почта была указана для восстановления доступа в тестирующей системе TestSys. + Если это сделали не Вы – проигнорируйте данное сообщение. + + Код-подтверждения: $verificationToken + """.trimIndent() + ) + + override fun verify(user: User, verificationToken: String): Boolean { + logger.info("Received verificationToken $verificationToken.") + val data = dataByVerificationToken[verificationToken] ?: run { + logger.info("No verification data where found by verificationToken $verificationToken") + return false + } + + if (data.userId != user.id) { + logger.info("VerificationToken $verificationToken is not linked to user(id${user.id})") + return false + } + dataByVerificationToken.remove(verificationToken) + + if (data.email == null) { + logger.info("Removed email ${user.email} from user(id=${user.id})") + userService.updateEmail(user, null) + + return true + } + + logger.info("Verified new email ${data.email} on user(id=${user.id})") + user.emailVerifiedAt = Instant.now() + userService.save(user) + + return true + } + + override fun sendAccessToken(email: String): Boolean { + logger.info("Requested accessToken restore by email $email") + + val user = userService.findByEmail(email) ?: run { + logger.info("User not found by email $email") + return false + } + + if (user.emailVerifiedAt == null) { + logger.info("User(id=${user.id}) has not verified email $email") + return false + } + + val email = buildAccessTokenRestoreEmail(email, requireNotNull(user.accessToken!!.value)) + emailClient.sendEmail(email) + return true + } + + private fun buildAccessTokenRestoreEmail(emailTo: String, accessToken: String) = EmailClient.Email( + FROM, + listOf(emailTo), + "Восстановление кода-доступа на платформе TestSys", + """ + Вы получили данное письмо, потому что эта почта была указана для восстановления доступа в тестирующей системе TestSys. + Если это сделали не Вы – проигнорируйте данное сообщение. + + Код-доступа: $accessToken + """.trimIndent() + ) + + @Scheduled(fixedRate = 3_600_000) + fun flushVerificationTokens() { + logger.debug("Started flushing old verification tokens.") + + val now = Instant.now() + val toRemove = mutableSetOf() + for ((token, data) in dataByVerificationToken) { + if (data.createdAt.plusMillis(VERIFICATION_TOKEN_TTL).isAfter(now)) continue + toRemove.add(token) + } + + logger.debug("Found ${toRemove.size} verification tokens to be removed.") + toRemove.forEach { token -> + dataByVerificationToken.remove(token) + } + + logger.debug("Finished flushing old verification tokens.") + } + + private data class EmailData( + val email: String?, + val userId: Long, + val createdAt: Instant = Instant.now() + ) + + companion object { + + private val logger = LoggerFactory.getLogger(UserEmailServiceImpl::class.java) + + private const val VERIFICATION_TOKEN_TTL = 60 * 60 * 1000L + + private const val FROM = "no-reply" + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/MenuBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/MenuBuilder.kt new file mode 100644 index 00000000..3f4297f7 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/MenuBuilder.kt @@ -0,0 +1,16 @@ +package trik.testsys.webapp.backoffice.service.menu + +import trik.testsys.webapp.backoffice.data.entity.impl.User + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface MenuBuilder { + + fun buildFor(user: User): List + + data class MenuSection(val title: String, val items: List) + + data class MenuItem(val name: String, val link: String) +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/impl/MenuBuilderImpl.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/impl/MenuBuilderImpl.kt new file mode 100644 index 00000000..742aa319 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/impl/MenuBuilderImpl.kt @@ -0,0 +1,46 @@ +package trik.testsys.webapp.backoffice.service.menu.impl + +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder +import kotlin.collections.plusAssign + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class MenuBuilderImpl( + context: ApplicationContext +) : MenuBuilder { + + private val menuSectionBuilders: Map MenuBuilder.MenuSection> by lazy { + val sectionBuilders = context.getBeansOfType(MenuSectionBuilder::class.java).values.associate { + it.privilege to it::buildSection + } + + sectionBuilders + } + + override fun buildFor(user: User): List { + val sections = mutableListOf() + + sections += MenuBuilder.MenuSection( + title = "Профиль", + items = listOf( + MenuBuilder.MenuItem(name = "Обзор", link = "/user") + ) + ) + + menuSectionBuilders.forEach { (privilege, builder) -> + if (user.privileges.contains(privilege)) { + val section = builder.invoke() + sections += section + } + } + + return sections + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/MenuSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/MenuSectionBuilder.kt new file mode 100644 index 00000000..d411898f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/MenuSectionBuilder.kt @@ -0,0 +1,15 @@ +package trik.testsys.webapp.backoffice.service.menu.section + +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface MenuSectionBuilder { + + val privilege: User.Privilege + + fun buildSection(): MenuBuilder.MenuSection +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/AdminSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/AdminSectionBuilder.kt new file mode 100644 index 00000000..413c4221 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/AdminSectionBuilder.kt @@ -0,0 +1,25 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class AdminSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.ADMIN + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Организатор", + items = listOf( + MenuBuilder.MenuItem(name = "Группы Участников", link = "/user/admin/groups") + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/DeveloperSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/DeveloperSectionBuilder.kt new file mode 100644 index 00000000..6f93a171 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/DeveloperSectionBuilder.kt @@ -0,0 +1,26 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +@Service +class DeveloperSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.DEVELOPER + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Разработчик", + items = listOf( + MenuBuilder.MenuItem(name = "Туры", link = "/user/developer/contests"), + MenuBuilder.MenuItem(name = "Файлы", link = "/user/developer/task-files"), +// MenuBuilder.MenuItem(name = "Шаблоны Задач", link = "/user/developer/task-templates"), + MenuBuilder.MenuItem(name = "Задачи", link = "/user/developer/tasks"), + ) + ) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/GroupAdminSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/GroupAdminSectionBuilder.kt new file mode 100644 index 00000000..4995c6e7 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/GroupAdminSectionBuilder.kt @@ -0,0 +1,27 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class GroupAdminSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.GROUP_ADMIN + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Администратор Групп", + items = listOf( + MenuBuilder.MenuItem(name = "Группы Пользователей", link = "/user/group-admin/groups"), + ) + ) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/JudgeSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/JudgeSectionBuilder.kt new file mode 100644 index 00000000..e6861736 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/JudgeSectionBuilder.kt @@ -0,0 +1,23 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +@Service +class JudgeSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.JUDGE + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Судья", + items = listOf( + MenuBuilder.MenuItem(name = "Посылки", link = "/user/judge/solutions"), + ) + ) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/StudentSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/StudentSectionBuilder.kt new file mode 100644 index 00000000..0e767f9f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/StudentSectionBuilder.kt @@ -0,0 +1,23 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +@Service +class StudentSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.STUDENT + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Участник", + items = listOf( + MenuBuilder.MenuItem(name = "Туры", link = "/user/student/contests"), + ) + ) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/SuperUserSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/SuperUserSectionBuilder.kt new file mode 100644 index 00000000..f448e41f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/SuperUserSectionBuilder.kt @@ -0,0 +1,26 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +/** + * Builds menu section for users with SUPER_USER privilege. + */ +@Service +class SuperUserSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.SUPER_USER + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Супервайзер", + items = listOf( + MenuBuilder.MenuItem(name = "Пользователи", link = "/user/superuser/users"), + ) + ) + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/ViewerSectionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/ViewerSectionBuilder.kt new file mode 100644 index 00000000..76db860d --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/menu/section/impl/ViewerSectionBuilder.kt @@ -0,0 +1,26 @@ +package trik.testsys.webapp.backoffice.service.menu.section.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder +import trik.testsys.webapp.backoffice.service.menu.section.MenuSectionBuilder + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class ViewerSectionBuilder : MenuSectionBuilder { + + override val privilege = User.Privilege.VIEWER + + override fun buildSection(): MenuBuilder.MenuSection { + return MenuBuilder.MenuSection( + title = "Наблюдатель", + items = listOf( + MenuBuilder.MenuItem(name = "Организаторы", link = "/user/viewer/admins"), + MenuBuilder.MenuItem(name = "Результаты", link = "/user/viewer/export"), + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/GraderInitializer.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/GraderInitializer.kt new file mode 100644 index 00000000..69a0c60c --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/GraderInitializer.kt @@ -0,0 +1,254 @@ +package trik.testsys.webapp.backoffice.service.startup.runner.impl + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.transaction.support.TransactionTemplate +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Task +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.service.FileManager +import trik.testsys.webapp.backoffice.service.Grader +import trik.testsys.webapp.core.service.startup.AbstractStartupRunner +import trik.testsys.webapp.notifier.CombinedIncidentNotifier +import java.io.File +import java.util.regex.Pattern +import javax.annotation.PostConstruct + +@Service +class GraderInitializer( + private val grader: Grader, + private val fileManager: FileManager, + private val solutionService: SolutionService, + private val taskService: TaskService, + private val contestService: ContestService, + private val verdictService: VerdictService, + private val transactionTemplate: TransactionTemplate, + + @Value("\${trik.testsys.trik-studio.container.name}") private val trikStudioContainerName: String, + @Value("\${trik.testsys.grading-node.addresses}") private val gradingNodeAddresses: String, private val notifier: CombinedIncidentNotifier +) : AbstractStartupRunner() { + + @PostConstruct + fun init() { + if (trikStudioContainerName.trim().isEmpty()) { + throw IllegalStateException("TRIK Studio container name must be defined.") + } + if (gradingNodeAddresses.trim().isEmpty()) { + throw IllegalStateException("Grading node addresses must be defined.") + } + } + + override suspend fun execute() { + addGraderNodes() + addGraderSubscription() + sendToGradeUngradedSolutions() + } + + private fun sendToGradeUngradedSolutions() { + logger.info("Sending ungraded solutions to grade...") + + val ungradedSolutions = solutionService.findAll() + .filter { it.status == Solution.Status.NOT_STARTED || it.status == Solution.Status.IN_PROGRESS } + .filter { + val file = fileManager.getSolution(it) + if (file == null) { + logger.error("Solution file for solution ${it.id} is missing.") + + val managed = solutionService.getById(requireNotNull(it.id)) + managed.status = Solution.Status.ERROR + verdictService.createNewForSolution(managed, 0) + +// if (managed.isLastTaskTest()) changeTaskTestingResult(managed) + + logger.debug("Calling solutionService.save(id=${managed.id}) in GraderInitializer.sendToGradeUngradedSolutions") + solutionService.save(managed) + + false + } else { + true + } + } + + logger.info("Found ${ungradedSolutions.size} ungraded solutions.") + + ungradedSolutions.forEach { + grader.sendToGrade(it, Grader.GradingOptions(true, trikStudioContainerName)) + } + } + + private fun addGraderNodes() { + logger.info("Adding grader nodes...") + + val parsedAddresses = gradingNodeAddresses.split(",") + logger.info("Parsed addresses: $parsedAddresses") + + parsedAddresses.forEach { grader.addNode(it) } + + logger.info("Grader nodes were added.") + } + + private fun addGraderSubscription() = grader.subscribeOnGraded { gradingInfo -> + logger.info("Grading info was received for solutionId: ${gradingInfo.submissionId}") + + transactionTemplate.execute { + try { + when (gradingInfo) { + is Grader.GradingInfo.Ok -> gradingInfo.parse() + is Grader.GradingInfo.Error -> { + notifyGradingError(gradingInfo) + gradingInfo.parse() + } + } + } catch (e: Exception) { + logger.error("Failed to parse grading info.", e) + + afterCatchException(gradingInfo) + } + } + } + + private fun notifyGradingError(error: Grader.GradingInfo.Error) { + val kind = error.kind + val errorDescription = when (kind) { + is Grader.ErrorKind.InnerTimeoutExceed -> null + is Grader.ErrorKind.NonZeroExitCode -> { + "non-zero exit code[${kind.code}]" + when (kind.code) { + 137 -> ", probably out of memory" + 125 -> ", probably invalid docker arguments" + else -> "" + } + } + else -> "[ ${kind::class.java.simpleName} ]: ${kind.description}" + } ?: return + + val message = "Error while grading solution(id=${error.submissionId}):\n\n $errorDescription" + + notifier.notify(message) + } + + private fun afterCatchException(gradingInfo: Grader.GradingInfo) { + try { + val solutionId = gradingInfo.submissionId + val solution = solutionService.findById(solutionId.toLong()) ?: return + val managed = solutionService.getById(requireNotNull(solution.id)) + + managed.status = Solution.Status.ERROR + + verdictService.createNewForSolution(managed, 0) + + logger.debug("Calling solutionService.save(id=${solution.id}) in GraderInitializer.afterCatchException") + solutionService.save(managed) + } catch (e: Exception) { + logger.error("Failed to handle exception.", e) + } + } + + private fun Grader.GradingInfo.Ok.parse() = let { (solutionId, _) -> + logger.info("Solution $solutionId was graded without errors.") + fileManager.saveSuccessfulGradingInfo(this) + + val solution = solutionService.findById(solutionId.toLong()) ?: return@let + val verdicts = fileManager.getVerdicts(solution) + + val objectMapper = createMapper() + + var allFailed = true + var totalScore = 0L + + verdicts.forEach { verdict -> + val elements = objectMapper.readVerdictElements(verdict) ?: run { + logger.error("Failed to read verdict elements from file $verdict.") + + return@forEach + } + + val infoElements = elements.filter { it.level == VerdictElement.LEVEL_INFO } + val errorElements = elements.filter { it.level == VerdictElement.LEVEL_ERROR } + + if (errorElements.isNotEmpty()) return@forEach + + val score = infoElements + .filter { (_, message) -> VerdictElement.SCORE_PATTERN.matcher(message).find() } + .mapNotNull { (_, message) -> VerdictElement.matchScore(message) } + .maxOrNull() ?: return@forEach + + allFailed = false + totalScore += score + } + + val managed = solutionService.getById(requireNotNull(solution.id)) + managed.status = Solution.Status.PASSED + verdictService.createNewForSolution(managed, totalScore) + + logger.debug("Calling solutionService.save(id=${solution.id}) in Grader.GradingInfo.Ok.parse") + solutionService.save(managed) + + } + + private fun ObjectMapper.readVerdictElements(verdict: File): List? = try { + readValue(verdict, object : TypeReference>() {}) ?: run { + logger.error("Failed to read verdict elements from file $verdict.") + null + } + } catch (e: Exception) { + logger.error("Failed to read verdict elements from file $verdict.", e) + null + } + + private fun createMapper(): ObjectMapper { + val objectMapper = ObjectMapper() + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return objectMapper + } + +// @XmlRootElement + private data class VerdictElement( + val level: String = "", + val message: String = "" + ) { + + companion object { + + val LEVEL_INFO = "info" + val LEVEL_ERROR = "error" + + // regex for strings like "Успешно пройдено. Набрано баллов: 100." + val SCORE_PATTERN = Pattern.compile("Набрано баллов:\\s*(-?\\d+)") + + fun matchScore(message: String): Long? { + val matcher = SCORE_PATTERN.matcher(message) + return if (matcher.find()) matcher.group(1).toLong() else null + } + } + } + + + private fun Grader.GradingInfo.Error.parse() = let { (solutionId, kind) -> + logger.info("Solution $solutionId was graded with error: $kind.") + val solution = solutionService.findById(solutionId.toLong()) ?: return@let + val managed = solutionService.getById(requireNotNull(solution.id)) + + managed.status = when (kind) { + is Grader.ErrorKind.InnerTimeoutExceed -> Solution.Status.TIMEOUT + else -> Solution.Status.ERROR + } + + verdictService.createNewForSolution(managed, 0) + + logger.debug("Calling solutionService.save(id=${solution.id}) in Grader.GradingInfo.Error.parse") + solutionService.save(managed) + + } + + companion object { + + private val logger = LoggerFactory.getLogger(GraderInitializer::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/PolygonAnalysisRestart.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/PolygonAnalysisRestart.kt new file mode 100644 index 00000000..8cff4333 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/PolygonAnalysisRestart.kt @@ -0,0 +1,27 @@ +package trik.testsys.webapp.backoffice.service.startup.runner.impl + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.taskFile.PolygonFile +import trik.testsys.webapp.backoffice.data.service.impl.taskFile.PolygonFileService +import trik.testsys.webapp.core.service.startup.AbstractStartupRunner + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class PolygonAnalysisRestart( + private val polygonFileService: PolygonFileService, +) : AbstractStartupRunner() { + + override suspend fun execute() { + val analysingPolygons = polygonFileService.findAnalyzing() + + logger.debug("Found ${analysingPolygons.size} analysing polygons, from previous session.") + analysingPolygons.forEach { polygon -> + polygon.analysisStatus = PolygonFile.AnalysisStatus.NOT_ANALYZED + } + + polygonFileService.saveAll(analysingPolygons) + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/StudentGroupTokenBackfillRunner.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/StudentGroupTokenBackfillRunner.kt new file mode 100644 index 00000000..25db43b8 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/StudentGroupTokenBackfillRunner.kt @@ -0,0 +1,49 @@ +package trik.testsys.webapp.backoffice.service.startup.runner.impl + +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.impl.StudentGroup +import trik.testsys.webapp.backoffice.data.service.StudentGroupService +import trik.testsys.webapp.backoffice.data.service.impl.StudentGroupTokenService +import trik.testsys.webapp.core.service.startup.AbstractStartupRunner + +@Service +@Order(10) +class StudentGroupTokenBackfillRunner( + private val studentGroupService: StudentGroupService, + private val studentGroupTokenService: StudentGroupTokenService +) : AbstractStartupRunner() { + + override suspend fun execute() { + backfillMissingTokens() + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun backfillMissingTokens() { + val groups: List = studentGroupService.findAll() + val missing = groups.filter { it.studentGroupToken == null } + + if (missing.isEmpty()) { + logger.info("StudentGroup token backfill: nothing to do.") + return + } + + logger.info("StudentGroup token backfill: found ${missing.size} groups without token. Generating...") + var processed = 0 + missing.forEach { group -> + try { + val token = studentGroupTokenService.generate() + group.studentGroupToken = token + studentGroupService.save(group) + processed++ + } catch (e: Exception) { + logger.error("Failed to backfill token for group id=${group.id}", e) + } + } + logger.info("StudentGroup token backfill: completed. Updated $processed groups.") + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/SuperUserCreator.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/SuperUserCreator.kt new file mode 100644 index 00000000..0df223f2 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/SuperUserCreator.kt @@ -0,0 +1,91 @@ +package trik.testsys.webapp.backoffice.service.startup.runner.impl + +import jakarta.persistence.PostLoad +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Service +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.SuperUserService +import trik.testsys.webapp.backoffice.data.service.UserGroupService +import trik.testsys.webapp.backoffice.data.service.UserService +import trik.testsys.webapp.backoffice.data.service.impl.AccessTokenService +import trik.testsys.webapp.core.service.startup.AbstractStartupRunner +import java.io.File + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +@Order(0) +class SuperUserCreator( + @Value("\${trik.testsys.superuser.name}") + private val name: String, + @Value("\${trik.testsys.superuser.accessToken.value}") + private val accessToken: String, + @Value("\${trik.testsys.superuser.accessToken.storeDir}") + private val storeDirName: String, + + private val superUserService: SuperUserService, + private val userService: UserService, + private val accessTokenService: AccessTokenService, + private val userGroupService: UserGroupService +) : AbstractStartupRunner() { + + private val storeDir = File(storeDirName) + + @PostLoad + fun init() { + if (!storeDir.exists()) { + storeDir.mkdir() + } + } + + override suspend fun execute() = createSuperUser().storeToken() + + private fun AccessToken.storeToken() { + val file = File("$storeDirName/$STORE_FILE_NAME") + + if (file.exists()) return + file.createNewFile() + file.writeText("${this.value!!}\n") + + logger.info("Stored access token in ${file.path}") + } + + private fun createSuperUser(): AccessToken { + val superUsers = superUserService.findAllSuperUser(isAllUserSuperUser = true).sortedBy { it.id } + val token: AccessToken + if (superUsers.isNotEmpty()) { + token = superUsers.first().accessToken!! + logger.info("Super User already exists. Skipping runner.\n\n Access token: ${token.value}\n") + return token + } + + token = if (accessToken.trim().isEmpty()) { + accessTokenService.generate() + } else AccessToken().also { + it.value = accessToken + } + + val user = User().also { + it.name = name + it.accessToken = token + it.privileges.addAll(setOf(User.Privilege.SUPER_USER, User.Privilege.GROUP_ADMIN)) + it.isAllUserSuperUser = true + } + val persisted = userService.save(user) + + // Ensure default PUBLIC group exists owned by Super User + userGroupService.getOrCreateDefaultGroup(persisted) + + logger.info("Created new Super User(id=${persisted.id}, name=$name).\n\n Access token: ${token.value}\n") + return token + } + + companion object { + + private const val STORE_FILE_NAME = ".access-token" + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/TaskFIleMigrator.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/TaskFIleMigrator.kt new file mode 100644 index 00000000..d8f602d7 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/service/startup/runner/impl/TaskFIleMigrator.kt @@ -0,0 +1,257 @@ +package trik.testsys.webapp.backoffice.service.startup.runner.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.backoffice.data.entity.AbstractFile +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.TaskFile +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.TaskFileService +import trik.testsys.webapp.backoffice.data.service.TaskService +import trik.testsys.webapp.backoffice.data.service.VerdictService +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.core.service.startup.AbstractStartupRunner + +//@Service +class TaskFIleMigrator( + private val taskService: TaskService, + private val taskFileService: TaskFileService, + private val fileManager: FileManager, + private val verdictService: VerdictService, + + private val conditionFileService: ConditionFileService, + private val exerciseFileService: ExerciseFileService, + private val polygonFileService: PolygonFileService, + private val solutionFileService: SolutionFileService +) : AbstractStartupRunner() { + + @Transactional(propagation = Propagation.REQUIRED) + override suspend fun execute() { + logger.info("Starting TaskFile migration") + + val conditionTaskFiles = mutableSetOf() + val exerciseTaskFiles = mutableSetOf() + val polygonTaskFiles = mutableSetOf() + val solutionTaskFiles = mutableSetOf() + + val allTaskFiles = taskFileService.findAll().filterNot { it.isRemoved } + + allTaskFiles.forEach { taskFile -> + when (taskFile.type) { + TaskFile.TaskFileType.CONDITION -> conditionTaskFiles.add(taskFile) + TaskFile.TaskFileType.EXERCISE -> exerciseTaskFiles.add(taskFile) + TaskFile.TaskFileType.POLYGON -> polygonTaskFiles.add(taskFile) + TaskFile.TaskFileType.SOLUTION -> solutionTaskFiles.add(taskFile) + else -> error("UNDEFINED") + } + } + + logger.info("Found ${conditionTaskFiles.size} condition files, ${exerciseTaskFiles.size} exercise files, ${polygonTaskFiles.size} polygon files, ${solutionTaskFiles.size} solution files") + + val conditionFileMapping = mutableMapOf() + conditionTaskFiles.forEach { taskFile -> + try { + val conditionFile = createConditionFile(taskFile) + var savedConditionFile = conditionFileService.save(conditionFile) + conditionFileMapping[taskFile.id!!] = savedConditionFile.id!! + + var fileVersion = taskFile.fileVersion + while (fileVersion >= 0) { + savedConditionFile.fileVersion++ + val file = fileManager.getTaskFileVersion(taskFile, fileVersion)!! + savedConditionFile = fileManager.saveConditionFile(savedConditionFile, file)!! + fileVersion-- + } + + logger.info("Successfully migrated condition file from TaskFile(id=${taskFile.id}) to ConditionFile(id=${savedConditionFile.id})") + } catch (e: Exception) { + logger.error("Failed to migrate condition file from TaskFile(id=${taskFile.id})", e) + } + } + + // Мигрируем Exercise Files + val exerciseFileMapping = mutableMapOf() + exerciseTaskFiles.forEach { taskFile -> + try { + val exerciseFile = createExerciseFile(taskFile) + var savedExerciseFile = exerciseFileService.save(exerciseFile) + exerciseFileMapping[taskFile.id!!] = savedExerciseFile.id!! + + var fileVersion = taskFile.fileVersion + while (fileVersion >= 0) { + savedExerciseFile.fileVersion++ + val file = fileManager.getTaskFileVersion(taskFile, fileVersion)!! + savedExerciseFile = fileManager.saveExerciseFile(savedExerciseFile, file)!! + fileVersion-- + } + + logger.info("Successfully migrated exercise file from TaskFile(id=${taskFile.id}) to ExerciseFile(id=${savedExerciseFile.id})") + } catch (e: Exception) { + logger.error("Failed to migrate exercise file from TaskFile(id=${taskFile.id})", e) + } + } + + // Мигрируем Polygon Files + val polygonFileMapping = mutableMapOf() + polygonTaskFiles.forEach { taskFile -> + try { + val polygonFile = createPolygonFile(taskFile) + var savedPolygonFile = polygonFileService.save(polygonFile) + polygonFileMapping[taskFile.id!!] = savedPolygonFile.id!! + + var fileVersion = taskFile.fileVersion + while (fileVersion >= 0) { + savedPolygonFile.fileVersion++ + val file = fileManager.getTaskFileVersion(taskFile, fileVersion)!! + savedPolygonFile = fileManager.savePolygonFile(savedPolygonFile, file)!! + fileVersion-- + } + + logger.info("Successfully migrated polygon file from TaskFile(id=${taskFile.id}) to PolygonFile(id=${savedPolygonFile.id})") + } catch (e: Exception) { + logger.error("Failed to migrate polygon file from TaskFile(id=${taskFile.id})", e) + } + } + + // Мигрируем Solution Files + val solutionFileMapping = mutableMapOf() + solutionTaskFiles.forEach { taskFile -> + try { + val solutionFile = createSolutionFile(taskFile) + var savedSolutionFile = solutionFileService.save(solutionFile) + solutionFileMapping[taskFile.id!!] = savedSolutionFile.id!! + + var fileVersion = taskFile.fileVersion + while (fileVersion >= 0) { + savedSolutionFile.fileVersion++ + val file = fileManager.getTaskFileVersion(taskFile, fileVersion)!! + savedSolutionFile = fileManager.saveSolutionFile(savedSolutionFile, file)!! + fileVersion-- + } + + logger.info("Successfully migrated solution file from TaskFile(id=${taskFile.id}) to SolutionFile(id=${savedSolutionFile.id})") + } catch (e: Exception) { + logger.error("Failed to migrate solution file from TaskFile(id=${taskFile.id})", e) + } + } + + val allTasks = taskService.findAll() + allTasks.forEach { task -> + logger.debug("Migrating relations with task(id=${task.id})") + + val conditions = task.taskFiles.filter { it.type == TaskFile.TaskFileType.CONDITION && !it.isRemoved } + val exercises = task.taskFiles.filter { it.type == TaskFile.TaskFileType.EXERCISE && !it.isRemoved } + val polygons = task.taskFiles.filter { it.type == TaskFile.TaskFileType.POLYGON && !it.isRemoved } + val solutions = task.taskFiles.filter { it.type == TaskFile.TaskFileType.SOLUTION && !it.isRemoved } + + logger.debug("Task containing conditions(size=${conditions.size}), exercises(size=${exercises.size}), polygons(size=${polygons.size}) and solutions(size=${solutions.size}).") + + val conditionFileIds = conditions.mapNotNull { conditionFileMapping[it.id] } + val exerciseFileIds = exercises.mapNotNull { exerciseFileMapping[it.id] } + val polygonFileIds = polygons.mapNotNull { polygonFileMapping[it.id] } + + val solution = task.solutions.filter { it.contest == null }.maxByOrNull { it.id!! } + val solitonFileScore = solution?.relevantVerdictId?.let { + val verdict = verdictService.findById(it) + verdict?.value + } ?: 0L + val solutionFileDataById = solutions.associate { + solutionFileMapping[it.id]!! to Task.SolutionFileData( + Solution.SolutionType.QRS, + solution?.id, + solitonFileScore, + solitonFileScore + ) + } + + task.data.apply { + this.conditionFileIds.addAll(conditionFileIds) + this.exerciseFileIds.addAll(exerciseFileIds) + this.polygonFileIds.addAll(polygonFileIds) + this.solutionFileDataById.putAll(solutionFileDataById) + } + taskService.save(task) + + val conditionFiles = conditionFileService.findAllById(conditionFileIds) + val exerciseFiles = exerciseFileService.findAllById(exerciseFileIds) + val polygonFiles = polygonFileService.findAllById(polygonFileIds) + val solutionFiles = solutionFileService.findAllById(solutionFileDataById.keys) + + conditionFiles.forEach { it.data.attachedTaskIds.add(task.id!!) } + exerciseFiles.forEach { it.data.attachedTaskIds.add(task.id!!) } + polygonFiles.forEach { it.data.attachedTaskIds.add(task.id!!) } + solutionFiles.forEach { it.data.attachedTaskIds.add(task.id!!) } + + conditionFileService.saveAll(conditionFiles) + exerciseFileService.saveAll(exerciseFiles) + polygonFileService.saveAll(polygonFiles) + solutionFileService.saveAll(solutionFiles) + + logger.debug("Successfully migrated relations with task(id=${task.id}). New data: ${task.data}") + } + + logger.info("TaskFile migration completed") + } + + private fun createConditionFile(taskFile: TaskFile): ConditionFile { + return ConditionFile().also { + it.type = FileType.PDF + it.name = taskFile.name +// it.fileVersion = taskFile.fileVersion + it.fileVersion = -1L + it.data = AbstractFile.Data(taskFile.data.originalFileNameByVersion.toMutableMap()) + it.developerId = taskFile.developer!!.id + it.info = taskFile.info + it.createdAt = taskFile.createdAt + } + } + + private fun createExerciseFile(taskFile: TaskFile): ExerciseFile { + return ExerciseFile().also { + it.type = FileType.QRS + it.name = taskFile.name +// it.fileVersion = taskFile.fileVersion + it.fileVersion = -1L + it.data = AbstractFile.Data(taskFile.data.originalFileNameByVersion.toMutableMap()) + it.developerId = taskFile.developer!!.id + it.info = taskFile.info + it.createdAt = taskFile.createdAt + } + } + + private fun createPolygonFile(taskFile: TaskFile): PolygonFile { + return PolygonFile().also { + it.type = FileType.XML + it.name = taskFile.name +// it.fileVersion = taskFile.fileVersion + it.fileVersion = -1L + it.data = AbstractFile.Data(taskFile.data.originalFileNameByVersion.toMutableMap()) + it.developerId = taskFile.developer!!.id + it.info = taskFile.info + it.createdAt = taskFile.createdAt + } + } + + private fun createSolutionFile(taskFile: TaskFile): SolutionFile { + return SolutionFile().also { + it.type = FileType.QRS + it.name = taskFile.name +// it.fileVersion = taskFile.fileVersion + it.fileVersion = -1L + it.data = AbstractFile.Data(taskFile.data.originalFileNameByVersion.toMutableMap()) + it.developerId = taskFile.developer!!.id + it.info = taskFile.info + it.createdAt = taskFile.createdAt + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/utils/ModelUtils.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/ModelUtils.kt new file mode 100644 index 00000000..6d0d76c3 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/ModelUtils.kt @@ -0,0 +1,36 @@ +package trik.testsys.webapp.backoffice.utils + +import jakarta.servlet.http.HttpSession +import org.springframework.ui.Model +import trik.testsys.webapp.backoffice.data.entity.impl.User +import trik.testsys.webapp.backoffice.service.menu.MenuBuilder + +const val SESSION_ACCESS_TOKEN = "accessToken" +const val HAS_ACTIVE_SESSION = "hasActiveSession" +const val USER = "user" +const val SECTIONS = "menuSections" + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +fun Model.addHasActiveSession(session: HttpSession) { + val hasActiveSession = session.getAttribute(SESSION_ACCESS_TOKEN) != null + addAttribute(HAS_ACTIVE_SESSION, hasActiveSession) +} + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +fun Model.addUser(user: User) { + addAttribute(USER, user) +} + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +fun Model.addSections(sections: Collection) { + addAttribute(SECTIONS, sections) +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/utils/PrivilegeI18n.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/PrivilegeI18n.kt new file mode 100644 index 00000000..2aca0a89 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/PrivilegeI18n.kt @@ -0,0 +1,35 @@ +package trik.testsys.webapp.backoffice.utils + +import trik.testsys.webapp.backoffice.data.entity.impl.User + +/** + * Centralized i18n for user privileges. + */ +object PrivilegeI18n { + + private val privilegeToRu: Map = mapOf( + User.Privilege.ADMIN to "Организатор", + User.Privilege.DEVELOPER to "Разработчик Задач", + User.Privilege.JUDGE to "Судья", + User.Privilege.STUDENT to "Участник", + User.Privilege.SUPER_USER to "Супервайзер", + User.Privilege.VIEWER to "Наблюдатель", + User.Privilege.GROUP_ADMIN to "Администратор Групп", + ) + + @JvmStatic + fun toRu(privilege: User.Privilege): String = privilegeToRu[privilege] ?: privilege.name + + @JvmStatic + fun listRu(privileges: Collection): List = + privileges.map { toRu(it) }.sorted() + + @JvmStatic + fun listOptions(): List> = + User.Privilege.entries.map { it.name to toRu(it) } + + @JvmStatic + fun asMap(): Map = privilegeToRu +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectAttributeUtils.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectAttributeUtils.kt new file mode 100644 index 00000000..f5feaab5 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectAttributeUtils.kt @@ -0,0 +1,11 @@ +package trik.testsys.webapp.backoffice.utils + +import org.springframework.web.servlet.mvc.support.RedirectAttributes + +const val MESSAGE = "message" + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +fun RedirectAttributes.addMessage(message: String) = addFlashAttribute(MESSAGE, message) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectionUtils.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectionUtils.kt new file mode 100644 index 00000000..128145f5 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/RedirectionUtils.kt @@ -0,0 +1,15 @@ +package trik.testsys.webapp.backoffice.utils + +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +fun getRedirection(httpStatus: HttpStatus, location: String): ResponseEntity { + return ResponseEntity.status(httpStatus) + .header(HttpHeaders.LOCATION, location) + .build() +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/backoffice/utils/SolutionI18n.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/SolutionI18n.kt new file mode 100644 index 00000000..3649db3d --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/SolutionI18n.kt @@ -0,0 +1,20 @@ +package trik.testsys.webapp.backoffice.utils + +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.Solution + +@Service("solutionI18n") +class SolutionI18n { + + private val statusToRu: Map = mapOf( + Solution.Status.NOT_STARTED to "В очереди", + Solution.Status.IN_PROGRESS to "В процессе", + Solution.Status.TIMEOUT to "Превышено время ожидания", + Solution.Status.PASSED to "Пройдено", + Solution.Status.ERROR to "Ошибка", + ) + + fun toRu(status: Solution.Status): String = statusToRu[status] ?: status.name +} + + diff --git a/src/main/kotlin/trik/testsys/webclient/util/config/Configuration.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/config/Configuration.kt similarity index 65% rename from src/main/kotlin/trik/testsys/webclient/util/config/Configuration.kt rename to src/main/kotlin/trik/testsys/webapp/backoffice/utils/config/Configuration.kt index fdadd473..cf07709d 100644 --- a/src/main/kotlin/trik/testsys/webclient/util/config/Configuration.kt +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/config/Configuration.kt @@ -1,6 +1,8 @@ -package trik.testsys.webclient.util.config +package trik.testsys.webapp.backoffice.utils.config +import jakarta.servlet.MultipartConfigElement import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.web.servlet.MultipartConfigFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy @@ -10,6 +12,8 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.SecurityFilterChain +import org.springframework.util.unit.DataSize +import org.springframework.util.unit.DataUnit import org.springframework.web.servlet.config.annotation.EnableWebMvc import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @@ -21,8 +25,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @EnableAspectJAutoProxy @EnableWebMvc class Configuration( - @Value("\${path.logos.sponsor}") private val sponsorLogosPath: String, - @Value("\${path.logos.main}") private val mainLogoFilePath: String + @Value("\${trik.testsys.paths.sponsorship}") private val sponsorshipDirPath: String ) : WebMvcConfigurer { override fun addResourceHandlers(registry: ResourceHandlerRegistry) { @@ -36,9 +39,13 @@ class Configuration( "classpath:/static/css/", "classpath:/static/js/", "classpath:/static/assets/", - "file:$sponsorLogosPath/", - "file:$mainLogoFilePath" ) + + val sponsorshipLocation = if (sponsorshipDirPath.endsWith("/")) "file:$sponsorshipDirPath" + else "file:$sponsorshipDirPath/" + registry.addResourceHandler("/sponsorship/**") + .addResourceLocations(sponsorshipLocation) + .resourceChain(true) } @Bean @@ -55,4 +62,12 @@ class Configuration( fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder(8) } + + @Bean + fun multipartConfigElement(): MultipartConfigElement { + val factory = MultipartConfigFactory() + factory.setMaxFileSize(DataSize.of(4, DataUnit.MEGABYTES)) + factory.setMaxRequestSize(DataSize.of(4, DataUnit.MEGABYTES)) + return factory.createMultipartConfig() + } } \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/handler/GradingSystemErrorHandler.kt b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/handler/GradingSystemErrorHandler.kt similarity index 58% rename from src/main/kotlin/trik/testsys/webclient/util/handler/GradingSystemErrorHandler.kt rename to src/main/kotlin/trik/testsys/webapp/backoffice/utils/handler/GradingSystemErrorHandler.kt index 42a87097..e7f7fe74 100644 --- a/src/main/kotlin/trik/testsys/webclient/util/handler/GradingSystemErrorHandler.kt +++ b/src/main/kotlin/trik/testsys/webapp/backoffice/utils/handler/GradingSystemErrorHandler.kt @@ -1,37 +1,40 @@ -package trik.testsys.webclient.util.handler +package trik.testsys.webapp.backoffice.utils.handler import org.slf4j.Logger import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatus +import org.springframework.http.HttpMethod import org.springframework.http.client.ClientHttpResponse import org.springframework.stereotype.Component import org.springframework.web.client.ResponseErrorHandler import java.io.IOException +import java.net.URI @Component class GradingSystemErrorHandler : ResponseErrorHandler { - private val logger: Logger = LoggerFactory.getLogger(this::class.java) - @Throws(IOException::class) override fun hasError(httpResponse: ClientHttpResponse): Boolean { - return (httpResponse.statusCode.series() === HttpStatus.Series.CLIENT_ERROR - || httpResponse.statusCode.series() === HttpStatus.Series.SERVER_ERROR) + return (httpResponse.statusCode.is4xxClientError || httpResponse.statusCode.is5xxServerError) } @Throws(IOException::class) - override fun handleError(httpResponse: ClientHttpResponse) { - if (httpResponse.statusCode.series() === HttpStatus.Series.SERVER_ERROR) { + override fun handleError(url: URI, method: HttpMethod, httpResponse: ClientHttpResponse) { + if (httpResponse.statusCode.is5xxServerError) { logger.warn("Grading system is not available. Status code: ${httpResponse.statusCode}") // handle SERVER_ERROR return } - if (httpResponse.statusCode.series() === HttpStatus.Series.CLIENT_ERROR) { + if (httpResponse.statusCode.is4xxClientError) { logger.warn("Client error. Status code: ${httpResponse.statusCode}") // handle CLIENT_ERROR return } } + + companion object { + + private val logger: Logger = LoggerFactory.getLogger(GradingSystemErrorHandler::class.java) + } } \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/core/config/JpaAuditingConfig.kt b/src/main/kotlin/trik/testsys/webapp/core/config/JpaAuditingConfig.kt new file mode 100644 index 00000000..c6eb7303 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/config/JpaAuditingConfig.kt @@ -0,0 +1,23 @@ +package trik.testsys.webapp.core.config + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.transaction.annotation.EnableTransactionManagement + +/** + * Configuration enabling Spring Data JPA auditing. + * + * With this enabled, fields annotated with `@CreatedDate` (and others like `@LastModifiedDate` + * if used) are populated automatically by the persistence layer. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Configuration +@EnableJpaAuditing(modifyOnCreate = true) +@EnableTransactionManagement +@EntityScan(basePackages = ["trik.testsys.**.entity"]) +@EnableJpaRepositories(basePackages = ["trik.testsys.**.repository"]) +class JpaAuditingConfig diff --git a/src/main/kotlin/trik/testsys/webapp/core/data/entity/AbstractEntity.kt b/src/main/kotlin/trik/testsys/webapp/core/data/entity/AbstractEntity.kt new file mode 100644 index 00000000..134c21bf --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/data/entity/AbstractEntity.kt @@ -0,0 +1,122 @@ +package trik.testsys.webapp.core.data.entity + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Version +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.SequenceGenerator +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.domain.Persistable +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Instant + +/** + * Base mapped superclass for all JPA entities. + * + * Provides a generated primary key, automatic creation timestamp via Spring Data JPA auditing, + * and a free-form text field for arbitrary metadata. Implements [Persistable] to ensure + * correct new/managed state detection by Spring Data. + * + * Equality is based on persistent identity: two entities are equal if and only if both have + * non-null identifiers that are equal. Transient (unsaved) instances are never equal. + * + * @property id database primary key (generated) + * @property createdAt entity creation date-time in UTC, set by auditing + * @property info optional free-form text for notes or metadata + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class AbstractEntity : Persistable { + + /** + * Database primary key. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "${TABLE_PREFIX}entity_seq") + @SequenceGenerator(name = "${TABLE_PREFIX}entity_seq") + @Column(name = "id", nullable = false) + private var id: Long? = null + + /** + * Optimistic lock version. Ensures an UPDATE on owner row when collections change, + * which triggers auditing callbacks for lastModified* fields. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + @Version + @Column(name = "version") + @Suppress("unused") + private var version: Long = 0 + + /** + * Setter for testing and framework usage. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + @Suppress("unused") + fun setId(id: Long?) { + this.id = id + } + + override fun getId() = id + + override fun isNew() = id == null + + /** + * Entity creation date-time in UTC, set by auditing. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + lateinit var createdAt: Instant + + /** + * Optional free-form text for notes or metadata. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + @Column(name = "info", columnDefinition = "TEXT") + var info: String? = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as AbstractEntity + if (id == null || other.id == null) return false + return id == other.id + } + + override fun hashCode(): Int = id?.hashCode() ?: System.identityHashCode(this) + + @Suppress("unused") + companion object { + + /** + * Default database table prefix for entities. + * + * @author Roman Shishkin + * @since 1.1.0 + */ + const val TABLE_PREFIX = "ts_" + + const val ID = "id" + const val CREATED_AT = "createdAt" + const val INFO = "info" + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/core/data/repository/EntityRepository.kt b/src/main/kotlin/trik/testsys/webapp/core/data/repository/EntityRepository.kt new file mode 100644 index 00000000..8b84a150 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/data/repository/EntityRepository.kt @@ -0,0 +1,23 @@ +package trik.testsys.webapp.core.data.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import org.springframework.data.repository.NoRepositoryBean +import trik.testsys.webapp.core.data.entity.AbstractEntity + +/** + * Base Spring Data repository for all entities extending [AbstractEntity]. + * + * Extends [JpaRepository] for CRUD operations and [JpaSpecificationExecutor] + * for flexible criteria queries. Marked as [NoRepositoryBean] so Spring + * does not try to instantiate it directly. + * + * Consumers should extend this interface for their concrete entities, e.g.: + * `interface ProjectRepository : BaseRepository` + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@NoRepositoryBean +interface EntityRepository : JpaRepository, JpaSpecificationExecutor + diff --git a/src/main/kotlin/trik/testsys/webapp/core/data/repository/support/ExtendedJpaRepository.kt b/src/main/kotlin/trik/testsys/webapp/core/data/repository/support/ExtendedJpaRepository.kt new file mode 100644 index 00000000..8f8756a1 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/data/repository/support/ExtendedJpaRepository.kt @@ -0,0 +1,30 @@ +@file:Suppress("unused") + +package trik.testsys.webapp.core.data.repository.support + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.jpa.repository.support.SimpleJpaRepository +import jakarta.persistence.EntityManager +import trik.testsys.webapp.core.data.entity.AbstractEntity + +/** + * Optional repository helpers to ease pagination and specification composition. + * + * This provides a base class compatible with Spring Data that projects can reuse + * if they need to implement custom shared behavior. It's safe to keep unused. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +class ExtendedJpaRepository( + domainClass: Class, + entityManager: EntityManager +) : SimpleJpaRepository(domainClass, entityManager) { + + override fun findAll(specification: Specification?, pageable: Pageable): Page = + super.findAll(specification, pageable) +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/core/data/service/AbstractService.kt b/src/main/kotlin/trik/testsys/webapp/core/data/service/AbstractService.kt new file mode 100644 index 00000000..af4b9d78 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/data/service/AbstractService.kt @@ -0,0 +1,65 @@ +package trik.testsys.webapp.core.data.service + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.repository.findByIdOrNull +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.webapp.core.data.entity.AbstractEntity +import trik.testsys.webapp.core.data.repository.EntityRepository + +/** + * Abstract base implementation of [EntityService] backed by a Spring Data [EntityRepository]. + * + * - Declares common CRUD and query operations with read-only transactions by default + * - Wires the concrete repository via Spring's dependency injection + * - Delegates to repository methods while providing a consistent service API + * + * Type parameters: + * - [E]: entity type extending [AbstractEntity] + * - [R]: repository type extending [EntityRepository] for [E] + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Transactional(readOnly = true) +abstract class AbstractService : EntityService + where E : AbstractEntity, + R : EntityRepository { + + @Autowired(required = true) + protected lateinit var repository: R + + override fun getById(id: Long): E = + repository.findById(id).orElseThrow { NoSuchElementException("Entity not found: id=$id") } + + override fun findById(id: Long): E? = repository.findByIdOrNull(id) + + override fun existsById(id: Long): Boolean = repository.existsById(id) + + override fun findAll(): List = repository.findAll() + + override fun findAllById(ids: Iterable): List = repository.findAllById(ids) + + override fun findAll(specification: Specification?): List = + if (specification == null) repository.findAll() else repository.findAll(specification) + + override fun findAll(specification: Specification?, pageable: Pageable): Page = + if (specification == null) repository.findAll(pageable) else repository.findAll(specification, pageable) + + override fun count(): Long = repository.count() + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + override fun save(entity: E): E = repository.save(entity) + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + override fun saveAll(entities: Iterable): List = repository.saveAll(entities) + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + override fun deleteById(id: Long) = repository.deleteById(id) + + @Transactional(propagation = Propagation.REQUIRED, readOnly = false) + override fun delete(entity: E) = repository.delete(entity) +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/core/data/service/EntityService.kt b/src/main/kotlin/trik/testsys/webapp/core/data/service/EntityService.kt new file mode 100644 index 00000000..96ea4c92 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/data/service/EntityService.kt @@ -0,0 +1,119 @@ +package trik.testsys.webapp.core.data.service + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.domain.Specification +import trik.testsys.webapp.core.data.entity.AbstractEntity + +/** + * Base service contract for entities extending [AbstractEntity]. + * + * Provides a thin abstraction over Spring Data repositories to allow + * business-layer extensions and shared behaviors. + * + * Type parameters: + * - [E]: entity type extending [AbstractEntity] + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface EntityService { + + /** + * Returns the existing entity by id or throws [NoSuchElementException] if not found. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun getById(id: Long): E + + /** + * Returns the entity by id or null if it does not exist. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun findById(id: Long): E? + + /** + * Checks whether an entity with the given id exists. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun existsById(id: Long): Boolean + + /** + * Returns all entities. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun findAll(): List + + /** + * Returns all entities matching the given ids. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun findAllById(ids: Iterable): List + + /** + * Returns all entities matching the optional JPA [Specification]. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun findAll(specification: Specification?): List + + /** + * Returns a page of entities matching the optional JPA [Specification] and [Pageable]. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun findAll(specification: Specification?, pageable: Pageable): Page + + /** + * Returns the total number of entities. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun count(): Long + + /** + * Saves the given entity and returns the persisted instance. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun save(entity: E): E + + /** + * Saves all the given entities and returns persisted instances. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun saveAll(entities: Iterable): List + + /** + * Deletes an entity by its id. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun deleteById(id: Long) + + /** + * Deletes an entity. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun delete(entity: E) +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/core/service/startup/AbstractStartupRunner.kt b/src/main/kotlin/trik/testsys/webapp/core/service/startup/AbstractStartupRunner.kt new file mode 100644 index 00000000..84572737 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/service/startup/AbstractStartupRunner.kt @@ -0,0 +1,43 @@ +package trik.testsys.webapp.core.service.startup + +import kotlinx.coroutines.runBlocking +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.ApplicationListener + +/** + * Interface for services, which should be executed exactly when application is ready. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + **/ +abstract class AbstractStartupRunner : ApplicationListener { + + protected val logger: Logger by lazy { LoggerFactory.getLogger(javaClass) } + + override fun onApplicationEvent(event: ApplicationReadyEvent) = try { + logger.info("Executing ${javaClass.simpleName} startup runner.") + executeBlocking() + } catch (e: Exception) { + logger.error("Error while running startup runner", e) + } finally { + logger.info("Startup runner finished.") + } + + /** + * Entry point to execute [AbstractStartupRunner] (suspend). + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + abstract suspend fun execute() + + /** + * Entry point to execute [AbstractStartupRunner]. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + fun executeBlocking() = runBlocking { execute() } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/core/utils/Utils.kt b/src/main/kotlin/trik/testsys/webapp/core/utils/Utils.kt new file mode 100644 index 00000000..e45d8286 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/utils/Utils.kt @@ -0,0 +1,10 @@ +package trik.testsys.webapp.core.utils + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +inline fun Boolean.ifTrue(block: () -> T?): T? { + return if (this) block.invoke() + else null +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/core/utils/enums/PersistableEnum.kt b/src/main/kotlin/trik/testsys/webapp/core/utils/enums/PersistableEnum.kt new file mode 100644 index 00000000..3e9d8d76 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/utils/enums/PersistableEnum.kt @@ -0,0 +1,20 @@ +package trik.testsys.webapp.core.utils.enums + +/** + * Marker for enums that are stored in DB via a short stable key. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +interface PersistableEnum { + + /** + * Short, stable key stored in DB instead of the enum name. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ + val dbKey: String +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/core/utils/enums/converter/AbstractPersistableEnumConverter.kt b/src/main/kotlin/trik/testsys/webapp/core/utils/enums/converter/AbstractPersistableEnumConverter.kt new file mode 100644 index 00000000..cff8377f --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/core/utils/enums/converter/AbstractPersistableEnumConverter.kt @@ -0,0 +1,41 @@ +package trik.testsys.webapp.core.utils.enums.converter + +import jakarta.persistence.AttributeConverter +import trik.testsys.webapp.core.utils.enums.PersistableEnum +import java.lang.IllegalStateException +import java.lang.reflect.ParameterizedType + +/** + * Generic JPA AttributeConverter that maps enums implementing [PersistableEnum] + * to their dbkey representation and back. + * + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Suppress("unused") +abstract class AbstractPersistableEnumConverter : AttributeConverter + where E : Enum, + E : PersistableEnum { + + @Suppress("UNCHECKED_CAST") + private val enumClass: Class by lazy { + val superType = javaClass.genericSuperclass + val type = (superType as ParameterizedType).actualTypeArguments[0] + when (type) { + is Class<*> -> type as Class + is ParameterizedType -> type.rawType as Class + else -> throw IllegalStateException("Cannot determine enum type for converter ${javaClass.name}") + } + } + + override fun convertToDatabaseColumn(attribute: E?): String? = attribute?.dbKey + + override fun convertToEntityAttribute(dbData: String?): E? { + if (dbData == null) return null + val all = enumClass.enumConstants ?: emptyArray() + return all.firstOrNull { it.dbKey == dbData } + ?: throw IllegalArgumentException("Unknown dbkey '$dbData' for enum ${enumClass.name}") + } +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/grading/BalancingGraderService.kt b/src/main/kotlin/trik/testsys/webapp/grading/BalancingGraderService.kt new file mode 100644 index 00000000..fc98866c --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/BalancingGraderService.kt @@ -0,0 +1,118 @@ +package trik.testsys.webapp.grading + +import io.grpc.StatusException +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import trik.testsys.grading.GradingNodeOuterClass.Submission +import trik.testsys.webapp.grading.communication.GradingNodeManager +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.service.SolutionService +import trik.testsys.webapp.backoffice.data.service.impl.taskFile.PolygonFileService +import trik.testsys.webapp.backoffice.service.FileManager +import trik.testsys.webapp.backoffice.service.Grader +import trik.testsys.webapp.grading.converter.SubmissionBuilder +import java.time.LocalDateTime + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +data class SubmissionInfo( + val solution: Solution, + val submission: Submission, + val sentTime: LocalDateTime, +) + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +@Primary +@Service +class BalancingGraderService( + private val fileManager: FileManager, + private val solutionService: SolutionService, + private val polygonFileService: PolygonFileService, + + + configuration: GraderConfiguration +): Grader { + + private val nodeManager = GradingNodeManager(configuration) + private val gradingManager = GradingManager( + nodeManager, + configuration, + onSent = { submission -> + solutionService.updateStatus(requireNotNull(submission.solution.id), Solution.Status.IN_PROGRESS) + } + ) + private val log = LoggerFactory.getLogger(this.javaClass) + + @Transactional(readOnly = true) + override fun sendToGrade(solution: Solution, gradingOptions: Grader.GradingOptions) { + val managedSolution = solutionService.getById(requireNotNull(solution.id) { "Solution ID must not be null" }) + val managedTask = managedSolution.task + + val taskFiles = managedTask.data.polygonFileIds.mapNotNull { + val polygonFile = polygonFileService.findById(it)!! + fileManager.getPolygonFile(polygonFile) + } + val solutionFile = fileManager.getSolution(managedSolution) + ?: throw IllegalArgumentException("Cannot find solution file") + + val submission = SubmissionBuilder.build { + this.solution = managedSolution + this.solutionFile = solutionFile + this.task = managedTask + this.taskFiles = taskFiles + this.gradingOptions = gradingOptions + } + + submission.task.fieldsList.forEach { + val name = it.name + val size = it.content.size() + log.info("Submission info for solution(id=${solution.id}): polygon(name=$name, size=$size)") + } + + gradingManager.enqueueSubmission(SubmissionInfo(managedSolution, submission, LocalDateTime.now())) + } + +// @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun subscribeOnGraded(onGraded: (Grader.GradingInfo) -> Unit) { + nodeManager.subscribeOnGraded(onGraded) + } + + override fun addNode(address: String) { + log.info("Adding node with address '$address'") + + try { + nodeManager.addNode(address) + 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) { + // message below does not correspond to the reality + // because of changed type of communication from streaming to direct call + TODO("Need discussion. Docs say that node's channel should be closed by server with Status.OK code for proper termination") + } + + /** + * @param address address of the node + * @return [NodeStatus] instance or null if no node with given [address] is tracked + */ + override fun getNodeStatus(address: String): Grader.NodeStatus? { + return nodeManager.getNodeStatus(address) + } + + override fun getAllNodeStatuses(): Map { + return nodeManager.getAllNodeStatuses() + } +} diff --git a/src/main/kotlin/trik/testsys/webapp/grading/BalancingUtils.kt b/src/main/kotlin/trik/testsys/webapp/grading/BalancingUtils.kt new file mode 100644 index 00000000..20b2ca23 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/BalancingUtils.kt @@ -0,0 +1,30 @@ +package trik.testsys.webapp.grading + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import trik.testsys.webapp.grading.communication.GraderClient +import trik.testsys.webapp.backoffice.service.Grader + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +object BalancingUtils { + private val log = LoggerFactory.getLogger(BalancingUtils::class.java) + + fun findOptimalNode(nodes: Map) = + nodes.mapNotNull { (client, status) -> + val aliveStatus = (status as? Grader.NodeStatus.Alive) ?: return@mapNotNull null + log.gotAliveStatus(aliveStatus.id, aliveStatus.queued, aliveStatus.capacity) + if (client.sentSubmissionsCount < aliveStatus.capacity) + client to aliveStatus + else null + }.minByOrNull { (client, status) -> + client.sentSubmissionsCount.toDouble() / status.capacity + } + ?.first?.address + + private fun Logger.gotAliveStatus(nodeId: Int, queued: Int, capacity: Int) { + debug("Get status for node[id=${nodeId}, queued=${queued}, capacity=${capacity}]") + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/grading/GraderConfiguration.kt b/src/main/kotlin/trik/testsys/webapp/grading/GraderConfiguration.kt new file mode 100644 index 00000000..d7c52c86 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/GraderConfiguration.kt @@ -0,0 +1,30 @@ +package trik.testsys.webapp.grading + +import org.springframework.stereotype.Component +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.times + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +interface GraderConfiguration { + val statusResponseTimeout: Duration + val nodePollingInterval: Duration + val resendHangingSubmissionsInterval: Duration + val hangTimeout: Duration +} + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +@Component +object DefaultGraderConfiguration : GraderConfiguration { + override val statusResponseTimeout = 2.seconds + override val nodePollingInterval = 1.seconds + override val resendHangingSubmissionsInterval = 1.minutes + override val hangTimeout = 2 * 5.minutes +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/grading/GradingManager.kt b/src/main/kotlin/trik/testsys/webapp/grading/GradingManager.kt new file mode 100644 index 00000000..8ca00f64 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/GradingManager.kt @@ -0,0 +1,86 @@ +package trik.testsys.webapp.grading + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import trik.testsys.webapp.grading.communication.GradingNodeManager +import java.util.concurrent.LinkedBlockingQueue +import kotlin.collections.iterator + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +@Suppress("UNUSED") +class GradingManager( + private val nodeManager: GradingNodeManager, + private val configuration: GraderConfiguration, + private val onSent: (SubmissionInfo) -> Unit, +) { + private val submissionQueue = LinkedBlockingQueue() + + init { + nodeManager.subscribeOnNodeFail { + it.drainSubmissionsTo(submissionQueue) + } + } + + private val log = LoggerFactory.getLogger(this.javaClass) + private val submissionSendScope = CoroutineScope(Dispatchers.IO) + private val submissionSendJob = submissionSendScope.launch { + while (isActive) { + try { + val submissionInfo = submissionQueue.take() + var address = findFreeNode() + while (address == null) { + delay(configuration.nodePollingInterval.inWholeMilliseconds) + address = findFreeNode() + } + + nodeManager.grade(address, submissionInfo) + onSent(submissionInfo) + } catch (e: Exception) { + log.sendCycleError(e) + } + } + } + + private fun findFreeNode(): String? { + val statuses = nodeManager.getClients2Statuses() + return BalancingUtils.findOptimalNode(statuses) + } + + private val resendHangingSubmissionsJob = submissionSendScope.launch { + while (isActive) { + try { + delay(configuration.resendHangingSubmissionsInterval.inWholeMilliseconds) + + for ((_, client) in nodeManager.nodes) { + client.drainHangingSubmissionsTo(submissionQueue) + } + } catch (e: Exception) { + log.resendHangingCycleError(e) + } + } + } + + fun enqueueSubmission(submissionInfo: SubmissionInfo) { + submissionQueue.put(submissionInfo) + } + + companion object { + private fun Logger.sendCycleError(e: Exception) { + error("Submission send job iteration end up with error:", e) + } + + private fun Logger.resendHangingCycleError(e: Exception) { + error("Submission resend job iteration end up with error:", e) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/grading/StubGraderService.kt b/src/main/kotlin/trik/testsys/webapp/grading/StubGraderService.kt new file mode 100644 index 00000000..a302a444 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/StubGraderService.kt @@ -0,0 +1,46 @@ +package trik.testsys.webapp.grading + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.service.VerdictService +import trik.testsys.webapp.backoffice.data.service.SolutionService +import trik.testsys.webapp.backoffice.service.Grader +import kotlin.random.Random + +/** + * @author Roman Shishkin + * @since %CURRENT_VERSION% + */ +@Service +class StubGraderService( + private val verdictService: VerdictService, + private val solutionService: SolutionService, +) : Grader { + + private val log = LoggerFactory.getLogger(StubGraderService::class.java) + private val subscribers = mutableListOf<(Grader.GradingInfo) -> Unit>() + + override fun sendToGrade(solution: Solution, gradingOptions: Grader.GradingOptions) { + log.info("Stub grading started for solution id=${solution.id} with ${gradingOptions.trikStudioVersion}") + + verdictService.createNewForSolution(solution, Random.nextLong()) + + // Notify subscribers with a fake OK event (use a simple numeric id wrapper) + subscribers.forEach { it.invoke(Grader.GradingInfo.Ok(solution.id?.toInt() ?: -1, emptyList())) } + } + + override fun subscribeOnGraded(onGraded: (Grader.GradingInfo) -> Unit) { + subscribers.add(onGraded) + } + + override fun addNode(address: String) { /* no-op */ } + + override fun removeNode(address: String) { /* no-op */ } + + override fun getNodeStatus(address: String): Grader.NodeStatus? = null + + override fun getAllNodeStatuses(): Map = emptyMap() +} + + diff --git a/src/main/kotlin/trik/testsys/webapp/grading/communication/GraderClient.kt b/src/main/kotlin/trik/testsys/webapp/grading/communication/GraderClient.kt new file mode 100644 index 00000000..bd8f512e --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/communication/GraderClient.kt @@ -0,0 +1,157 @@ +package trik.testsys.webapp.grading.communication + +import com.google.protobuf.Empty +import io.grpc.Channel +import io.grpc.ManagedChannelBuilder +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.StatusRuntimeException +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeout +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import trik.testsys.webapp.grading.GraderConfiguration +import trik.testsys.grading.GradingNodeGrpc +import trik.testsys.grading.GradingNodeOuterClass +import trik.testsys.webapp.backoffice.service.Grader +import trik.testsys.webapp.grading.SubmissionInfo +import trik.testsys.webapp.grading.converter.FieldResultConverter +import trik.testsys.webapp.grading.converter.FileConverter +import trik.testsys.webapp.grading.converter.ResultConverter +import java.time.Duration +import java.time.LocalDateTime +import java.util.concurrent.LinkedBlockingQueue + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +class GraderClient( + val address: String, + private val configuration: GraderConfiguration, + private val nodeManager: GradingNodeManager, +) { + private val gradingStub: GradingNodeGrpc.GradingNodeStub + private val statusStub: GradingNodeGrpc.GradingNodeBlockingStub + + private val sentSubmissions = LinkedBlockingQueue() + + private val log = LoggerFactory.getLogger(this.javaClass) + private val converter = ResultConverter(FieldResultConverter(FileConverter())) + + init { + val channel: Channel = ManagedChannelBuilder.forTarget(address) + .usePlaintext() // TODO: Make proper channel initialization + .maxInboundMessageSize(MAX_MESSAGE_SIZE) + .maxInboundMetadataSize(MAX_METADATA_SIZE) + .build() + gradingStub = GradingNodeGrpc.newStub(channel) + statusStub = GradingNodeGrpc.newBlockingStub(channel) + } + + fun grade(submission: SubmissionInfo) { + gradingStub.grade( + submission.submission, + GradingResultObserver(submission), + ) + sentSubmissions.add(submission) + log.sentToGrade(submission, address) + } + + suspend fun getStatus(): Grader.NodeStatus = coroutineScope { + try { + withTimeout(configuration.statusResponseTimeout.inWholeMilliseconds) { + val status = statusStub.getStatus(Empty.getDefaultInstance()) + Grader.NodeStatus.Alive(status.id, status.queued, status.capacity) + } + } catch (se: StatusException) { + Grader.NodeStatus.Unreachable("The request end up with status error (code ${se.status.code})") + } catch (se: StatusRuntimeException) { + Grader.NodeStatus.Unreachable("The request end up with status error (code ${se.status.code})") + } catch (_: TimeoutCancellationException) { + Grader.NodeStatus.Unreachable("Status request timeout reached") + } catch (_: Exception) { + Grader.NodeStatus.Unreachable("Unknown reason") + } + } + + val sentSubmissionsCount: Int get() = sentSubmissions.size + + fun drainHangingSubmissionsTo(to: MutableCollection) { + val currentTime = LocalDateTime.now() + val hangingSubmissions = mutableListOf() + sentSubmissions.filterTo(hangingSubmissions) { + Duration.between(currentTime, it.sentTime) > Duration.ofMillis(configuration.hangTimeout.inWholeMilliseconds) + } + to += hangingSubmissions + sentSubmissions.removeAll(hangingSubmissions) + } + + fun drainSubmissionsTo(to: MutableCollection) { + sentSubmissions.drainTo(to) + } + + private inner class GradingResultObserver( + private val submissionInfo: SubmissionInfo, + ) : StreamObserver { + + override fun onNext(value: GradingNodeOuterClass.Result?) { + if (value == null) { + log.nullGradingResult( + submissionId = submissionInfo.submission.id, + nodeAddress = address, + ) + return + } + + sentSubmissions.remove(submissionInfo) + + value + .let(converter::convert) + .also { log.graded(it) } + .let(nodeManager::handleGraded) + } + + override fun onError(t: Throwable?) { + when (t) { + is StatusException -> { + log.statusError(t.status) + nodeManager.handleCommunicationError(this@GraderClient) + } + is Exception -> { + log.unknownError(t) + nodeManager.handleCommunicationError(this@GraderClient) + } + } + } + + override fun onCompleted() { } + } + + companion object { + private const val MAX_MESSAGE_SIZE = 400_000_000 + private const val MAX_METADATA_SIZE = 400_000_000 + + private fun Logger.nullGradingResult(submissionId: Int, nodeAddress: String) { + error("No grading result on submission[id=${submissionId}] from node[addr=${nodeAddress}]") + } + + private fun Logger.statusError(status: Status) { + error("RPC is finished with status code ${status.code.value()}, description ${status.description}, cause ${status.cause}") + } + + private fun Logger.unknownError(e: Exception) { + error("Unexpected error while processing results", e) + } + + private fun Logger.graded(result: Grader.GradingInfo) { + info("Submission[id=${result.submissionId}] graded") + } + + private fun Logger.sentToGrade(submission: SubmissionInfo, nodeAddress: String) { + debug("Submission[id=${submission.submission.id}] sent to grade on node[addr=${nodeAddress}]") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webapp/grading/communication/GradingNodeManager.kt b/src/main/kotlin/trik/testsys/webapp/grading/communication/GradingNodeManager.kt new file mode 100644 index 00000000..3a15cec9 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/communication/GradingNodeManager.kt @@ -0,0 +1,87 @@ +package trik.testsys.webapp.grading.communication + +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import trik.testsys.webapp.backoffice.service.Grader +import trik.testsys.webapp.grading.GraderConfiguration +import trik.testsys.webapp.grading.SubmissionInfo +import java.util.concurrent.ConcurrentHashMap +import kotlin.collections.mapKeys + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +class GradingNodeManager( + private val configuration: GraderConfiguration, +) { + private val _nodes = ConcurrentHashMap() + private val onGradedSubscribers = mutableListOf<(Grader.GradingInfo) -> Unit>() + private val onNodeFailSubscribers = mutableListOf<(GraderClient) -> Unit>() + + private val log = LoggerFactory.getLogger(this.javaClass) + + val nodes get() = _nodes.toMap() + + fun addNode(address: String) { + _nodes[address] = GraderClient(address, configuration, this) + } + + fun subscribeOnGraded(action: (Grader.GradingInfo) -> Unit) { + onGradedSubscribers.add(action) + } + + fun subscribeOnNodeFail(action: (GraderClient) -> Unit) { + onNodeFailSubscribers.add(action) + } + + fun handleGraded(result: Grader.GradingInfo) { + onGradedSubscribers.forEach { it(result) } + } + + fun handleCommunicationError(graderClient: GraderClient) { + onNodeFailSubscribers.forEach { it(graderClient) } + } + + fun grade(address: String, submission: SubmissionInfo) { + val node = _nodes[address] ?: return + node.grade(submission) + } + + fun getNodeStatus(address: String): Grader.NodeStatus? { + val node = _nodes[address] ?: return null + return runBlocking { node.getStatus() } + } + + fun getAllNodeStatuses(): Map { + val nodeStatuses = runBlocking { + _nodes.mapValues { async { it.value.getStatus() } } + .mapValues { it.value.await() } + } + + nodeStatuses.forEach { (address, nodeStatus) -> + when (nodeStatus) { + is Grader.NodeStatus.Alive -> log.nodeAlive(nodeStatus, address) + is Grader.NodeStatus.Unreachable -> log.nodeUnreachable(nodeStatus, address) + } + } + + return nodeStatuses + } + + fun getClients2Statuses(): Map { + return getAllNodeStatuses().mapKeys { (address, _) -> _nodes.getValue(address) } + } + + companion object { + private fun Logger.nodeAlive(nodeStatus: Grader.NodeStatus.Alive, address: String) { + debug("Node with ID ${nodeStatus.id} available by address $address (${nodeStatus.queued}/${nodeStatus.capacity})") + } + + private fun Logger.nodeUnreachable(nodeStatus: Grader.NodeStatus.Unreachable, address: String) { + warn("Node is not available by address $address: ${nodeStatus.reason}") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/grading/converter/FieldResultConverter.kt b/src/main/kotlin/trik/testsys/webapp/grading/converter/FieldResultConverter.kt similarity index 76% rename from src/main/kotlin/trik/testsys/grading/converter/FieldResultConverter.kt rename to src/main/kotlin/trik/testsys/webapp/grading/converter/FieldResultConverter.kt index 6a2a436c..354a412d 100644 --- a/src/main/kotlin/trik/testsys/grading/converter/FieldResultConverter.kt +++ b/src/main/kotlin/trik/testsys/webapp/grading/converter/FieldResultConverter.kt @@ -1,9 +1,13 @@ -package trik.testsys.grading.converter +package trik.testsys.webapp.grading.converter import trik.testsys.grading.GradingNodeOuterClass.FieldResult import trik.testsys.grading.videoOrNull -import trik.testsys.webclient.service.Grader +import trik.testsys.webapp.backoffice.service.Grader +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ class FieldResultConverter(private val fileConverter: FileConverter) { fun convert(fieldResult: FieldResult): Grader.GradingInfo.FieldResult { val name = fieldResult.name diff --git a/src/main/kotlin/trik/testsys/grading/converter/FileConverter.kt b/src/main/kotlin/trik/testsys/webapp/grading/converter/FileConverter.kt similarity index 57% rename from src/main/kotlin/trik/testsys/grading/converter/FileConverter.kt rename to src/main/kotlin/trik/testsys/webapp/grading/converter/FileConverter.kt index 3142bf08..69787c4f 100644 --- a/src/main/kotlin/trik/testsys/grading/converter/FileConverter.kt +++ b/src/main/kotlin/trik/testsys/webapp/grading/converter/FileConverter.kt @@ -1,8 +1,12 @@ -package trik.testsys.grading.converter +package trik.testsys.webapp.grading.converter import trik.testsys.grading.GradingNodeOuterClass.File -import trik.testsys.webclient.service.Grader +import trik.testsys.webapp.backoffice.service.Grader +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ class FileConverter { fun convert(file: File): Grader.GradingInfo.File { return Grader.GradingInfo.File(file.name, file.content.toByteArray()) diff --git a/src/main/kotlin/trik/testsys/webapp/grading/converter/KindConverter.kt b/src/main/kotlin/trik/testsys/webapp/grading/converter/KindConverter.kt new file mode 100644 index 00000000..4fde50de --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/grading/converter/KindConverter.kt @@ -0,0 +1,31 @@ +package trik.testsys.webapp.grading.converter + +import trik.testsys.webapp.backoffice.service.Grader + +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ +object KindConverter { + fun convert(kind: Int, description: String): Grader.ErrorKind { + return when (kind) { + 1 -> Grader.ErrorKind.UnexpectedException(description) + 2 -> { + val code = parseExitCode(description) + Grader.ErrorKind.NonZeroExitCode(code, description) + } + 3 -> Grader.ErrorKind.MismatchedFiles(description) + 4 -> Grader.ErrorKind.InnerTimeoutExceed(description) + 5 -> Grader.ErrorKind.UnsupportedImageVersion(description) + else -> Grader.ErrorKind.Unknown(description) + } + } + + private fun parseExitCode(str: String) = try { + str.split(' ') + .last() + .toInt() + } catch (e: Exception) { + 500 + } +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/grading/converter/ResultConverter.kt b/src/main/kotlin/trik/testsys/webapp/grading/converter/ResultConverter.kt similarity index 63% rename from src/main/kotlin/trik/testsys/grading/converter/ResultConverter.kt rename to src/main/kotlin/trik/testsys/webapp/grading/converter/ResultConverter.kt index 1c375ed9..1dddeb41 100644 --- a/src/main/kotlin/trik/testsys/grading/converter/ResultConverter.kt +++ b/src/main/kotlin/trik/testsys/webapp/grading/converter/ResultConverter.kt @@ -1,9 +1,13 @@ -package trik.testsys.grading.converter +package trik.testsys.webapp.grading.converter import trik.testsys.grading.GradingNodeOuterClass import trik.testsys.grading.okOrNull -import trik.testsys.webclient.service.Grader +import trik.testsys.webapp.backoffice.service.Grader +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ class ResultConverter(private val fieldResultConverter: FieldResultConverter) { fun convert(result: GradingNodeOuterClass.Result): Grader.GradingInfo { val submissionId = result.id @@ -12,8 +16,9 @@ class ResultConverter(private val fieldResultConverter: FieldResultConverter) { val fieldResults = ok.resultsList.map { fieldResultConverter.convert(it) } Grader.GradingInfo.Ok(submissionId, fieldResults) } ?: result.error.let { - Grader.GradingInfo.Error(submissionId, it.kind, it.description) + val errorKind = KindConverter.convert(it.kind, it.description) + Grader.GradingInfo.Error(submissionId, errorKind) } } -} +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/grading/converter/SubmissionBuilder.kt b/src/main/kotlin/trik/testsys/webapp/grading/converter/SubmissionBuilder.kt similarity index 82% rename from src/main/kotlin/trik/testsys/grading/converter/SubmissionBuilder.kt rename to src/main/kotlin/trik/testsys/webapp/grading/converter/SubmissionBuilder.kt index d09906bd..f2dc4664 100644 --- a/src/main/kotlin/trik/testsys/grading/converter/SubmissionBuilder.kt +++ b/src/main/kotlin/trik/testsys/webapp/grading/converter/SubmissionBuilder.kt @@ -1,13 +1,17 @@ -package trik.testsys.grading.converter +package trik.testsys.webapp.grading.converter import com.google.protobuf.ByteString import trik.testsys.grading.* import trik.testsys.grading.GradingNodeOuterClass.Submission -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.service.Grader +import trik.testsys.webapp.backoffice.data.entity.impl.Solution +import trik.testsys.webapp.backoffice.data.entity.impl.Task +import trik.testsys.webapp.backoffice.service.Grader import java.io.File +/** + * @author Vyacheslav Buchin + * @since %CURRENT_VERSION% + */ class SubmissionBuilder private constructor() { lateinit var solution: Solution lateinit var solutionFile: File @@ -58,6 +62,6 @@ private fun SubmissionKt.Dsl.fillSubmission(solution: Solution, solutionFile: Fi when (solution.type) { Solution.SolutionType.QRS -> visualLanguageSubmission = visualLanguageSubmission { file = encodedFile } Solution.SolutionType.PYTHON -> pythonSubmission = pythonSubmission { file = encodedFile } - Solution.SolutionType.JAVASCRIPT -> javascriptSubmission = javaScriptSubmission { file = encodedFile } + Solution.SolutionType.JAVA_SCRIPT -> javascriptSubmission = javaScriptSubmission { file = encodedFile } } } diff --git a/src/main/kotlin/trik/testsys/webapp/notifier/CombinedIncidentNotifier.kt b/src/main/kotlin/trik/testsys/webapp/notifier/CombinedIncidentNotifier.kt new file mode 100644 index 00000000..d811bbf7 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/notifier/CombinedIncidentNotifier.kt @@ -0,0 +1,29 @@ +package trik.testsys.webapp.notifier + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Component +class CombinedIncidentNotifier( + @Value("\${spring.application.name}") + private val appName: String, + context: ApplicationContext +): IncidentNotifier { + + private val notifiers = context.getBeansOfType(IncidentNotifier::class.java).also { + it.remove("combinedIncidentNotifier") + } + + override fun notify(msg: String) { + notifiers.values.forEach { it.notify("[ $appName ] \n\n $msg") } + } + + override fun notify(msg: String, e: Exception) { + notifiers.values.forEach { it.notify("[ $appName ] \n\n $msg", e) } + } +} diff --git a/src/main/kotlin/trik/testsys/webapp/notifier/EmailIncidentNotifier.kt b/src/main/kotlin/trik/testsys/webapp/notifier/EmailIncidentNotifier.kt new file mode 100644 index 00000000..28780824 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/notifier/EmailIncidentNotifier.kt @@ -0,0 +1,58 @@ +package trik.testsys.webapp.notifier + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import trik.testsys.webapp.backoffice.service.impl.EmailClient +import javax.annotation.PostConstruct + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +@Component +class EmailIncidentNotifier( + private val emailClient: EmailClient, + @Value("\${trik.testsys.notifier.email.receivers}") + private val receiversMail: String, + @Value("\${spring.application.name}") + private val appName: String, +): IncidentNotifier { + + private val mails = receiversMail.split(",") + + @PostConstruct + fun init() { + if (receiversMail.trim().isEmpty()) error("Email receivers must be initialized") + + logger.info("Initialized mails to be notified: $mails") + } + + private val subject = "[ $appName ] TestSys incident" + + private fun sendMessage(body: String) { + val message = EmailClient.Email( + from = FROM, + to = mails, + subject = subject, + body = body + ) + emailClient.sendEmail(message) + } + + override fun notify(msg: String) { + sendMessage(msg) + } + + override fun notify(msg: String, e: Exception) { + val body = "${msg}\n${e.stackTraceToString()}" + sendMessage(body) + } + + companion object { + + private val logger = LoggerFactory.getLogger(EmailIncidentNotifier::class.java) + + private const val FROM = "incident-notifier" + } +} diff --git a/src/main/kotlin/trik/testsys/webapp/notifier/IncidentNotifier.kt b/src/main/kotlin/trik/testsys/webapp/notifier/IncidentNotifier.kt new file mode 100644 index 00000000..8f6b9df9 --- /dev/null +++ b/src/main/kotlin/trik/testsys/webapp/notifier/IncidentNotifier.kt @@ -0,0 +1,10 @@ +package trik.testsys.webapp.notifier + +/** + * @author Viktor Karasev + * @since %CURRENT_VERSION% + */ +interface IncidentNotifier { + fun notify(msg: String) + fun notify(msg: String, e: Exception) +} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/ErrorController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/main/ErrorController.kt deleted file mode 100644 index a2dd227a..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/ErrorController.kt +++ /dev/null @@ -1,31 +0,0 @@ -package trik.testsys.webclient.controller.impl.main - -import org.springframework.boot.web.servlet.error.ErrorController -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.RequestMapping -import javax.servlet.http.HttpServletRequest - -@Controller -class ErrorController : ErrorController { - - @RequestMapping(ERROR_PATH) - fun handleError(request: HttpServletRequest, model: Model): String { - val statusCode = request.getAttribute("javax.servlet.error.status_code") as? Int - - if (statusCode != null) { - if (statusCode % 100 == 5) { - model.addAttribute("message", "Произошла ошибка на сервере. Пожалуйста, попробуйте позже") - } else if (statusCode % 100 == 4) { - model.addAttribute("message", "Запрашиваемая страница не найдена") - } - } - return ERROR_PAGE - } - - companion object { - - const val ERROR_PATH = "/error" - const val ERROR_PAGE = "error" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/LoginController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/main/LoginController.kt deleted file mode 100644 index 8ee983aa..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/LoginController.kt +++ /dev/null @@ -1,58 +0,0 @@ -package trik.testsys.webclient.controller.impl.main - -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.webclient.service.LogoService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.service.security.login.impl.LoginProcessor -import trik.testsys.webclient.util.addInvalidAccessTokenMessage -import trik.testsys.webclient.util.addSessionActiveInfo - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Controller -@RequestMapping("/login") -class LoginController( - private val loginData: LoginData, - private val loginProcessor: LoginProcessor, - - private val logoService: LogoService -) { - - @GetMapping - fun loginGet(model: Model): String { - loginData.accessToken?.let { model.addSessionActiveInfo() } - - val logos = logoService.getLogos() - model.addAttribute("logos", logos) - - return LOGIN_PAGE - } - - @PostMapping - fun loginPost( - @RequestParam(required = true) accessToken: String, - redirectAttributes: RedirectAttributes - ): String { - loginProcessor.setCredentials(accessToken) - - val isLoggedIn = loginProcessor.login() - if (isLoggedIn) return "redirect:${RedirectController.REDIRECT_PATH}" - - redirectAttributes.addInvalidAccessTokenMessage() - return "redirect:/$LOGIN_PAGE" - } - - companion object { - - internal const val LOGIN_PAGE = "login" - internal const val LOGIN_PATH = "/login" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/MainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/main/MainController.kt deleted file mode 100644 index 152ffaa9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/MainController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package trik.testsys.webclient.controller.impl.main - -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 trik.testsys.webclient.service.LogoService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addSessionActiveInfo - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Controller -@RequestMapping(MainController.MAIN_PATH) -class MainController( - private val loginData: LoginData, - private val logoService: LogoService -) { - - @GetMapping - fun mainGet(model: Model): String { - loginData.accessToken?.let { model.addSessionActiveInfo() } - - val logos = logoService.getLogos() - model.addAttribute("logos", logos) - - return MAIN_PAGE - } - - companion object { - - internal const val MAIN_PAGE = "main" - internal const val MAIN_PATH = "/" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RedirectController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RedirectController.kt deleted file mode 100644 index 3b544887..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RedirectController.kt +++ /dev/null @@ -1,56 +0,0 @@ -package trik.testsys.webclient.controller.impl.main - -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.servlet.View -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.entity.user.WebUser.UserType -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.service.security.UserValidator -import trik.testsys.webclient.util.addInvalidAccessTokenMessage -import trik.testsys.webclient.util.addSessionExpiredMessage -import javax.servlet.http.HttpServletRequest - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Controller -@RequestMapping(RedirectController.REDIRECT_PATH) -class RedirectController( - private val loginData: LoginData, - private val userValidator: UserValidator -) { - - @GetMapping - fun redirectGet( - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - val accessToken = loginData.accessToken ?: run { - redirectAttributes.addSessionExpiredMessage() - return "redirect:${LoginController.LOGIN_PATH}" - } - val user = userValidator.validateExistence(accessToken) ?: run { - redirectAttributes.addInvalidAccessTokenMessage() - return "redirect:${LoginController.LOGIN_PATH}" - } - - request.setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, HttpStatus.TEMPORARY_REDIRECT) - return when (user.type) { - UserType.ADMIN -> "redirect:/admin/login" - UserType.DEVELOPER -> "redirect:/developer/login" - UserType.JUDGE -> "redirect:/judge/login" - UserType.STUDENT -> "redirect:/student/login" - UserType.SUPER_USER -> "redirect:/superuser/login" - UserType.VIEWER -> "redirect:/viewer/login" - } - } - - companion object { - - internal const val REDIRECT_PATH = "/redirect" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RegistrationController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RegistrationController.kt deleted file mode 100644 index 8b99842d..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/main/RegistrationController.kt +++ /dev/null @@ -1,76 +0,0 @@ -package trik.testsys.webclient.controller.impl.main - -import org.springframework.context.ApplicationContext -import org.springframework.http.HttpStatus -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.View -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.core.entity.user.UserEntity -import trik.testsys.webclient.service.LogoService -import trik.testsys.webclient.service.entity.RegEntityService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.util.addSessionActiveInfo -import javax.servlet.http.HttpServletRequest - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Controller -@RequestMapping(RegistrationController.REGISTRATION_PATH) -class RegistrationController( - context: ApplicationContext, - private val loginData: LoginData, - - private val logoService: LogoService -) { - - private val registrationServices = context.getBeansOfType(RegEntityService::class.java).values - - @GetMapping - fun registrationGet(model: Model): String { - loginData.accessToken?.let { model.addSessionActiveInfo() } - - val logos = logoService.getLogos() - model.addAttribute("logos", logos) - - return REGISTRATION_PAGE - } - - @PostMapping - fun registrationPost( - @RequestParam(required = true) regToken: String, - @RequestParam(required = true) name: String, - redirectAttributes: RedirectAttributes, - request: HttpServletRequest - ): String { - val neededService = registrationServices.firstOrNull { - it.findByRegToken(regToken) != null - } ?: run { - redirectAttributes.addPopupMessage("Некорректный Код-доступа. Попробуйте еще раз.") - return "redirect:$REGISTRATION_PATH" - } - - val registeredEntity = neededService.register(regToken, name) ?: run { - redirectAttributes.addPopupMessage("Псевдоним не должен содержать Код-регистрации. Попробуйте другой вариант.") - return "redirect:$REGISTRATION_PATH" - } - - redirectAttributes.addAttribute(UserEntity::accessToken.name, registeredEntity.accessToken) - request.setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, HttpStatus.TEMPORARY_REDIRECT) - - return "redirect:${LoginController.LOGIN_PATH}" - } - - companion object { - - internal const val REGISTRATION_PAGE = "registration" - internal const val REGISTRATION_PATH = "/registration" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/RestStudentControllerImpl.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/RestStudentControllerImpl.kt deleted file mode 100644 index a203b280..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/RestStudentControllerImpl.kt +++ /dev/null @@ -1,128 +0,0 @@ -package trik.testsys.webclient.controller.impl.rest - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.beans.factory.annotation.Value -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -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.bind.annotation.RestController -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.controller.rest.RestStudentController -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.impl.SolutionService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.token.access.AccessTokenGenerator -import java.util.UUID - -@RestController -@RequestMapping("rest/student") -class RestStudentControllerImpl( - @Value("\${lektorium-group-reg-token}") private val groupRegToken: AccessToken, - - private val studentService: StudentService, - private val groupService: GroupService, - - @Qualifier("studentAccessTokenGenerator") - private val studentAccessTokenGenerator: AccessTokenGenerator, - - private val solutionService: SolutionService, - private val fileManager: FileManager -) : RestStudentController { - - private fun getGroup(): Group { - return groupService.findByRegToken(groupRegToken)!! - } - - @PostMapping("register") - override fun register( - @RequestParam(required = true) apiKey: String - ): ResponseEntity { - val uuid = UUID.randomUUID().toString() - val name = "Student $uuid" - val accessToken = studentAccessTokenGenerator.generate(name) - - val student = Student(name, accessToken).also { - it.group = getGroup() - } - studentService.save(student) - - val responseBody = RestStudentController.StudentData(student.id!!, accessToken) - - return ResponseEntity.ok(responseBody) - } - - @GetMapping("results") - override fun getResults( - @RequestParam(required = true) apiKey: String, - @RequestParam(required = true) testSysId: Long - ): ResponseEntity> { - val student = studentService.find(testSysId) ?: run { - return ResponseEntity.notFound().build() - } - - val contests = getGroup().contests - val tasks = contests.map { it.tasks }.flatten() - val results = tasks.map { task -> - val solutions = solutionService.findByStudentAndTask(student, task).sortedByDescending { it.creationDate } - val lastSolution = solutions.firstOrNull() - val firstPassed = solutions.firstPassed() - - val gradingResult = when (lastSolution?.status) { - null -> RestStudentController.GradingResult.NO_SUBMISSIONS - Solution.SolutionStatus.PASSED -> RestStudentController.GradingResult.PASSED - Solution.SolutionStatus.FAILED, Solution.SolutionStatus.ERROR -> if (firstPassed != null) RestStudentController.GradingResult.PASSED else RestStudentController.GradingResult.FAILED - Solution.SolutionStatus.IN_PROGRESS, Solution.SolutionStatus.NOT_STARTED -> if (firstPassed != null) RestStudentController.GradingResult.PASSED else RestStudentController.GradingResult.QUEUED - } - - val bestSolutionId = when (gradingResult) { - RestStudentController.GradingResult.NO_SUBMISSIONS, - RestStudentController.GradingResult.FAILED, - RestStudentController.GradingResult.QUEUED -> null - - RestStudentController.GradingResult.PASSED -> firstPassed!!.id - } - - val trikTask = RestStudentController.TrikTask(task.id!!, task.name, task.contests.first().id!!, task.contests.first().name) - val submission = bestSolutionId?.let { RestStudentController.Submission(it) } - val trikResult = RestStudentController.TrikResult(trikTask, gradingResult, submission) - - trikResult - } - - return ResponseEntity.ok(results) - } - - private fun Collection.firstPassed(): Solution? { - return this.sortedBy { it.creationDate }.firstOrNull { it.status == Solution.SolutionStatus.PASSED } - } - - @GetMapping("submission") - override fun loadSubmission( - @RequestParam(required = true) apiKey: String, - @RequestParam(required = true) submissionId: Long - ): ResponseEntity { - val solution = solutionService.find(submissionId) ?: run { - return ResponseEntity.notFound().build() - } - val solutionFile = fileManager.getSolutionFile(solution) ?: run { - return ResponseEntity.internalServerError().build() - } - val bytes = solutionFile.readBytes() - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"${solutionFile.name}\"") - .header("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE) - .header("Content-Transfer-Encoding", "binary") - .header("Content-Length", bytes.size.toString()) - .body(bytes) - - return responseEntity - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerImpl.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerImpl.kt deleted file mode 100644 index 42998394..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerImpl.kt +++ /dev/null @@ -1,94 +0,0 @@ -package trik.testsys.webclient.controller.impl.rest - -import org.springframework.http.ResponseEntity -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.bind.annotation.RequestPart -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.multipart.MultipartFile -import trik.testsys.webclient.controller.rest.StudentExportController -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.entity.user.impl.StudentService - -/** - * @author Roman Shishkin - * @since 2.5.0 - */ -@RestController -@RequestMapping("rest/student/export") -class StudentExportControllerImpl( - private val adminService: AdminService, - private val groupService: GroupService, - private val studentService: StudentService -) : StudentExportController { - - - @PostMapping("csv") - override fun exportFromCsvFile( - @RequestParam(required = true) apiKey: String, - @RequestParam(required = true) adminId: Long, - @RequestParam(required = true) groupId: Long, - @RequestPart(required = true) file: MultipartFile - ): ResponseEntity { - val admin = adminService.find(adminId) ?: return ResponseEntity.badRequest().body( - StudentExportController.ResponseData.error("Admin with ID $adminId not found") - ) - val group = groupService.find(groupId) ?: return ResponseEntity.badRequest().body( - StudentExportController.ResponseData.error("Group with ID $groupId not found") - ) - - if (group.admin.id != admin.id) { - return ResponseEntity.badRequest().body( - StudentExportController.ResponseData.error("Admin with ID $adminId is not the owner of the group with ID $groupId") - ) - } - - val studentsAdditionalInfo = file.parseCsvFile() ?: return ResponseEntity.badRequest().body( - StudentExportController.ResponseData.error("File is invalid") - ) - val students = studentService.generate(studentsAdditionalInfo, group) - val studentsInfo = students.toStudentsInfo() - - return ResponseEntity.ok(StudentExportController.ResponseData.success(studentsInfo)) - } - - private fun MultipartFile.parseCsvFile(): List? { - val lines = inputStream.bufferedReader().readLines() - if (lines.isEmpty()) { - return null - } - - val header = lines[0] - val students = lines.drop(1) - - if (students.isEmpty()) { - return null - } - - val fields = header.fields - return students.map { student -> - val values = student.fields - - if (fields.size != values.size) return null - fields.zip(values).joinToString(", ") { (field, value) -> "$field: $value" } - } - } - - private val String.fields: List - get() = split(",").map { it.trim() } - - private fun Student.toStudentInfo(): StudentExportController.StudentInfo { - return StudentExportController.StudentInfo(id!!, name, additionalInfo, accessToken) - } - - private fun List.toStudentsInfo(): StudentExportController.StudentsInfo { - return StudentExportController.StudentsInfo( - adminId = first().group.admin.id!!, - groupId = first().group.id!!, - students = map { it.toStudentInfo() } - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupController.kt deleted file mode 100644 index b5e2a666..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupController.kt +++ /dev/null @@ -1,361 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.admin - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.HttpHeaders -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.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.admin.AdminGroupController.Companion.GROUP_PATH -import trik.testsys.webclient.controller.impl.user.admin.AdminGroupsController.Companion.GROUPS_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.service.UserAgentParser -import trik.testsys.webclient.service.entity.impl.ContestService -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.impl.UserAgentParserImpl.Companion.WINDOWS_1251 -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.service.token.reg.RegTokenGenerator -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.AdminView -import trik.testsys.webclient.view.impl.ContestView.Companion.toView -import trik.testsys.webclient.view.impl.GroupCreationView -import trik.testsys.webclient.view.impl.GroupView -import trik.testsys.webclient.view.impl.GroupView.Companion.toView -import trik.testsys.webclient.view.impl.StudentView.Companion.toView -import java.util.* -import javax.servlet.http.HttpServletRequest - -@Controller -@RequestMapping(GROUP_PATH) -class AdminGroupController( - loginData: LoginData, - - private val groupService: GroupService, - @Qualifier("groupRegTokenGenerator") private val groupRegTokenGenerator: RegTokenGenerator, - - private val contestService: ContestService, - private val studentService: StudentService, - private val userAgentParser: UserAgentParser -) : AbstractWebUserController(loginData) { - - override val mainPage = GROUP_PAGE - - override val mainPath = GROUP_PATH - - override fun Admin.toView(timeZoneId: String?) = TODO() - - @PostMapping("/create") - fun groupPost( - @ModelAttribute("group") groupView: GroupCreationView, - timeZone: TimeZone, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val regToken = groupRegTokenGenerator.generate(groupView.name) - val group = groupView.toEntity(regToken, webUser) - - groupService.validate(group, redirectAttributes, "redirect:$GROUPS_PATH")?.let { return it } - - groupService.save(group) - - redirectAttributes.addPopupMessage("Группа ${group.name} успешно создана.") - - return "redirect:$GROUPS_PATH" - } - - @GetMapping("/{groupId}") - fun groupGet( - @PathVariable("groupId") id: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(id)) { - redirectAttributes.addPopupMessage("Группа с ID $id не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(id) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $id не найдена.") - return "redirect:$GROUPS_PATH" - } - - val groupView = group.toView(timezone) - model.addAttribute(GROUP_ATTR, groupView) - - val publicContests = contestService.findAllPublic() - val linkedContests = group.contests - val unLinkedContests = publicContests.filter { it !in linkedContests }.toSet() - - model.addAttribute(LINKED_CONTESTS_ATTR, linkedContests.map { it.toView(timezone) }.sortedBy { it.id }) - model.addAttribute(UNLINKED_CONTESTS_ATTR, unLinkedContests.map { it.toView(timezone) }.sortedBy { it.id }) - model.addAttribute(STUDENTS_ATTR, group.students.map { it.toView(timezone) }.sortedBy { it.id }) - - return GROUP_PAGE - } - - @PostMapping("/generateStudents/{groupId}") - fun groupGenerateStudents( - @PathVariable("groupId") groupId: Long, - @RequestParam("count") count: Long, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(groupId) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - if (count < 1) { - redirectAttributes.addPopupMessage("Количество Участников должно быть больше 0.") - return "redirect:$GROUP_PATH/$groupId" - } - - if (count > 100) { - redirectAttributes.addPopupMessage("Количество Участников не должно превышать 100.") - return "redirect:$GROUP_PATH/$groupId" - } - - if (group.students.size + count > 500) { - redirectAttributes.addPopupMessage("Количество Участников в Группе не должно превышать 500.") - return "redirect:$GROUP_PATH/$groupId" - } - - val students = studentService.generate(count, group) - - group.students.addAll(students) - - redirectAttributes.addPopupMessage("Сгенерировано $count Участников.") - - return "redirect:$GROUP_PATH/$groupId" - } - - @GetMapping("/exportStudents/{groupId}") - fun groupExportStudents( - @PathVariable("groupId") groupId: Long, - @RequestHeader("User-Agent") userAgent: String, - @RequestParam("Windows") isWindows: String?, // remove lately - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(groupId) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val students = group.students.sortedBy { it.id } - - val filename = "students_${System.currentTimeMillis()}.csv" - val contentDisposition = "attachment; filename=$filename" - - val csv = students.joinToString("\n") { "${it.id};${it.name};${it.accessToken}" } -// val charset = userAgentParser.getCharset(userAgent) TODO(commented for later usage) - val charset = isWindows?.let { WINDOWS_1251 } ?: Charsets.UTF_8 - val bytes = csv.toByteArray(charset) - - val responseEntity = ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .header(HttpHeaders.CONTENT_ENCODING, charset.name()) - .contentType(MediaType.TEXT_PLAIN) - .body(bytes) - - redirectAttributes.addPopupMessage("Студенты успешно экспортированы.") - - return responseEntity - } - - @GetMapping("/exportResults/{groupId}") - fun groupExportResults( - @PathVariable("groupId") groupId: Long, - @RequestHeader("User-Agent") userAgent: String, - @RequestParam("Windows") isWindows: String?, // remove lately - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(groupId) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val exportData = studentService.export(listOf(group)) - - val filename = "result_${System.currentTimeMillis()}.csv" - val contentDisposition = "attachment; filename=$filename" - // val charset = userAgentParser.getCharset(userAgent) TODO(commented for later usage) - val charset = isWindows?.let { WINDOWS_1251 } ?: Charsets.UTF_8 - val bytes = exportData.toByteArray(charset) - - val responseEntity = ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .header(HttpHeaders.CONTENT_ENCODING, charset.name()) - .contentType(MediaType.TEXT_PLAIN) - .body(bytes) - - return responseEntity - } - - @PostMapping("/linkContest/{groupId}") - fun groupLinkContest( - @PathVariable("groupId") groupId: Long, - @RequestParam("contestId") contestId: Long, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(groupId) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$GROUP_PATH/$groupId" - } - - if (!contest.isPublic()) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$GROUP_PATH/$groupId" - } - - group.contests.add(contest) - groupService.save(group) - contest.groups.add(group) - contestService.save(contest) - - redirectAttributes.addPopupMessage("Тур ${contest.name} успешно привязан к группе ${group.name}.") - - return "redirect:$GROUP_PATH/$groupId" - } - - @PostMapping("/unlinkContest/{groupId}") - fun groupUnlinkContest( - @PathVariable("groupId") groupId: Long, - @RequestParam("contestId") contestId: Long, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupService.find(groupId) ?: run { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$GROUP_PATH/$groupId" - } - - if (!contest.isPublic()) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$GROUP_PATH/$groupId" - } - - contest.groups.remove(group) - contestService.save(contest) - - group.contests.remove(contest) - groupService.save(group) - - redirectAttributes.addPopupMessage("Тур ${contest.name} успешно откреплен от группы ${group.name}.") - - return "redirect:$GROUP_PATH/$groupId" - } - - @PostMapping("/update/{groupId}") - fun groupUpdate( - @PathVariable("groupId") groupId: Long, - @ModelAttribute("group") groupView: GroupView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.validateGroupExistence(groupId)) { - redirectAttributes.addPopupMessage("Группа с ID $groupId не найдена.") - return "redirect:$GROUPS_PATH" - } - - val group = groupView.toEntity(timezone) - group.admin = webUser - - groupService.validate(group, redirectAttributes, "redirect:$GROUP_PATH/$groupId")?.let { return it } - - val updatedGroup = groupService.save(group) - - model.addAttribute(GROUP_ATTR, updatedGroup.toView(timezone)) - redirectAttributes.addPopupMessage("Данные успешно изменены.") - - return "redirect:$GROUP_PATH/$groupId" - } - - companion object { - - const val GROUP_PATH = "$GROUPS_PATH/group" - const val GROUP_PAGE = "admin/group" - - const val GROUP_ATTR = "group" - - const val STUDENTS_ATTR = "students" - - const val LINKED_CONTESTS_ATTR = "linkedContests" - const val UNLINKED_CONTESTS_ATTR = "unlinkedContests" - - fun Admin.validateGroupExistence(groupId: Long?) = groups.any { it.id == groupId } - - fun GroupService.validate(group: Group, redirectAttributes: RedirectAttributes, redirect: String): String? { - if (!validateName(group)) { - redirectAttributes.addPopupMessage("Название группы не должно содержать Код-доступа.") - return redirect - } - - if (!validateAdditionalInfo(group)) { - redirectAttributes.addPopupMessage("Дополнительная информация не должна содержать Код-доступа.") - return redirect - } - - return null - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupsController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupsController.kt deleted file mode 100644 index a41eb2c9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminGroupsController.kt +++ /dev/null @@ -1,61 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.admin - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.admin.AdminGroupController.Companion.GROUP_ATTR -import trik.testsys.webclient.controller.impl.user.admin.AdminMainController.Companion.ADMIN_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.AdminView -import trik.testsys.webclient.view.impl.GroupCreationView -import trik.testsys.webclient.view.impl.GroupView.Companion.toView - -@Controller -@RequestMapping(AdminGroupsController.GROUPS_PATH) -class AdminGroupsController( - loginData: LoginData -) : AbstractWebUserController(loginData) { - - override val mainPage = GROUPS_PAGE - - override val mainPath = GROUPS_PATH - - override fun Admin.toView(timeZoneId: String?) = AdminView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - viewer = this.viewer, - additionalInfo = this.additionalInfo, - groups = this.groups.map { it.toView(timeZoneId) }.sortedBy { it.id } - ) - - @GetMapping - fun groupsGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - model.addAttribute(GROUP_ATTR, GroupCreationView.empty()) - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - - return GROUPS_PAGE - } - - companion object { - - const val GROUPS_PATH = "$ADMIN_PATH/groups" - const val GROUPS_PAGE = "admin/groups" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminMainController.kt deleted file mode 100644 index 44960b2f..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/admin/AdminMainController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.admin - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.RequestMapping -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.AdminView - -@Controller -@RequestMapping(AdminMainController.ADMIN_PATH) -class AdminMainController( - loginData: LoginData -) : AbstractWebUserMainController(loginData) { - - override val mainPath = ADMIN_PATH - - override val mainPage = ADMIN_PAGE - - override fun Admin.toView(timeZoneId: String?) = AdminView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - viewer = this.viewer, - additionalInfo = this.additionalInfo - ) - - companion object { - - const val ADMIN_PATH = "/admin" - const val ADMIN_PAGE = "admin" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestController.kt deleted file mode 100644 index cfc6dc8b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestController.kt +++ /dev/null @@ -1,318 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.user.developer.DeveloperContestController.Companion.CONTEST_PATH -import trik.testsys.webclient.controller.impl.user.developer.DeveloperContestsController.Companion.CONTESTS_PATH -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.entity.impl.ContestService -import trik.testsys.webclient.service.entity.impl.TaskService -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.util.isLocalTimeFormatted -import trik.testsys.webclient.view.impl.ContestCreationView -import trik.testsys.webclient.view.impl.ContestView -import trik.testsys.webclient.view.impl.ContestView.Companion.toView -import trik.testsys.webclient.view.impl.DeveloperView -import trik.testsys.webclient.view.impl.TaskView.Companion.toView -import java.time.LocalTime -import javax.servlet.http.HttpServletRequest - -@Controller -@RequestMapping(CONTEST_PATH) -class DeveloperContestController( - loginData: LoginData, - - private val contestService: ContestService, - private val taskService: TaskService -) : AbstractWebUserController(loginData) { - - override val mainPath = CONTEST_PATH - - override val mainPage = CONTEST_PAGE - - override fun Developer.toView(timeZoneId: String?) = TODO() - - @PostMapping("/create") - fun contestPost( - @ModelAttribute("contest") contestView: ContestCreationView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (contestView.isOpenEnded == false && !contestView.duration.isLocalTimeFormatted()) { - redirectAttributes.addPopupMessage("Время на выполнение Тура должно быть в формате HH:mm.") - return "redirect:$CONTESTS_PATH" - } - - val contest = contestView.toEntity(webUser, timezone) - - contestService.validate(contest, redirectAttributes, "redirect:$CONTESTS_PATH")?.let { return it } - - if (contest.startDate.isAfter(contest.endDate)) { - redirectAttributes.addPopupMessage("Дата начала не может быть позже даты окончания.") - return "redirect:$CONTESTS_PATH" - } - - if (contestView.isOpenEnded == false && - (contest.startDate.isEqual(contest.endDate) || contest.duration == LocalTime.of(0, 0)) - ) { - redirectAttributes.addPopupMessage("Время на выполнение Тура должно быть положительным.") - return "redirect:$CONTESTS_PATH" - } - - contestService.save(contest) - - redirectAttributes.addPopupMessage("Тур ${contest.name} успешно создан.") - - return "redirect:$CONTESTS_PATH" - } - - @GetMapping("/{contestId}") - fun contestGet( - @PathVariable("contestId") contestId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkContestExistence(contestId)) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val contestView = contest.toView(timezone) - model.addAttribute(CONTEST_ATTR, contestView) - - val linkedTasks = contest.tasks - .map { it.toView(timezone) } - .sortedBy { it.id } - val unlinkedTasks = taskService.findByDeveloper(webUser) - .filter { it !in contest.tasks } - .map { it.toView(timezone) } - .sortedBy { it.id } - - model.addAttribute(LINKED_TASKS_ATTR, linkedTasks) - model.addAttribute(UNLINKED_TASKS_ATTR, unlinkedTasks) - - return CONTEST_PAGE - } - - @PostMapping("/update/{contestId}") - fun contestUpdate( - @PathVariable("contestId") contestId: Long, - @ModelAttribute("contest") contestView: ContestView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkContestExistence(contestId)) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val prevContest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - - if (contestView.isOpenEnded == false && !contestView.duration.isLocalTimeFormatted()) { - redirectAttributes.addPopupMessage("Время на выполнение Тура должно быть в формате HH:mm.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val contest = contestView.toEntity(timezone).also { - it.startTimesByStudentId.putAll(prevContest.startTimesByStudentId) - } - - if (contest.startDate.isAfter(contest.endDate)) { - redirectAttributes.addPopupMessage("Дата начала не может быть позже даты окончания.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (contestView.isOpenEnded == false && - (contest.startDate.isEqual(contest.endDate) || contest.duration == LocalTime.of(0, 0)) - ) { - redirectAttributes.addPopupMessage("Время на выполнение Тура должно быть положительным.") - return "redirect:$CONTEST_PATH/$contestId" - } - - contest.developer = webUser - val groups = contestService.find(contestId)?.groups ?: mutableSetOf() - contest.groups.addAll(groups) - - contestService.validate(contest, redirectAttributes, "redirect:$CONTEST_PATH/$contestId")?.let { return it } - - val updatedContest = contestService.save(contest) - - model.addAttribute(CONTEST_ATTR, updatedContest.toView(timezone)) - redirectAttributes.addPopupMessage("Данные успешно изменены.") - - return "redirect:$CONTEST_PATH/$contestId" - } - - @PostMapping("/switchVisibility/{contestId}") - fun contestSwitchVisibility( - @PathVariable("contestId") contestId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkContestExistence(contestId)) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - contest.switchVisibility() - - if (!contest.isPublic()) { - contest.groups.clear() - } - - contestService.save(contest) - - redirectAttributes.addPopupMessage("Видимость Тура '${contest.name}' изменена с '${contest.visibility.opposite()}' на '${contest.visibility}'.") - - return "redirect:$CONTESTS_PATH" - } - - @PostMapping("/linkTask/{contestId}") - fun linkTask( - @PathVariable("contestId") contestId: Long, - @RequestParam("taskId") taskId: Long, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkContestExistence(contestId)) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдена.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (task.developer != webUser) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдена.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!task.passedTests) { - redirectAttributes.addPopupMessage("Задание '${task.name}' не прошла тестирование. Прикрепление невозможно.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!task.hasExercise) { - redirectAttributes.addPopupMessage("Задание '${task.name}' не содержит Упражнение. Прикрепление невозможно.") - return "redirect:$CONTEST_PATH/$contestId" - } - - contest.tasks.add(task) - contestService.save(contest) - - task.contests.add(contest) - taskService.save(task) - - redirectAttributes.addPopupMessage("Задание '${task.name}' успешно добавлено в Тур '${contest.name}'.") - - return "redirect:$CONTEST_PATH/$contestId" - } - - @PostMapping("/unlinkTask/{contestId}") - fun unlinkTask( - @PathVariable("contestId") contestId: Long, - @RequestParam("taskId") taskId: Long, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkContestExistence(contestId)) { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур с ID $contestId не найден.") - return "redirect:$CONTESTS_PATH" - } - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдена.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (task.developer != webUser) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдена.") - return "redirect:$CONTEST_PATH/$contestId" - } - - contest.tasks.remove(task) - contestService.save(contest) - - task.contests.remove(contest) - taskService.save(task) - - redirectAttributes.addPopupMessage("Задание '${task.name}' успешно откреплено от Тура '${contest.name}'.") - - return "redirect:$CONTEST_PATH/$contestId" - } - - companion object { - - const val CONTEST_ATTR = "contest" - - const val CONTEST_PATH = "$CONTESTS_PATH/contest" - const val CONTEST_PAGE = "$DEVELOPER_PAGE/contest" - - const val LINKED_TASKS_ATTR = "linkedTasks" - const val UNLINKED_TASKS_ATTR = "unlinkedTasks" - - fun Developer.checkContestExistence(contestId: Long?) = contests.any { it.id == contestId } - - fun ContestService.validate(contest: Contest, redirectAttributes: RedirectAttributes, redirect: String): String? { - if (!validateName(contest)) { - redirectAttributes.addPopupMessage("Название Тура не должно содержать Код-доступа.") - return redirect - } - - if (!validateAdditionalInfo(contest)) { - redirectAttributes.addPopupMessage("Дополнительная информация не должна содержать Код-доступа.") - return redirect - } - - return null - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestsController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestsController.kt deleted file mode 100644 index 97526086..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperContestsController.kt +++ /dev/null @@ -1,62 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.user.developer.DeveloperContestController.Companion.CONTEST_ATTR -import trik.testsys.webclient.controller.impl.user.developer.DeveloperContestsController.Companion.CONTESTS_PATH -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.ContestCreationView -import trik.testsys.webclient.view.impl.ContestView.Companion.toView -import trik.testsys.webclient.view.impl.DeveloperView - -@Controller -@RequestMapping(CONTESTS_PATH) -class DeveloperContestsController( - loginData: LoginData -) : AbstractWebUserController(loginData) { - - override val mainPage = CONTESTS_PAGE - - override val mainPath = CONTESTS_PATH - - override fun Developer.toView(timeZoneId: String?) = DeveloperView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - creationDate = this.creationDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - contests = this.contests.map { it.toView(timeZoneId) }.sortedBy { it.id } - ) - - @GetMapping - fun contestsGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - model.addAttribute(CONTEST_ATTR, ContestCreationView.empty()) - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - - return CONTESTS_PAGE - } - - companion object { - - const val CONTESTS_PATH = "$DEVELOPER_PATH/contests" - const val CONTESTS_PAGE = "$DEVELOPER_PAGE/contests" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperMainController.kt deleted file mode 100644 index e949d5af..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperMainController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.* -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.* - - -@Controller -@RequestMapping(DeveloperMainController.DEVELOPER_PATH) -class DeveloperMainController( - loginData: LoginData -) : AbstractWebUserMainController(loginData) { - - override val mainPath = DEVELOPER_PATH - - override val mainPage = DEVELOPER_PAGE - - override fun Developer.toView(timeZoneId: String?) = DeveloperView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - creationDate = this.creationDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo - ) - - companion object { - - const val DEVELOPER_PATH = "/developer" - const val DEVELOPER_PAGE = "developer" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskController.kt deleted file mode 100644 index aca0c257..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskController.kt +++ /dev/null @@ -1,401 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.beans.factory.annotation.Value -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.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.impl.user.developer.DeveloperTaskFileController.Companion.checkTaskFileExistence -import trik.testsys.webclient.controller.impl.user.developer.DeveloperTasksController.Companion.TASKS_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.Grader -import trik.testsys.webclient.service.entity.impl.ContestService -import trik.testsys.webclient.service.entity.impl.SolutionService -import trik.testsys.webclient.service.entity.impl.TaskFileService -import trik.testsys.webclient.service.entity.impl.TaskService -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.DeveloperView -import trik.testsys.webclient.view.impl.TaskCreationView -import trik.testsys.webclient.view.impl.TaskFileView.Companion.toView -import trik.testsys.webclient.view.impl.TaskTestResultView.Companion.toTaskTestResultView -import trik.testsys.webclient.view.impl.TaskView -import trik.testsys.webclient.view.impl.TaskView.Companion.toView -import java.util.* -import javax.servlet.http.HttpServletRequest - -@Controller -@RequestMapping(DeveloperTaskController.TASK_PATH) -class DeveloperTaskController( - loginData: LoginData, - - private val taskService: TaskService, - private val taskFileService: TaskFileService, - private val solutionService: SolutionService, - private val contestService: ContestService, - - private val grader: Grader, - private val fileManager: FileManager, - - @Value("\${trik-studio-version}") private val trikStudioVersion: String -) : AbstractWebUserController(loginData) { - - override val mainPage = TASK_PAGE - - override val mainPath = TASK_PATH - - override fun Developer.toView(timeZoneId: String?) = TODO() - - @PostMapping("/create") - fun taskPost( - @ModelAttribute("task") taskView: TaskCreationView, - timeZone: TimeZone, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val task = taskView.toEntity(webUser) - - taskService.validate(task, redirectAttributes, "redirect:$TASKS_PATH")?.let { return it } - - taskService.save(task) - - redirectAttributes.addPopupMessage("Задание '${task.name}' успешно создана.") - - return "redirect:$TASKS_PATH" - } - - @GetMapping("/{taskId}") - fun taskGet( - @PathVariable("taskId") taskId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val taskView = task.toView(timezone) - model.addAttribute(TASK_ATTR, taskView) - - val taskFiles = webUser.taskFiles - .filter { it !in task.taskFiles } - .map { it.toView(timezone) } - .sortedBy { it.id } - model.addAttribute(TASK_FILES_ATTR, taskFiles) - - val taskTests = solutionService.findTaskTests(task) - model.addAttribute(TEST_RESULTS, taskTests.sortedByDescending { it.creationDate }.map { it.toTaskTestResultView(timezone) }) - - return TASK_PAGE - } - - @GetMapping("/testResult/{taskId}") - fun getTaskTestResult( - @PathVariable("taskId") taskId: Long, - @RequestParam("solutionId") solutionId: Long, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val solution = solutionService.find(solutionId) ?: run { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не найдено.") - return "redirect:$TASK_PATH/$taskId" - } - - if (solution.task != task || solution.student != null) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не принадлежит Заданию ${task.name}.") - return "redirect:$TASK_PATH/$taskId" - } - - if (solution.status == Solution.SolutionStatus.IN_PROGRESS) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId находится в процессе тестирования.") - return "redirect:$TASK_PATH/$taskId" - } - - val testResult = fileManager.getSolutionResultFilesCompressed(solution) - val bytes = testResult.readBytes() - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"${testResult.name}\"") - .header("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE) - .header("Content-Transfer-Encoding", "binary") - .header("Content-Length", bytes.size.toString()) - .body(bytes) - - return responseEntity - } - - @PostMapping("/update/{taskId}") - fun taskUpdate( - @PathVariable("taskId") taskId: Long, - @ModelAttribute("task") taskView: TaskView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val prevTask = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val task = taskView.toEntity(timezone).also { - if (prevTask.passedTests) { - it.pass() - } else { - it.fail() - } - - it.developer = webUser - } - - taskService.validate(task, redirectAttributes, "redirect:$TASK_PATH/$taskId")?.let { return it } - - val updatedTask = taskService.save(task) - - model.addAttribute(TASK_ATTR, updatedTask.toView(timezone)) - redirectAttributes.addPopupMessage("Данные успешно изменены.") - - return "redirect:$TASK_PATH/$taskId" - } - - @PostMapping("/attachTaskFile/{taskId}") - fun attachTaskFileToTask( - @PathVariable("taskId") taskId: Long, - @RequestParam("taskFileId") taskFileId: Long, - timeZone: TimeZone, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val taskFile = taskFileService.find(taskFileId) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_PATH/$taskId" - } - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_PATH/$taskId" - } - - if (taskFile.type == TaskFile.TaskFileType.SOLUTION && task.hasSolution) { - redirectAttributes.addPopupMessage("Задание ${task.name} уже имеет Эталонное решение.") - return "redirect:$TASK_PATH/$taskId" - } - - if (taskFile.type == TaskFile.TaskFileType.EXERCISE && task.hasExercise) { - redirectAttributes.addPopupMessage("Задание ${task.name} уже имеет Упражнение.") - return "redirect:$TASK_PATH/$taskId" - } - - if (taskFile.type == TaskFile.TaskFileType.CONDITION && task.hasCondition) { - redirectAttributes.addPopupMessage("Задание ${task.name} уже имеет Условие.") - return "redirect:$TASK_PATH/$taskId" - } - - if (taskFile.type.cannotBeRemovedOnTaskTesting()) { - task.contests.forEach { - it.tasks.remove(task) - contestService.save(it) - } - task.contests.clear() - - task.fail() - } - - taskFile.tasks.add(task) - taskFileService.save(taskFile) - - task.taskFiles.add(taskFile) - taskService.save(task) - - redirectAttributes.addPopupMessage("Файл ${taskFile.name} успешно прикреплен к заданию ${task.name}.") - - return "redirect:$TASK_PATH/$taskId" - } - - @PostMapping("/deAttachTaskFile/{taskId}") - fun deAttachTaskFileToTask( - @PathVariable("taskId") taskId: Long, - @RequestParam("taskFileId") taskFileId: Long, - timeZone: TimeZone, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - val taskFile = taskFileService.find(taskFileId) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_PATH/$taskId" - } - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_PATH/$taskId" - } - - val taskTest = solutionService.findTaskTests(task) - val isTaskTestingNow = taskTest.any { it.status == Solution.SolutionStatus.IN_PROGRESS } - - if (isTaskTestingNow && taskFile.type.cannotBeRemovedOnTaskTesting()) { - redirectAttributes.addPopupMessage("Тестирование Задания ${task.name} в процессе. Открепление Файла невозможно.") - return "redirect:$TASK_PATH/$taskId" - } - - if (taskFile.type.cannotBeRemovedOnTaskTesting()) { - task.contests.forEach { - it.tasks.remove(task) - contestService.save(it) - } - task.contests.clear() - - task.fail() - } - - taskFile.tasks.remove(task) - taskFileService.save(taskFile) - - task.taskFiles.remove(taskFile) - taskService.save(task) - - redirectAttributes.addPopupMessage("Файл ${taskFile.name} успешно откреплен от задания ${task.name}.") - - return "redirect:$TASK_PATH/$taskId" - } - - @PostMapping("/test/{taskId}") - fun testTask( - @PathVariable("taskId") taskId: Long, - timeZone: TimeZone, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val task = taskService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - if (!webUser.checkTaskExistence(taskId)) { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено.") - return "redirect:$TASKS_PATH" - } - - if (!task.hasSolution) { - redirectAttributes.addPopupMessage("Задание ${task.name} не имеет Эталонного решения. Тестирование невозможно.") - return "redirect:$TASK_PATH/$taskId" - } - - if (task.polygonsCount == 0L) { - redirectAttributes.addPopupMessage("Задание ${task.name} не имеет Полигонов. Тестирование невозможно.") - return "redirect:$TASK_PATH/$taskId" - } - - if (!task.hasExercise) { - redirectAttributes.addPopupMessage("Задание ${task.name} не имеет Упражнение. Тестирование невозможно.") - return "redirect:$TASK_PATH/$taskId" - } - - val solutionFile = fileManager.getTaskFile(task.solution!!) - val solution = Solution.qrsSolution(task).also { - it.additionalInfo = "Тестирование Эталонного решения '${task.solution?.id}: ${task.solution?.name}' Задания '${task.id}: ${task.name}'." - } - solutionService.save(solution) - - fileManager.saveSolutionFile(solution, solutionFile!!) - - grader.sendToGrade( - solution, - Grader.GradingOptions(true, trikStudioVersion) - ) - - redirectAttributes.addPopupMessage("Тестирование задания ${task.name} запущено.") - - return "redirect:$TASK_PATH/$taskId" - } - - companion object { - - const val TASK_PATH = "$TASKS_PATH/task" - const val TASK_PAGE = "$DEVELOPER_PAGE/task" - - const val TASK_ATTR = "task" - - const val TASK_FILES_ATTR = "taskFiles" - - const val TEST_RESULTS = "testResults" - - fun Developer.checkTaskExistence(taskId: Long?) = tasks.any { it.id == taskId } - - fun TaskService.validate(task: Task, redirectAttributes: RedirectAttributes, redirect: String): String? { - if (!validateName(task)) { - redirectAttributes.addPopupMessage("Название Задания не должно содержать Код-доступа.") - return redirect - } - - if (!validateAdditionalInfo(task)) { - redirectAttributes.addPopupMessage("Дополнительная информация не должна содержать Код-доступа.") - return redirect - } - - return null - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFileController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFileController.kt deleted file mode 100644 index 12c0bff9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFileController.kt +++ /dev/null @@ -1,261 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.* -import org.springframework.web.multipart.MultipartFile -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PATH -import trik.testsys.webclient.controller.impl.user.developer.DeveloperTaskFilesController.Companion.TASK_FILES_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.impl.TaskFile.TaskFileType.Companion.localized -import trik.testsys.webclient.entity.impl.TaskFileAudit -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.entity.impl.TaskFileAuditService -import trik.testsys.webclient.service.entity.impl.TaskFileService -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.DeveloperView -import trik.testsys.webclient.view.impl.TaskFileAuditCreationView -import trik.testsys.webclient.view.impl.TaskFileCreationView -import trik.testsys.webclient.view.impl.TaskFileView -import trik.testsys.webclient.view.impl.TaskFileView.Companion.toView -import java.util.* -import javax.servlet.http.HttpServletRequest - -@Controller -@RequestMapping(DeveloperTaskFileController.TASK_FILE_PATH) -class DeveloperTaskFileController( - loginData: LoginData, - - private val taskFileService: TaskFileService, - private val taskFileAuditService: TaskFileAuditService, - private val fileManager: FileManager -) : AbstractWebUserController(loginData) { - - override val mainPage = TASK_FILE_PAGE - - override val mainPath = TASK_FILE_PATH - - override fun Developer.toView(timeZoneId: String?) = TODO("Not yet implemented") - - @PostMapping("/create") - fun taskFilePost( - @ModelAttribute("taskFileView") taskFileView: TaskFileCreationView, - @RequestParam("file") file: MultipartFile, - timeZone: TimeZone, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val taskFile = taskFileView.toEntity(webUser) - - taskFileService.validate(taskFile, redirectAttributes, "redirect:$TASK_FILES_PATH")?.let { return it } - taskFileService.save(taskFile) - - val taskFileAudit = TaskFileAudit(taskFile) - taskFileAuditService.save(taskFileAudit) - - val fileSavingResult = fileManager.saveTaskFile(taskFileAudit, file) - if (!fileSavingResult) { - taskFileAuditService.delete(taskFileAudit) - taskFileService.delete(taskFile) - redirectAttributes.addPopupMessage("Ошибка при сохранении файла.") - return "redirect:$TASK_FILES_PATH" - } - - redirectAttributes.addPopupMessage("${taskFile.type.localized()} ${taskFile.name} успешно создан.") - - val anchor = when (taskFile.type) { - TaskFile.TaskFileType.POLYGON -> "" - TaskFile.TaskFileType.EXERCISE -> "exercises" - TaskFile.TaskFileType.SOLUTION -> "solutions" - TaskFile.TaskFileType.CONDITION -> "conditions" - } - - return "redirect:$TASK_FILES_PATH#$anchor" - } - - @GetMapping("/{taskFileId}") - fun taskFileGet( - @PathVariable("taskFileId") taskFileId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val taskFile = taskFileService.find(taskFileId) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val taskFileView = taskFile.toView(timezone) - model.addAttribute(TASK_FILE_ATTR, taskFileView) - - model.addAttribute(TASK_FILE_AUDIT_ATTR, TaskFileAuditCreationView.empty()) - - return TASK_FILE_PAGE - } - - @GetMapping("/download/{taskFileId}") - fun taskFileDownload( - @PathVariable("taskFileId") taskFileId: Long, - redirectAttributes: RedirectAttributes - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val taskFile = taskFileService.find(taskFileId) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val file = fileManager.getTaskFile(taskFile) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val extension = fileManager.getTaskFileExtension(taskFile) - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${taskFile.latestFileName}${extension}") - .body(file.readBytes()) - - return responseEntity - } - - @PostMapping("/updateFile/{taskFileId}") - fun updateFile( - @PathVariable("taskFileId") taskFileId: Long, - @RequestParam("file") file: MultipartFile, - @ModelAttribute("taskFileAudit") taskFileAuditView: TaskFileAuditCreationView, - redirectAttributes: RedirectAttributes - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$DEVELOPER_PATH$TASK_FILES_PATH" - } - - val taskFile = taskFileService.find(taskFileId) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$DEVELOPER_PATH$TASK_FILES_PATH" - } - - if (taskFile.tasks.any { it.contests.any { contest -> contest.isPublic() } }) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не может быть изменен, так как он используется в публичных контестах.") - return "redirect:$TASK_FILE_PATH/$taskFileId" - } - - val taskFileAudit = taskFileAuditView.toEntity(taskFile) - taskFileAuditService.save(taskFileAudit) - - val fileSavingResult = fileManager.saveTaskFile(taskFileAudit, file) - if (!fileSavingResult) { - taskFileAuditService.delete(taskFileAudit) - redirectAttributes.addPopupMessage("Ошибка при сохранении файла.") - return "redirect:$TASK_FILE_PATH/$taskFileId" - } - - redirectAttributes.addPopupMessage("Файл успешно обновлен.") - - return "redirect:$TASK_FILE_PATH/$taskFileId" - } - - @GetMapping("/downloadAudit/{taskFileAuditId}") - fun downloadAudit( - @PathVariable("taskFileAuditId") taskFileAuditId: Long, - redirectAttributes: RedirectAttributes - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val taskFileAudit = taskFileAuditService.find(taskFileAuditId) ?: run { - redirectAttributes.addPopupMessage("Аудит с ID $taskFileAuditId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val file = fileManager.getTaskFileAuditFile(taskFileAudit) ?: run { - redirectAttributes.addPopupMessage("Файл с ID $taskFileAuditId не найден.") - return "redirect:$TASK_FILES_PATH" - } - - val extension = fileManager.getTaskFileExtension(taskFileAudit.taskFile) - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${taskFileAudit.fileName}${extension}") - .body(file.readBytes()) - - return responseEntity - } - - @PostMapping("/update/{taskFileId}") - fun taskFileUpdate( - @PathVariable("taskFileId") taskFileId: Long, - @ModelAttribute("taskFile") taskFileView: TaskFileView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (!webUser.checkTaskFileExistence(taskFileId)) { - redirectAttributes.addPopupMessage("Файл с ID $taskFileId не найден.") - return "redirect:$DEVELOPER_PATH$TASK_FILES_PATH" - } - - val taskFile = taskFileView.toEntity(timezone) - taskFile.developer = webUser - - taskFileService.validate(taskFile, redirectAttributes, "redirect:$TASK_FILE_PATH/$taskFileId")?.let { return it } - - val updatedTaskFile = taskFileService.save(taskFile) - - model.addAttribute(TASK_FILE_ATTR, updatedTaskFile.toView(timezone)) - redirectAttributes.addPopupMessage("Данные успешно изменены.") - - return "redirect:$TASK_FILE_PATH/$taskFileId" - } - - companion object { - - const val TASK_FILE_PATH = "$TASK_FILES_PATH/taskFile" - const val TASK_FILE_PAGE = "$DEVELOPER_PAGE/taskFile" - - const val TASK_FILE_ATTR = "taskFile" - - const val TASK_FILE_AUDIT_ATTR = "taskFileAudit" - - fun Developer.checkTaskFileExistence(taskFileId: Long?) = taskFiles.any { it.id == taskFileId } - - fun TaskFileService.validate(taskFile: TaskFile, redirectAttributes: RedirectAttributes, redirect: String): String? { - if (!validateName(taskFile)) { - val taskTileTypeLocalized = taskFile.type.localized() - redirectAttributes.addPopupMessage("Название Файла с типом '$taskTileTypeLocalized' не должно содержать Код-доступа.") - return redirect - } - - if (!validateAdditionalInfo(taskFile)) { - redirectAttributes.addPopupMessage("Дополнительная информация не должна содержать Код-доступа.") - return redirect - } - - return null - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFilesController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFilesController.kt deleted file mode 100644 index 845de122..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTaskFilesController.kt +++ /dev/null @@ -1,72 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.DeveloperView -import trik.testsys.webclient.view.impl.TaskFileCreationView -import trik.testsys.webclient.view.impl.TaskFileView.Companion.toView - -@Controller -@RequestMapping(DeveloperTaskFilesController.TASK_FILES_PATH) -class DeveloperTaskFilesController( - loginData: LoginData -) : AbstractWebUserController(loginData){ - - override val mainPage = TASK_FILES_PAGE - - override val mainPath = TASK_FILES_PATH - - override fun Developer.toView(timeZoneId: String?) = DeveloperView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - creationDate = this.creationDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - polygons = this.polygons.map { it.toView(timeZoneId) }.sortedBy { it.id }, - exercises = this.exercises.map { it.toView(timeZoneId) }.sortedBy { it.id }, - solutions = this.solutions.map { it.toView(timeZoneId) }.sortedBy { it.id }, - conditions = this.conditions.map { it.toView(timeZoneId) }.sortedBy { it.id } - ) - - @GetMapping - fun taskFilesGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - model.addAttribute(TASK_FILE_POLYGON_ATTR, TaskFileCreationView.emptyPolygon()) - model.addAttribute(TASK_FILE_EXERCISE_ATTR, TaskFileCreationView.emptyExercise()) - model.addAttribute(TASK_FILE_SOLUTION_ATTR, TaskFileCreationView.emptySolution()) - model.addAttribute(TASK_FILE_CONDITION_ATTR, TaskFileCreationView.emptyCondition()) - - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - - return TASK_FILES_PAGE - } - - companion object { - - const val TASK_FILES_PATH = "$DEVELOPER_PATH/taskFiles" - const val TASK_FILES_PAGE = "$DEVELOPER_PAGE/taskFiles" - - const val TASK_FILE_POLYGON_ATTR = "polygon" - const val TASK_FILE_EXERCISE_ATTR = "exercise" - const val TASK_FILE_SOLUTION_ATTR = "solution" - const val TASK_FILE_CONDITION_ATTR = "condition" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTasksController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTasksController.kt deleted file mode 100644 index a4af7b1b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/developer/DeveloperTasksController.kt +++ /dev/null @@ -1,61 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.developer - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PAGE -import trik.testsys.webclient.controller.impl.user.developer.DeveloperMainController.Companion.DEVELOPER_PATH -import trik.testsys.webclient.controller.impl.user.developer.DeveloperTaskController.Companion.TASK_ATTR -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.controller.user.AbstractWebUserMainController.Companion.LOGIN_PATH -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.ContestCreationView -import trik.testsys.webclient.view.impl.DeveloperView -import trik.testsys.webclient.view.impl.TaskView.Companion.toView - -@Controller -@RequestMapping(DeveloperTasksController.TASKS_PATH) -class DeveloperTasksController( - loginData: LoginData -) : AbstractWebUserController(loginData) { - - override val mainPage = TASKS_PAGE - - override val mainPath = TASKS_PATH - - override fun Developer.toView(timeZoneId: String?) = DeveloperView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - tasks = this.tasks.map { it.toView(timeZoneId) }.sortedBy { it.id } - ) - - @GetMapping - fun tasksGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - model.addAttribute(TASK_ATTR, ContestCreationView.empty()) - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - - return TASKS_PAGE - } - - companion object { - - const val TASKS_PATH = "$DEVELOPER_PATH/tasks" - const val TASKS_PAGE = "$DEVELOPER_PAGE/tasks" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeMainController.kt deleted file mode 100644 index 2f6cd8e4..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeMainController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.judge - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.RequestMapping -import trik.testsys.webclient.controller.impl.user.judge.JudgeMainController.Companion.JUDGE_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.service.entity.user.impl.JudgeService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.JudgeView - -@Controller -@RequestMapping(JUDGE_PATH) -class JudgeMainController( - loginData: LoginData -) : AbstractWebUserMainController(loginData) { - - override val mainPath = JUDGE_PATH - - override val mainPage = JUDGE_PAGE - - override fun Judge.toView(timeZoneId: String?) = JudgeView( - id = id, - name = name, - accessToken = accessToken, - creationDate = creationDate?.atTimeZone(timeZoneId), - lastLoginDate = lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = additionalInfo - ) - - companion object { - - const val JUDGE_PATH = "/judge" - const val JUDGE_PAGE = "judge" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentController.kt deleted file mode 100644 index 5132c8be..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentController.kt +++ /dev/null @@ -1,307 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.judge - -import org.springframework.beans.factory.annotation.Value -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.main.LoginController.Companion.LOGIN_PATH -import trik.testsys.webclient.controller.impl.user.judge.JudgeMainController.Companion.JUDGE_PAGE -import trik.testsys.webclient.controller.impl.user.judge.JudgeStudentController.Companion.STUDENT_PATH -import trik.testsys.webclient.controller.impl.user.judge.JudgeStudentsController.Companion.STUDENTS_PAGE -import trik.testsys.webclient.controller.impl.user.judge.JudgeStudentsController.Companion.STUDENTS_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.Grader -import trik.testsys.webclient.service.entity.impl.SolutionService -import trik.testsys.webclient.service.entity.impl.SolutionVerdictService -import trik.testsys.webclient.service.entity.user.impl.JudgeService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.* -import trik.testsys.webclient.view.impl.SolutionVerdictView.Companion.toView -import trik.testsys.webclient.view.impl.StudentView.Companion.toView -import trik.testsys.webclient.view.impl.TaskTestResultView.Companion.toTaskTestResultView - -@Controller -@RequestMapping(STUDENT_PATH) -class JudgeStudentController( - loginData: LoginData, - - private val studentService: StudentService, - private val solutionService: SolutionService, - private val solutionVerdictService: SolutionVerdictService, - - private val fileManager: FileManager, - private val grader: Grader, - - @Value("\${trik-studio-version}") private val trikStudioVersion: String -) : AbstractWebUserController(loginData) { - - override val mainPath = STUDENTS_PATH - - override val mainPage = STUDENTS_PAGE - - override fun Judge.toView(timeZoneId: String?) = TODO() - - @GetMapping("/{studentId}") - fun studentGet( - @PathVariable studentId: Long, - @ModelAttribute("studentFilter") filter: StudentSolutionFilter, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - model.addAttribute(STUDENT_ATTR, student.toView(timezone)) - - val verdicts = solutionVerdictService.findByStudent(student) - val verdictsTasks = verdicts.map { it.task } - model.addAttribute(VERDICTS_ATTR, verdicts.map { it.toView(timezone) }.sortedByDescending { it.creationDate }) - - val solutions = student.solutions.asSequence() - .filter { filter.taskId?.let { taskId -> it.task.id == taskId } ?: true } - .filter { filter.solutionId?.let { solutionId -> it.id == solutionId } ?: true } - .map { it.toTaskTestResultView(timezone) } - .toList() - .sortedByDescending { it.creationDate } - - val tasks = student.solutions - .map { it.task } - .filter { it !in verdictsTasks } - .toSet().sortedBy { it.id }.toList() - model.addAttribute(TASKS_ATTR, tasks) - - if (solutions.isEmpty()) { - model.addAttribute("message", "По заданному фильтру Решений не найдено") - model.addAttribute(SOLUTIONS_ATTR, emptyList()) - return STUDENT_PAGE - } - - model.addAttribute(SOLUTIONS_ATTR, solutions) - - return STUDENT_PAGE - } - - @PostMapping("/rerunSolution/{studentId}") - fun rerunStudentSolution( - @PathVariable studentId: Long, - @RequestParam solutionId: Long, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - - val solution = solutionService.find(solutionId) ?: run { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не найдено") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (student.solutions.none { it.id == solutionId }) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не принадлежит участнику с ID $studentId") - return "redirect:$STUDENT_PATH/$studentId" - } - - val file = fileManager.getSolutionFile(solution)!! - val newSolution = Solution.qrsSolution(solution.task).also { - it.student = solution.student - it.additionalInfo = "Перезапуск посылки с ID ${solution.id} Судьей ${webUser.id}: ${webUser.name}" - } - solutionService.save(newSolution) - fileManager.saveSolutionFile(newSolution, file) - - grader.sendToGrade( - newSolution, - Grader.GradingOptions(true, trikStudioVersion) - ) - - redirectAttributes.addPopupMessage("Посылка с ID $solutionId перезапущена") - - return "redirect:$STUDENT_PATH/$studentId" - } - - @GetMapping("/downloadSolution/{studentId}") - fun downloadStudentSolution( - @PathVariable studentId: Long, - @RequestParam solutionId: Long, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - - val solution = solutionService.find(solutionId) ?: run { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не найдено") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (student.solutions.none { it.id == solutionId }) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не принадлежит участнику с ID $studentId") - return "redirect:$STUDENT_PATH/$studentId" - } - - val file = fileManager.getSolutionFile(solution)!! - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${file.name}") - .header("Content-Type", "application/octet-stream") - .header("Content-Length", file.length().toString()) - .body(file.readBytes()) - - return responseEntity - } - - @GetMapping("/downloadResults/{studentId}") - fun downloadStudentResults( - @PathVariable studentId: Long, - @RequestParam solutionId: Long, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - - val solution = solutionService.find(solutionId) ?: run { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не найдено") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (student.solutions.none { it.id == solutionId }) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId не принадлежит участнику с ID $studentId") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (solution.status == Solution.SolutionStatus.IN_PROGRESS || solution.status == Solution.SolutionStatus.NOT_STARTED) { - redirectAttributes.addPopupMessage("Решение с ID $solutionId еще не проверено") - return "redirect:$STUDENT_PATH/$studentId" - } - - val file = fileManager.getSolutionResultFilesCompressed(solution) - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${file.name}") - .header("Content-Type", "application/octet-stream") - .header("Content-Length", file.length().toString()) - .body(file.readBytes()) - - return responseEntity - } - - @PostMapping("/addVerdict/{studentId}") - fun addVerdict( - @PathVariable studentId: Long, - @RequestParam taskId: Long, - @RequestParam score: Long, - @RequestParam additionalInfo: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - - val task = student.solutions.firstOrNull { it.task.id == taskId }?.task ?: run { - redirectAttributes.addPopupMessage("Задание с ID $taskId не найдено у участника с ID $studentId") - return "redirect:$STUDENT_PATH/$studentId" - } - - val solutionVerdict = solutionVerdictService.findByStudentAndTask(student, task) - .firstOrNull()?.also { - it.score = score - it.additionalInfo = additionalInfo - } - ?: SolutionVerdict( - name = "Вердикт для Участника '${student.id}: ${student.name}' по Заданию '${task.id}: ${task.name}'", - judge = webUser, - student = student, - task = task, - contest = task.contests.firstOrNull(), - score = score - ).also { it.additionalInfo = additionalInfo } - solutionVerdictService.save(solutionVerdict) - - redirectAttributes.addPopupMessage("Вердикт добавлен") - - return "redirect:$STUDENT_PATH/$studentId" - } - - @PostMapping("/deleteVerdict/{studentId}") - fun deleteVerdict( - @PathVariable studentId: Long, - @RequestParam verdictId: Long, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - val student = studentService.find(studentId) ?: run { - redirectAttributes.addPopupMessage("Участник с ID $studentId не найден") - return "redirect:$STUDENTS_PATH" - } - - val solutionVerdict = solutionVerdictService.find(verdictId) ?: run { - redirectAttributes.addPopupMessage("Вердикт с ID $verdictId не найден") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (solutionVerdict.judge.id != webUser.id) { - redirectAttributes.addPopupMessage("Вердикт с ID $verdictId не принадлежит судье с ID ${webUser.id}") - return "redirect:$STUDENT_PATH/$studentId" - } - - if (solutionVerdict.student.id != studentId) { - redirectAttributes.addPopupMessage("Вердикт с ID $verdictId не принадлежит участнику с ID $studentId") - return "redirect:$STUDENT_PATH/$studentId" - } - - solutionVerdictService.delete(solutionVerdict) - - redirectAttributes.addPopupMessage("Вердикт удален") - - return "redirect:$STUDENT_PATH/$studentId" - } - - companion object { - - const val STUDENT_PATH = "$STUDENTS_PATH/student" - const val STUDENT_PAGE = "$JUDGE_PAGE/student" - - const val STUDENT_ATTR = "student" - const val SOLUTIONS_ATTR = "solutions" - const val VERDICTS_ATTR = "verdicts" - - const val TASKS_ATTR = "tasks" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentsController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentsController.kt deleted file mode 100644 index 63854eca..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/judge/JudgeStudentsController.kt +++ /dev/null @@ -1,87 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.judge - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController.Companion.LOGIN_PATH -import trik.testsys.webclient.controller.impl.user.judge.JudgeMainController.Companion.JUDGE_PAGE -import trik.testsys.webclient.controller.impl.user.judge.JudgeMainController.Companion.JUDGE_PATH -import trik.testsys.webclient.controller.impl.user.judge.JudgeStudentsController.Companion.STUDENTS_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.service.entity.user.impl.JudgeService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.view.impl.JudgeView -import trik.testsys.webclient.view.impl.StudentFilter -import trik.testsys.webclient.view.impl.StudentView -import trik.testsys.webclient.view.impl.StudentView.Companion.toView - -@Controller -@RequestMapping(STUDENTS_PATH) -class JudgeStudentsController( - loginData: LoginData, - - private val studentService: StudentService -) : AbstractWebUserController(loginData) { - - override val mainPath = JUDGE_PATH - - override val mainPage = JUDGE_PAGE - - override fun Judge.toView(timeZoneId: String?) = TODO() - - @GetMapping - fun studentsGetFiltered( - @ModelAttribute("studentFilter") filter: StudentFilter, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - loginData.validate(redirectAttributes) ?: return "redirect:$LOGIN_PATH" - - if (filter.isEmpty()) { - model.addAttribute(STUDENT_FILTER_ATTR, StudentFilter.empty()) - model.addAttribute(STUDENTS_ATTR, emptyList()) - - return STUDENTS_PAGE - } - - model.addAttribute(STUDENT_FILTER_ATTR, filter) - - val students = studentService.findAll().asSequence() - .filter { filter.studentId?.let { studentId -> it.id == studentId } ?: true } - .filter { filter.groupId?.let { groupId -> it.group.id == groupId } ?: true } - .filter { filter.adminId?.let { adminId -> it.group.admin.id == adminId } ?: true } - .filter { filter.contestId?.let { contestId -> it.group.contests.any { contest -> contest.id == contestId } } ?: true } - .filter { filter.solutionId?.let { solutionId -> it.solutions.any { solution -> solution.id == solutionId } } ?: true } - .filter { filter.taskId?.let { taskId -> it.solutions.any { solution -> solution.task.id == taskId } } ?: true } - .toList() - - if (students.isEmpty()) { - model.addAttribute("message", "По заданному фильтру Участников не найдено") - - model.addAttribute(STUDENTS_ATTR, emptyList()) - return STUDENTS_PAGE - } - - model.addAttribute("message", "Найдено ${students.size} Участников") - model.addAttribute(STUDENTS_ATTR, students.map { it.toView(timezone) }) - - return STUDENTS_PAGE - } - - companion object { - - const val STUDENTS_PATH = "$JUDGE_PATH/students" - const val STUDENTS_PAGE = "$JUDGE_PAGE/students" - - const val STUDENTS_ATTR = "students" - - const val STUDENT_FILTER_ATTR = "studentFilter" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestController.kt deleted file mode 100644 index e3b63aeb..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestController.kt +++ /dev/null @@ -1,374 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.student - -import org.springframework.beans.factory.annotation.Value -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.* -import org.springframework.web.multipart.MultipartFile -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.student.StudentContestsController.Companion.CONTESTS_PATH -import trik.testsys.webclient.controller.impl.user.student.StudentMainController.Companion.STUDENT_PAGE -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.Grader -import trik.testsys.webclient.service.entity.impl.ContestService -import trik.testsys.webclient.service.entity.impl.SolutionService -import trik.testsys.webclient.service.entity.impl.TaskService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.StudentContestView.Companion.toStudentView -import trik.testsys.webclient.view.impl.StudentView -import trik.testsys.webclient.view.impl.TaskTestResultView.Companion.toTaskTestResultView -import trik.testsys.webclient.view.impl.TaskView.Companion.toView - -@Controller -@RequestMapping(StudentContestController.CONTEST_PATH) -class StudentContestController( - loginData: LoginData, - - private val studentService: StudentService, - private val tasksService: TaskService, - private val solutionService: SolutionService, - private val contestService: ContestService, - - private val fileManager: FileManager, - private val grader: Grader, - - @Value("\${trik-studio-version}") private val trikStudioVersion: String -) : AbstractWebUserController(loginData) { - - override val mainPage = CONTEST_PAGE - - override val mainPath = CONTEST_PATH - - override fun Student.toView(timeZoneId: String?) = TODO() - - @GetMapping("/start/{contestId}") - fun contestStart( - @PathVariable contestId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.group.isContestAvailable(contestId)) { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val contest = webUser.group.contests.first { it.id == contestId } - if (!contest.isGoingOn()) { - redirectAttributes.addPopupMessage("Тур '${contest.name}' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - if (webUser.startTimesByContestId.keys.contains(contest.id)) { - return "redirect:$CONTEST_PATH/$contestId" - } - - webUser.startContest(contest) - studentService.save(webUser) - - redirectAttributes.addPopupMessage("Тур '${contest.name}' начат.") - - return "redirect:$CONTEST_PATH/$contestId" - } - - @GetMapping("/{contestId}") - fun contestGet( - @PathVariable contestId: Long, - @RequestParam(name = "outdated", defaultValue = "false") isOutdated: Boolean, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (isOutdated) { - if (!webUser.startTimesByContestId.keys.contains(contestId)) { - return "redirect:$CONTEST_PATH/start/$contestId" - } - - val outdatedContest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не найден.") - return "redirect:$CONTESTS_PATH" - } - - if (!outdatedContest.isOutdatedFor(webUser)) { - redirectAttributes.addPopupMessage("Тур '${outdatedContest.name}' не закончен.") - return "redirect:$CONTEST_PATH/$contestId" - } - - model.addAttribute(CONTEST_ATTR, outdatedContest.toStudentView(timezone)) - val outdatedSolutions = solutionService.findByStudentAndContest(webUser, outdatedContest) - .sortedByDescending { it.creationDate } - .map { it.toTaskTestResultView(timezone) } - model.addAttribute(SOLUTIONS_ATTR, outdatedSolutions) - - return CONTEST_PAGE - } - - if (!webUser.group.isContestAvailable(contestId)) { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val contest = webUser.group.contests.first { it.id == contestId } - if (!contest.isGoingOn()) { - redirectAttributes.addPopupMessage("Тур '${contest.name}' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - if (!webUser.startTimesByContestId.keys.contains(contest.id)) { - return "redirect:$CONTEST_PATH/start/$contestId" - } - - val lastTime = webUser.remainingTimeFor(contest) - - if (lastTime.toSecondOfDay() < 1) { - redirectAttributes.addPopupMessage("Время на участие в Туре истекло.") - return "redirect:$CONTESTS_PATH" - } - - model.addAttribute(CONTEST_ATTR, contest.toStudentView(timezone, lastTime)) - - val tasksView = contest.tasks - .sortedBy { it.id } - .map { it.toView(timezone) } - model.addAttribute(TASKS_ATTR, tasksView) - - val solutions = solutionService.findByStudentAndContest(webUser, contest) - .sortedByDescending { it.creationDate } - .map { it.toTaskTestResultView(timezone) } - model.addAttribute(SOLUTIONS_ATTR, solutions) - - return CONTEST_PAGE - } - - @GetMapping("/downloadResults/{contestId}") - fun downloadTaskResults( - @PathVariable contestId: Long, - @RequestParam solutionId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val contest = contestService.find(contestId) ?: run { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не найден.") - return "redirect:$CONTESTS_PATH" - } - - val solution = solutionService.find(solutionId) ?: run { - redirectAttributes.addPopupMessage("Решение c ID '$solutionId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (solution.student?.id != webUser.id) { - redirectAttributes.addPopupMessage("Решение c ID '$solutionId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val task = solution.task - - if (contest.tasks.none { it.id == task.id }) { - redirectAttributes.addPopupMessage("Решение c ID '$solutionId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val results = fileManager.getSolutionResultFilesCompressed(solution) - val bytes = results.readBytes() - - val responseEntity = 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) - - return responseEntity - } - - @GetMapping("/downloadExercise/{contestId}") - fun downloadExercise( - @PathVariable contestId: Long, - @RequestParam taskId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.group.isContestAvailable(contestId)) { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val contest = webUser.group.contests.first { it.id == contestId } - if (!contest.isGoingOn()) { - redirectAttributes.addPopupMessage("Тур '${contest.name}' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val task = tasksService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (contest.tasks.none { it.id == task.id }) { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не доступно.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!webUser.startTimesByContestId.keys.contains(contest.id)) { - return "redirect:$CONTEST_PATH/start/$contestId" - } - - if (webUser.remainingTimeFor(contest).toSecondOfDay() < 1) { - redirectAttributes.addPopupMessage("Время на решение Задания истекло.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val exercise = fileManager.getTaskFile(task.exercise!!)!! - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${task.id}.qrs") - .body(exercise.readBytes()) - - return responseEntity - } - - @GetMapping("/downloadCondition/{contestId}") - fun downloadCondition( - @PathVariable contestId: Long, - @RequestParam taskId: Long, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.group.isContestAvailable(contestId)) { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val contest = webUser.group.contests.first { it.id == contestId } - if (!contest.isGoingOn()) { - redirectAttributes.addPopupMessage("Тур '${contest.name}' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val task = tasksService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (contest.tasks.none { it.id == task.id }) { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не доступно.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!webUser.startTimesByContestId.keys.contains(contest.id)) { - return "redirect:$CONTEST_PATH/start/$contestId" - } - - if (webUser.remainingTimeFor(contest).toSecondOfDay() < 1) { - redirectAttributes.addPopupMessage("Время на решение Задания истекло.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!task.hasCondition) { - redirectAttributes.addPopupMessage("У задания '${task.name}' нет Условия.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val condition = fileManager.getTaskFile(task.condition!!)!! - - val responseEntity = ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${task.id}.pdf") - .body(condition.readBytes()) - - return responseEntity - } - - - @PostMapping("/submitSolution/{contestId}") - fun submitSolution( - @PathVariable contestId: Long, - @RequestParam taskId: Long, - @RequestParam("file") file: MultipartFile, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (!webUser.group.isContestAvailable(contestId)) { - redirectAttributes.addPopupMessage("Тур c ID '$contestId' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val contest = webUser.group.contests.first { it.id == contestId } - if (!contest.isGoingOn()) { - redirectAttributes.addPopupMessage("Тур '${contest.name}' не доступен.") - return "redirect:$CONTESTS_PATH" - } - - val task = tasksService.find(taskId) ?: run { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не найдено.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (contest.tasks.none { it.id == task.id }) { - redirectAttributes.addPopupMessage("Задание c ID '$taskId' не доступно.") - return "redirect:$CONTEST_PATH/$contestId" - } - - if (!webUser.startTimesByContestId.keys.contains(contest.id)) { - return "redirect:$CONTEST_PATH/start/$contestId" - } - - if (webUser.remainingTimeFor(contest).toSecondOfDay() < 1) { - redirectAttributes.addPopupMessage("Время на решение Задания истекло.") - return "redirect:$CONTEST_PATH/$contestId" - } - - val solution = Solution.qrsSolution(task).also { - it.student = webUser - it.additionalInfo = "Оригинальное решение Участника '${webUser.id}: ${webUser.name}' для Задания '${task.id}: ${task.name}'." - } - solutionService.save(solution) - - fileManager.saveSolutionFile(solution, file) - grader.sendToGrade( - solution, - Grader.GradingOptions(true, trikStudioVersion) - ) - - redirectAttributes.addPopupMessage("Решение отправлено.") - - return "redirect:$CONTEST_PATH/$contestId" - } - - companion object { - - const val CONTEST_PATH = "$CONTESTS_PATH/contest" - const val CONTEST_PAGE = "$STUDENT_PAGE/contest" - - const val CONTEST_ATTR = "contest" - const val TASKS_ATTR = "tasks" - const val SOLUTIONS_ATTR = "solutions" - - fun Group.isContestAvailable(contestId: Long) = contests.any { it.id == contestId } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestsController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestsController.kt deleted file mode 100644 index 33392f51..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentContestsController.kt +++ /dev/null @@ -1,63 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.student - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.student.StudentMainController.Companion.STUDENT_PAGE -import trik.testsys.webclient.controller.impl.user.student.StudentMainController.Companion.STUDENT_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.service.entity.impl.ContestService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.view.impl.StudentContestView.Companion.toStudentView -import trik.testsys.webclient.view.impl.StudentView - -@Controller -@RequestMapping(StudentContestsController.CONTESTS_PATH) -class StudentContestsController( - loginData: LoginData, - - private val contestService: ContestService -) : AbstractWebUserController(loginData) { - - override val mainPage = CONTESTS_PAGE - - override val mainPath = CONTESTS_PATH - - override fun Student.toView(timeZoneId: String?) = TODO() - - @GetMapping - fun contestsGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val groupContests = webUser.group.contests - val goingOnContests = groupContests.filter { !it.isOutdatedFor(webUser) && it.isGoingOn() } - - model.addAttribute(CONTESTS_ATTR, goingOnContests.map { it.toStudentView(timezone, webUser.remainingTimeFor(it)) }.sortedBy { it.id }) - - val allContests = contestService.findAll() - val outdatedStudentContests = allContests.filter { it.isOutdatedFor(webUser) && webUser.startTimesByContestId.containsKey(it.id!!) } - - model.addAttribute(OUTDATED_CONTESTS_ATTR, outdatedStudentContests.map { it.toStudentView(timezone) }.sortedBy { it.id }) - - return CONTESTS_PAGE - } - - companion object { - - const val CONTESTS_PATH = "$STUDENT_PATH/contests" - const val CONTESTS_PAGE = "$STUDENT_PAGE/contests" - - const val CONTESTS_ATTR = "contests" - const val OUTDATED_CONTESTS_ATTR = "outdatedContests" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentMainController.kt deleted file mode 100644 index 2441b779..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/student/StudentMainController.kt +++ /dev/null @@ -1,38 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.student - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.* -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.StudentView - - -@Controller -@RequestMapping(StudentMainController.STUDENT_PATH) -class StudentMainController( - loginData: LoginData, -) : AbstractWebUserMainController(loginData) { - - override val mainPath = STUDENT_PATH - - override val mainPage = STUDENT_PAGE - - override fun Student.toView(timeZoneId: String?) = StudentView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - group = this.group - ) - - companion object { - - internal const val STUDENT_PATH = "/student" - internal const val STUDENT_PAGE = "student" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/DeveloperUsersController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/DeveloperUsersController.kt deleted file mode 100644 index 02094664..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/DeveloperUsersController.kt +++ /dev/null @@ -1,160 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.superuser - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserMainController.Companion.SUPER_USER_PAGE -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserMainController.Companion.SUPER_USER_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.entity.user.impl.* -import trik.testsys.webclient.service.entity.user.impl.* -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.service.token.access.AccessTokenGenerator -import trik.testsys.webclient.service.token.reg.RegTokenGenerator -import trik.testsys.webclient.service.token.reg.impl.GroupRegTokenGenerator -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.SuperUserView -import trik.testsys.webclient.view.impl.UserCreationView - -/** - * @author Roman Shishkin - * @since 2.2.0 - */ -@Controller -@RequestMapping(DeveloperUsersController.USERS_PATH) -class DeveloperUsersController( - loginData: LoginData, - - private val viewerService: ViewerService, - private val developerService: DeveloperService, - private val adminService: AdminService, - private val judgeService: JudgeService, - @Qualifier("webUserAccessTokenGenerator") private val webUserAccessTokenGenerator: AccessTokenGenerator, - @Qualifier("adminRegTokenGenerator") private val adminRegTokenGenerator: RegTokenGenerator, -) : AbstractWebUserController(loginData) { - - override val mainPage = USERS_PAGE - - override val mainPath = USERS_PATH - - override fun SuperUser.toView(timeZoneId: String?) = SuperUserView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - viewers = viewerService.findAll().sortedBy { it.id }, - developers = developerService.findAll().sortedBy { it.id }, - admins = adminService.findAll().sortedBy { it.id }, - judges = judgeService.findAll().sortedBy { it.id } - ) - - @GetMapping - fun usersGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - model.addAttribute(VIEWER_ATTR, UserCreationView.emptyViewer()) - model.addAttribute(DEVELOPER_ATTR, UserCreationView.emptyDeveloper()) - model.addAttribute(ADMIN_ATTR, UserCreationView.emptyAdmin()) - model.addAttribute(JUDGE_ATTR, UserCreationView.emptyJudge()) - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - - return USERS_PAGE - } - - @PostMapping("/create") - fun userPost( - @ModelAttribute("userCreationView") userCreationView: UserCreationView, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val accessToken = webUserAccessTokenGenerator.generate(userCreationView.name) - - when (userCreationView.type) { - WebUser.UserType.VIEWER -> { - val regToken = adminRegTokenGenerator.generate(userCreationView.name) - val viewer = Viewer( - userCreationView.name, accessToken, regToken - ).also { - it.additionalInfo = userCreationView.additionalInfo - } - - viewerService.save(viewer) - redirectAttributes.addPopupMessage("Наблюдатель ${viewer.name} успешно создан.") - } - WebUser.UserType.DEVELOPER -> { - val developer = Developer( - userCreationView.name, accessToken - ).also { - it.additionalInfo = userCreationView.additionalInfo - } - developerService.save(developer) - - redirectAttributes.addPopupMessage("Разработчик ${developer.name} успешно создан.") - } - WebUser.UserType.ADMIN -> { - val viewer = viewerService.find(userCreationView.viewerId) ?: run { - redirectAttributes.addPopupMessage("Наблюдатель не найден.") - return "redirect:$USERS_PATH" - } - - val admin = Admin( - userCreationView.name, accessToken - ).also { - it.additionalInfo = userCreationView.additionalInfo - it.viewer = viewer - } - - adminService.save(admin) - redirectAttributes.addPopupMessage("Администратор ${admin.name} успешно создан.") - } - WebUser.UserType.JUDGE -> { - val judge = Judge( - userCreationView.name, accessToken - ).also { - it.additionalInfo = userCreationView.additionalInfo - } - judgeService.save(judge) - - redirectAttributes.addPopupMessage("Судья ${judge.name} успешно создан.") - } - else -> { - redirectAttributes.addPopupMessage("Неизвестный тип пользователя.") - } - } - - val anchor = when (userCreationView.type) { - WebUser.UserType.VIEWER -> "#viewers" - WebUser.UserType.DEVELOPER -> "#developers" - WebUser.UserType.ADMIN -> "#admins" - WebUser.UserType.JUDGE -> "#judges" - else -> "" - } - - return "redirect:$USERS_PATH$anchor" - } - - companion object { - - const val USERS_PATH = "$SUPER_USER_PATH/users" - const val USERS_PAGE = "$SUPER_USER_PAGE/users" - - const val VIEWER_ATTR = "viewer" - const val ADMIN_ATTR = "admin" - const val DEVELOPER_ATTR = "developer" - const val JUDGE_ATTR = "judge" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessageController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessageController.kt deleted file mode 100644 index a29bc8ec..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessageController.kt +++ /dev/null @@ -1,83 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.superuser - -import org.springframework.stereotype.Controller -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.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserEmergencyMessagesController.Companion.EMERGENCY_MESSAGES_PAGE -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserEmergencyMessagesController.Companion.EMERGENCY_MESSAGES_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.impl.SuperUser -import trik.testsys.webclient.service.entity.impl.EmergencyMessageService -import trik.testsys.webclient.service.entity.user.impl.SuperUserService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage -import trik.testsys.webclient.view.impl.EmergencyMessageCreationView -import trik.testsys.webclient.view.impl.SuperUserView -import java.util.* -import javax.servlet.http.HttpServletRequest - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Controller -@RequestMapping(SuperUserEmergencyMessageController.EMERGENCY_MESSAGE_PATH) -class SuperUserEmergencyMessageController( - loginData: LoginData, - - private val emergencyMessageService: EmergencyMessageService -) : AbstractWebUserController(loginData) { - - override val mainPage = EMERGENCY_MESSAGE_PAGE - - override val mainPath = EMERGENCY_MESSAGE_PATH - - override fun SuperUser.toView(timeZoneId: String?) = TODO() - - @PostMapping("/create") - fun emergencyMessagePost( - @ModelAttribute(EMERGENCY_MESSAGE_ATTR) emergencyMessageView: EmergencyMessageCreationView, - timeZone: TimeZone, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val emergencyMessage = emergencyMessageView.toEntity() - emergencyMessageService.save(emergencyMessage) - - redirectAttributes.addPopupMessage("Сообщение '${emergencyMessageView.additionalInfo}' успешно создано для пользователей типа '${emergencyMessageView.userType}'.") - - return "redirect:$EMERGENCY_MESSAGES_PATH" - } - - @PostMapping("/remove/{id}") - fun emergencyMessageRemove( - @PathVariable("id") emergencyMessageId: Long, - timeZone: TimeZone, - request: HttpServletRequest, - redirectAttributes: RedirectAttributes - ): String { - loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (emergencyMessageService.delete(emergencyMessageId)) { - redirectAttributes.addPopupMessage("Сообщение с ID $emergencyMessageId успешно удалено.") - } else { - redirectAttributes.addPopupMessage("Сообщение с ID $emergencyMessageId не найдено.") - } - - return "redirect:$EMERGENCY_MESSAGES_PATH" - } - - companion object { - - const val EMERGENCY_MESSAGE_PATH = "$EMERGENCY_MESSAGES_PATH/emergency-message" - const val EMERGENCY_MESSAGE_PAGE = "$EMERGENCY_MESSAGES_PAGE/emergency-message" - - const val EMERGENCY_MESSAGE_ATTR = "emergencyMessageObj" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessagesController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessagesController.kt deleted file mode 100644 index ce5c0c53..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserEmergencyMessagesController.kt +++ /dev/null @@ -1,71 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.superuser - -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.CookieValue -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.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserEmergencyMessageController.Companion.EMERGENCY_MESSAGE_ATTR -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserMainController.Companion.SUPER_USER_PAGE -import trik.testsys.webclient.controller.impl.user.superuser.SuperUserMainController.Companion.SUPER_USER_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.entity.user.impl.SuperUser -import trik.testsys.webclient.service.entity.impl.EmergencyMessageService -import trik.testsys.webclient.service.entity.user.impl.SuperUserService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.EmergencyMessageCreationView -import trik.testsys.webclient.view.impl.SuperUserView - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Controller -@RequestMapping(SuperUserEmergencyMessagesController.EMERGENCY_MESSAGES_PATH) -class SuperUserEmergencyMessagesController( - loginData: LoginData, - - private val emergencyMessageService: EmergencyMessageService -) : AbstractWebUserController(loginData) { - - override val mainPage = EMERGENCY_MESSAGES_PAGE - - override val mainPath = EMERGENCY_MESSAGES_PATH - - override fun SuperUser.toView(timeZoneId: String?) = SuperUserView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo, - emergencyMessages = emergencyMessageService.findAll() - ) - - @GetMapping - fun emergencyMessagesGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - model.addAttribute(EMERGENCY_MESSAGE_ATTR, EmergencyMessageCreationView.empty()) - model.addAttribute(USER_TYPES_ATTR, WebUser.UserType.all()) - - return EMERGENCY_MESSAGES_PAGE - } - - companion object { - - const val EMERGENCY_MESSAGES_PATH = "$SUPER_USER_PATH/emergency-messages" - const val EMERGENCY_MESSAGES_PAGE = "$SUPER_USER_PAGE/emergency-messages" - - const val USER_TYPES_ATTR = "userTypes" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserMainController.kt deleted file mode 100644 index eae5d8b9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/superuser/SuperUserMainController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.superuser - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.RequestMapping -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.SuperUser -import trik.testsys.webclient.service.entity.user.impl.SuperUserService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.SuperUserView - -@Controller -@RequestMapping(SuperUserMainController.SUPER_USER_PATH) -class SuperUserMainController( - loginData: LoginData -) : AbstractWebUserMainController(loginData) { - - override val mainPath = SUPER_USER_PATH - - override val mainPage = SUPER_USER_PAGE - - override fun SuperUser.toView(timeZoneId: String?) = SuperUserView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo = this.additionalInfo - ) - - companion object { - - const val SUPER_USER_PATH = "/superuser" - const val SUPER_USER_PAGE = "superuser" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerAdminsController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerAdminsController.kt deleted file mode 100644 index a6e83e49..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerAdminsController.kt +++ /dev/null @@ -1,88 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.viewer - -import org.springframework.http.HttpHeaders -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.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.controller.impl.user.viewer.ViewerMainController.Companion.VIEWER_PAGE -import trik.testsys.webclient.controller.impl.user.viewer.ViewerMainController.Companion.VIEWER_PATH -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.service.UserAgentParser -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.entity.user.impl.ViewerService -import trik.testsys.webclient.service.impl.UserAgentParserImpl.Companion.WINDOWS_1251 -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.view.impl.AdminViewerView.Companion.toViewerView -import trik.testsys.webclient.view.impl.ViewerView - -@Controller -@RequestMapping(ViewerAdminsController.ADMINS_PATH) -class ViewerAdminsController( - loginData: LoginData, - - private val userAgentParser: UserAgentParser, - - private val studentService: StudentService -) : AbstractWebUserController(loginData) { - - override val mainPath = ADMINS_PATH - - override val mainPage = ADMINS_PAGE - - override fun Viewer.toView(timeZoneId: String?) = TODO() - - @GetMapping - fun adminsGet( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val admins = webUser.admins.map { it.toViewerView(timezone) }.sortedBy { it.id } - model.addAttribute(ADMINS_ATTR, admins) - - return ADMINS_PAGE - } - - @GetMapping("/export") - fun exportAdmins( - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - @RequestHeader("User-Agent") userAgent: String, - @RequestParam("Windows") isWindows: String?, // remove lately - redirectAttributes: RedirectAttributes, - model: Model - ): Any { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val groups = webUser.admins.map { it.groups }.flatten() - val exportData = studentService.export(groups) - - val filename = "result_${System.currentTimeMillis()}.csv" - val contentDisposition = "attachment; filename=$filename" - // val charset = userAgentParser.getCharset(userAgent) TODO(commented for later usage) - val charset = isWindows?.let { WINDOWS_1251 } ?: Charsets.UTF_8 - val bytes = exportData.toByteArray(charset) - - val responseEntity = ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .header(HttpHeaders.CONTENT_ENCODING, charset.name()) - .contentType(MediaType.TEXT_PLAIN) - .body(bytes) - - return responseEntity - } - - companion object { - - const val ADMINS_PATH = "$VIEWER_PATH/admins" - const val ADMINS_PAGE = "$VIEWER_PAGE/admins" - - const val ADMINS_ATTR = "admins" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerMainController.kt deleted file mode 100644 index 94cbc904..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/impl/user/viewer/ViewerMainController.kt +++ /dev/null @@ -1,58 +0,0 @@ -package trik.testsys.webclient.controller.impl.user.viewer - -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.RequestMapping -import trik.testsys.webclient.controller.user.AbstractWebUserMainController -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.service.entity.user.impl.ViewerService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.impl.ViewerView - - -@Controller -@RequestMapping(ViewerMainController.VIEWER_PATH) -class ViewerMainController( - loginData: LoginData -) : AbstractWebUserMainController(loginData) { - - override val mainPath = VIEWER_PATH - - override val mainPage = VIEWER_PAGE - - override fun Viewer.toView(timeZoneId: String?) = ViewerView( - id = this.id, - name = this.name, - accessToken = this.accessToken, - lastLoginDate = this.lastLoginDate?.atTimeZone(timeZoneId), - creationDate = this.creationDate?.atTimeZone(timeZoneId), - regToken = this.regToken, - additionalInfo = this.additionalInfo - ) - - companion object { - - const val VIEWER_PATH = "/viewer" - - const val VIEWER_PAGE = "viewer" - } -} - -// val headers = HttpHeaders() -// headers.contentType = MediaType.APPLICATION_OCTET_STREAM -// headers.contentDisposition = ContentDisposition.builder("attachment") -// .filename("results-${LocalDateTime.now(UTC).plusHours(3)}.csv") -// .build() -// -// headers.acceptLanguage = Locale.LanguageRange.parse("ru-RU, en-US") -// headers.acceptCharset = listOf(Charsets.UTF_8, Charsets.ISO_8859_1, Charsets.US_ASCII) -// -// headers.acceptCharset.add(Charset.forName("windows-1251")) -// headers.acceptCharset.add(Charset.forName("windows-1252")) -// headers.acceptCharset.add(Charset.forName("windows-1254")) -// headers.acceptCharset.add(Charset.forName("windows-1257")) -// headers.acceptCharset.add(Charset.forName("windows-1258")) -// headers.acceptCharset.add(Charset.forName("windows-874")) -// headers.acceptCharset.add(Charset.forName("windows-949")) -// headers.acceptCharset.add(Charset.forName("windows-950")) -// headers.acceptCharset.add(Charset.forName("ANSI_X3.4-1968")) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/rest/RestStudentController.kt b/src/main/kotlin/trik/testsys/webclient/controller/rest/RestStudentController.kt deleted file mode 100644 index bdce8d75..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/rest/RestStudentController.kt +++ /dev/null @@ -1,40 +0,0 @@ -package trik.testsys.webclient.controller.rest - -import org.springframework.http.ResponseEntity -import trik.testsys.core.controller.TrikRestController - - -interface RestStudentController : TrikRestController { - - fun register(apiKey: String): ResponseEntity - - fun getResults(apiKey: String, testSysId: Long): ResponseEntity> - - fun loadSubmission(apiKey: String, submissionId: Long): ResponseEntity - - data class StudentData( - val testSysId: Long, - val testSysKey: String - ) - - data class TrikResult( - val trikTask: TrikTask, - val gradingResult: GradingResult, - val submission: Submission? - ) - - data class TrikTask( - val id: Long, - val name: String, - val contestId: Long, - val contestName: String - ) - - enum class GradingResult { - PASSED, FAILED, QUEUED, NO_SUBMISSIONS - } - - data class Submission( - val submissionId: Long - ) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/rest/StudentExportController.kt b/src/main/kotlin/trik/testsys/webclient/controller/rest/StudentExportController.kt deleted file mode 100644 index 43d59025..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/rest/StudentExportController.kt +++ /dev/null @@ -1,69 +0,0 @@ -package trik.testsys.webclient.controller.rest - -import org.springframework.http.ResponseEntity -import org.springframework.web.multipart.MultipartFile -import trik.testsys.core.controller.TrikRestController - -/** - * Controller for exporting students from files. - * - * @author Roman Shishkin - * @since 2.5.0 - */ -interface StudentExportController : TrikRestController { - - /** - * Generates and registers a new student in the system. - * - * @param apiKey API key for the request - * @param adminId ID of the admin who is registering the student - * @param groupId ID of the group to which the student will be added - * @param file File with student data - * - * @return Student data - * - * @author Roman Shishkin - * @since 2.5.0 - */ - fun exportFromCsvFile( - apiKey: String, - adminId: Long, - groupId: Long, - file: MultipartFile - ): ResponseEntity - - data class ResponseData( - val message: String, - val status: Status, - val studentsInfo: StudentsInfo? = null - ) { - - enum class Status { - SUCCESS, FAILURE - } - - companion object { - - fun success(studentsInfo: StudentsInfo): ResponseData { - return ResponseData("Students have been successfully exported", Status.SUCCESS, studentsInfo) - } - - fun error(message: String): ResponseData { - return ResponseData(message, Status.FAILURE) - } - } - } - - data class StudentsInfo( - val adminId: Long, - val groupId: Long, - val students: List - ) - - data class StudentInfo( - val id: Long, - val name: String, - val additionalInfo: String, - val accessToken: String - ) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserController.kt b/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserController.kt deleted file mode 100644 index fc512b27..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserController.kt +++ /dev/null @@ -1,59 +0,0 @@ -package trik.testsys.webclient.controller.user - -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.service.entity.user.WebUserService -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addExitMessage -import trik.testsys.webclient.util.addSessionExpiredMessage - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -abstract class AbstractWebUserController, S : WebUserService>>( - val loginData: LoginData -) { - - protected abstract val mainPath: String - - protected abstract val mainPage: String - - @Autowired - protected lateinit var service: S - - protected abstract fun U.toView(timeZoneId: String?): V - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - protected fun LoginData.validate(redirectAttributes: RedirectAttributes): U? { - val webUser = accessToken?.let { service.findByAccessToken(it) } ?: run { - invalidate() - redirectAttributes.addSessionExpiredMessage() - return null - } - - return webUser - } - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - protected fun String?.checkLogout(redirectAttributes: RedirectAttributes): Boolean { - this ?: return false - - redirectAttributes.addExitMessage() - return true - } - - companion object { - - const val WEB_USER_ATTR = "webUser" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserMainController.kt b/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserMainController.kt deleted file mode 100644 index 4ba4d07c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/controller/user/AbstractWebUserMainController.kt +++ /dev/null @@ -1,84 +0,0 @@ -package trik.testsys.webclient.controller.user - -import org.springframework.ui.Model -import org.springframework.web.bind.annotation.* -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.controller.impl.main.LoginController -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.service.entity.user.WebUserService -import trik.testsys.webclient.service.entity.user.WebUserService.Companion.isFirstTimeLoggedIn -import trik.testsys.webclient.service.security.login.impl.LoginData -import trik.testsys.webclient.util.addPopupMessage - -abstract class AbstractWebUserMainController, S : WebUserService>> ( - loginData: LoginData -) : AbstractWebUserController(loginData) { - - @GetMapping - open fun mainGet( - @RequestParam(required = false, name = "Logout") logout: String?, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes, - model: Model - ): String { - if (logout.checkLogout(redirectAttributes)) { - loginData.invalidate() - return "redirect:${LoginController.LOGIN_PATH}" - } - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - model.addAttribute(WEB_USER_ATTR, webUser.toView(timezone)) - return mainPage - } - - @GetMapping(LOGIN_PATH) - open fun loginGet(redirectAttributes: RedirectAttributes): String { - val webUser = loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - if (webUser.isFirstTimeLoggedIn()) { - redirectAttributes.addPopupMessage( - "Вы успешно зарегистрировались в системе! \n\n" + - "Пожалуйста, сохраните сгенерированный Код-доступа, чтобы не потерять его: \n\n" + - webUser.accessToken - ) - } - - webUser.updateLastLoginDate() - service.save(webUser) - - return "redirect:$mainPath" - } - - @PostMapping(UPDATE_PATH) - open fun updatePost( - @ModelAttribute(WEB_USER_ATTR) webUserView: V, - @CookieValue(name = "X-Timezone", defaultValue = "UTC") timezone: String, - redirectAttributes: RedirectAttributes - ): String { - loginData.validate(redirectAttributes) ?: return "redirect:${LoginController.LOGIN_PATH}" - - val updatedWebUser = webUserView.toEntity(timezone) - - if (!service.validateName(updatedWebUser)) { - redirectAttributes.addPopupMessage("Псевдоним не должен быть пустым, содержать Код–доступа или Код-регистрации. Попробуйте другой вариант.") - return "redirect:$mainPath" - } - if (!service.validateAdditionalInfo(updatedWebUser)) { - redirectAttributes.addPopupMessage("Дополнительная информация не должна содержать Код-доступа или Код-регистрации. Попробуйте другой вариант.") - return "redirect:$mainPath" - } - - service.save(updatedWebUser) - - redirectAttributes.addPopupMessage("Данные успешно изменены.") - return "redirect:$mainPath" - } - - companion object { - - const val LOGIN_PATH = "/login" - const val UPDATE_PATH = "/update" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/AbstractNotedEntity.kt b/src/main/kotlin/trik/testsys/webclient/entity/AbstractNotedEntity.kt deleted file mode 100644 index bac4754f..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/AbstractNotedEntity.kt +++ /dev/null @@ -1,33 +0,0 @@ -package trik.testsys.webclient.entity - -import trik.testsys.core.entity.named.AbstractNamedEntity -import javax.persistence.Column -import javax.persistence.MappedSuperclass - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@MappedSuperclass -abstract class AbstractNotedEntity( - name: String -) : NotedEntity, AbstractNamedEntity(name) { - - @Column( - nullable = false, unique = false, updatable = true, - length = NOTE_MAX_LEN - ) override var note: String = NOTE_DEFAULT - - companion object { - - /** - * Maximum length of the [note] property. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - private const val NOTE_MAX_LEN = 255 - - private const val NOTE_DEFAULT = "" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/NotedEntity.kt b/src/main/kotlin/trik/testsys/webclient/entity/NotedEntity.kt deleted file mode 100644 index 3cc469a4..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/NotedEntity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package trik.testsys.webclient.entity - -import trik.testsys.core.entity.named.NamedEntity - - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -interface NotedEntity : NamedEntity { - - var note: String -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/RegEntity.kt b/src/main/kotlin/trik/testsys/webclient/entity/RegEntity.kt deleted file mode 100644 index 2c0b0e7b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/RegEntity.kt +++ /dev/null @@ -1,32 +0,0 @@ -package trik.testsys.webclient.entity - -import trik.testsys.core.entity.named.NamedEntity -import trik.testsys.core.entity.user.AccessToken - -/** - * Interface for entities that have registration token [regToken]. - * - * @author Roman Shishkin - * @since 2.0.0 - **/ -interface RegEntity : NamedEntity { - - /** - * Registration token field. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - val regToken: AccessToken - - companion object { - - /** - * Length of registration token. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - const val REG_TOKEN_LENGTH = 50 - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/ApiKey.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/ApiKey.kt deleted file mode 100644 index f0f10342..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/ApiKey.kt +++ /dev/null @@ -1,14 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.AbstractEntity -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.Table - -@Entity -@Table(name = "${TABLE_PREFIX}_API_KEY") -class ApiKey( - @Column(nullable = false, unique = true, updatable = false) - val value: String -) : AbstractEntity() \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/Contest.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/Contest.kt deleted file mode 100644 index 7b34fe80..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/Contest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.utils.enums.Enum -import trik.testsys.core.utils.enums.converter.AbstractEnumConverter -import trik.testsys.webclient.entity.AbstractNotedEntity -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.util.* -import java.time.LocalDateTime -import java.time.LocalTime -import javax.persistence.* -import kotlin.math.min - - -@Entity -@Table(name = "${TABLE_PREFIX}_CONTEST") -class Contest( - name: String, - - @Column(nullable = false, unique = false, updatable = true) - var startDate: LocalDateTime, - - @Column(nullable = false, unique = false, updatable = true) - var endDate: LocalDateTime, - - @Column(nullable = false, unique = false, updatable = true) - var duration: LocalTime, -) : AbstractNotedEntity(name) { - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = true, - name = "developer_id", referencedColumnName = "id" - ) - lateinit var developer: Developer - - @Column(nullable = false, unique = false, updatable = true) - var visibility: Visibility = Visibility.PRIVATE - - fun switchVisibility() { - visibility = visibility.opposite() - } - - fun isPublic() = visibility == Visibility.PUBLIC - - @ManyToMany - @JoinTable( - name = "CONTESTS_BY_GROUPS", - joinColumns = [JoinColumn(name = "contest_id")], - inverseJoinColumns = [JoinColumn(name = "group_id")] - ) - val groups: MutableSet = mutableSetOf() - - fun isGoingOn(): Boolean { - val now = LocalDateTime.now(DEFAULT_ZONE_ID) - return startDate.isBeforeOrEqual(now) && endDate.isAfterOrEqual(now) - } - - fun isOutdatedFor(student: Student): Boolean { - val studentRemainingTime = student.remainingTimeFor(this) - return studentRemainingTime.toSecondOfDay() == 0 - } - - @get:Transient - val durationInSeconds: Long - get() { - return endDate.toEpochSecond() - startDate.toEpochSecond() + 1 - } - - @get:Transient - val lastSeconds: Long - get() { - if (!isGoingOn()) { - return 0 - } - - val now = LocalDateTime.now(DEFAULT_ZONE_ID) - val lastSeconds = endDate.toEpochSecond() - now.toEpochSecond() + 1 - - return if (isOpenEnded) lastSeconds else min(duration.toSecondOfDay().toLong(), lastSeconds) - } - - @ElementCollection - @MapKeyColumn(name = "student_id") - @Column(name = "start_time") - @CollectionTable( - name = "CONTESTS_START_TIMES", - joinColumns = [JoinColumn(name = "contest_id")] - ) - val startTimesByStudentId: MutableMap = mutableMapOf() - - @ManyToMany(mappedBy = "contests") - val tasks: MutableSet = mutableSetOf() - - /** - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ - var isOpenEnded: Boolean = false - - enum class Visibility(override val dbkey: String) : Enum { - - PUBLIC("PLC"), - PRIVATE("PRV"); - - fun opposite() = when (this) { - PUBLIC -> PRIVATE - PRIVATE -> PUBLIC - } - - @Converter(autoApply = true) - class VisibilityConverter : AbstractEnumConverter() - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/EmergencyMessage.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/EmergencyMessage.kt deleted file mode 100644 index 89e64758..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/EmergencyMessage.kt +++ /dev/null @@ -1,15 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.AbstractEntity -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.Table - -@Entity -@Table(name = "${TABLE_PREFIX}_EMERGENCY_MESSAGE") -class EmergencyMessage( - @Column(nullable = false, unique = false, updatable = false) - val userType: WebUser.UserType -) : AbstractEntity() \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/Group.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/Group.kt deleted file mode 100644 index 991b67ee..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/Group.kt +++ /dev/null @@ -1,34 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.named.AbstractNamedEntity -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.RegEntity -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.user.impl.Student -import javax.persistence.* - -@Entity -@Table(name = "${TABLE_PREFIX}_GROUP") -class Group( - name: String, - - @Column( - nullable = false, unique = true, updatable = false, - length = RegEntity.REG_TOKEN_LENGTH - ) override val regToken: AccessToken -) : AbstractNamedEntity(name), RegEntity { - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "admin_id", referencedColumnName = "id", - ) - lateinit var admin: Admin - - @OneToMany(mappedBy = "group") - val students: MutableSet = mutableSetOf() - - @ManyToMany(mappedBy = "groups") - val contests: MutableSet = mutableSetOf() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/Solution.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/Solution.kt deleted file mode 100644 index 422f94b4..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/Solution.kt +++ /dev/null @@ -1,87 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.AbstractEntity -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.utils.enums.Enum -import trik.testsys.core.utils.enums.converter.AbstractEnumConverter -import trik.testsys.webclient.entity.user.impl.Student -import javax.persistence.* - -@Entity -@Table(name = "${TABLE_PREFIX}_SOLUTION") -class Solution( - @Column(nullable = false, unique = false, updatable = false) - val type: SolutionType, -) : AbstractEntity() { - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "task_id", referencedColumnName = "id" - ) - lateinit var task: Task - - @ManyToOne - @JoinColumn( - nullable = true, unique = false, updatable = false, - name = "student_id", referencedColumnName = "id" - ) - var student: Student? = null - - @get:Transient - val isTest: Boolean - get() = student == null - - @Column(nullable = false, unique = false, updatable = true) - var status: SolutionStatus = SolutionStatus.NOT_STARTED - - @Column(nullable = false, unique = false, updatable = true) - var score: Long = -1 - - fun isScored() = score != -1L - - // solutions should be linked not only with tasks but also with contests - - /** - * @author Roman Shishkin - * @since 1.1.0 - */ - enum class SolutionStatus(override val dbkey: String) : Enum { - - FAILED("FLD"), - PASSED("PAS"), - IN_PROGRESS("INP"), - NOT_STARTED("NST"), - ERROR("ERR"); - - companion object { - - @Converter(autoApply = true) - class SolutionStatusConverter : AbstractEnumConverter() - } - } - - /** - * Solution type enum class. - * @since 2.0.0 - */ - enum class SolutionType(override val dbkey: String) : Enum { - - QRS("QRS"), - PYTHON("PY"), - JAVASCRIPT("JS"); - - companion object { - - @Converter(autoApply = true) - class SolutionTypeConverter : AbstractEnumConverter() - } - } - - companion object { - - fun qrsSolution(task: Task) = Solution(SolutionType.QRS).also { - it.task = task - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/SolutionVerdict.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/SolutionVerdict.kt deleted file mode 100644 index 0710775b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/SolutionVerdict.kt +++ /dev/null @@ -1,66 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.webclient.entity.AbstractNotedEntity -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.entity.user.impl.Student -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.JoinColumn -import javax.persistence.ManyToOne -import javax.persistence.Table -import javax.persistence.Transient - -@Entity -@Table(name = "${TABLE_PREFIX}_SOLUTION_VERDICT") -class SolutionVerdict( - name: String, - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "judge_id", referencedColumnName = "id" - ) - val judge: Judge, - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "student_id", referencedColumnName = "id" - ) - val student: Student, - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "task_id", referencedColumnName = "id" - ) - val task: Task, - - @ManyToOne - @JoinColumn( - nullable = true, unique = false, updatable = false, - name = "contest_id", referencedColumnName = "id" - ) - val contest: Contest?, - - @Column(nullable = false, unique = false, updatable = true) - var score: Long -) : AbstractNotedEntity(name) { - - @get:Transient - val judgeFullMame: String - get() = "${judge.id}: ${judge.name}" - - @get:Transient - val studentFullMame: String - get() = "${student.id}: ${student.name}" - - @get:Transient - val taskFullMame: String - get() = "${task.id}: ${task.name}" - - @get:Transient - val contestFullMame: String - get() = "${contest?.id}: ${contest?.name}" -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/Task.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/Task.kt deleted file mode 100644 index 725c5dea..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/Task.kt +++ /dev/null @@ -1,101 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.webclient.entity.AbstractNotedEntity -import trik.testsys.webclient.entity.user.impl.Developer -import javax.persistence.* - -@Entity -@Table(name = "${TABLE_PREFIX}_TASK") -class Task( - name: String -) : AbstractNotedEntity(name) { - - /** - * @author Roman Shishkin - * @since 1.1.0 - */ - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "developer_id", referencedColumnName = "id" - ) - lateinit var developer: Developer - - @ManyToMany(mappedBy = "tasks", fetch = FetchType.EAGER) - val taskFiles: MutableSet = mutableSetOf() - - @get:Transient - val polygons: Set - get() = taskFiles.filter { it.type == TaskFile.TaskFileType.POLYGON }.toSet() - - @get:Transient - val exercise: TaskFile? - get() = taskFiles.firstOrNull { it.type == TaskFile.TaskFileType.EXERCISE } - - @get:Transient - val solution: TaskFile? - get() = taskFiles.firstOrNull { it.type == TaskFile.TaskFileType.SOLUTION } - - @get:Transient - val condition: TaskFile? - get() = taskFiles.firstOrNull { it.type == TaskFile.TaskFileType.CONDITION } - - @get:Transient - val polygonsCount: Long - get() = polygons.size.toLong() - - @get:Transient - val hasExercise: Boolean - get() = exercise != null - - @get:Transient - val hasSolution: Boolean - get() = solution != null - - @get:Transient - val hasCondition: Boolean - get() = condition != null - - @Column(nullable = false, unique = false, updatable = true) - var passedTests: Boolean = false - private set - - fun fail() { - passedTests = false - } - - fun pass() { - passedTests = true - } - - @get:Transient - val truncatedName: String - get() = name.removePostfix() - - fun compareNames(other: Task): Boolean { - return truncatedName == other.truncatedName - } - - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable( - name = "TASKS_BY_CONTESTS", - joinColumns = [JoinColumn(name = "task_id")], - inverseJoinColumns = [JoinColumn(name = "contest_id")] - ) - val contests: MutableSet = mutableSetOf() - - companion object { - - private const val TRIK_POSTFIX1 = "TRIK" - private const val TRIK_POSTFIX2 = "ТРИК" - - private const val EV3_POSTFIX = "EV3" - - private val POSTFIXES = setOf(TRIK_POSTFIX1, TRIK_POSTFIX2, EV3_POSTFIX) - - private fun String.removePostfix(): String { - return POSTFIXES.fold(this) { acc, postfix -> acc.removeSuffix(postfix) } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFile.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFile.kt deleted file mode 100644 index a74d2f7a..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFile.kt +++ /dev/null @@ -1,80 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.utils.enums.Enum -import trik.testsys.core.utils.enums.converter.AbstractEnumConverter -import trik.testsys.webclient.entity.AbstractNotedEntity -import trik.testsys.webclient.entity.user.impl.Developer -import javax.persistence.* - -@Entity -@Table(name = "${TABLE_PREFIX}_TASK_FILE") -class TaskFile( - name: String, - - @Column(nullable = false, unique = false, updatable = false) - val type: TaskFileType -) : AbstractNotedEntity(name) { - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "developer_id", referencedColumnName = "id" - ) - lateinit var developer: Developer - - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable( - name = "TASK_FILES_BY_TASKS", - joinColumns = [JoinColumn(name = "task_file_id")], - inverseJoinColumns = [JoinColumn(name = "task_id")] - ) - val tasks: MutableSet = mutableSetOf() - - @OneToMany(mappedBy = "taskFile", fetch = FetchType.EAGER) - val taskFileAudits: MutableSet = mutableSetOf() - - @get:Transient - val latestAudit: TaskFileAudit - get() = taskFileAudits.maxBy { it.creationDate!! } - - @get:Transient - val latestFileName: String - get() = latestAudit.fileName - - enum class TaskFileType(override val dbkey: String) : Enum { - - POLYGON("PLG"), - EXERCISE("EXR"), - SOLUTION("SLN"), - CONDITION("CND"); - - fun canBeRemovedOnTaskTesting() = this == CONDITION || this == EXERCISE - - fun cannotBeRemovedOnTaskTesting() = !canBeRemovedOnTaskTesting() - - companion object { - - @Converter(autoApply = true) - class TaskFileTypeConverter : AbstractEnumConverter() - - fun TaskFileType.localized() = when(this) { - POLYGON -> "Полигон" - EXERCISE -> "Упражнение" - SOLUTION -> "Решение" - CONDITION -> "Условие" - } - } - } - - companion object { - - fun polygon(name: String) = TaskFile(name, TaskFileType.POLYGON) - - fun exercise(name: String) = TaskFile(name, TaskFileType.EXERCISE) - - fun solution(name: String) = TaskFile(name, TaskFileType.SOLUTION) - - fun condition(name: String) = TaskFile(name, TaskFileType.CONDITION) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFileAudit.kt b/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFileAudit.kt deleted file mode 100644 index 2f8b1fc8..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/impl/TaskFileAudit.kt +++ /dev/null @@ -1,29 +0,0 @@ -package trik.testsys.webclient.entity.impl - -import trik.testsys.core.entity.AbstractEntity -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import javax.persistence.Entity -import javax.persistence.JoinColumn -import javax.persistence.ManyToOne -import javax.persistence.Table -import javax.persistence.Transient - -/** - * @author Roman Shishkin - * @since 2.1.0 - */ -@Entity -@Table(name = "${TABLE_PREFIX}_TASK_FILE_AUDIT") -class TaskFileAudit( - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "task_file_id", referencedColumnName = "id" - ) - val taskFile: TaskFile -) : AbstractEntity() { - - @get:Transient - val fileName: String - get() = "${taskFile.id}-${id}" -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/WebUser.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/WebUser.kt deleted file mode 100644 index 21b086a6..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/WebUser.kt +++ /dev/null @@ -1,49 +0,0 @@ -package trik.testsys.webclient.entity.user - -import trik.testsys.core.entity.user.AbstractUser -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.utils.enums.Enum -import trik.testsys.core.utils.enums.converter.AbstractEnumConverter -import javax.persistence.Column -import javax.persistence.Converter -import javax.persistence.MappedSuperclass - -/** - * Web user abstract class which extends [AbstractUser] with additional [type] field. - * - * @author Roman Shishkin - * @since 2.0.0 - */ -@MappedSuperclass -abstract class WebUser( - name: String, - accessToken: AccessToken, - - @Column(nullable = false, unique = false, updatable = false) - val type: UserType -) : AbstractUser(name, accessToken) { - - /** - * User type enum class. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - enum class UserType(override val dbkey: String) : Enum { - - ADMIN("ADM"), - DEVELOPER("DEV"), - JUDGE("JDG"), - STUDENT("STT"), - SUPER_USER("SUR"), - VIEWER("VWR"); - - companion object { - - @Converter(autoApply = true) - class UserTypeConverter : AbstractEnumConverter() - - fun all() = values().toList() - } - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Admin.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Admin.kt deleted file mode 100644 index 0f863711..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Admin.kt +++ /dev/null @@ -1,33 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.* - -@Entity -@Table(name = "${TABLE_PREFIX}_ADMIN") -class Admin( - name: String, - accessToken: AccessToken, -) : WebUser(name, accessToken, UserType.ADMIN) { - - /** - * @author Roman Shishkin - * @since 1.1.0 - */ - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "viewer_id", referencedColumnName = "id" - ) - lateinit var viewer: Viewer - - @OneToMany(mappedBy = "admin") - val groups: MutableSet = mutableSetOf() - -// -// @ManyToMany(mappedBy = "admins") -// val tasks: MutableSet = mutableSetOf() -} diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Developer.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Developer.kt deleted file mode 100644 index da00d1a0..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Developer.kt +++ /dev/null @@ -1,66 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.* - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Entity -@Table(name = "${TABLE_PREFIX}_DEVELOPER") -class Developer( - name: String, - accessToken: AccessToken -) : WebUser(name, accessToken, UserType.DEVELOPER) { - - @OneToMany(mappedBy = "developer") - val tasks: MutableSet = mutableSetOf() - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - @OneToMany(mappedBy = "developer") - val contests: MutableSet = mutableSetOf() - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - @OneToMany(mappedBy = "developer") - val taskFiles: MutableSet = mutableSetOf() - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - @get:Transient - val polygons: MutableSet - get() = taskFiles.filter { it.type == TaskFile.TaskFileType.POLYGON }.toMutableSet() - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - @get:Transient - val exercises: MutableSet - get() = taskFiles.filter { it.type == TaskFile.TaskFileType.EXERCISE }.toMutableSet() - - /** - * @author Roman Shishkin - * @since 2.0.0 - **/ - @get:Transient - val solutions: MutableSet - get() = taskFiles.filter { it.type == TaskFile.TaskFileType.SOLUTION }.toMutableSet() - - @get:Transient - val conditions: MutableSet - get() = taskFiles.filter { it.type == TaskFile.TaskFileType.CONDITION }.toMutableSet() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Judge.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Judge.kt deleted file mode 100644 index 0d105283..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Judge.kt +++ /dev/null @@ -1,19 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.* - - -@Entity -@Table(name = "${TABLE_PREFIX}_JUDGE") -class Judge( - name: String, - accessToken: AccessToken -) : WebUser(name, accessToken, UserType.JUDGE) { - - @OneToMany(mappedBy = "judge") - val solutionVerdicts: MutableSet = mutableSetOf() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Student.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Student.kt deleted file mode 100644 index a3f0bdbf..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Student.kt +++ /dev/null @@ -1,65 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.util.toEpochSecond -import java.time.LocalDateTime -import java.time.LocalTime -import javax.persistence.* -import kotlin.math.max -import kotlin.math.min - -@Entity -@Table(name = "${TABLE_PREFIX}_STUDENT") -class Student( - name: String, - accessToken: AccessToken -) : WebUser(name, accessToken, UserType.STUDENT) { - - @ManyToOne - @JoinColumn( - nullable = false, unique = false, updatable = false, - name = "group_id", referencedColumnName = "id" - ) - lateinit var group: Group - - @OneToMany(mappedBy = "student") - val solutions: MutableSet = mutableSetOf() - - @ElementCollection - @MapKeyColumn(name = "contest_id") - @Column(name = "start_time") - @CollectionTable( - name = "CONTESTS_START_TIMES", - joinColumns = [JoinColumn(name = "student_id")] - ) - val startTimesByContestId: MutableMap = mutableMapOf() - - @OneToMany(mappedBy = "student") - val solutionVerdicts: MutableSet = mutableSetOf() - - fun startContest(contest: Contest) { - startTimesByContestId[contest.id!!] = LocalDateTime.now(DEFAULT_ZONE_ID) - } - - fun remainingTimeFor(contest: Contest): LocalTime { - val now = LocalDateTime.now(DEFAULT_ZONE_ID) - val nowInSeconds = now.toEpochSecond() - val contestLastSeconds = contest.lastSeconds - - if (contest.isOpenEnded && contestLastSeconds > 0) return LocalTime.MAX - - val startTime = startTimesByContestId[contest.id!!] ?: return LocalTime.ofSecondOfDay(contestLastSeconds) - val startTimeInSeconds = startTime.toEpochSecond() - - val personalRemainingTime = contest.duration.toSecondOfDay() - (nowInSeconds - startTimeInSeconds) - val globalRemainingTime = contest.endDate.toEpochSecond() - nowInSeconds - - return LocalTime.ofSecondOfDay(max(0, min(personalRemainingTime, globalRemainingTime))) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/SuperUser.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/SuperUser.kt deleted file mode 100644 index c71e7e45..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/SuperUser.kt +++ /dev/null @@ -1,12 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.* - -@Entity -@Table(name = "SUPER_USERS") -class SuperUser( - name: String, - accessToken: AccessToken -): WebUser(name, accessToken, UserType.SUPER_USER) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Viewer.kt b/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Viewer.kt deleted file mode 100644 index c00ecc97..00000000 --- a/src/main/kotlin/trik/testsys/webclient/entity/user/impl/Viewer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package trik.testsys.webclient.entity.user.impl - -import trik.testsys.core.entity.Entity.Companion.TABLE_PREFIX -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.RegEntity -import trik.testsys.webclient.entity.user.WebUser -import javax.persistence.* - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Entity -@Table(name = "${TABLE_PREFIX}_VIEWER") -class Viewer( - name: String, - accessToken: AccessToken, - - @Column( - nullable = false, unique = true, updatable = false, - length = RegEntity.REG_TOKEN_LENGTH - ) - override val regToken: AccessToken -) : WebUser(name, accessToken, UserType.VIEWER), RegEntity { - - @OneToMany(mappedBy = "viewer") - val admins: MutableSet = mutableSetOf() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/ApiKeyRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/ApiKeyRepository.kt deleted file mode 100644 index c61bd69b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/ApiKeyRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.EntityRepository -import trik.testsys.webclient.entity.impl.ApiKey - -@Repository -interface ApiKeyRepository : EntityRepository { - - fun findByValue(value: String): ApiKey? -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/ContestRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/ContestRepository.kt deleted file mode 100644 index ff6ddabd..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/ContestRepository.kt +++ /dev/null @@ -1,16 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.named.NamedEntityRepository -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.user.impl.Developer - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Repository -interface ContestRepository : NamedEntityRepository { - - fun findByDeveloper(developer: Developer): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/EmergencyMessageRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/EmergencyMessageRepository.kt deleted file mode 100644 index 33e42b52..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/EmergencyMessageRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.EntityRepository -import trik.testsys.webclient.entity.impl.EmergencyMessage -import trik.testsys.webclient.entity.user.WebUser - -@Repository -interface EmergencyMessageRepository : EntityRepository { - - fun findByUserType(userType: WebUser.UserType): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/GroupRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/GroupRepository.kt deleted file mode 100644 index db98617c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/GroupRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.impl.Group - -@Repository -interface GroupRepository : RegEntityRepository { - - fun findGroupsByAdmin(admin: Admin): List? -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/RegEntityRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/RegEntityRepository.kt deleted file mode 100644 index 90cf7b5d..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/RegEntityRepository.kt +++ /dev/null @@ -1,28 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.data.repository.NoRepositoryBean -import trik.testsys.core.repository.EntityRepository -import trik.testsys.core.repository.named.NamedEntityRepository -import trik.testsys.webclient.entity.RegEntity - -/** - * Repository for [RegEntity] entities. Extends [EntityRepository] with methods working with registration token: - * - * 1. [findByRegToken] – Find entity by registration token. - * - * @author Roman Shishkin - * @since 2.0.0 - **/ -@NoRepositoryBean -interface RegEntityRepository : NamedEntityRepository { - - /** - * Find entity by [RegEntity.regToken]. - * - * @param regToken Registration token. - * @return Entity with given registration token or `null` if not found. - * @author Roman Shishkin - * @since 2.0.0 - */ - fun findByRegToken(regToken: String): E? -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/SolutionRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/SolutionRepository.kt deleted file mode 100644 index 9ef696b9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/SolutionRepository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.EntityRepository -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Student - -@Repository -interface SolutionRepository : EntityRepository { - - fun findByTask(task: Task): List - - fun findByStudent(student: Student): List - - fun findByStudentAndTask(student: Student, task: Task): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/SolutionVerdictRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/SolutionVerdictRepository.kt deleted file mode 100644 index 3bcd6cf4..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/SolutionVerdictRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.named.NamedEntityRepository -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.entity.user.impl.Student - -@Repository -interface SolutionVerdictRepository : NamedEntityRepository { - - fun findByJudgeAndStudent(judge: Judge, student: Student): List - - fun findByStudent(student: Student): List - - fun findByStudentAndTask(student: Student, task: Task): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/TaskFileAuditRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/TaskFileAuditRepository.kt deleted file mode 100644 index 83c6baca..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/TaskFileAuditRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -package trik.testsys.webclient.repository - -import trik.testsys.core.repository.EntityRepository -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.impl.TaskFileAudit - -/** - * @author Roman Shishkin - * @since 2.1.0 - */ -interface TaskFileAuditRepository : EntityRepository { - - fun findAllByTaskFile(taskFile: TaskFile): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/TaskFileRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/TaskFileRepository.kt deleted file mode 100644 index 0597b784..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/TaskFileRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.named.NamedEntityRepository -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.user.impl.Developer - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Repository -interface TaskFileRepository : NamedEntityRepository { - - fun findByDeveloper(developer: Developer): List - - fun findByDeveloperAndType(developer: Developer, type: TaskFile.TaskFileType): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/TaskRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/TaskRepository.kt deleted file mode 100644 index 97b07578..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/TaskRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package trik.testsys.webclient.repository - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.named.NamedEntityRepository -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Developer - - -@Repository -interface TaskRepository : NamedEntityRepository { - - fun findByDeveloper(developer: Developer): List -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/AdminRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/AdminRepository.kt deleted file mode 100644 index 36528aa6..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/AdminRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.Admin - -@Repository -interface AdminRepository: UserRepository \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/DeveloperRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/DeveloperRepository.kt deleted file mode 100644 index 1949732d..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/DeveloperRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.Developer - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Repository -interface DeveloperRepository: UserRepository \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/JudgeRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/JudgeRepository.kt deleted file mode 100644 index 12103392..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/JudgeRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.Judge - -@Repository -interface JudgeRepository : UserRepository \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/StudentRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/StudentRepository.kt deleted file mode 100644 index a67bf0e0..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/StudentRepository.kt +++ /dev/null @@ -1,35 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.Student - -/** - * @author Roman Shishkin - * @since 1.0.0 - */ -@Repository -interface StudentRepository : UserRepository { - - /** - * Returns max number of student with same username prefix. - * - * For example, if username prefix is "student" and there are students with usernames "student_1", "student_2", "student_3" - * then this method will return 3. - * @author Roman Shishkin - * @since 1.1.0 - */ - @Query( - nativeQuery = true, - value = "SELECT CAST(SUBSTRING_INDEX(wu.username, '_', -1) AS UNSIGNED) as number " + - "FROM students JOIN web_users wu on wu.id = students.web_user_id " + - "WHERE REGEXP_LIKE(wu.username, :prefixRegex) and students.group_id = :groupId " + - "ORDER BY number DESC LIMIT 1" - ) - fun findMaxNumberWithSameNamePrefix( - @Param("prefixRegex") prefixRegex: String, - @Param("groupId") groupId: Long - ): Long? -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/SuperUserRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/SuperUserRepository.kt deleted file mode 100644 index d5ab797b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/SuperUserRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.SuperUser - -@Repository -interface SuperUserRepository: UserRepository \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/repository/user/ViewerRepository.kt b/src/main/kotlin/trik/testsys/webclient/repository/user/ViewerRepository.kt deleted file mode 100644 index 9e81e418..00000000 --- a/src/main/kotlin/trik/testsys/webclient/repository/user/ViewerRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package trik.testsys.webclient.repository.user - -import org.springframework.stereotype.Repository -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.repository.RegEntityRepository - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Repository -interface ViewerRepository : UserRepository, RegEntityRepository \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/FileManager.kt b/src/main/kotlin/trik/testsys/webclient/service/FileManager.kt deleted file mode 100644 index 4c731088..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/FileManager.kt +++ /dev/null @@ -1,40 +0,0 @@ -package trik.testsys.webclient.service - -import org.springframework.web.multipart.MultipartFile -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.impl.TaskFileAudit -import java.io.File - -/** - * Interface for managing files. - * @author Roman Shishkin - * @since 2.0.0 - */ -interface FileManager { - - fun saveTaskFile(taskFileAudit: TaskFileAudit, fileData: MultipartFile): Boolean - - fun getTaskFile(taskFile: TaskFile): File? - - fun getTaskFileAuditFile(taskFileAudit: TaskFileAudit): File? - - fun getTaskFileExtension(taskFile: TaskFile): String - - fun saveSolutionFile(solution: Solution, file: File): Boolean - - fun saveSolutionFile(solution: Solution, fileData: MultipartFile): Boolean - - fun getTaskFiles(task: Task): Collection - - fun getSolutionFile(solution: Solution): File? - - fun saveSuccessfulGradingInfo(fieldResult: Grader.GradingInfo.Ok) - - fun getVerdictFiles(solution: Solution): List - - fun getRecordingFiles(solution: Solution): List - - fun getSolutionResultFilesCompressed(solution: Solution): File -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/LogoService.kt b/src/main/kotlin/trik/testsys/webclient/service/LogoService.kt deleted file mode 100644 index fd298241..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/LogoService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package trik.testsys.webclient.service - -import trik.testsys.webclient.view.impl.LogosView - -/** - * Service for logos management. - * - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ -interface LogoService { - - fun getLogos(): LogosView -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/UserAgentParser.kt b/src/main/kotlin/trik/testsys/webclient/service/UserAgentParser.kt deleted file mode 100644 index 4bc5e319..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/UserAgentParser.kt +++ /dev/null @@ -1,11 +0,0 @@ -package trik.testsys.webclient.service - -import ua_parser.Client -import java.nio.charset.Charset - -interface UserAgentParser { - - fun getClientInfo(userAgent: String): Client - - fun getCharset(userAgent: String): Charset -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/RegEntityService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/RegEntityService.kt deleted file mode 100644 index 867c77df..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/RegEntityService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.service.entity - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.entity.user.UserEntity -import trik.testsys.core.service.named.NamedEntityService -import trik.testsys.webclient.entity.RegEntity - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -interface RegEntityService : NamedEntityService { - - fun findByRegToken(regToken: AccessToken): E? - - fun register(regToken: AccessToken, name: String): RE? - - override fun validateName(entity: E) = !entity.name.containsRegToken(entity.regToken) - - override fun validateAdditionalInfo(entity: E) = !entity.additionalInfo.containsRegToken(entity.regToken) - - companion object { - - fun String.containsRegToken(regToken: AccessToken) = contains(regToken, ignoreCase = true) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ApiKeyService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ApiKeyService.kt deleted file mode 100644 index 5b6b4df1..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ApiKeyService.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.AbstractService -import trik.testsys.webclient.entity.impl.ApiKey -import trik.testsys.webclient.repository.ApiKeyRepository - -@Service -class ApiKeyService : AbstractService() { - - fun findByValue(value: String): ApiKey? { - return repository.findByValue(value) - } - - fun validate(value: String): Boolean { - return findByValue(value) != null - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ContestService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ContestService.kt deleted file mode 100644 index 077c65a9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/ContestService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.named.AbstractNamedEntityService -import trik.testsys.core.service.user.AbstractUserService.Companion.containsAccessToken -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.repository.ContestRepository - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Service -class ContestService : AbstractNamedEntityService() { - - fun findByDeveloper(developer: Developer) = repository.findByDeveloper(developer) - - override fun validateName(entity: Contest) = - super.validateName(entity) && !entity.name.containsAccessToken(entity.developer.accessToken) - - override fun validateAdditionalInfo(entity: Contest) = - super.validateAdditionalInfo(entity) && !entity.additionalInfo.containsAccessToken(entity.developer.accessToken) - - fun findAllPublic() = repository.findAll().filter { it.visibility == Contest.Visibility.PUBLIC } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/EmergencyMessageService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/EmergencyMessageService.kt deleted file mode 100644 index 1c5095d1..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/EmergencyMessageService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.AbstractService -import trik.testsys.webclient.entity.impl.EmergencyMessage -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.repository.EmergencyMessageRepository - -@Service -class EmergencyMessageService : AbstractService() { - - fun findByUserType(userType: WebUser.UserType) = repository.findByUserType(userType).firstOrNull() - - override fun save(entity: EmergencyMessage): EmergencyMessage { - val existing = repository.findByUserType(entity.userType) - - return if (existing.isEmpty()) { - repository.save(entity) - } else { - repository.deleteAll(existing) - repository.save(entity) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/GroupService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/GroupService.kt deleted file mode 100644 index 038dea2e..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/GroupService.kt +++ /dev/null @@ -1,42 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Service -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.service.named.AbstractNamedEntityService -import trik.testsys.core.service.user.AbstractUserService.Companion.containsAccessToken -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.repository.GroupRepository -import trik.testsys.webclient.service.entity.RegEntityService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import trik.testsys.webclient.service.token.access.AccessTokenGenerator - -@Service -class GroupService( - private val studentService: StudentService, - @Qualifier("studentAccessTokenGenerator") private val studentAccessTokenGenerator: AccessTokenGenerator, -) : - RegEntityService, - AbstractNamedEntityService() { - - override fun findByRegToken(regToken: String) = repository.findByRegToken(regToken) - - override fun register(regToken: AccessToken, name: String): Student? { - val group = findByRegToken(regToken) ?: return null - - val accessToken = studentAccessTokenGenerator.generate(name) - val student = Student(name, accessToken) - student.group = group - - return student.takeIf { studentService.validateName(it) }?.also { studentService.save(it) } - } - - override fun validateName(entity: Group) = - super.validateName(entity) && super.validateName(entity) && - !entity.name.containsAccessToken(entity.admin.accessToken) - - override fun validateAdditionalInfo(entity: Group) = - super.validateAdditionalInfo(entity) && super.validateAdditionalInfo(entity) && - !entity.additionalInfo.containsAccessToken(entity.admin.accessToken) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionService.kt deleted file mode 100644 index d54006b5..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionService.kt +++ /dev/null @@ -1,38 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.AbstractService -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.repository.SolutionRepository - -@Service -class SolutionService : AbstractService() { - - fun findTaskTests(task: Task): List { - val allTaskSolutions = repository.findByTask(task) - val taskTests = allTaskSolutions.filter { it.isTest } - - return taskTests - } - - fun findByStudent(student: Student): List { - return repository.findByStudent(student) - } - - fun findByStudentAndTask(student: Student, task: Task): List { - return repository.findByStudentAndTask(student, task) - } - - fun findByStudentAndContest(student: Student, contest: Contest): List { - val solutions = mutableListOf() - contest.tasks.forEach { task -> - val taskSolutions = findByStudentAndTask(student, task) - solutions.addAll(taskSolutions) - } - - return solutions - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionVerdictService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionVerdictService.kt deleted file mode 100644 index 2e45bdbc..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/SolutionVerdictService.kt +++ /dev/null @@ -1,19 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.named.AbstractNamedEntityService -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.repository.SolutionVerdictRepository - -@Service -class SolutionVerdictService : AbstractNamedEntityService() { - - fun findByJudgeAndStudent(judge: Judge, student: Student) = repository.findByJudgeAndStudent(judge, student) - - fun findByStudent(student: Student) = repository.findByStudent(student) - - fun findByStudentAndTask(student: Student, task: Task) = repository.findByStudentAndTask(student, task) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileAuditService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileAuditService.kt deleted file mode 100644 index c8bd0100..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileAuditService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.AbstractService -import trik.testsys.webclient.entity.impl.TaskFileAudit -import trik.testsys.webclient.repository.TaskFileAuditRepository - -/** - * @author Roman Shishkin - * @since 2.1.0 - */ -@Service -class TaskFileAuditService : AbstractService() { -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileService.kt deleted file mode 100644 index 1869f71b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskFileService.kt +++ /dev/null @@ -1,25 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.named.AbstractNamedEntityService -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.repository.TaskFileRepository - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Service -class TaskFileService : AbstractNamedEntityService() { - - override fun validateName(entity: TaskFile) = - super.validateName(entity) && !entity.name.contains(entity.developer.accessToken) - - override fun validateAdditionalInfo(entity: TaskFile) = - super.validateAdditionalInfo(entity) && !entity.additionalInfo.contains(entity.developer.accessToken) - - fun findByDeveloper(developer: Developer) = repository.findByDeveloper(developer) - - fun findByDeveloper(developer: Developer, type: TaskFile.TaskFileType) = repository.findByDeveloperAndType(developer, type) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskService.kt deleted file mode 100644 index e6b7f8a7..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/impl/TaskService.kt +++ /dev/null @@ -1,30 +0,0 @@ -package trik.testsys.webclient.service.entity.impl - -import org.springframework.stereotype.Service -import trik.testsys.core.service.named.AbstractNamedEntityService -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.repository.TaskRepository -import java.time.LocalDateTime - -@Service -class TaskService( - private val solutionService: SolutionService -) : AbstractNamedEntityService() { - - override fun validateName(entity: Task) = - super.validateName(entity) && !entity.name.contains(entity.developer.accessToken) - - override fun validateAdditionalInfo(entity: Task) = - super.validateAdditionalInfo(entity) && !entity.additionalInfo.contains(entity.developer.accessToken) - - fun findByDeveloper(developer: Developer) = repository.findByDeveloper(developer) - - fun getLastTest(task: Task): Solution? { - val solutions = solutionService.findTaskTests(task) - val lastTest = solutions.maxByOrNull { it.creationDate ?: LocalDateTime.MIN } - - return lastTest - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/WebUserService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/WebUserService.kt deleted file mode 100644 index c0f86259..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/WebUserService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package trik.testsys.webclient.service.entity.user - -import trik.testsys.core.repository.user.UserRepository -import trik.testsys.core.service.user.AbstractUserService -import trik.testsys.webclient.entity.user.WebUser - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -abstract class WebUserService> : AbstractUserService() { - - companion object { - - /** - * Checks if the [WebUser] is logging in for the first time. - * - * @return `true` if the [WebUser] is logging in for the first time, `false` otherwise. - * @author Roman Shishkin - * @since 2.0.0 - **/ - fun WebUser.isFirstTimeLoggedIn() = lastLoginDate == null - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/AdminService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/AdminService.kt deleted file mode 100644 index 3a598a67..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/AdminService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.repository.user.AdminRepository -import trik.testsys.webclient.service.entity.user.WebUserService - -@Service -class AdminService : WebUserService() { - - override fun validateName(entity: Admin) = - !entity.name.contains(entity.viewer.regToken) && super.validateName(entity) - - override fun validateAdditionalInfo(entity: Admin) = - !entity.additionalInfo.contains(entity.viewer.regToken) && super.validateAdditionalInfo(entity) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/DeveloperService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/DeveloperService.kt deleted file mode 100644 index a1fcfda1..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/DeveloperService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.repository.user.DeveloperRepository -import trik.testsys.webclient.service.entity.user.WebUserService - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Service -class DeveloperService : WebUserService() - diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/JudgeService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/JudgeService.kt deleted file mode 100644 index 251667db..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/JudgeService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.repository.user.JudgeRepository -import trik.testsys.webclient.service.entity.user.WebUserService - -@Service -class JudgeService : WebUserService() \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/StudentService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/StudentService.kt deleted file mode 100644 index 02a492e0..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/StudentService.kt +++ /dev/null @@ -1,126 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Service -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.repository.user.StudentRepository -import trik.testsys.webclient.service.entity.impl.SolutionVerdictService -import trik.testsys.webclient.service.entity.user.WebUserService -import trik.testsys.webclient.service.token.access.AccessTokenGenerator -import java.util.* - -/** - * @author Roman Shishkin - * @since 1.0.0 - */ -@Service -class StudentService( - @Qualifier("studentAccessTokenGenerator") private val accessTokenGenerator: AccessTokenGenerator, - - private val solutionVerdictService: SolutionVerdictService -): WebUserService() { - - override fun validateName(entity: Student) = - !entity.name.contains(entity.group.regToken) && super.validateName(entity) - - override fun validateAdditionalInfo(entity: Student) = - !entity.additionalInfo.contains(entity.group.regToken) && super.validateAdditionalInfo(entity) - - fun generate(count: Long, group: Group): List { - val students = mutableListOf() - - for (i in 1..count) { - val number = i - val accessToken = accessTokenGenerator.generate(number.toString() + group.regToken) - val name = "st-${group.id}-${UUID.randomUUID().toString().substring(4, 18)}-$number" - - val student = Student(name, accessToken) - student.group = group - - students.add(student) - } - - repository.saveAll(students) - - return students - } - - fun export(groups: List): String { - val students = groups.asSequence() - .map { it.students }.flatten() - .toSet() - .sortedWith( - compareBy( - { it.group.admin.id }, - { it.group.id }, - { it.id } - ) - ) - - val tasks = students.asSequence() - .map { it.solutions }.flatten() - .map { it.task } - .distinct() - .toSet() - .sortedBy { it.id } - - val bestScoresByStudents: Map> = students.associateWith { student -> - tasks.map { task -> - val solution = student.getBestSolutionFor(task) ?: return@map "–" - - val solutionVerdict = solutionVerdictService.findByStudentAndTask(student, task).firstOrNull() - solutionVerdict?.score?.toString() ?: solution.score.toString() - } - } - - val csvHeader = listOf("ID Организатора", "Псевдоним Организатора", "ID Группы", "Псевдоним Группы", "ID Участника", "Псевдоним Участника", *tasks.map { "${it.id}: ${it.name}" }.toTypedArray()) - .joinToString(separator = ";") - .plus("\n") - - val csvData = students.map { student -> - listOf( - student.group.admin.id.toString(), - student.group.admin.name, - student.group.id.toString(), - student.group.name, - student.id.toString(), - student.name, - *bestScoresByStudents[student]!!.toTypedArray() - ) - } - - val csvDataString = csvData.joinToString(separator = "\n") { it.joinToString(separator = ";") } - val csv = csvHeader.plus(csvDataString) - - return csv - } - - fun generate(additionalInfos: List, group: Group): List { - val students = mutableListOf() - - for (additionalInfo in additionalInfos) { - val accessToken = accessTokenGenerator.generate(additionalInfo) - val name = "st-${UUID.randomUUID().toString().substring(4, 18)}" - - val student = Student(name, accessToken).also { it.group = group } - student.additionalInfo = additionalInfo - - students.add(student) - } - - repository.saveAll(students) - - return students - } - - companion object { - fun Student.getBestSolutionFor(task: Task): Solution? { - return solutions - .filter { it.task.id == task.id && it.status == Solution.SolutionStatus.PASSED } - .maxByOrNull { it.score } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/SuperUserService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/SuperUserService.kt deleted file mode 100644 index bdaed0b6..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/SuperUserService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.entity.user.impl.SuperUser -import trik.testsys.webclient.repository.user.SuperUserRepository -import trik.testsys.webclient.service.entity.user.WebUserService - -@Service -class SuperUserService : WebUserService() \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/ViewerService.kt b/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/ViewerService.kt deleted file mode 100644 index bdb78baa..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/entity/user/impl/ViewerService.kt +++ /dev/null @@ -1,42 +0,0 @@ -package trik.testsys.webclient.service.entity.user.impl - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Service -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.repository.user.ViewerRepository -import trik.testsys.webclient.service.entity.RegEntityService -import trik.testsys.webclient.service.entity.user.WebUserService -import trik.testsys.webclient.service.token.access.AccessTokenGenerator - -/** - * @author Roman Shishkin - * @since 1.1.0 - */ -@Service -class ViewerService( - private val adminService: AdminService, - @Qualifier("webUserAccessTokenGenerator") private val webUserAccessTokenGenerator: AccessTokenGenerator -) : - RegEntityService, - WebUserService() { - - override fun findByRegToken(regToken: AccessToken) = repository.findByRegToken(regToken) - - override fun register(regToken: AccessToken, name: String): Admin? { - val viewer = findByRegToken(regToken) ?: return null - - val accessToken = webUserAccessTokenGenerator.generate(name) - val admin = Admin(name, accessToken) - admin.viewer = viewer - - return admin.takeIf { adminService.validateName(it) }?.also { adminService.save(it) } - } - - override fun validateAdditionalInfo(entity: Viewer) = - super.validateAdditionalInfo(entity) && super.validateAdditionalInfo(entity) - - override fun validateName(entity: Viewer) = - super.validateName(entity) && super.validateName(entity) -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/impl/FileManagerImpl.kt b/src/main/kotlin/trik/testsys/webclient/service/impl/FileManagerImpl.kt deleted file mode 100644 index 6fd5b009..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/impl/FileManagerImpl.kt +++ /dev/null @@ -1,237 +0,0 @@ -package trik.testsys.webclient.service.impl - -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile -import org.zeroturnaround.zip.ZipUtil -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.impl.TaskFileAudit -import trik.testsys.webclient.service.FileManager -import trik.testsys.webclient.service.Grader -import java.io.* -import java.nio.file.Files -import javax.annotation.PostConstruct - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Service -class FileManagerImpl( - @Value("\${path.taskFiles.solutions}") taskFileSolutionsPath: String, - @Value("\${path.taskFiles.exercises}") taskFileExercisesPath: String, - @Value("\${path.taskFiles.polygons}") taskFilePolygonsPath: String, - @Value("\${path.taskFiles.conditions}") taskFileConditionsPath: String, - - - @Value("\${path.files.solutions}") solutionsPath: String, - @Value("\${path.files.verdicts}") verdictsPath: String, - @Value("\${path.files.recordings}") recordingsPath: String, - @Value("\${path.files.results}") resultsPath: String -) : FileManager { - - private val taskFileSolutionsDir = File(taskFileSolutionsPath) - private val taskFileExercisesDir = File(taskFileExercisesPath) - private val taskFilePolygonsDir = File(taskFilePolygonsPath) - private val taskFileConditionsDir = File(taskFileConditionsPath) - - private val solutionsDir = File(solutionsPath) - private val verdictsDir = File(verdictsPath) - private val recordingsDir = File(recordingsPath) - private val resultsDir = File(resultsPath) - - @PostConstruct - fun init() { - if (!taskFileSolutionsDir.exists()) taskFileSolutionsDir.mkdirs() - if (!taskFileExercisesDir.exists()) taskFileExercisesDir.mkdirs() - if (!taskFilePolygonsDir.exists()) taskFilePolygonsDir.mkdirs() - if (!taskFileConditionsDir.exists()) taskFileConditionsDir.mkdirs() - - if (!solutionsDir.exists()) solutionsDir.mkdirs() - if (!verdictsDir.exists()) verdictsDir.mkdirs() - if (!recordingsDir.exists()) recordingsDir.mkdirs() - if (!resultsDir.exists()) resultsDir.mkdirs() - } - - override fun saveTaskFile(taskFileAudit: TaskFileAudit, fileData: MultipartFile): Boolean { - val taskFile = taskFileAudit.taskFile - logger.info("Saving task file with id ${taskFile.id}") - - val dir = getTaskFileDir(taskFile) - val extension = getTaskFileExtension(taskFile) - - try { - val file = File(dir, "${taskFileAudit.fileName}$extension") - fileData.transferTo(file) - } catch (e: Exception) { - logger.error("Error while saving task file with id ${taskFile.id}", e) - return false - } - - return true - } - - override fun getTaskFile(taskFile: TaskFile): File? { - logger.info("Getting task file with type '${taskFile.type}' and id ${taskFile.id}") - - val dir = getTaskFileDir(taskFile) - val extension = getTaskFileExtension(taskFile) - val file = File(dir, "${taskFile.latestFileName}$extension") - - if (!file.exists()) { - logger.error("Task file with id ${taskFile.id} not found") - return null - } - - return file - } - - override fun getTaskFileAuditFile(taskFileAudit: TaskFileAudit): File? { - logger.info("Getting task file audit file with id ${taskFileAudit.id}") - - val taskFile = taskFileAudit.taskFile - val dir = getTaskFileDir(taskFile) - val extension = getTaskFileExtension(taskFile) - val file = File(dir, "${taskFileAudit.fileName}$extension") - - if (!file.exists()) { - logger.error("Task file audit file with id ${taskFileAudit.id} not found") - return null - } - - return file - } - - override fun getTaskFileExtension(taskFile: TaskFile) = when (taskFile.type) { - TaskFile.TaskFileType.SOLUTION -> ".qrs" - TaskFile.TaskFileType.EXERCISE -> ".qrs" - TaskFile.TaskFileType.POLYGON -> ".xml" - TaskFile.TaskFileType.CONDITION -> ".pdf" - } - - private fun getTaskFileDir(taskFile: TaskFile) = when (taskFile.type) { - TaskFile.TaskFileType.SOLUTION -> taskFileSolutionsDir - TaskFile.TaskFileType.EXERCISE -> taskFileExercisesDir - TaskFile.TaskFileType.POLYGON -> taskFilePolygonsDir - TaskFile.TaskFileType.CONDITION -> taskFileConditionsDir - } - - override fun getTaskFiles(task: Task): Collection { - TODO() - } - - override fun saveSolutionFile(solution: Solution, file: File): Boolean { - logger.info("Saving solution file with id ${solution.id}") - - val solutionFile = File(solutionsDir, "${solution.id}.qrs") - - if (!file.exists()) { - logger.error("Solution file with id ${solution.id} not found") - return false - } - - try { - Files.copy(file.toPath(), solutionFile.toPath()) - } catch (e: Exception) { - logger.error("Error while saving solution file with id ${solution.id}", e) - return false - } - - return true - } - - override fun saveSolutionFile(solution: Solution, fileData: MultipartFile): Boolean { - logger.info("Saving solution file with id ${solution.id}") - - try { - val solutionFile = File(solutionsDir, "${solution.id}.qrs") - fileData.transferTo(solutionFile) - } catch (e: Exception) { - logger.error("Error while saving solution file with id ${solution.id}", e) - return false - } - - return true - } - - override fun getSolutionFile(solution: Solution): File? { - logger.info("Getting solution file with id ${solution.id}") - - val file = File(solutionsDir, "${solution.id}.qrs") - - if (!file.exists()) { - logger.error("Solution file with id ${solution.id} not found") - return null - } - - return file - } - - override fun saveSuccessfulGradingInfo(fieldResult: Grader.GradingInfo.Ok) { - logger.info("Saving ok grading info") - - val (solutionId, fieldResults) = fieldResult - fieldResults.forEach { (fieldName, verdict, recording) -> - logger.info("Field $fieldName: verdict ${verdict.name}, recording ${recording?.name}") - - verdict.content.let { verdictContent -> - val verdictFile = File(verdictsDir, "${solutionId}_$fieldName.txt") - verdictFile.writeBytes(verdictContent) - - logger.info("Verdict saved to ${verdictFile.absolutePath}") - } - - recording?.content?.let { recordingContent -> - val recordingFile = File(recordingsDir, "${solutionId}_$fieldName.mp4") - recordingFile.writeBytes(recordingContent) - - logger.info("Recording saved to ${recordingFile.absolutePath}") - } - - } - } - - override fun getVerdictFiles(solution: Solution): List { - logger.info("Getting verdict files for solution with id ${solution.id}") - - val verdictFiles = verdictsDir.listFiles { _, name -> name.startsWith("${solution.id}_") } ?: emptyArray() - - return verdictFiles.toList() - } - - override fun getRecordingFiles(solution: Solution): List { - logger.info("Getting recording files for solution with id ${solution.id}") - - val recordingFiles = recordingsDir.listFiles { _, name -> name.startsWith("${solution.id}_") } ?: emptyArray() - - return recordingFiles.toList() - } - - override fun getSolutionResultFilesCompressed(solution: Solution): File { - logger.info("Getting compressed solution result files for solution with id ${solution.id}") - - val resultsFile = File(resultsDir, "${solution.id}_results.zip") - - if (resultsFile.exists()) { - logger.info("Compressed solution result files for solution with id ${solution.id} already exist") - - return resultsFile - } - - val verdicts = getVerdictFiles(solution) - val recordings = getRecordingFiles(solution) - val results = verdicts + recordings - - ZipUtil.packEntries(results.toTypedArray(), resultsFile) - - return resultsFile - } - - companion object { - - private val logger = LoggerFactory.getLogger(FileManagerImpl::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/impl/LogoServiceImpl.kt b/src/main/kotlin/trik/testsys/webclient/service/impl/LogoServiceImpl.kt deleted file mode 100644 index 2ecd926b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/impl/LogoServiceImpl.kt +++ /dev/null @@ -1,50 +0,0 @@ -package trik.testsys.webclient.service.impl - -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.LogoService -import trik.testsys.webclient.view.impl.LogosView -import java.io.File - -/** - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ -@Service -class LogoServiceImpl( - @Value("\${path.logos.sponsor}") private val sponsorLogosPath: String, - @Value("\${path.logos.main}") private val mainLogoFilePath: String, -) : LogoService { - - private val sponsorLogosDir = File(sponsorLogosPath) - private val mainLogo = File(mainLogoFilePath) - - init { - if (!sponsorLogosDir.exists() || !sponsorLogosDir.isDirectory) { - logger.warn("Sponsor logos directory not found: $sponsorLogosPath. Creating...") - sponsorLogosDir.mkdirs() - } - - if (!mainLogo.exists() || !mainLogo.isFile) { - logger.warn("Main logo file not found: $mainLogoFilePath") - } - } - - override fun getLogos(): LogosView { - val builder = LogosView.builder() - - sponsorLogosDir.listFiles() - ?.filter { it.isFile } - ?.forEach { builder.addSponsorLogo(it.path) } - - mainLogo.takeIf { it.exists() }?.let { builder.addMainLogo(it.path) } - - return builder.build() - } - - companion object { - - private val logger = LoggerFactory.getLogger(LogoServiceImpl::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/impl/UserAgentParserImpl.kt b/src/main/kotlin/trik/testsys/webclient/service/impl/UserAgentParserImpl.kt deleted file mode 100644 index e37a841c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/impl/UserAgentParserImpl.kt +++ /dev/null @@ -1,108 +0,0 @@ -package trik.testsys.webclient.service.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.UserAgentParser -import ua_parser.Client -import ua_parser.Parser -import java.nio.charset.Charset - -@Service -class UserAgentParserImpl : UserAgentParser { - - private val parser = Parser() - - override fun getClientInfo(userAgent: String): Client = parser.parse(userAgent) - - override fun getCharset(userAgent: String): Charset { - try { - val clientInfo = getClientInfo(userAgent) - val osFamily = OsFamily.getOsFamily(clientInfo.os.family) - val charset = osFamily.getCharsetByVersion(clientInfo.os.major) - - return charset - } catch (e: Exception) { - return UTF_8 - } - } - - sealed interface OsFamily { - - val name: String - - fun getCharsetByVersion(version: String?): Charset = UTF_8 - - object WINDOWS : OsFamily { - - override val name = "Windows NT" - - override fun getCharsetByVersion(version: String?): Charset { - version ?: super.getCharsetByVersion(version) - val charset = charsetsByVersion[version] ?: WINDOWS_1251 - - return charset - } - - private val charsetsByVersion = mapOf( - "5.1" to WINDOWS_1251, - "6.0" to WINDOWS_1251, - "6.1" to WINDOWS_1251, - "6.2" to WINDOWS_1251, - "6.3" to WINDOWS_1251, - "10.0" to UTF_8 - ) - } - - object MacOs : OsFamily { - - override val name = "Mac OS X" - } - - object Linux : OsFamily { - - override val name = "Linux" - } - - object Android : OsFamily { - - override val name = "Android" - } - - object IOS : OsFamily { - - override val name = "iPhone OS" - } - - object IpadOS : OsFamily { - - override val name = "iPad; CPU OS" - } - - object Unknown : OsFamily { - - override val name = "Unknown" - } - - companion object { - - val osFamiliesByName = mapOf( - WINDOWS.name to WINDOWS, - MacOs.name to MacOs, - Linux.name to Linux, - Android.name to Android, - IOS.name to IOS, - IpadOS.name to IpadOS, - Unknown.name to Unknown - ) - - fun getOsFamily(osFamily: String?): OsFamily = osFamiliesByName[osFamily] ?: Unknown - } - } - - companion object { - - val WINDOWS_1251: Charset = Charset.forName("windows-1251") - val WINDOWS_1252: Charset = Charset.forName("windows-1252") - - val UTF_8 = Charsets.UTF_8 - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/UserValidator.kt b/src/main/kotlin/trik/testsys/webclient/service/security/UserValidator.kt deleted file mode 100644 index 68421161..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/UserValidator.kt +++ /dev/null @@ -1,19 +0,0 @@ -package trik.testsys.webclient.service.security - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.user.WebUser - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -interface UserValidator { - - /** - * Validates if [WebUser] exists by [accessToken]. Returns existence [WebUser] object, `null` - otherwise. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - fun validateExistence(accessToken: AccessToken?): WebUser? -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/impl/UserValidatorImpl.kt b/src/main/kotlin/trik/testsys/webclient/service/security/impl/UserValidatorImpl.kt deleted file mode 100644 index df74a245..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/impl/UserValidatorImpl.kt +++ /dev/null @@ -1,22 +0,0 @@ -package trik.testsys.webclient.service.security.impl - -import org.springframework.context.ApplicationContext -import org.springframework.stereotype.Service -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.user.WebUser -import trik.testsys.webclient.service.entity.user.WebUserService -import trik.testsys.webclient.service.security.UserValidator - -@Service -class UserValidatorImpl( - context: ApplicationContext -) : UserValidator { - - private val webUserServices = context.getBeansOfType(WebUserService::class.java).values - - override fun validateExistence(accessToken: AccessToken?): WebUser? { - accessToken ?: return null - val entity = webUserServices.firstNotNullOfOrNull { it.findByAccessToken(accessToken) } - return entity - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/login/SecurityProcessor.kt b/src/main/kotlin/trik/testsys/webclient/service/security/login/SecurityProcessor.kt deleted file mode 100644 index 57b45323..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/login/SecurityProcessor.kt +++ /dev/null @@ -1,7 +0,0 @@ -package trik.testsys.webclient.service.security.login - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -interface SecurityProcessor \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/login/SessionData.kt b/src/main/kotlin/trik/testsys/webclient/service/security/login/SessionData.kt deleted file mode 100644 index 85392c06..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/login/SessionData.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.service.security.login - -import java.io.Serializable - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -interface SessionData : Serializable { - - /** - * Sets all fields to null. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - fun invalidate() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginData.kt b/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginData.kt deleted file mode 100644 index bba0955b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginData.kt +++ /dev/null @@ -1,21 +0,0 @@ -package trik.testsys.webclient.service.security.login.impl - -import org.springframework.stereotype.Service -import org.springframework.web.context.annotation.SessionScope -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.service.security.login.SessionData - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service -@SessionScope -class LoginData : SessionData { - - var accessToken: AccessToken? = null - - override fun invalidate() { - accessToken = null - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginProcessor.kt b/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginProcessor.kt deleted file mode 100644 index 70e7885c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/security/login/impl/LoginProcessor.kt +++ /dev/null @@ -1,32 +0,0 @@ -package trik.testsys.webclient.service.security.login.impl - -import org.springframework.stereotype.Service -import org.springframework.web.context.annotation.RequestScope -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.service.security.UserValidator -import trik.testsys.webclient.service.security.login.SecurityProcessor - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service -@RequestScope -class LoginProcessor( - private val loginData: LoginData, - private val userValidator: UserValidator -) : SecurityProcessor { - - private lateinit var accessToken: String - - fun login(): Boolean { - val webUser = userValidator.validateExistence(accessToken) ?: return false - - loginData.accessToken = webUser.accessToken - return true - } - - fun setCredentials(accessToken: AccessToken) { - this.accessToken = accessToken - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/startup/impl/StartupListener.kt b/src/main/kotlin/trik/testsys/webclient/service/startup/impl/StartupListener.kt deleted file mode 100644 index 8b0ee4e9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/startup/impl/StartupListener.kt +++ /dev/null @@ -1,51 +0,0 @@ -package trik.testsys.webclient.service.startup.impl - -import org.slf4j.LoggerFactory -import org.springframework.boot.context.event.ApplicationReadyEvent -import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationListener -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.startup.runner.StartupRunner - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Service -class StartupListener( - context: ApplicationContext -) : ApplicationListener { - - private val startUpRunnersMap = context.getBeansOfType(StartupRunner::class.java) - - override fun onApplicationEvent(event: ApplicationReadyEvent) { - val startTime = System.currentTimeMillis() - - logger.info("Starting startup runners...") - logger.info("Found ${startUpRunnersMap.size} startup runners.") - - startUpRunnersMap.forEach { (name, runner) -> - logger.info("Starting runner $name...") - - runner.tryRun() - } - - val endTime = System.currentTimeMillis() - logger.info("All startup runners finished.") - - logger.info("Startup took ${(endTime - startTime) / 1000.0} seconds.") - } - - private fun StartupRunner.tryRun() = try { - runBlocking() - } catch (e: Exception) { - logger.error("Error while running startup runner", e) - } finally { - logger.info("Startup runner finished.") - } - - companion object { - - private val logger = LoggerFactory.getLogger(StartupListener::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/StartupRunner.kt b/src/main/kotlin/trik/testsys/webclient/service/startup/runner/StartupRunner.kt deleted file mode 100644 index 42144c15..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/StartupRunner.kt +++ /dev/null @@ -1,12 +0,0 @@ -package trik.testsys.webclient.service.startup.runner - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -interface StartupRunner { - - fun runBlocking() - - suspend fun run() -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/EntityCreatorRunner.kt b/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/EntityCreatorRunner.kt deleted file mode 100644 index 95c5546c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/EntityCreatorRunner.kt +++ /dev/null @@ -1,352 +0,0 @@ -package trik.testsys.webclient.service.startup.runner.impl - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.beans.factory.config.BeanDefinition -import org.springframework.context.annotation.Scope -import org.springframework.stereotype.Service -import trik.testsys.core.entity.Entity -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.entity.user.UserEntity -import trik.testsys.core.service.EntityService -import trik.testsys.core.service.user.UserService -import trik.testsys.webclient.entity.RegEntity -import trik.testsys.webclient.entity.impl.ApiKey -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.* -import trik.testsys.webclient.service.entity.RegEntityService -import trik.testsys.webclient.service.entity.impl.ApiKeyService -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.user.impl.* -import trik.testsys.webclient.service.startup.runner.StartupRunner -import java.io.File -import java.nio.file.Files -import javax.xml.bind.annotation.XmlRootElement - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -@Service -@Scope(BeanDefinition.SCOPE_PROTOTYPE) -class EntityCreatorRunner( - @Value("\${path.startup.entities}") private val entitiesDirPath: String, - - private val adminService: AdminService, - private val developerService: DeveloperService, - private val judgeService: JudgeService, - private val studentService: StudentService, - private val superUserService: SuperUserService, - private val viewerService: ViewerService, - private val groupService: GroupService, - private val apiKeyService: ApiKeyService -) : StartupRunner { - - private lateinit var entitiesDir: File - - private var hasInjectedEntities = false - - private fun initEntitiesDir(): Boolean { - logger.info("Checking entities directory by path $entitiesDirPath.") - entitiesDir = File(entitiesDirPath) - - if (!entitiesDir.exists()) { - logger.error("Entities directory by path $entitiesDirPath does not exist.") - return false - } - - if (!entitiesDir.isDirectory) { - logger.error("Entities directory by path $entitiesDirPath is not a directory.") - return false - } - - logger.info("Entities directory by path $entitiesDirPath exists.") - return true - } - - override fun runBlocking() { - initEntitiesDir() - - // Entities without dependencies to others. They should be created first. - developerService.createEntitiesWithoutDependencies(DEVELOPERS_FILE_NAME) - judgeService.createEntitiesWithoutDependencies(JUDGES_FILE_NAME) - superUserService.createEntitiesWithoutDependencies(SUPER_USERS_FILE_NAME) - viewerService.createEntitiesWithoutDependencies(VIEWERS_FILE_NAME) - apiKeyService.createEntitiesWithoutDependencies(API_KEYS_FILE_NAME) - - // Entities with dependencies to others. They should be created after entities without dependencies. - adminService.createEntitiesWithWebUserDependencies(ADMINS_FILE_NAME, viewerService) // Depends on Viewer - groupService.createEntitiesWithWebUserDependencies(GROUPS_FILE_NAME, adminService) // Depends on Admin - studentService.createEntitiesWithGroupDependencies(STUDENTS_FILE_NAME, groupService) // Depends on Group - - if (hasInjectedEntities) afterRun() - } - - override suspend fun run() { - TODO() - } - - private fun afterRun() { - logger.info("All entities were created.") - logger.info("Moving entities files to backup directory.") - - val backupDir = File(entitiesDir, BACKUP_DIR_NAME + "_" + System.currentTimeMillis()) - if (!backupDir.exists()) backupDir.mkdir() - - entitiesDir.listFiles()?.forEach { file -> - if (file.isDirectory) return@forEach - Files.move(file.toPath(), File(backupDir, file.name).toPath()) - } - - logger.info("Entities files were moved to backup directory.") - } - - private inline fun > EntityService.createEntitiesWithoutDependencies( - fileName: String - ) { - logger.info("Creating ${E::class.simpleName}s from file $fileName.") - val file = getEntitiesFile(fileName) ?: return - - val objectMapper = createMapper() - val entitiesData = objectMapper.getEntitiesData(file) ?: return - - saveAll(entitiesData) - } - - private inline fun > EntityService.createEntitiesWithWebUserDependencies( - fileName: String, externalService: UserService - ) { - logger.info("Creating ${E::class.simpleName}s from file $fileName.") - val file = getEntitiesFile(fileName) ?: return - - val objectMapper = createMapper() - val entitiesData = objectMapper.getEntitiesData(file) ?: return - - saveAll(entitiesData, externalService) - } - - private inline fun > EntityService.createEntitiesWithGroupDependencies( - fileName: String, externalService: RegEntityService - ) { - logger.info("Creating ${E::class.simpleName}s from file $fileName.") - val file = getEntitiesFile(fileName) ?: return - - val objectMapper = createMapper() - val entitiesData = objectMapper.getEntitiesData(file) ?: return - - saveAll(entitiesData, externalService) - } - - private fun > EntityService.saveAll( - entitiesData: Collection - ) = entitiesData.forEach { entityData -> entityData?.toEntity()?.let { trySave(it) } } - - private fun > EntityService.saveAll( - entitiesData: Collection, - externalService: UserService - ) = entitiesData.forEach { entityData -> entityData?.toEntity(externalService)?.let { trySave(it) } } - - private fun > EntityService.saveAll( - entitiesData: Collection, - externalService: RegEntityService - ) = entitiesData.forEach { entityData -> entityData?.toEntity(externalService)?.let { trySave(it) } } - - private fun EntityService.trySave(entity: E) = try { - save(entity) - hasInjectedEntities = true - } catch (e: Exception) { - logger.error("Error while saving entity $entity.", e) - } - - private inline fun > ObjectMapper.getEntitiesData(file: File): List? = - try { - readValue(file, object : TypeReference?>() {}) ?: run { - logger.error("Entities data from file ${file.name} is null.") - null - } - } catch (e: Exception) { - logger.error("Error while reading entities data from file ${file.name}.", e) - null - } - - - private fun getEntitiesFile(fileName: String): File? { - val file = File(entitiesDir, fileName) - - if (!file.exists()) { - logger.warn("File $fileName does not exist.") - return null - } - - return file - } - - private fun createMapper(): ObjectMapper { - val objectMapper = ObjectMapper() - objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return objectMapper - } - - sealed interface EntityData { - - interface EntityDataWithoutDependencies : EntityData { - - fun toEntity(): E - } - - sealed interface EntityDataWithDependencies : EntityData { - - interface EntityDataWithUserEntityDependencies : EntityDataWithDependencies { - - fun > toEntity(externalService: S): E? - } - - interface EntityDataWithRegEntityDependencies : EntityDataWithDependencies { - - fun > toEntity(externalService: S): E? - } - } - } - - @XmlRootElement - private data class AdminData( - val name: String = "", - val accessToken: AccessToken = "", - val viewerAccessToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithDependencies.EntityDataWithUserEntityDependencies { - - override fun > toEntity(externalService: S) = - externalService.findByAccessToken(viewerAccessToken)?.let { viewer -> - Admin(name, accessToken).also { - it.viewer = viewer as Viewer - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } ?: run { - logger.error("Viewer with accessToken $viewerAccessToken not found.") - null - } - } - - @XmlRootElement - private data class DeveloperData( - val name: String = "", - val accessToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithoutDependencies { - - override fun toEntity() = Developer(name, accessToken).also { - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } - - @XmlRootElement - private data class JudgeData( - val name: String = "", - val accessToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithoutDependencies { - - override fun toEntity() = Judge(name, accessToken).also { - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } - - @XmlRootElement - private data class StudentData( - val name: String = "", - val accessToken: AccessToken = "", - val groupRegToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithDependencies.EntityDataWithRegEntityDependencies { - - - override fun > toEntity(externalService: S): Student? = - externalService.findByRegToken(groupRegToken)?.let { group -> - Student(name, accessToken).also { - it.group = group as Group - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } ?: run { - logger.error("Group with accessToken $groupRegToken not found.") - null - } - } - - @XmlRootElement - private data class SuperUserData( - val name: String = "", - val accessToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithoutDependencies { - - override fun toEntity() = SuperUser(name, accessToken).also { - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } - - @XmlRootElement - private data class ViewerData( - val name: String = "", - val accessToken: AccessToken = "", - val regToken: String = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithoutDependencies { - - override fun toEntity() = Viewer(name, accessToken, regToken).also { - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } - - @XmlRootElement - private data class GroupData( - val name: String = "", - val regToken: AccessToken = "", - val adminAccessToken: AccessToken = "", - val additionalInfo: String? = null - ) : EntityData.EntityDataWithDependencies.EntityDataWithUserEntityDependencies { - - override fun > toEntity(externalService: S): Group? = - externalService.findByAccessToken(adminAccessToken)?.let { admin -> - Group(name, regToken).also { - it.admin = admin as Admin - additionalInfo?.let { addInfo -> it.additionalInfo = addInfo } - } - } ?: run { - logger.error("Admin with accessToken $adminAccessToken not found.") - null - } - } - - /** - * @author Roman Shishkin - * @since 2.5.0 - */ - @XmlRootElement - private data class ApiKeyData( - val value: String = "" - ) : EntityData.EntityDataWithoutDependencies { - - override fun toEntity() = ApiKey(value) - } - - companion object { - - private val logger = LoggerFactory.getLogger(EntityCreatorRunner::class.java) - - private const val ADMINS_FILE_NAME = "admins.json" - private const val DEVELOPERS_FILE_NAME = "developers.json" - private const val JUDGES_FILE_NAME = "judges.json" - private const val STUDENTS_FILE_NAME = "students.json" - private const val SUPER_USERS_FILE_NAME = "superusers.json" - private const val VIEWERS_FILE_NAME = "viewers.json" - private const val GROUPS_FILE_NAME = "groups.json" - - private const val API_KEYS_FILE_NAME = "api_keys.json" - - private const val BACKUP_DIR_NAME = "backup" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/GradingInfoParserRunner.kt b/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/GradingInfoParserRunner.kt deleted file mode 100644 index 40602281..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/GradingInfoParserRunner.kt +++ /dev/null @@ -1,223 +0,0 @@ -package trik.testsys.webclient.service.startup.runner.impl - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -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.entity.impl.ContestService -import trik.testsys.webclient.service.entity.impl.SolutionService -import trik.testsys.webclient.service.entity.impl.TaskService -import trik.testsys.webclient.service.startup.runner.StartupRunner -import java.io.File -import java.util.regex.Pattern -import javax.xml.bind.annotation.XmlRootElement - -@Service -class GradingInfoParserRunner( - private val grader: Grader, - private val fileManager: FileManager, - private val solutionService: SolutionService, - private val taskService: TaskService, - private val contestService: ContestService, - - @Value("\${trik-studio-version}") private val trikStudioVersion: String, - @Value("\${grading-node-addresses}") private val gradingNodeAddresses: String -) : StartupRunner { - - override suspend fun run() { - TODO("Not yet implemented") - } - - override fun runBlocking() { - addGraderNodes() - addGraderSubscription() - sendToGradeUngradedSolutions() - } - - private fun sendToGradeUngradedSolutions() { - logger.info("Sending ungraded solutions to grade...") - - val ungradedSolutions = solutionService.findAll() - .filter { it.status == Solution.SolutionStatus.NOT_STARTED || it.status == Solution.SolutionStatus.IN_PROGRESS } - .filter { - val file = fileManager.getSolutionFile(it) - if (file == null) { - logger.error("Solution file for solution ${it.id} is missing.") - - it.status = Solution.SolutionStatus.ERROR - it.score = 0 - if (it.isLastTaskTest()) changeTaskTestingResult(it) - solutionService.save(it) - - false - } else { - true - } - } - - logger.info("Found ${ungradedSolutions.size} ungraded solutions.") - - ungradedSolutions.forEach { - grader.sendToGrade(it, Grader.GradingOptions(true, trikStudioVersion)) - } - } - - private fun addGraderNodes() { - logger.info("Adding grader nodes...") - - val parsedAddresses = gradingNodeAddresses.split(",") - logger.info("Parsed addresses: $parsedAddresses") - - parsedAddresses.forEach { grader.addNode(it) } - - logger.info("Grader nodes were added.") - } - - private fun addGraderSubscription() = grader.subscribeOnGraded { gradingInfo -> - logger.info("Grading info was received for solutionId: ${gradingInfo.submissionId}") - - try { - when (gradingInfo) { - is Grader.GradingInfo.Ok -> gradingInfo.parse() - is Grader.GradingInfo.Error -> gradingInfo.parse() - } - } catch (e: Exception) { - logger.error("Failed to parse grading info.", e) - - afterCatchException(gradingInfo) - } - } - - private fun afterCatchException(gradingInfo: Grader.GradingInfo) { - try { - val solutionId = gradingInfo.submissionId - val solution = solutionService.find(solutionId.toLong()) ?: return - - solution.status = Solution.SolutionStatus.ERROR - solution.score = 0 - - solutionService.save(solution) - } catch (e: Exception) { - logger.error("Failed to handle exception.", e) - } - } - - private fun Grader.GradingInfo.Ok.parse() = let { (solutionId, _) -> - logger.info("Solution $solutionId was graded without errors.") - fileManager.saveSuccessfulGradingInfo(this) - - val solution = solutionService.find(solutionId.toLong()) ?: return@let - val verdicts = fileManager.getVerdictFiles(solution) - - val objectMapper = createMapper() - - var allFailed = true - var totalScore = 0L - - verdicts.forEach { verdict -> - val elements = objectMapper.readVerdictElements(verdict) ?: run { - logger.error("Failed to read verdict elements from file $verdict.") - - return@forEach - } - - val infoElements = elements.filter { it.level == VerdictElement.LEVEL_INFO } - val errorElements = elements.filter { it.level == VerdictElement.LEVEL_ERROR } - - if (errorElements.isNotEmpty()) return@forEach - - val score = infoElements - .filter { (_, message) -> VerdictElement.SCORE_PATTERN.matcher(message).find() } - .mapNotNull { (_, message) -> VerdictElement.matchScore(message) } - .maxOrNull() ?: return@forEach - - allFailed = false - totalScore += score - } - - solution.status = if (allFailed) Solution.SolutionStatus.FAILED else Solution.SolutionStatus.PASSED - solution.score = totalScore - - if (solution.isLastTaskTest()) changeTaskTestingResult(solution) - - solutionService.save(solution) - } - - fun Solution.isLastTaskTest() = student == null && taskService.getLastTest(task)?.id == this.id - - private fun changeTaskTestingResult(solution: Solution) { - if (solution.status == Solution.SolutionStatus.FAILED || !solution.task.hasExercise || !solution.task.hasSolution || solution.task.polygonsCount == 0L) { - solution.task.fail() - - solution.task.contests.forEach { - it.tasks.remove(solution.task) - contestService.save(it) - } - solution.task.contests.clear() - } - - if (solution.status == Solution.SolutionStatus.PASSED && solution.task.hasExercise && solution.task.hasSolution && solution.task.polygonsCount > 0L) { - solution.task.pass() - } - taskService.save(solution.task) - } - - private fun ObjectMapper.readVerdictElements(verdict: File): List? = try { - readValue(verdict, object : TypeReference>() {}) ?: run { - logger.error("Failed to read verdict elements from file $verdict.") - null - } - } catch (e: Exception) { - logger.error("Failed to read verdict elements from file $verdict.", e) - null - } - - private fun createMapper(): ObjectMapper { - val objectMapper = ObjectMapper() - objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - return objectMapper - } - - @XmlRootElement - private data class VerdictElement( - val level: String = "", - val message: String = "" - ) { - - companion object { - - val LEVEL_INFO = "info" - val LEVEL_ERROR = "error" - - // regex for strings like "Успешно пройдено. Набрано баллов: 100." - val SCORE_PATTERN = Pattern.compile("Набрано баллов:\\s*(\\d+)") - - fun matchScore(message: String): Long? { - val matcher = SCORE_PATTERN.matcher(message) - return if (matcher.find()) matcher.group(1).toLong() else null - } - } - } - - - private fun Grader.GradingInfo.Error.parse() = let { (solutionId, kind, description) -> - logger.info("Solution $solutionId was graded with error: $kind. Description: $description.") - val solution = solutionService.find(solutionId.toLong()) ?: return@let - - // timeout error - solution.status = if (kind == 4) Solution.SolutionStatus.FAILED else Solution.SolutionStatus.ERROR - solution.score = 0 - - solutionService.save(solution) - } - - companion object { - - private val logger = LoggerFactory.getLogger(GradingInfoParserRunner::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/LektoriumEntityCreatorRunner.kt b/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/LektoriumEntityCreatorRunner.kt deleted file mode 100644 index 77d7afed..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/startup/runner/impl/LektoriumEntityCreatorRunner.kt +++ /dev/null @@ -1,91 +0,0 @@ -package trik.testsys.webclient.service.startup.runner.impl - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.beans.factory.annotation.Value -import org.springframework.beans.factory.config.BeanDefinition -import org.springframework.context.annotation.Scope -import org.springframework.stereotype.Service -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.entity.user.impl.DeveloperService -import trik.testsys.webclient.service.entity.user.impl.ViewerService -import trik.testsys.webclient.service.startup.runner.StartupRunner -import trik.testsys.webclient.service.token.access.AccessTokenGenerator -import trik.testsys.webclient.service.token.reg.RegTokenGenerator - -@Service -@Scope(BeanDefinition.SCOPE_PROTOTYPE) -class LektoriumEntityCreatorRunner( - @Value("\${create-lektorium-users}") private val createLektoriumUsers: Boolean, - @Value("\${lektorium-group-reg-token}") private val groupRegToken: AccessToken, - - private val groupService: GroupService, - private val developerService: DeveloperService, - private val viewerService: ViewerService, - private val adminService: AdminService, - - @Qualifier("adminRegTokenGenerator") private val adminRegTokenGenerator: RegTokenGenerator, - @Qualifier("webUserAccessTokenGenerator") private val webUserAccessTokenGenerator: AccessTokenGenerator, -) : StartupRunner { - - override fun runBlocking() { - if (!createLektoriumUsers || hasLektoriumInitialized()) { - return - } - - val viewer = createViewer() - val admin = createAdmin(viewer) - val group = createGroup(admin) - val developer = createDeveloper() - } - - override suspend fun run() { - TODO("Not yet implemented") - } - - private fun hasLektoriumInitialized(): Boolean { - return viewerService.findAll().any { it.accessToken.startsWith(LEKTORIUM_PREFIX) } - } - - private fun createViewer(): Viewer { - val adminRegToken = adminRegTokenGenerator.generate(LEKTORIUM_PREFIX) - val viewerToken = webUserAccessTokenGenerator.generate(LEKTORIUM_PREFIX) - val viewer = Viewer("Lektorium Viewer", "$LEKTORIUM_PREFIX-$viewerToken", adminRegToken) - - return viewerService.save(viewer) - } - - private fun createAdmin(viewer: Viewer): Admin { - val adminToken = webUserAccessTokenGenerator.generate(LEKTORIUM_PREFIX) - val admin = Admin("Lektorium Admin", "$LEKTORIUM_PREFIX-$adminToken").also { - it.viewer = viewer - } - - return adminService.save(admin) - } - - private fun createGroup(admin: Admin): Group { - val group = Group("Lektorium Group", groupRegToken).also { - it.admin = admin - } - - return groupService.save(group) - } - - private fun createDeveloper(): Developer { - val developerToken = webUserAccessTokenGenerator.generate(LEKTORIUM_PREFIX) - val developer = Developer("Lektorium Developer", "$LEKTORIUM_PREFIX-$developerToken") - - return developerService.save(developer) - } - - companion object { - - private const val LEKTORIUM_PREFIX = "lkt" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/AbstractTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/AbstractTokenGenerator.kt deleted file mode 100644 index 93fbb76a..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/AbstractTokenGenerator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.service.token - -import trik.testsys.core.entity.user.AccessToken -import java.util.* - -/** - * @author Roman Shishkin - * @since 2.0.0 -**/ -abstract class AbstractTokenGenerator( - private val prefix: String = "" -) : TokenGenerator { - - // generate token like a61d-e3f2-4b3a-8b1c - override fun generate(string: String): AccessToken { - val uuid = UUID.randomUUID().toString() - val middleUUID = uuid.substringAfter("-").substringBeforeLast("-") - - val number = string.charsSum() % 10000 - val numberString = number.toString().padStart(4, '0') - - return "$prefix-$numberString-$middleUUID" - } - - private fun String.charsSum() = fold(0) { sum, char -> sum + char.code } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/TokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/TokenGenerator.kt deleted file mode 100644 index 1b94406c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/TokenGenerator.kt +++ /dev/null @@ -1,19 +0,0 @@ -package trik.testsys.webclient.service.token - - -/** - * Interface for token generator services. - * - * @author Roman Shishkin - * @since 2.0.0 - */ -interface TokenGenerator { - - /** - * Generates token by input [string]. - * - * @author Roman Shishkin - * @since 2.0.0 - */ - fun generate(string: String): String -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/access/AccessTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/access/AccessTokenGenerator.kt deleted file mode 100644 index f23961d0..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/access/AccessTokenGenerator.kt +++ /dev/null @@ -1,25 +0,0 @@ -package trik.testsys.webclient.service.token.access - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.entity.user.UserEntity -import trik.testsys.webclient.service.token.TokenGenerator - -/** - * Interface for [UserEntity.accessToken] generators. - * - * @author Roman Shishkin - * @since 2.0.0 - */ -interface AccessTokenGenerator : TokenGenerator { - - - /** - * Generates [UserEntity.accessToken] by [UserEntity.name]. - * - * @param string [UserEntity.name] - * - * @author Roman Shishkin - * @since 2.0.0 - */ - override fun generate(string: String): AccessToken -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/StudentAccessTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/StudentAccessTokenGenerator.kt deleted file mode 100644 index b55bf77b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/StudentAccessTokenGenerator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.service.token.access.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.token.AbstractTokenGenerator -import trik.testsys.webclient.service.token.access.AccessTokenGenerator - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service("studentAccessTokenGenerator") -class StudentAccessTokenGenerator : AccessTokenGenerator, AbstractTokenGenerator(STUDENT_ACCESS_TOKEN_PREFIX) { - - companion object { - - const val STUDENT_ACCESS_TOKEN_PREFIX = "st" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/WebUserAccessTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/WebUserAccessTokenGenerator.kt deleted file mode 100644 index d4878fa6..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/access/impl/WebUserAccessTokenGenerator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.service.token.access.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.token.AbstractTokenGenerator -import trik.testsys.webclient.service.token.access.AccessTokenGenerator - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service("webUserAccessTokenGenerator") -class WebUserAccessTokenGenerator : AccessTokenGenerator, AbstractTokenGenerator(WEB_USER_ACCESS_TOKEN_PREFIX) { - - companion object { - - const val WEB_USER_ACCESS_TOKEN_PREFIX = "wu" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/reg/RegTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/reg/RegTokenGenerator.kt deleted file mode 100644 index ee1bc247..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/reg/RegTokenGenerator.kt +++ /dev/null @@ -1,11 +0,0 @@ -package trik.testsys.webclient.service.token.reg - -import trik.testsys.webclient.service.token.TokenGenerator - -/** - * Interface for registration token generators. - * - * @author Roman Shishkin - * @since 2.0.0 - */ -interface RegTokenGenerator : TokenGenerator \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/AdminRegTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/AdminRegTokenGenerator.kt deleted file mode 100644 index 3875690e..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/AdminRegTokenGenerator.kt +++ /dev/null @@ -1,19 +0,0 @@ -package trik.testsys.webclient.service.token.reg.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.token.AbstractTokenGenerator -import trik.testsys.webclient.service.token.reg.RegTokenGenerator - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service("adminRegTokenGenerator") -class AdminRegTokenGenerator : RegTokenGenerator, AbstractTokenGenerator(ADMIN_REG_TOKEN_PREFIX) { - - - companion object { - - const val ADMIN_REG_TOKEN_PREFIX = "adm" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/GroupRegTokenGenerator.kt b/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/GroupRegTokenGenerator.kt deleted file mode 100644 index f1aa7853..00000000 --- a/src/main/kotlin/trik/testsys/webclient/service/token/reg/impl/GroupRegTokenGenerator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package trik.testsys.webclient.service.token.reg.impl - -import org.springframework.stereotype.Service -import trik.testsys.webclient.service.token.AbstractTokenGenerator -import trik.testsys.webclient.service.token.reg.RegTokenGenerator - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -@Service("groupRegTokenGenerator") -class GroupRegTokenGenerator : RegTokenGenerator, AbstractTokenGenerator(GROUP_REG_TOKEN_PREFIX) { - - companion object { - - const val GROUP_REG_TOKEN_PREFIX = "grp" - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/LocalDateTimeUtils.kt b/src/main/kotlin/trik/testsys/webclient/util/LocalDateTimeUtils.kt deleted file mode 100644 index 19e5597b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/LocalDateTimeUtils.kt +++ /dev/null @@ -1,75 +0,0 @@ -package trik.testsys.webclient.util - -import trik.testsys.core.entity.AbstractEntity -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.util.TimeZone - -/** - * Converts [this] from [AbstractEntity.DEFAULT_ZONE_ID] to [timeZone] zone. - * - * @author Roman Shishkin - * @since 2.0.0 - **/ -fun LocalDateTime.atTimeZone(timeZoneId: String?): LocalDateTime = - atZone(AbstractEntity.DEFAULT_ZONE_ID).withZoneSameInstant(TimeZone.getTimeZone(timeZoneId ?: "UTC").toZoneId()) - .toLocalDateTime() - -/** - * Converts [this] from [timeZone] zone to [AbstractEntity.DEFAULT_ZONE_ID]. - * - * @author Roman Shishkin - * @since 2.0.0 - **/ -fun LocalDateTime.fromTimeZone(timeZoneId: String?): LocalDateTime = - atZone(TimeZone.getTimeZone(timeZoneId ?: "UTC").toZoneId()).withZoneSameInstant(AbstractEntity.DEFAULT_ZONE_ID) - .toLocalDateTime() - -fun LocalDateTime.format(): String = format(DEFAULT_FORMATTER) - -fun LocalDateTime.formatDate(): String = format(DEFAULT_DATE_FORMATTER) - -fun LocalTime.format(): String = format(DEFAULT_TIME_FORMATTER) - -val LocalDateTime.DEFAULT_FORMATTER: DateTimeFormatter - get() = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm") - -val LocalDateTime.DEFAULT_DATE_FORMATTER: DateTimeFormatter - get() = DateTimeFormatter.ofPattern("dd.MM.yyyy") - -val DEFAULT_TIME_FORMATTER: DateTimeFormatter - get() = DateTimeFormatter.ofPattern("HH:mm") - -fun LocalDateTime.isBeforeOrEqual(other: LocalDateTime): Boolean = this.isBefore(other) || this == other - -fun LocalDateTime.isAfterOrEqual(other: LocalDateTime): Boolean = this.isAfter(other) || this == other - -fun LocalDateTime.isBeforeOrEqualNow(): Boolean = isBeforeOrEqual(LocalDateTime.now(AbstractEntity.DEFAULT_ZONE_ID)) - -fun LocalDateTime.isAfterOrEqualNow(): Boolean = isAfterOrEqual(LocalDateTime.now(AbstractEntity.DEFAULT_ZONE_ID)) - -fun LocalDateTime.isBeforeNow(): Boolean = isBefore(LocalDateTime.now(AbstractEntity.DEFAULT_ZONE_ID)) - -fun LocalDateTime.isAfterNow(): Boolean = isAfter(LocalDateTime.now(AbstractEntity.DEFAULT_ZONE_ID)) - -fun LocalDateTime.toEpochSecond(): Long = toEpochSecond(ZoneOffset.UTC) - -/** - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ -fun String.isLocalTimeFormatted(pattern: String = "HH:mm"): Boolean = try { - LocalTime.parse(this, DateTimeFormatter.ofPattern(pattern)) - true -} catch (e: Exception) { - false -} - - -/** - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ -fun String.convertToLocalTime(): LocalTime = LocalTime.parse(this, DEFAULT_TIME_FORMATTER) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/ModelUtils.kt b/src/main/kotlin/trik/testsys/webclient/util/ModelUtils.kt deleted file mode 100644 index 9451b9b1..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/ModelUtils.kt +++ /dev/null @@ -1,11 +0,0 @@ -package trik.testsys.webclient.util - -import org.springframework.ui.Model - -/** - * Adds `hasActiveSession` with value `true` attribute to model which indicates that user has active session. - * - * @author Roman Shishkin - * @since 2.0.0 -**/ -fun Model.addSessionActiveInfo() = addAttribute("hasActiveSession", true) \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/RedirectAttribitessUtils.kt b/src/main/kotlin/trik/testsys/webclient/util/RedirectAttribitessUtils.kt deleted file mode 100644 index fd990329..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/RedirectAttribitessUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package trik.testsys.webclient.util - -import org.springframework.web.servlet.mvc.support.RedirectAttributes - -/** - * Adds flash attribute `message` with [message] value, which will be used as a popup message. - * - * @author Roman Shishkin - * @since 2.0.0 - */ -fun RedirectAttributes.addPopupMessage(message: String?) = addFlashAttribute("message", message) - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -fun RedirectAttributes.addSessionExpiredMessage() = addPopupMessage("Ваша сессия истекла, введите Код-доступа повторно.") - -/** - * @author Roman Shishkin - * @since 2.0.0 - */ -fun RedirectAttributes.addExitMessage() = addPopupMessage("Вы успешно вышли из своего кабинета.") - -/** - * @author Roman Shishkin - * @since 2.0.0 -**/ -fun RedirectAttributes.addInvalidAccessTokenMessage() = addPopupMessage("Некорректный Код-доступа. Попробуйте еще раз.") \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/aop/RestControllerAspect.kt b/src/main/kotlin/trik/testsys/webclient/util/aop/RestControllerAspect.kt deleted file mode 100644 index 2b7e42ba..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/aop/RestControllerAspect.kt +++ /dev/null @@ -1,71 +0,0 @@ -package trik.testsys.webclient.util.aop - -import org.aspectj.lang.ProceedingJoinPoint -import org.aspectj.lang.annotation.Around -import org.aspectj.lang.annotation.Aspect -import org.slf4j.LoggerFactory -import org.springframework.core.annotation.Order -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Component -import trik.testsys.core.controller.TrikRestController -import trik.testsys.webclient.service.entity.impl.ApiKeyService - -@Aspect -@Component -class RestControllerAspect( - private val apiKeyService: ApiKeyService -) { - - @Order(1) - @Around("target(trik.testsys.core.controller.TrikRestController)") - fun log(joinPoint: ProceedingJoinPoint): Any { - try { - val target = joinPoint.target as TrikRestController - - val controllerName = target.javaClass.simpleName - val methodName = joinPoint.signature.name - val methodArgNames = target.javaClass.methods.find { it.name == methodName }?.parameters?.map { it.name } - val methodArgValues = joinPoint.args.mapIndexed { index, arg -> "${methodArgNames?.get(index)}=$arg" } - - logger.info("Calling $controllerName.$methodName($methodArgValues)") - - val result = joinPoint.proceed() as ResponseEntity<*> - val logResult = if (result.body != null) { - val body = result.body - if (body is ByteArray) { - "byte[${body.size}]" - } else { - body.toString() - } - } else { - "null" - } - - logger.info("Called $controllerName.$methodName($methodArgValues). Result: $logResult") - - return result - } catch (e: Exception) { - logger.error("Error in RestControllerAspect", e) - return joinPoint.proceed() - } - } - - @Order(2) - @Around("target(trik.testsys.core.controller.TrikRestController)") - fun validateApiKey(joinPoint: ProceedingJoinPoint): Any { - val apiKey = joinPoint.args.firstOrNull { it is String } as? String ?: return ResponseEntity.badRequest().build() - - if (!apiKeyService.validate(apiKey)) { - logger.warn("Invalid API key: $apiKey") - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() - } - - return joinPoint.proceed() - } - - companion object { - - private val logger = LoggerFactory.getLogger(RestControllerAspect::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/aop/WebUserControllerAspect.kt b/src/main/kotlin/trik/testsys/webclient/util/aop/WebUserControllerAspect.kt deleted file mode 100644 index 4a643011..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/aop/WebUserControllerAspect.kt +++ /dev/null @@ -1,85 +0,0 @@ -package trik.testsys.webclient.util.aop - -import org.aspectj.lang.ProceedingJoinPoint -import org.aspectj.lang.annotation.Around -import org.aspectj.lang.annotation.Aspect -import org.slf4j.LoggerFactory -import org.springframework.core.annotation.Order -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Component -import org.springframework.validation.support.BindingAwareModelMap -import org.springframework.web.servlet.mvc.support.RedirectAttributes -import trik.testsys.webclient.controller.user.AbstractWebUserController -import trik.testsys.webclient.service.entity.impl.EmergencyMessageService -import trik.testsys.webclient.service.security.UserValidator - -@Aspect -@Component -class WebUserControllerAspect( - private val emergencyMessageService: EmergencyMessageService, - private val userValidator: UserValidator -) { - - @Order(2) - @Around("target(trik.testsys.webclient.controller.user.AbstractWebUserController)") - fun addEmergencyMessage(joinPoint: ProceedingJoinPoint): Any { - val target = joinPoint.target as AbstractWebUserController<*, *, *> - - val redirectAttributes = joinPoint.args.firstOrNull { it is RedirectAttributes } as? RedirectAttributes - val model = joinPoint.args.firstOrNull { it is BindingAwareModelMap } as? BindingAwareModelMap - - val accessToken = target.loginData.accessToken ?: run { - return joinPoint.proceed() - } - - val webUser = userValidator.validateExistence(accessToken) ?: run { - return joinPoint.proceed() - } - - val emergencyMessage = emergencyMessageService.findByUserType(webUser.type) ?: run { - return joinPoint.proceed() - } - - redirectAttributes?.addFlashAttribute("emergencyMessage", emergencyMessage.additionalInfo) - model?.addAttribute("emergencyMessage", emergencyMessage.additionalInfo) - - return joinPoint.proceed() - } - - @Order(1) - @Around("target(trik.testsys.webclient.controller.user.AbstractWebUserController)") - fun log(joinPoint: ProceedingJoinPoint): Any { - try { - val target = joinPoint.target as AbstractWebUserController<*, *, *> - - val accessToken = target.loginData.accessToken - val webUserId = userValidator.validateExistence(accessToken)?.id - - val controllerName = target.javaClass.simpleName - val methodName = joinPoint.signature.name - val methodArgNames = target.javaClass.methods.find { it.name == methodName }?.parameters?.map { it.name } - val methodArgValues = joinPoint.args.mapIndexed { index, arg -> "${methodArgNames?.get(index)}=$arg" } - - logger.info("[ $webUserId: $accessToken ] : Calling $controllerName.$methodName($methodArgValues)") - - val result = joinPoint.proceed() - val logResult = if (result is ResponseEntity<*>) { - result.headers.contentDisposition.filename - } else { - result.toString() - } - - logger.info("[ $webUserId: $accessToken ] : Called $controllerName.$methodName($methodArgValues). Result: $logResult") - - return result - } catch (e: Exception) { - logger.error("Error in WebUserControllerAspect", e) - return joinPoint.proceed() - } - } - - companion object { - - private val logger = LoggerFactory.getLogger(WebUserControllerAspect::class.java) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/util/config/SpringFoxConfig.kt b/src/main/kotlin/trik/testsys/webclient/util/config/SpringFoxConfig.kt deleted file mode 100644 index d4bd3915..00000000 --- a/src/main/kotlin/trik/testsys/webclient/util/config/SpringFoxConfig.kt +++ /dev/null @@ -1,22 +0,0 @@ -package trik.testsys.webclient.util.config - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import springfox.documentation.builders.PathSelectors -import springfox.documentation.builders.RequestHandlerSelectors -import springfox.documentation.spi.DocumentationType -import springfox.documentation.spring.web.plugins.Docket - - -@Configuration -class SpringFoxConfig { - - @Bean - fun api(): Docket { - return Docket(DocumentationType.SWAGGER_2) - .select() - .apis(RequestHandlerSelectors.basePackage("trik.testsys.webclient.controller.impl.rest")) - .paths(PathSelectors.any()) - .build() - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/NotedEntityView.kt b/src/main/kotlin/trik/testsys/webclient/view/NotedEntityView.kt deleted file mode 100644 index fcd5b42d..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/NotedEntityView.kt +++ /dev/null @@ -1,13 +0,0 @@ -package trik.testsys.webclient.view - -import trik.testsys.core.view.named.NamedEntityView -import trik.testsys.webclient.entity.NotedEntity - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -interface NotedEntityView : NamedEntityView { - - val note: String -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/AdminView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/AdminView.kt deleted file mode 100644 index 1fa4aacb..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/AdminView.kt +++ /dev/null @@ -1,29 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -data class AdminView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val creationDate: LocalDateTime?, - override val lastLoginDate: LocalDateTime?, - override val additionalInfo: String, - val viewer: Viewer, - val groups: List? = null -) : UserView { - - override fun toEntity(timeZoneId: String?) = Admin( - name, accessToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - it.viewer = viewer - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/AdminViewerView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/AdminViewerView.kt deleted file mode 100644 index 46010719..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/AdminViewerView.kt +++ /dev/null @@ -1,24 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.format - - -data class AdminViewerView( - val id: Long?, - val name: String, - val creationDate: String?, - val additionalInfo: String -) { - - companion object { - - fun Admin.toViewerView(timeZoneId: String?) = AdminViewerView( - id = this.id, - name = this.name, - creationDate = this.creationDate?.atTimeZone(timeZoneId)?.format(), - additionalInfo = this.additionalInfo - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/ContestCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/ContestCreationView.kt deleted file mode 100644 index ef692f53..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/ContestCreationView.kt +++ /dev/null @@ -1,53 +0,0 @@ -package trik.testsys.webclient.view.impl - -import org.springframework.format.annotation.DateTimeFormat -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.util.convertToLocalTime -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.format.DateTimeFormatter - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class ContestCreationView( - val name: String, - val additionalInfo: String, - val note: String, - @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") - val startDate: LocalDateTime, - @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") - val endDate: LocalDateTime, - val duration: String, - val isOpenEnded: Boolean?, -) { - - fun toEntity(developer: Developer, timeZoneId: String?) = Contest( - name, - startDate.fromTimeZone(timeZoneId), endDate.fromTimeZone(timeZoneId), - if (isOpenEnded == true) LocalTime.MIN else duration.convertToLocalTime() - ).also { - it.additionalInfo = additionalInfo - it.note = note - it.developer = developer - it.isOpenEnded = isOpenEnded ?: false - } - - val formattedStartDate: String - get() = startDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")) - - val formattedEndDate: String - get() = endDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")) - - companion object { - - fun empty() = ContestCreationView( - "", "", "", - LocalDateTime.now(), LocalDateTime.now(), "", - false - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/ContestView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/ContestView.kt deleted file mode 100644 index 04ed2dfe..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/ContestView.kt +++ /dev/null @@ -1,66 +0,0 @@ -package trik.testsys.webclient.view.impl - -import org.springframework.format.annotation.DateTimeFormat -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.convertToLocalTime -import trik.testsys.webclient.util.format -import trik.testsys.webclient.util.fromTimeZone -import trik.testsys.webclient.view.NotedEntityView -import java.time.LocalDateTime -import java.time.LocalTime - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class ContestView( - override val id: Long?, - override val additionalInfo: String, - override val creationDate: LocalDateTime?, - override val name: String, - override val note: String, - val visibility: Contest.Visibility, - @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") - val startDate: LocalDateTime, - @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") - val endDate: LocalDateTime, - val duration: String, - var isOpenEnded: Boolean?, -) : NotedEntityView { - - override fun toEntity(timeZoneId: String?) = Contest( - name, startDate.fromTimeZone(timeZoneId), endDate.fromTimeZone(timeZoneId), - if (isOpenEnded == true) LocalTime.MIN else duration.convertToLocalTime() - ).also { - it.id = id - it.additionalInfo = additionalInfo - it.note = note - it.visibility = visibility - it.isOpenEnded = isOpenEnded ?: false - } - - val formattedStartDate: String - get() = startDate.format() - - val formattedEndDate: String - get() = endDate.format() - - val formattedDuration = if (isOpenEnded == true) "Неограниченно" else duration - - companion object { - - fun Contest.toView(timeZone: String?) = ContestView( - id = this.id, - additionalInfo = this.additionalInfo, - creationDate = this.creationDate?.atTimeZone(timeZone), - name = this.name, - note = this.note, - visibility = this.visibility, - startDate = this.startDate.atTimeZone(timeZone), - endDate = this.endDate.atTimeZone(timeZone), - duration = this.duration.toString(), - isOpenEnded = this.isOpenEnded, - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/DeveloperView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/DeveloperView.kt deleted file mode 100644 index 8233f0a2..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/DeveloperView.kt +++ /dev/null @@ -1,31 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.user.impl.Developer -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -data class DeveloperView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val lastLoginDate: LocalDateTime?, - override val creationDate: LocalDateTime?, - override val additionalInfo: String, - val contests: List = emptyList(), - val tasks: List = emptyList(), - val polygons: List = emptyList(), - val exercises: List = emptyList(), - val solutions: List = emptyList(), - val conditions: List = emptyList() -) : UserView { - - override fun toEntity(timeZoneId: String?) = Developer( - name, accessToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/EmergencyMessageCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/EmergencyMessageCreationView.kt deleted file mode 100644 index 8b5b3b4c..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/EmergencyMessageCreationView.kt +++ /dev/null @@ -1,25 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.EmergencyMessage -import trik.testsys.webclient.entity.user.WebUser - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class EmergencyMessageCreationView( - val userType: WebUser.UserType, - val additionalInfo: String -) { - - fun toEntity() = EmergencyMessage( - userType - ).also { - it.additionalInfo = additionalInfo - } - - companion object { - - fun empty() = EmergencyMessageCreationView(WebUser.UserType.SUPER_USER, "") - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/GroupCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/GroupCreationView.kt deleted file mode 100644 index f130489f..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/GroupCreationView.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Admin - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class GroupCreationView( - val name: String, - val additionalInfo: String -) { - - fun toEntity(regToken: String, admin: Admin) = Group( - name, regToken - ).also { - it.admin = admin - it.additionalInfo = additionalInfo - } - - companion object { - - fun empty() = GroupCreationView("", "") - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/GroupView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/GroupView.kt deleted file mode 100644 index 574afc6d..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/GroupView.kt +++ /dev/null @@ -1,43 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.named.NamedEntityView -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.util.atTimeZone -import java.time.LocalDateTime - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class GroupView( - override val id: Long?, - override val creationDate: LocalDateTime?, - override val name: String, - override val additionalInfo: String, - val regToken: AccessToken, - val admin: AdminView? = null -): NamedEntityView { - - override fun toEntity(timeZoneId: String?) = Group( - name, regToken - ).also { - it.id = id - it.additionalInfo = additionalInfo - } - - companion object { - - fun Group.toView(timeZoneId: String?) = GroupView( - id = this.id, - creationDate = this.creationDate?.atTimeZone(timeZoneId), - name = this.name, - regToken = this.regToken, - additionalInfo = this.additionalInfo - ) - - fun GroupView.withAdmin(adminView: AdminView) = copy( - admin = adminView - ) - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/JudgeView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/JudgeView.kt deleted file mode 100644 index 3f8ccaa9..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/JudgeView.kt +++ /dev/null @@ -1,25 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.user.impl.Judge -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -data class JudgeView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val creationDate: LocalDateTime?, - override val lastLoginDate: LocalDateTime?, - override val additionalInfo: String, -) : UserView { - - override fun toEntity(timeZoneId: String?) = Judge( - name, accessToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/LogosView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/LogosView.kt deleted file mode 100644 index ee489383..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/LogosView.kt +++ /dev/null @@ -1,42 +0,0 @@ -package trik.testsys.webclient.view.impl - -/** - * View for sponsor logos. - * - * @author Roman Shishkin - * @since %CURRENT_VERSION% - */ -data class LogosView( - val hasSponsors: Boolean = false, - val hasMain: Boolean = false, - val sponsorLogos: List? = null, - val mainLogo: LogoView? = null -) { - - data class LogoView(val path: String, val name: String = path.substringAfterLast('/')) - - class Builder { - - private var sponsorLogos: MutableList = mutableListOf() - private var mainLogo: LogoView? = null - - fun addSponsorLogo(path: String) = apply { sponsorLogos.add(LogoView(path)) } - - fun addMainLogo(path: String) = apply { mainLogo = LogoView(path) } - - fun build(): LogosView { - return LogosView( - hasSponsors = sponsorLogos.isNotEmpty(), - hasMain = mainLogo != null, - sponsorLogos, - mainLogo) - } - } - - companion object { - - fun empty() = LogosView() - - fun builder() = Builder() - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/SolutionVerdictView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/SolutionVerdictView.kt deleted file mode 100644 index cc7d0447..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/SolutionVerdictView.kt +++ /dev/null @@ -1,41 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.SolutionVerdict -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.format -import trik.testsys.webclient.view.NotedEntityView -import java.time.LocalDateTime - -data class SolutionVerdictView( - override val id: Long?, - override val note: String, - override val name: String, - override val creationDate: LocalDateTime?, - override val additionalInfo: String, - val score: Long, - val judgeFullName: String?, - val taskFullName: String?, - val contestFullName: String? -) : NotedEntityView { - - override fun toEntity(timeZoneId: String?): SolutionVerdict { - TODO("Not yet implemented") - } - - val formattedCreationDate = creationDate?.format() - - companion object { - - fun SolutionVerdict.toView(timeZone: String?) = SolutionVerdictView( - id = this.id, - note = this.note, - name = this.name, - creationDate = this.creationDate?.atTimeZone(timeZone), - additionalInfo = this.additionalInfo, - taskFullName = this.taskFullMame, - contestFullName = this.contestFullMame, - judgeFullName = this.judgeFullMame, - score = this.score - ) - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentContestView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/StudentContestView.kt deleted file mode 100644 index 28de5aa3..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentContestView.kt +++ /dev/null @@ -1,60 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.view.named.NamedEntityView -import trik.testsys.webclient.entity.impl.Contest -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.format -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.format.DateTimeFormatter - -data class StudentContestView( - override val id: Long?, - override val additionalInfo: String, - override val creationDate: LocalDateTime?, - override val name: String, - val startDate: LocalDateTime, - val endDate: LocalDateTime, - val lastTime: String, - val isGoingOn: Boolean, - val duration: LocalTime, - val tasks: List = emptyList(), - val outdated: Boolean = false -) : NamedEntityView { - - override fun toEntity(timeZoneId: String?) = TODO() - - val formattedStartDate: String - get() = startDate.format() - - val formattedEndDate: String - get() = endDate.format() - - companion object { - - fun Contest.toStudentView(timeZone: String?, outdated: Boolean = true) = StudentContestView( - id = this.id, - additionalInfo = this.additionalInfo, - creationDate = this.creationDate?.atTimeZone(timeZone), - name = this.name, - startDate = this.startDate.atTimeZone(timeZone), - endDate = this.endDate.atTimeZone(timeZone), - isGoingOn = this.isGoingOn(), - duration = this.duration, - lastTime = "—", - outdated = outdated - ) - - fun Contest.toStudentView(timeZone: String?, lastTime: LocalTime) = StudentContestView( - id = this.id, - additionalInfo = this.additionalInfo, - creationDate = this.creationDate?.atTimeZone(timeZone), - name = this.name, - lastTime = if (isOpenEnded) "Неограниченно" else lastTime.format(DateTimeFormatter.ofPattern("HH:mm:ss")), - startDate = this.startDate.atTimeZone(timeZone), - endDate = this.endDate.atTimeZone(timeZone), - isGoingOn = this.isGoingOn(), - duration = this.duration - ) - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentFilter.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/StudentFilter.kt deleted file mode 100644 index 5c5de385..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentFilter.kt +++ /dev/null @@ -1,17 +0,0 @@ -package trik.testsys.webclient.view.impl - -data class StudentFilter( - val studentId: Long?, - val groupId: Long?, - val adminId: Long?, - val contestId: Long?, - val taskId: Long?, - val solutionId: Long? -) { - - fun isEmpty() = studentId == null && groupId == null && adminId == null && contestId == null && taskId == null && solutionId == null - - companion object { - fun empty() = StudentFilter(null, null, null, null, null, null) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentSolutionFilter.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/StudentSolutionFilter.kt deleted file mode 100644 index c9344ce6..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentSolutionFilter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package trik.testsys.webclient.view.impl - -data class StudentSolutionFilter( - val taskId: Long?, - val solutionId: Long? -) { - - fun isEmpty() = taskId == null && solutionId == null - - companion object { - fun empty() = StudentSolutionFilter(null, null) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/StudentView.kt deleted file mode 100644 index e19ebd53..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/StudentView.kt +++ /dev/null @@ -1,42 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -data class StudentView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val creationDate: LocalDateTime?, - override val lastLoginDate: LocalDateTime?, - override val additionalInfo: String, - val group: Group -) : UserView { - - - override fun toEntity(timeZoneId: String?) = Student( - name, accessToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - it.group = group - } - - companion object { - fun Student.toView(timeZoneId: String?) = StudentView( - id, - name, - accessToken, - creationDate?.atTimeZone(timeZoneId), - lastLoginDate?.atTimeZone(timeZoneId), - additionalInfo, - group - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/SuperUserView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/SuperUserView.kt deleted file mode 100644 index acc29680..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/SuperUserView.kt +++ /dev/null @@ -1,35 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.impl.EmergencyMessage -import trik.testsys.webclient.entity.user.impl.* -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class SuperUserView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val creationDate: LocalDateTime?, - override val lastLoginDate: LocalDateTime?, - override val additionalInfo: String, - val viewers: List = emptyList(), - val developers: List = emptyList(), - val judges: List = emptyList(), - val admins: List = emptyList(), - val emergencyMessages: List = emptyList() -) : UserView { - - override fun toEntity(timeZoneId: String?) = SuperUser( - name, accessToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskCreationView.kt deleted file mode 100644 index fa1fa355..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskCreationView.kt +++ /dev/null @@ -1,28 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.entity.user.impl.Developer - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class TaskCreationView( - val name: String, - val additionalInfo: String, - val note: String -) { - - fun toEntity(developer: Developer) = Task( - name - ).also { - it.additionalInfo = additionalInfo - it.note = note - it.developer = developer - } - - companion object { - - fun empty() = TaskCreationView("", "", "") - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditCreationView.kt deleted file mode 100644 index 61ec86ad..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditCreationView.kt +++ /dev/null @@ -1,24 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.impl.TaskFileAudit - -/** - * @author Roman Shishkin - * @since 2.1.0 - */ -data class TaskFileAuditCreationView( - val additionalInfo: String -) { - - fun toEntity(taskFile: TaskFile) = TaskFileAudit( - taskFile - ).also { - it.additionalInfo = additionalInfo - } - - companion object { - - fun empty() = TaskFileAuditCreationView("") - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditView.kt deleted file mode 100644 index 08056bcf..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileAuditView.kt +++ /dev/null @@ -1,25 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.TaskFileAudit -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.format - -/** - * @author Roman Shishkin - * @since 2.1.0 - */ -data class TaskFileAuditView( - val id: Long?, - val additionalInfo: String, - val creationDate: String? -) { - - companion object { - - fun TaskFileAudit.toView(timeZone: String?) = TaskFileAuditView( - id = id, - additionalInfo = additionalInfo, - creationDate = creationDate?.atTimeZone(timeZone)?.format() - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileCreationView.kt deleted file mode 100644 index 2dd17436..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileCreationView.kt +++ /dev/null @@ -1,33 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.entity.user.impl.Developer - -data class TaskFileCreationView( - val name: String, - val additionalInfo: String, - val note: String, - val type: TaskFile.TaskFileType -) { - - fun toEntity(developer: Developer) = TaskFile( - name, type - ).also { - it.additionalInfo = additionalInfo - it.note = note - it.developer = developer - } - - companion object { - - fun emptyPolygon() = empty(TaskFile.TaskFileType.POLYGON) - - fun emptyExercise() = empty(TaskFile.TaskFileType.EXERCISE) - - fun emptySolution() = empty(TaskFile.TaskFileType.SOLUTION) - - fun emptyCondition() = empty(TaskFile.TaskFileType.CONDITION) - - fun empty(type: TaskFile.TaskFileType) = TaskFileCreationView("", "", "", type) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileView.kt deleted file mode 100644 index be9a82ae..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskFileView.kt +++ /dev/null @@ -1,43 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.TaskFile -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.NotedEntityView -import trik.testsys.webclient.view.impl.TaskFileAuditView.Companion.toView -import java.time.LocalDateTime - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class TaskFileView( - override val id: Long?, - override val name: String, - override val creationDate: LocalDateTime?, - override val additionalInfo: String, - override val note: String, - val type: TaskFile.TaskFileType, - val taskFileAudits: List = emptyList() -) : NotedEntityView { - - override fun toEntity(timeZoneId: String?) = TaskFile( - name, type - ).also { - it.id = id - it.additionalInfo = additionalInfo - it.note = note - } - - companion object { - - fun TaskFile.toView(timeZone: String?) = TaskFileView( - id = id, - name = name, - creationDate = creationDate?.atTimeZone(timeZone), - additionalInfo = additionalInfo, - note = note, - type = type, - taskFileAudits = taskFileAudits.sortedByDescending { it.creationDate }.map { it.toView(timeZone) } - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskTestResultView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskTestResultView.kt deleted file mode 100644 index c221ee0a..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskTestResultView.kt +++ /dev/null @@ -1,27 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.Solution -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.util.format - -data class TaskTestResultView( - val id: Long?, - val taskName: String, - val creationDate: String?, - val status: Solution.SolutionStatus, - val score: Long, - val additionalInfo: String -) { - - companion object { - - fun Solution.toTaskTestResultView(timeZoneId: String?) = TaskTestResultView( - id = id, - creationDate = creationDate?.atTimeZone(timeZoneId)?.format(), - status = status, - score = score, - taskName = "${task.id}: ${task.name}", - additionalInfo = additionalInfo - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/TaskView.kt deleted file mode 100644 index 04d9bdcf..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/TaskView.kt +++ /dev/null @@ -1,45 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.impl.Task -import trik.testsys.webclient.util.atTimeZone -import trik.testsys.webclient.view.NotedEntityView -import trik.testsys.webclient.view.impl.TaskFileView.Companion.toView -import java.time.LocalDateTime - -/** - * @author Roman Shishkin - * @since 2.0.0 - **/ -data class TaskView( - override val id: Long?, - override val name: String, - override val creationDate: LocalDateTime?, - override val additionalInfo: String, - override val note: String, - val passedTest: Boolean = false, - val taskFiles: List = emptyList(), - val hasCondition: Boolean? -) : NotedEntityView { - - override fun toEntity(timeZoneId: String?) = Task( - name - ).also { - it.id = id - it.additionalInfo = additionalInfo - it.note = note - } - - companion object { - - fun Task.toView(timeZone: String?) = TaskView( - id = id, - name = name, - creationDate = creationDate?.atTimeZone(timeZone), - additionalInfo = additionalInfo, - note = note, - taskFiles = taskFiles.map { it.toView(timeZone) }.sortedBy { it.id }, - passedTest = passedTests, - hasCondition = hasCondition - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/UserCreationView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/UserCreationView.kt deleted file mode 100644 index f1687e29..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/UserCreationView.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.webclient.entity.user.WebUser - -/** - * @author Roman Shishkin - * @since 2.2.0 - */ -data class UserCreationView( - val name: String = "", - val additionalInfo: String = "", - val type: WebUser.UserType, - val viewerId: Long? = null -) { - - companion object { - - fun emptyViewer() = UserCreationView(type = WebUser.UserType.VIEWER) - - fun emptyAdmin() = UserCreationView(type = WebUser.UserType.ADMIN) - - fun emptyDeveloper() = UserCreationView(type = WebUser.UserType.DEVELOPER) - - fun emptyJudge() = UserCreationView(type = WebUser.UserType.JUDGE) - } -} diff --git a/src/main/kotlin/trik/testsys/webclient/view/impl/ViewerView.kt b/src/main/kotlin/trik/testsys/webclient/view/impl/ViewerView.kt deleted file mode 100644 index 2886af3b..00000000 --- a/src/main/kotlin/trik/testsys/webclient/view/impl/ViewerView.kt +++ /dev/null @@ -1,26 +0,0 @@ -package trik.testsys.webclient.view.impl - -import trik.testsys.core.entity.user.AccessToken -import trik.testsys.core.view.user.UserView -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.util.fromTimeZone -import java.time.LocalDateTime - -data class ViewerView( - override val id: Long?, - override val name: String, - override val accessToken: AccessToken, - override val lastLoginDate: LocalDateTime?, - override val creationDate: LocalDateTime?, - override val additionalInfo: String, - val regToken: AccessToken -) : UserView { - - override fun toEntity(timeZoneId: String?) = Viewer( - name, accessToken, regToken - ).also { - it.id = id - it.lastLoginDate = lastLoginDate?.fromTimeZone(timeZoneId) - it.additionalInfo = additionalInfo - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b0cc4667..9be04c4d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + application: + name: "${TS_APP_NAME:web-app}" jpa: hibernate: ddl-auto: update # Update database schema on start if necessary @@ -54,26 +56,44 @@ server: enabled: false include-message: always -path: - l10n: "${PWD}/src/main/resources/static/l10n" - startup: - entities: "${PWD}/data/entities/" - taskFiles: - solutions: "${PWD}/data/taskFiles/solutions/" - polygons: "${PWD}/data/taskFiles/polygons/" - exercises: "${PWD}/data/taskFiles/exercises/" - conditions: "${PWD}/data/taskFiles/conditions/" - files: - solutions: "${PWD}/data/files/solutions/" - verdicts: "${PWD}/data/files/verdicts/" - recordings: "${PWD}/data/files/recordings/" - results: "${PWD}/data/files/results/" - logos: - sponsor: "${PWD}/data/logos/sponsor/" - main: "${PWD}/data/logos/main.png" - -trik-studio-version: "${TRIK_STUDIO_VERSION:latest}" -grading-node-addresses: "${GRADING_NODE_ADDRESSES:}" - -create-lektorium-users: ${CREATE_LEKTORIUM_USERS:false} -lektorium-group-reg-token: "${LEKTORIUM_GROUP_REG_TOKEN:}" +trik: + testsys: + superuser: + name: "${SUPER_USER_NAME:Default Super User}" + accessToken: + value: "${SUPER_USER_AT_VALUE:}" + storeDir: "${PWD}/data" + trik-studio: + container: + name: "${TS_CONTAINER_NAME:}" + grading-node: + addresses: "${GN_ADDRESSES:}" + paths: + taskFiles: + solutions: "${PWD}/data/taskFiles/solutions/" + polygons: "${PWD}/data/taskFiles/polygons/" + exercises: "${PWD}/data/taskFiles/exercises/" + conditions: "${PWD}/data/taskFiles/conditions/" + files: + solutions: "${PWD}/data/files/solutions/" + verdicts: "${PWD}/data/files/verdicts/" + recordings: "${PWD}/data/files/recordings/" + results: "${PWD}/data/files/results/" + sponsorship: "${PWD}/data/sponsorship/" + notifier: + email: + receivers: "${NTF_RECEIVERS:}" + diagnostics-availability: + polygon: + timelimit: ${TL_DIAG:false} + score-output: ${SO_DIAG:false} + invalid-event-id: ${IEI_DIAG:false} + email-client: + domain: "${EC_DOMAIN:}" + credentials: + username: "${EC_USERNAME:}" + password: "${EC_PASSWORD:}" + smtp: + host: "${SMTP_HOST:}" + port: "${SMTP_PORT:}" + display-app-name: "${DISPLAY_APP_NANE:}" diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css new file mode 100644 index 00000000..68d74395 --- /dev/null +++ b/src/main/resources/static/css/base.css @@ -0,0 +1,14 @@ +* { box-sizing: border-box; } +html, body { height: 100%; } +html { font-size: 13px; } +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + color: var(--color-text); + background: var(--color-muted); +} +a { color: var(--color-primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); border: 0; } + diff --git a/src/main/resources/static/css/block.css b/src/main/resources/static/css/block.css index 3385e907..6dd6a0a4 100644 --- a/src/main/resources/static/css/block.css +++ b/src/main/resources/static/css/block.css @@ -18,12 +18,12 @@ /* Стили для заголовка блока */ .block-title { - font-size: 24px; + font-size: 18px; font-weight: bold; - margin-bottom: 15px; + margin-bottom: 12px; color: var(--trik-blue); border-bottom: 2px solid var(--trik-grey); - padding-bottom: 10px; + padding-bottom: 8px; } /* Конфигурируемые параметры */ @@ -39,7 +39,7 @@ } .block-title { - font-size: 20px; + font-size: 16px; } } @@ -49,6 +49,6 @@ } .block-title { - font-size: 18px; + font-size: 14px; } } diff --git a/src/main/resources/static/css/cell.css b/src/main/resources/static/css/cell.css new file mode 100644 index 00000000..6f065339 --- /dev/null +++ b/src/main/resources/static/css/cell.css @@ -0,0 +1,20 @@ +.date-cell { + white-space: nowrap; + width: 150px; +} + +.id-cell { + width: 80px; + white-space: nowrap; +} + +.token-cell { + min-width: 400px; + white-space: nowrap; +} + +.version-cell { + width: 100px; +} + + diff --git a/src/main/resources/static/css/components.css b/src/main/resources/static/css/components.css new file mode 100644 index 00000000..050df52d --- /dev/null +++ b/src/main/resources/static/css/components.css @@ -0,0 +1,75 @@ +.card { background: var(--color-surface); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 16px; } +.card .card-body { padding: 20px; } +.card .card-title { margin: 0; padding: 14px 18px; font-size: 18px; font-weight: 700; background: var(--color-muted); border-bottom: 1px solid var(--table-border); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } + +.kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 20px; } +.kv dt { color: var(--color-text-muted); font-weight: 600; } +.kv dd { margin: 0; } + +.pill-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 8px 0 0 0; } +.pill { background: var(--color-muted); border-radius: 999px; padding: 4px 8px; font-size: 12px; } + +.btn, .button { appearance: none; border: 0; background: var(--color-muted); color: var(--color-text); padding: 10px 14px; border-radius: 10px; cursor: pointer; display: inline-block; text-decoration: none; } +.btn:hover:not(:disabled), .button:hover:not(:disabled) { background: #e9ebef; } +.btn-primary, .button--primary { background: var(--color-primary); color: #fff; } +.btn-primary:hover:not(:disabled), .button--primary:hover:not(:disabled) { filter: brightness(0.95); } +.btn:disabled, .button:disabled { background: #d1d5db; color: #9aa3af; cursor: not-allowed; filter: none; } + +/* Destructive actions */ +.button--danger { background: var(--color-danger); color: #fff; border: 1px solid #c62828; } +.button--danger:hover:not(:disabled) { background: #d32f2f; box-shadow: 0 0 0 3px rgba(229,57,53,.15); } +.button--danger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(229,57,53,.25); } +.button--danger:disabled { background: #f3b0b0; color: #fff; border-color: #e5bbbb; box-shadow: none; } + +/* Icons inside buttons */ +.button i { margin-right: 6px; } + +/* Info actions */ +.button--info { background: #eef5ff; color: #245ea6; border: 1px solid #c7ddff; } +.button--info:hover:not(:disabled) { background: #e3efff; box-shadow: 0 0 0 3px rgba(47,128,237,.15); } +.button--info:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(47,128,237,.25); } + +.token { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; filter: blur(5px); transition: filter .25s ease; } +.token:hover { filter: blur(0); } + +.alert { font-size: 18px; padding: 10px 12px; border-radius: 10px; background: #eef5ff; color: #245ea6; margin-bottom: 12px; } +.warningAlert { font-size: 18px; padding: 10px 12px; border-radius: 10px; background: var(--color-danger); color: #fff; margin-bottom: 12px; } + +/* Small inline help note */ +.help-note { color: var(--color-text-muted); font-size: 12px; margin-top: 6px; } +.token-box { background: #f8fafc; border: 1px dashed #cbd5e1; padding: 10px 12px; border-radius: 10px; display: inline-flex; align-items: center; gap: 10px; } +.token-box .token-label { color: var(--color-text-muted); font-weight: 600; } + +/* Tables */ +table { width: 100%; border-collapse: separate; border-spacing: 0; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); } +thead th { position: sticky; top: 0; background: var(--table-header-bg); color: var(--table-header-fg); z-index: 1; border-bottom: 1px solid var(--table-border); font-weight: 700; } +th, td { padding: 10px 12px; border: 1px solid var(--table-border); text-align: left; vertical-align: top; } +tbody tr:nth-child(odd) { background: var(--table-zebra); } +tbody tr:hover { background: var(--table-hover); } + +/* Rounded corners for first/last cells */ +thead tr:first-child th:first-child { border-top-left-radius: var(--radius); } +thead tr:first-child th:last-child { border-top-right-radius: var(--radius); } +tbody tr:last-child td:first-child { border-bottom-left-radius: var(--radius); } +tbody tr:last-child td:last-child { border-bottom-right-radius: var(--radius); } + +/* Sort indicators */ +th.sortable { cursor: pointer; user-select: none; position: relative; } +th.sortable::after { content: '\25B4\25BE'; opacity: .35; font-size: 9px; margin-left: 6px; } +th.sort-asc::after { content: '\25B4'; opacity: .9; } +th.sort-desc::after { content: '\25BE'; opacity: .9; } + +/* Density modifiers */ +.table--dense th, .table--dense td { padding: 6px 8px; } +.table--comfortable th, .table--comfortable td { padding: 14px 16px; } + +/* Sticky first column (optional) */ +.table--sticky-first-col th:first-child, .table--sticky-first-col td:first-child { position: sticky; left: 0; z-index: 2; background: #fff; } +.table--sticky-first-col thead th:first-child { z-index: 3; background: var(--table-header-bg); color: var(--table-header-fg); } + +/* Subtle shadow under sticky header when scrolled */ +table.scrolled thead th { box-shadow: 0 2px 0 rgba(0,0,0,.06); } + +/* Separator */ +.separator { height: 1px; background: #e5e7eb; border: 0; margin: 16px 0; } + diff --git a/src/main/resources/static/css/error-popup.css b/src/main/resources/static/css/error-popup.css new file mode 100644 index 00000000..9209d913 --- /dev/null +++ b/src/main/resources/static/css/error-popup.css @@ -0,0 +1,63 @@ +.error-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.error-popup { + background: #fff; + padding: 24px; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.2); + width: 400px; + max-width: 90%; + font-family: system-ui, sans-serif; +} +.error-popup h3 { + margin-top: 0; + margin-bottom: 8px; + font-size: 18px; +} +.warning { + font-size: 14px; + color: #a33; + margin-bottom: 12px; +} +.entity-name { + font-weight: bold; +} +.input-field { + width: 100%; + padding: 8px; + margin-bottom: 16px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; +} +.error-popup-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.button--secondary { + background: #e5e7eb; + border: none; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; +} +.button--danger { + background: #d73a49; + color: #fff; + border: none; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; +} +.button--danger:disabled { + opacity: 0.6; + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/main/resources/static/css/form.css b/src/main/resources/static/css/form.css index ea0e2dd0..91a6c0e6 100644 --- a/src/main/resources/static/css/form.css +++ b/src/main/resources/static/css/form.css @@ -14,7 +14,7 @@ /* Заголовок окна */ .form-header h1 { - font-size: 20px; + font-size: 16px; font-weight: 600; color: var(--trik-white); } @@ -54,8 +54,8 @@ form { } .form-input input { - padding: 5px; - font-size: 1rem; + padding: 3px; + font-size: 0.9rem; border: 1px solid #ccc; border-radius: 4px; width: 100%; @@ -65,19 +65,116 @@ form { background-color: #e9e9e9; } +/* Disabled state for using forms as read-only viewers */ +.form-input input:disabled, +.form-input textarea:disabled, +.form-input select:disabled, +input:disabled, +textarea:disabled, +select:disabled { + background-color: #ffffff; + color: var(--color-text); + -webkit-text-fill-color: var(--color-text); + border-color: #e5e7eb; + font-weight: 400; /* look like normal text */ + opacity: 1; /* prevent UA styles from dimming content */ +} + +/* Make placeholders clearly distinct from real data */ +.form-input input::placeholder, +.form-input textarea::placeholder, +input::placeholder, +textarea::placeholder { + color: var(--color-text-muted); + opacity: .85; + font-style: italic; +} + +.form-input input:disabled::placeholder, +.form-input textarea:disabled::placeholder, +input:disabled::placeholder, +textarea:disabled::placeholder { + color: #cbd5e1; + font-weight: 400; +} + +/* Explicitly differentiate disabled fields with real data vs placeholders */ +input:disabled:placeholder-shown, +textarea:disabled:placeholder-shown { + color: #8a98ab; + font-weight: 400; + font-style: italic; + opacity: 1; +} + +input:disabled:not(:placeholder-shown), +textarea:disabled:not(:placeholder-shown) { + color: var(--color-text); + font-weight: 400; + font-style: normal; + opacity: 1; +} + +/* JS-assisted classes for reliable styling across browsers */ +input.has-value:disabled, +textarea.has-value:disabled { + color: var(--color-text); + font-weight: 400; + opacity: 1; +} + +input.is-empty:disabled, +textarea.is-empty:disabled { + color: #8a98ab; + font-style: italic; + font-weight: 400; + opacity: 1; +} + +/* Date/time fields: show an explicit placeholder style when empty */ +input[type="date"].is-empty:disabled, +input[type="datetime-local"].is-empty:disabled, +input[type="time"].is-empty:disabled { + color: #8a98ab; + font-style: italic; + opacity: 1; +} + +input[type="date"].has-value:disabled, +input[type="datetime-local"].has-value:disabled, +input[type="time"].has-value:disabled { + color: var(--color-text); + font-weight: 400; + opacity: 1; +} + +/* Ensure Safari/WebKit does not dim text for disabled date/time inputs */ +input[type="date"].has-value:disabled, +input[type="datetime-local"].has-value:disabled, +input[type="time"].has-value:disabled { + -webkit-text-fill-color: var(--color-text); +} + +input[type="date"].is-empty:disabled, +input[type="datetime-local"].is-empty:disabled, +input[type="time"].is-empty:disabled { + -webkit-text-fill-color: #8a98ab; +} + /* Стиль для кнопок */ .form-buttons { display: flex; flex-basis: 100%; - justify-content: flex-end; + justify-content: flex-start; margin-top: 20px; } button { - padding: 10px 20px; - font-size: 1rem; - border: none; - border-radius: 4px; + appearance: none; + padding: 7px 11px; + font-size: 0.9rem; + border: 0; + border-radius: 10px; cursor: pointer; } @@ -107,7 +204,8 @@ button { } .form-buttons { - justify-content: center; + justify-content: flex-start; + align-items: flex-start; flex-direction: column; } @@ -117,16 +215,16 @@ button { } select { - padding: 5px; - font-size: 1rem; + padding: 3px; + font-size: 0.9rem; border: 1px solid var(--trik-grey); border-radius: 4px; width: 100%; } option { - padding: 5px; - font-size: 1rem; + padding: 3px; + font-size: 0.9rem; border: 1px solid var(--trik-grey); border-radius: 4px; width: 100%; diff --git a/src/main/resources/static/css/layout.css b/src/main/resources/static/css/layout.css new file mode 100644 index 00000000..bbd18ec3 --- /dev/null +++ b/src/main/resources/static/css/layout.css @@ -0,0 +1,57 @@ +.app-header { + position: fixed; top: 0; left: 0; right: 0; z-index: 20; height: 60px; + display: flex; align-items: center; justify-content: space-between; + padding: 12px 20px; background: var(--color-bg); color: #fff; +} +.app-header .brand { display: flex; align-items: center; gap: 12px; color: #fff; } +.app-header .brand img { height: 36px; } +.app-header .brand-title { font-weight: 700; letter-spacing: .3px; } +.header-actions { display: flex; gap: 12px; align-items: center; } +.icon-button { + display: inline-flex; align-items: center; justify-content: center; + width: 36px; height: 36px; border-radius: 50%; + background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.15); + color: #fff; font-size: 18px; cursor: pointer; text-decoration: none; + transition: background-color .2s ease, box-shadow .2s ease, transform .05s ease; +} +.icon-button:hover { background: rgba(255,255,255,.14); box-shadow: 0 0 0 3px rgba(255,255,255,.12); text-decoration: none; } +.icon-button:active { transform: translateY(1px); } +.icon-button:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(47,128,237,.35); } +/* ensure the icon itself is centered with no offset */ +.icon-button i { margin: 0; line-height: 1; display: inline-block; } +.header-border { position: absolute; left: 0; right: 0; bottom: -1px; height: 3px; background: linear-gradient(90deg, var(--color-accent), var(--color-primary)); } + +.app-sidebar { + position: fixed; top: 60px; left: 0; bottom: 0; width: 240px; padding: 16px; + background: var(--color-bg); color: #fff; transform: translateX(-100%); + transition: transform .25s ease; overflow-y: auto; -webkit-overflow-scrolling: touch; +} +.app-sidebar.open { transform: translateX(0); } +.app-sidebar nav ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 4px; } +.app-sidebar nav a { color: #fff; padding: 6px 8px; border-radius: 6px; display: block; font-size: 13px; } +.app-sidebar nav a:hover { background: rgba(255,255,255,.08); text-decoration: none; } + +.menu-sections { display: grid; gap: 16px; } +.menu-section-title { font-weight: 700; margin-bottom: 6px; opacity: .9; font-size: 1.1rem; } +.menu-items { list-style: none; padding: 0; margin: 0; display: grid; gap: 4px; } +.menu-item a { color: #fff; padding: 6px 8px; border-radius: 6px; display: block; font-size: 13px; } +.menu-item a:hover { background: rgba(255,255,255,.08); text-decoration: none; } +.menu-item a.active { background: rgba(255,255,255,.16); font-weight: 700; } +.menu-section.active .menu-section-title { color: #fff; text-decoration: underline; } +.menu-logout { color: #fff; opacity: .8; } + +.app-main { padding: 24px; margin-left: 0; padding-top: 72px; } +@media (min-width: 960px) { + .app-sidebar { transform: none; } + .app-main { margin-left: 240px; } +} + +.app-footer { padding: 16px 24px; color: var(--color-text-muted); } + +/* Sidebar scrollbar: more visible */ +.app-sidebar { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.6) rgba(255,255,255,.12); } +.app-sidebar::-webkit-scrollbar { width: 10px; } +.app-sidebar::-webkit-scrollbar-track { background: rgba(255,255,255,.12); border-radius: 8px; } +.app-sidebar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.6); border-radius: 8px; } +.app-sidebar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.75); } + diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index aee3a649..9c9f093b 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -12,55 +12,99 @@ body { font-family: sans-serif; color: var(--trik-blue); } +.container { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; + padding: 16px; +} + +.page { + max-width: 1100px; + margin: 0 auto; +} + +.card { + background: var(--trik-white); + border-radius: 10px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + padding: 20px; +} + +.kv-row { + display: grid; + grid-template-columns: 180px 1fr; + gap: 8px 16px; + margin-bottom: 8px; +} + +.kv-row b { + color: var(--trik-blue); +} + +.pill-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + list-style: none; + padding: 0; + margin: 8px 0 0 0; +} + +.pill { + background: var(--trik-grey); + border-radius: 999px; + padding: 3px 7px; + font-size: 11px; +} + .background-container { background-color: var(--trik-blue); background-image: url("../img/trik_testsys_logo.svg"); background-repeat: no-repeat; - background-size: 50%; - background-position: center; - height: 100vh; - width: 100vw; - position: fixed; + background-size: 420px auto; + background-position: left 24px center; + height: 160px; + width: 100%; + position: relative; top: 0; left: 0; - z-index: -1; - opacity: 0.8; + z-index: 0; + opacity: 1; + border-bottom: 4px solid #fff; } .button { + appearance: none; display: inline-block; - padding: 10px 20px; - margin: 5px; - background-color: var(--trik-grey); /* Цвет фона */ - color: var(--trik-blue); /* Цвет текста */ - text-decoration: none; /* Убираем подчеркивание у ссылок */ - border: none; /* Убираем стандартную рамку у кнопок */ - border-radius: 5px; /* Закругленные углы */ - transition: background-color 0.3s, transform 0.3s; /* Плавные переходы */ - cursor: pointer; /* Курсор как у кнопки */ - font-size: 16px; + padding: 7px 11px; + margin: 4px; + background-color: var(--trik-grey); + color: var(--trik-blue); + text-decoration: none; + border: 0; + border-radius: 10px; + transition: background-color 0.2s ease; + cursor: pointer; + font-size: 13px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } -.button:hover { - background-color: var(--trik-green); /* Цвет фона при наведении */ - transform: scale(1.05); /* Увеличение кнопки при наведении */ -} - -.button:active { - transform: scale(0.95); /* Уменьшение кнопки при нажатии */ +.button:hover:not(:disabled) { + background-color: #e9ebef; } .button:disabled { background-color: var(--trik-grey); - color: var(--trik-blue); + color: #9aa3af; cursor: not-allowed; + box-shadow: none; } .accessToken { font-family: monospace; - font-size: 15px; + font-size: 12px; filter: blur(5px); transition: filter 0.6s ease, transform 0.6s ease; } @@ -69,4 +113,19 @@ body { cursor: pointer; color: var(--trik-green); filter: blur(0); +} + +/* Full-page background for landing/auth pages */ +.full-page-bg .background-container { + position: fixed; + inset: 0; + width: auto !important; + height: auto !important; + background-color: var(--trik-blue) !important; + background-image: url("../img/trik_testsys_logo.svg") !important; + background-repeat: no-repeat !important; + background-position: center !important; + background-size: cover !important; + border-bottom: none !important; + z-index: 0 !important; } \ No newline at end of file diff --git a/src/main/resources/static/css/menu.css b/src/main/resources/static/css/menu.css index 8229759b..ab178a3d 100644 --- a/src/main/resources/static/css/menu.css +++ b/src/main/resources/static/css/menu.css @@ -26,12 +26,12 @@ } .logo-img { - height: 50px; + height: 44px; margin-right: 10px; } .cabinet-name { - font-size: 1.5rem; + font-size: 1.25rem; font-weight: bold; } @@ -45,7 +45,7 @@ } .menu-button { - font-size: 1.8rem; + font-size: 1.6rem; background: none; border: none; color: var(--trik-white); @@ -67,10 +67,12 @@ color: var(--trik-white); transition: right 0.3s ease; padding: 20px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; display: flex; flex-direction: column; - align-items: center; - justify-content: center; + align-items: stretch; + justify-content: flex-start; z-index: 100; box-shadow: -5px 0 15px rgba(0, 0, 0, 0.3); } @@ -81,13 +83,13 @@ } .side-menu ul li { - margin: 20px 0; + margin: 12px 0; } .side-menu ul li a { text-decoration: none; color: var(--trik-white); - font-size: 1.2rem; + font-size: 0.95rem; transition: color 0.3s ease; } @@ -99,11 +101,18 @@ right: 0; } +/* Side menu scrollbar: more visible */ +.side-menu { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.6) rgba(255,255,255,.12); } +.side-menu::-webkit-scrollbar { width: 10px; } +.side-menu::-webkit-scrollbar-track { background: rgba(255,255,255,.12); border-radius: 8px; } +.side-menu::-webkit-scrollbar-thumb { background: rgba(255,255,255,.6); border-radius: 8px; } +.side-menu::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.75); } + .close-button { background: none; border: none; color: var(--trik-red); - font-size: 2rem; + font-size: 1.6rem; align-self: flex-end; cursor: pointer; } diff --git a/src/main/resources/static/css/modal.css b/src/main/resources/static/css/modal.css index b19e71c1..befb59bd 100644 --- a/src/main/resources/static/css/modal.css +++ b/src/main/resources/static/css/modal.css @@ -12,6 +12,7 @@ background-size: 50%; background-position: center; flex-direction: column; + z-index: 1; } /* Окно (модальное) */ @@ -86,17 +87,44 @@ height: 100%; } +.sponsorship-block { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 10; +} + +.sponsor-logos-wrapper { + background-color: var(--trik-white); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 16px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + width: 320px; + box-sizing: border-box; +} + +.sponsor-logos-title { + font-size: 13px; + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + .sponsor-logos { display: flex; - justify-content: center; - gap: 20px; - margin: 10px 0; - height: 120px; - width: auto; + flex-direction: column; + align-items: stretch; + gap: 12px; + width: 100%; } .sponsor-logos img { - height: 100%; + width: 100%; + max-height: 120px; object-fit: contain; - margin: auto; } diff --git a/src/main/resources/static/css/popup.css b/src/main/resources/static/css/popup.css index 3c137d40..866c3e27 100644 --- a/src/main/resources/static/css/popup.css +++ b/src/main/resources/static/css/popup.css @@ -3,10 +3,10 @@ position: fixed; bottom: 30px; right: 30px; - width: 300px; + width: 280px; background: var(--trik-blue); /* Полупрозрачный чёрный фон */ color: var(--trik-white); - padding: 20px; + padding: 16px; border-radius: 15px; /* Закруглённые углы */ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); /* Плавная тень */ display: none; /* По умолчанию скрыт */ @@ -29,7 +29,7 @@ position: absolute; top: 10px; right: 10px; - font-size: 18px; + font-size: 16px; font-weight: bold; cursor: pointer; color: var(--trik-red); /* Красный цвет для контраста */ @@ -52,10 +52,10 @@ position: fixed; top: 90px; right: 30px; - width: 300px; + width: 280px; background: var(--trik-red); /* Полупрозрачный чёрный фон */ color: var(--trik-blue); - padding: 20px; + padding: 16px; border-radius: 15px; /* Закруглённые углы */ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); /* Плавная тень */ display: block; diff --git a/src/main/resources/static/css/table.css b/src/main/resources/static/css/table.css index 21a77a67..1d8c2011 100644 --- a/src/main/resources/static/css/table.css +++ b/src/main/resources/static/css/table.css @@ -1,155 +1,50 @@ table { - width: 100%; - background: #f5ffff; - border-collapse: collapse; - text-align: left; + width: 100%; } - -/* top-left border-radius */ -table tr:first-child th:first-child { - border-top-left-radius: 6px; -} - -/* top-right border-radius */ -table tr:first-child th:last-child { - border-top-right-radius: 6px; -} - -/* bottom-left border-radius */ -table tr:last-child td:first-child { - border-bottom-left-radius: 6px; -} - -/* bottom-right border-radius */ -table tr:last-child td:last-child { - border-bottom-right-radius: 6px; -} - -table th { - background: var(--trik-blue); - color: white; - padding: 10px 15px; - position: sticky; - top: 0; - z-index: 10; -} - -table th:after { - content: ""; - display: block; - position: absolute; - z-index: 0; - left: 0; - top: 25%; - height: 25%; - width: 100%; -} - -table tr:nth-child(odd) { - background: #ebf3f9; -} - -table td { - border: 1px solid #e3eef7; - padding: 10px 15px; - position: relative; - transition: all 0.5s ease; - word-break: break-all; -} - -table tbody:hover td { - color: transparent; - text-shadow: 0 0 3px #a09f9d; -} - -table tbody:hover tr:hover td { - color: #444444; - text-shadow: none; +thead, tbody tr { + display: table; + width: 100%; + table-layout: fixed; } - -/* Заголовок окна */ -.table-header h1 { - font-size: 20px; - font-weight: 600; - color: var(--trik-white); -} - -.table-header { - padding: 10px; - border-top-left-radius: 6px; - border-top-right-radius: 6px; - background-color: var(--trik-blue); - margin: -20px -20px 15px; -} - -caption { - font-size: 24px; - font-weight: bold; - padding: 10px; - border-radius: 5px; +tbody { + display: block; + overflow-y: auto; + table-layout: fixed; + max-height: 350px; } -/*center table*/ -.table { - max-width: 100%; - margin: 40px auto; - padding: 20px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - border-radius: 6px; +/* Filter row */ +tr.table-filter-row th { + background: var(--color-muted, #f4f5f7); + top: 42px; /* offset below sticky header row */ } - -.table-container { - max-height: 400px; - overflow-y: auto; -} - -.table-container::-webkit-scrollbar { - width: 10px; -} - -.table-container::-webkit-scrollbar-thumb { - background-color: var(--trik-blue); - border-radius: 10px; +.table-filter-input { + width: 100%; + box-sizing: border-box; + padding: 4px; + font-size: 0.9rem; + border: 1px solid var(--table-border, #e2e8f0); + border-radius: 4px; + background: #fff; } -.table-container::-webkit-scrollbar-track { - background-color: var(--trik-white); +.table-filter-input:focus { + outline: 0; + border-color: var(--color-primary, #2f80ed); + box-shadow: 0 0 0 3px rgba(47, 128, 237, .12); } -.table-container::-webkit-scrollbar-thumb:hover { - background-color: var(--trik-blue); +table.filter-empty tbody::after { + content: 'Нет совпадений по фильтру'; + display: block; + padding: 12px; + color: var(--color-text-muted, #888); } -.table-container::-webkit-scrollbar-thumb:active { - background-color: var(--trik-blue); -} - -.table-container::-webkit-scrollbar-button { - display: none; -} - -.table-container::-webkit-scrollbar-corner { - background-color: var(--trik-white); -} - -.table-container::-webkit-scrollbar-track-piece { - background-color: var(--trik-white); -} - -.buttons { - display: flex; - justify-content: space-around; - align-items: center; -/* Space between elements 6px*/ - gap: 6px; -} +/* Tools above table */ +.table-tools { display: flex; justify-content: flex-end; margin-bottom: 8px; } +.table-tools .table-filter-toggle { background: var(--color-muted); color: var(--color-text); } +.table-tools .table-filter-toggle:hover { background: #e9ebef; } -.table-button { - background: var(--trik-white); - border-radius: 25%; - width: 25px; - border: 1px solid var(--trik-blue); - outline: none; - cursor: pointer; - text-align: center; -} \ No newline at end of file +/* Hide filter row when toggled */ +table.filters-hidden thead tr.table-filter-row { display: none; } \ No newline at end of file diff --git a/src/main/resources/static/css/variables.css b/src/main/resources/static/css/variables.css new file mode 100644 index 00000000..d6705055 --- /dev/null +++ b/src/main/resources/static/css/variables.css @@ -0,0 +1,21 @@ +:root { + --color-bg: #0f1a2b; + --color-surface: #ffffff; + --color-muted: #f4f5f7; + --color-text: #13233a; + --color-text-muted: #516175; + --color-primary: #2f80ed; + --color-accent: #99CC00; + --color-danger: #e53935; + --radius: 12px; + --shadow: 0 8px 24px rgba(0,0,0,.08); + + /* Table theme */ + --table-header-bg: var(--color-primary); + --table-header-fg: #ffffff; + --table-border: #e6ebf2; + --table-zebra: #fbfdff; + --table-hover: #f6f8fe; + --table-hover-accent: var(--trik-blue); +} + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 00000000..4eff393b --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,125 @@ +window.App = (function () { + const sidebar = () => document.getElementById('app-sidebar'); + function toggleSidebar() { + const el = sidebar(); + if (!el) return; + el.classList.toggle('open'); + } + + function closeSidebarOnNavigate() { + document.addEventListener('click', (e) => { + const el = sidebar(); + if (!el || !el.classList.contains('open')) return; + const target = e.target; + if (target.closest && target.closest('#app-sidebar')) return; + if (target.tagName === 'A') el.classList.remove('open'); + }); + } + + function mount() { + closeSidebarOnNavigate(); + markActiveNavigation(); + initReadonlyForms(); + } + + function toggleEdit(field) { + const view = document.getElementById(`${field}-view`); + const form = document.getElementById(`${field}-form`); + if (!view || !form) return; + const isHidden = getComputedStyle(form).display === 'none'; + if (isHidden) { + form.style.display = 'flex'; + view.style.display = 'none'; + const input = form.querySelector('input, textarea'); + if (input) input.focus(); + } else { + form.style.display = 'none'; + view.style.display = ''; + } + } + + function setFormEnabled(formId, enabled) { + const form = document.getElementById(formId); + if (!form) return; + const inputs = form.querySelectorAll('input, textarea, select'); + inputs.forEach(el => { el.disabled = !enabled; }); + const editBtn = form.querySelector('.edit-button'); + const saveBtn = form.querySelector('.save-button'); + const cancelBtn = form.querySelector('.cancel-button'); + if (editBtn) editBtn.style.display = enabled ? 'none' : 'inline-block'; + if (saveBtn) saveBtn.style.display = enabled ? 'inline-block' : 'none'; + if (cancelBtn) cancelBtn.style.display = enabled ? 'inline-block' : 'none'; + } + + function enableForm(formId) { setFormEnabled(formId, true); } + + function disableForm(formId) { + const form = document.getElementById(formId); + if (form) { try { form.reset(); } catch (_) {} } + setFormEnabled(formId, false); + setDisabledVisualState(form); + } + + function setDisabledVisualState(form) { + if (!form) return; + const fields = form.querySelectorAll('input, textarea'); + fields.forEach((el) => { + el.classList.remove('has-value', 'is-empty'); + if (el.disabled) { + const hasValue = (el.value || '').trim().length > 0; + el.classList.add(hasValue ? 'has-value' : 'is-empty'); + } + }); + } + + function initReadonlyForms() { + const forms = document.querySelectorAll('form[data-readonly-toggle]'); + forms.forEach((form) => setDisabledVisualState(form)); + } + + return { toggleSidebar, toggleEdit, enableForm, disableForm, mount, initReadonlyForms }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + window.App.mount(); +}); + + + +function markActiveNavigation() { + try { + var currentPath = window.location.pathname; + var links = Array.from(document.querySelectorAll('.app-sidebar .menu-item a')); + if (!links.length) return; + + var bestLink = null; + var bestLen = -1; + + links.forEach(function (a) { + var hrefPath; + try { + hrefPath = new URL(a.getAttribute('href'), window.location.origin).pathname; + } catch (_) { + hrefPath = a.getAttribute('href') || ''; + } + if (!hrefPath) return; + var matches = (currentPath === hrefPath) || (hrefPath !== '/' && currentPath.indexOf(hrefPath + '/') === 0); + if (matches && hrefPath.length > bestLen) { + bestLink = a; + bestLen = hrefPath.length; + } + }); + + // Reset existing states to avoid multiple actives + links.forEach(function (a) { a.classList.remove('active'); }); + Array.from(document.querySelectorAll('.app-sidebar .menu-section')).forEach(function (s) { s.classList.remove('active'); }); + + if (bestLink) { + bestLink.classList.add('active'); + var section = bestLink.closest('.menu-section'); + if (section) section.classList.add('active'); + } + } catch (_) { + // no-op + } +} diff --git a/src/main/resources/static/js/table-sort.js b/src/main/resources/static/js/table-sort.js index f72f3072..7520259d 100644 --- a/src/main/resources/static/js/table-sort.js +++ b/src/main/resources/static/js/table-sort.js @@ -1,23 +1,247 @@ -$(document).ready(function() { - $('th').click(function() { - var table = $(this).parents('table').eq(0); - var rows = table.find('tr:gt(0)').toArray().sort(comparer($(this).index())); - this.asc = !this.asc; - if (!this.asc) { - rows = rows.reverse(); - } - table.children('tbody').empty().html(rows); - }); -}); +// Lightweight, dependency-free table sorter +(function () { + // --- Utilities --- + function isNumeric(value) { + if (value === null || value === undefined) return false; + const n = parseFloat(value.replace?.(/\s+/g, '') ?? value); + return !Number.isNaN(n) && Number.isFinite(n); + } + + function getCellText(row, index) { + const cell = row.children[index]; + if (!cell) return ''; + return (cell.textContent || '').trim(); + } -function comparer(index) { - return function(a, b) { - var valA = getCellValue(a, index); - var valB = getCellValue(b, index); - return $.isNumeric(valA) && $.isNumeric(valB) ? valA - valB : valA.localeCompare(valB); + function compareFactory(index, asc) { + return function (rowA, rowB) { + const a = getCellText(rowA, index); + const b = getCellText(rowB, index); + + const aNum = isNumeric(a) ? parseFloat(a.replace(/\s+/g, '')) : null; + const bNum = isNumeric(b) ? parseFloat(b.replace(/\s+/g, '')) : null; + + let result; + if (aNum !== null && bNum !== null) { + result = aNum - bNum; + } else { + result = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); + } + return asc ? result : -result; }; -} + } + + function clearSortIndicators(thElements) { + thElements.forEach(function (th) { + th.classList.remove('sort-asc'); + th.classList.remove('sort-desc'); + }); + } + + function makeTableSortable(table) { + const thead = table.tHead; + const tbody = table.tBodies[0] || table.createTBody(); + if (!thead || !tbody) return; + + const headerRow = thead.rows[0]; + if (!headerRow) return; + + const ths = Array.from(headerRow.cells); + ths.forEach(function (th, index) { + th.classList.add('sortable'); + th.addEventListener('click', function () { + const asc = !(th.dataset.sortAsc === 'true'); + const rows = Array.from(tbody.rows); + + rows.sort(compareFactory(index, asc)); + + // Re-attach sorted rows + const frag = document.createDocumentFragment(); + rows.forEach(function (r) { frag.appendChild(r); }); + tbody.appendChild(frag); + + // Update indicators + clearSortIndicators(ths); + th.dataset.sortAsc = String(asc); + th.classList.add(asc ? 'sort-asc' : 'sort-desc'); + }); + }); + } + + // --- Filtering --- + function createFilterInput(placeholder) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'table-filter-input'; + input.placeholder = placeholder || 'Фильтр'; + input.setAttribute('aria-label', 'Фильтр'); + return input; + } + + function buildFilterRow(table) { + const thead = table.tHead; + const tbody = table.tBodies[0]; + if (!thead || !tbody) return; + + const headerRow = thead.rows[0]; + if (!headerRow) return; + + // Avoid duplicating filter-row + if (thead.querySelector('tr.table-filter-row')) return; + + const filterRow = document.createElement('tr'); + filterRow.className = 'table-filter-row'; + + const filters = []; + Array.from(headerRow.cells).forEach(function (th, colIndex) { + const filterCell = document.createElement('th'); + const noFilter = th.hasAttribute('data-no-filter') || th.classList.contains('no-filter'); + if (!noFilter) { + const input = createFilterInput('Фильтр…'); + filters.push({ colIndex: colIndex, input: input }); + filterCell.appendChild(input); + } else { + filterCell.appendChild(document.createTextNode('')); + } + filterRow.appendChild(filterCell); + }); + + thead.appendChild(filterRow); + table.classList.add('has-filter'); + + // Ensure sticky stacking: place filter row below header row + function setFilterOffsets() { + try { + const headerHeight = headerRow.offsetHeight || 0; + const cells = Array.from(filterRow.cells); + cells.forEach(function (cell) { + cell.style.top = headerHeight + 'px'; + }); + } catch (_) { /* no-op */ } + } + setFilterOffsets(); + window.addEventListener('resize', setFilterOffsets, { passive: true }); + table.__updateFilterOffsets = setFilterOffsets; + + function applyFilter() { + const activeFilters = filters + .map(function (f) { return { colIndex: f.colIndex, value: (f.input.value || '').trim().toLowerCase() }; }) + .filter(function (f) { return f.value.length > 0; }); + + const rows = Array.from(tbody.rows); + if (activeFilters.length === 0) { + rows.forEach(function (row) { row.style.display = ''; }); + updateEmptyState(table, false); + return; + } + + let visibleCount = 0; + rows.forEach(function (row) { + const matches = activeFilters.every(function (f) { + const cell = row.children[f.colIndex]; + if (!cell) return false; + const text = (cell.textContent || '').toLowerCase(); + return text.indexOf(f.value) !== -1; + }); + row.style.display = matches ? '' : 'none'; + if (matches) visibleCount += 1; + }); + + updateEmptyState(table, visibleCount === 0); + } + + // Bind events + filters.forEach(function (f) { + f.input.addEventListener('input', applyFilter); + f.input.addEventListener('change', applyFilter); + }); + + // Expose for testing if needed + table.__applyFilter = applyFilter; + } + + function updateEmptyState(table, isEmpty) { + // Optionally toggle a class when no rows match + if (isEmpty) table.classList.add('filter-empty'); + else table.classList.remove('filter-empty'); + } + + // --- Toggle visibility --- + function insertFilterToggle(table) { + // Do not add toggle if no filters for this table + if (!table.tHead || !table.tHead.querySelector('tr.table-filter-row')) return; + if (table.classList.contains('no-filter')) return; + const container = document.createElement('div'); + container.className = 'table-tools'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn table-filter-toggle'; + + function setButtonText(hidden) { + btn.textContent = hidden ? 'Показать фильтры' : 'Скрыть фильтры'; + btn.setAttribute('aria-pressed', String(!hidden)); + } + + function isHidden() { return table.classList.contains('filters-hidden'); } + + btn.addEventListener('click', function () { + const hidden = !isHidden(); + table.classList.toggle('filters-hidden', hidden); + setButtonText(hidden); + }); + + setButtonText(isHidden()); + container.appendChild(btn); + + // Insert before table + const parent = table.parentNode; + if (parent) parent.insertBefore(container, table); + } + + function init() { + // All tables + const tables = Array.from(document.querySelectorAll('table')); + tables.forEach(function (table) { + makeTableSortable(table); + // If table has explicit opt-out attribute, skip filters + const noFilter = table.hasAttribute('data-no-filter') || table.classList.contains('no-filter'); + if (!noFilter) { + buildFilterRow(table); + + // Visibility defaults: hidden by default unless explicitly made visible + let defaultHidden = true; + if (table.hasAttribute('data-filter-default-visible') || table.classList.contains('filters-visible')) { + defaultHidden = false; + } + if (table.hasAttribute('data-filter-default-hidden') || table.classList.contains('filters-hidden')) { + defaultHidden = true; + } + if (defaultHidden) { + table.classList.add('filters-hidden'); + } + + insertFilterToggle(table); + } + + // Add subtle shadow to sticky headers when body scrolled + try { + const tbody = table.tBodies && table.tBodies[0]; + if (tbody) { + function updateShadow() { + if (tbody.scrollTop > 0) table.classList.add('scrolled'); + else table.classList.remove('scrolled'); + } + tbody.addEventListener('scroll', updateShadow, { passive: true }); + updateShadow(); + } + } catch (_) { /* no-op */ } + }); + } -function getCellValue(row, index) { - return $(row).children('td').eq(index).text(); -} \ No newline at end of file + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/src/main/resources/static/js/timezone.js b/src/main/resources/static/js/timezone.js new file mode 100644 index 00000000..277926d2 --- /dev/null +++ b/src/main/resources/static/js/timezone.js @@ -0,0 +1,54 @@ +document.addEventListener('DOMContentLoaded', function () { + function pad2(num) { + return String(num).padStart(2, '0'); + } + + function formatLocal(date, includeSeconds) { + var d = date; + var day = pad2(d.getDate()); + var month = pad2(d.getMonth() + 1); + var year = d.getFullYear(); + var hours = pad2(d.getHours()); + var minutes = pad2(d.getMinutes()); + var seconds = pad2(d.getSeconds()); + return includeSeconds ? (day + '.' + month + '.' + year + ' ' + hours + ':' + minutes + ':' + seconds) + : (day + '.' + month + '.' + year + ' ' + hours + ':' + minutes); + } + + function toLocalDatetimeLocalValue(date) { + var d = date; + var year = d.getFullYear(); + var month = pad2(d.getMonth() + 1); + var day = pad2(d.getDate()); + var hours = pad2(d.getHours()); + var minutes = pad2(d.getMinutes()); + return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; + } + + var tz = null; + try { + tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null; + } catch (e) { + tz = null; + } + + document.querySelectorAll('input[type="hidden"][name="timezone"]').forEach(function (el) { + if (tz) el.value = tz; + }); + + document.querySelectorAll('[data-utc]').forEach(function (el) { + var iso = el.getAttribute('data-utc'); + if (!iso) return; + var date = new Date(iso); + if (isNaN(date.getTime())) return; + + if (el.tagName === 'INPUT' && el.getAttribute('type') === 'datetime-local') { + el.value = toLocalDatetimeLocalValue(date); + } else { + var includeSeconds = el.hasAttribute('data-include-seconds'); + el.textContent = formatLocal(date, includeSeconds); + } + }); +}); + + diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html deleted file mode 100644 index 7a98c91d..00000000 --- a/src/main/resources/templates/admin.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- - -
- -
-
Профиль
- -
-
-

Информация о профиле

-
- - -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- -
- - - -
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/admin/group-create.html b/src/main/resources/templates/admin/group-create.html new file mode 100644 index 00000000..6d197731 --- /dev/null +++ b/src/main/resources/templates/admin/group-create.html @@ -0,0 +1,37 @@ + + + + + + +
+ + +
+
+

Группы / Создание группы

+
+
+ + +
+
+ + Отмена +
+
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/admin/group.html b/src/main/resources/templates/admin/group.html index 0fa5d7bb..07fe0510 100644 --- a/src/main/resources/templates/admin/group.html +++ b/src/main/resources/templates/admin/group.html @@ -1,275 +1,145 @@ - + - - - - - - - - - - -
- + + + - -
- -
- -
- -
- -
- - -
- -
-
Группа
- -
-
-

Информация о Группе

-
- - -
-
-
-
-
-
-
-
-
-
-
- -
- - - -
-
+
+ + +
+
+

Группы /

+
+
+

Основная информация

+
+
ID
+
+
Название
+
+
Описание
+
+
Код-регистрации
+
+
-
- -
-
Туры
- -
-
-

Список прикрепленных Туров

+ + +
+

Участники

+
+

Операции с Участниками

+
+
+ + +
+ Экспорт CSV + Экспорт результатов
-
- +
+
+

Список Участников

+
Здесь будут отображены состоящие в этой Группе Участники
+
- - - - - - - + + + + + - - - - - - - - - + + + + + + +
- ID - - Название - Дополнительная информация - Дата и время начала - - Дата и время окончания - - Время на выполнение - - IDПсевдонимДата созданияПоследний входКод-доступа
-
- - -
+
+
- -
-
-

Список доступных к прикреплению Туров

-
-
- + + +
+

Туры

+
+
+

Прикреплённые Туры

+
Здесь будут отображены Туры, прикрепленные к этой Группе Участников
+
- - - - - - - - - + + + + + + + + - - - - - - - - - - + + + + + + + + + +
- ID - - Название - Дополнительная информация - Дата и время начала - - Дата и время окончания - - Время на выполнение - -
IDНазваниеНачалоОкончаниеВремя на прохождениеДействия
-
- - -
-
+
+ + +
+
-
-
- -
-
Участники
- -
-
-

Генерация Участников

-
- - -
-
-
- -
-
- -
-
- -
- - - -
-
- - - Экспорт Участников (UTF-8) - - - - Экспорт Участников (Windows-1251) - - -
- - - Экспорт результатов (UTF-8) - - - Экспорт результатов (Windows-1251) - -
- -
-
-

Список Участников

-
-
- +
+
+

Туры, доступные для прикрепления

+
Здесь будут отображены Туры, доступные к прикреплению к этой Группе Участников
+
- - - - - + + + + + + + + - - - - - - + + + + + + + + + +
- ID - - Псевдоним - Дополнительная информация
IDНазваниеНачалоОкончаниеВремя на прохождениеДействия
+
+ + +
+
-
-
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/admin/groups.html b/src/main/resources/templates/admin/groups.html index 77a36a54..33890a75 100644 --- a/src/main/resources/templates/admin/groups.html +++ b/src/main/resources/templates/admin/groups.html @@ -1,110 +1,56 @@ - - - - - - - - + + - -
- - -
- -
- -
- -
- -
- - -
- -
-
Группы
- -
-
-

Создание Группы

+
+ + +
+
+

+ Группы Участников + Справка +

+
+
+ +
- -
-
-
-
-
- -
- - - -
-
-
- -
-
-

Список Групп

-
-
- - +

Созданные Группы Участников

+
Здесь будут отображены созданные Вами Группы Участников
+
+ - - - - - + + + + + - - - - - - - - + + + + + + -
- ID - - Название - Дополнительная информация - Код-регистрации - - IDНазваниеОписаниеДата созданияУчастники
-
- -
+ +
+
-
+ + +
-
-
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/developer.html b/src/main/resources/templates/developer.html deleted file mode 100644 index 5a26a519..00000000 --- a/src/main/resources/templates/developer.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- -
- -
-
Профиль
- -
-
-

Информация о профиле

-
- - -
-
- -
-
-
-
-
-
-
-
-
-
- - -
- - - -
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/developer/condition-create.html b/src/main/resources/templates/developer/condition-create.html new file mode 100644 index 00000000..e4a4b075 --- /dev/null +++ b/src/main/resources/templates/developer/condition-create.html @@ -0,0 +1,67 @@ + + + + + + + +
+ + +
+
+

Файлы / Создание Условий

+
+
+ + + + +
+
+ + Отмена +
+
+ +
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/developer/condition-file.html b/src/main/resources/templates/developer/condition-file.html new file mode 100644 index 00000000..c9c96a81 --- /dev/null +++ b/src/main/resources/templates/developer/condition-file.html @@ -0,0 +1,215 @@ + + + + + + + +
+ + +
+
+

+ Файлы / [Условие] + + + + + + + +

+
+

Основная информация

+ +
+ + +
+
Тип
+
+
+
+
Дата создания
+
+
+
+ + + +
+
+
+
+ +
+

История изменений

+ +
+

Загрузить новую версию

+
+ +
+ +
+
+ +

Список версий

+
История пуста.
+ + + + + + + + + + + + + + + + + +
ВерсияИмя файлаДата созданияДействия
+ Скачать +
+
+
+
+ + + + +
+ + + + diff --git a/src/main/resources/templates/developer/contest-create.html b/src/main/resources/templates/developer/contest-create.html new file mode 100644 index 00000000..5c3a5c7b --- /dev/null +++ b/src/main/resources/templates/developer/contest-create.html @@ -0,0 +1,59 @@ + + + + + + + +
+ + +
+
+

Туры / Создание Тура

+
+
+ + +
+ + +
+ + +
+
+ + Отмена +
+
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/developer/contest.html b/src/main/resources/templates/developer/contest.html index df436488..6f2ffa2c 100644 --- a/src/main/resources/templates/developer/contest.html +++ b/src/main/resources/templates/developer/contest.html @@ -1,234 +1,444 @@ - + + + + + +
+ -
- -
- -
+
+
+

+ Туры / -
+ -
+ +

-
+
+
-
- -
-
Тур
+

Основная информация

-
-
-

Информация о Туре

-
- - -
-
-
-
-
-
-
-
-
-
-
- +
+ + + + + + +
+ + +
-
- -
-
-
-
- -
-
- -
-
+ + +
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
-
-
- -
- - - -
- +
+
ID
+
+
Название
+
+
Описание
+
+
Начало
+
+
Окончание
+
+
Время на прохождение
+
+
-
- -
-
Файлы
- -
-
-

Список прикрепленных Заданий

-
-
- + +
+

Задачи

+
+

Прикрепленные Задачи

+
Пока нет прикреплённых Задач.
+
+
+
- - - - - + + + + + + - - - - - - - + + + + + + + +
- ID - - Название - Дополнительная информацияЗаметкаIDНазваниеСтатус тестированияДата создания
-
- - - +
+ + 1 + + + + + + +
+
+ +
+ + +
+ +

Доступные к прикреплению

+
Нет Задач для прикрепления.
+ + + + + + + + + + + + + + + + + + + + + +
IDНазваниеСтатус тестированияДоступно изДата создания
+ +
    +
  • +
  • +
+
+
+ + +
+
+
+
+

Доступ для Групп Пользователей

+
+
Доступно только Вам
+
    +
  • + +
  • +
-
-
-

Список доступных к прикреплению Заданий

-
-
- - - - - - - - - - - - - - - - - - - - -
- ID - - Название - Дополнительная информацияЗаметкаРезультат тестирования
-
- - - -
-
+
+
+ + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/developer/contests.html b/src/main/resources/templates/developer/contests.html index 591b5d8d..41bc6ddb 100644 --- a/src/main/resources/templates/developer/contests.html +++ b/src/main/resources/templates/developer/contests.html @@ -1,169 +1,102 @@ - - - - - - - - + + - -
- +
+ -
- -
- -
- -
- -
- -
- -
-
Туры
- -
-
-

Создание Тура

+
+
+

+ Туры + Справка +

+
+ - - -
-
-
-
-
- -
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
-
-
- -
- - - -
-
-
- -
-
-

Список Туров

-
-
- - +
+

Созданные Туры

+
Вы пока не создали ни одного Тура.
+
+ - - - - - - + + + + + + + - - - - - - - - + + + - + + + + -
- ID - - Название - Дополнительная информацияЗаметка - Видимость - - IDНазваниеНачалоОкончаниеДата созданияВремя на прохождениеГруппы Пользователей
- Публичный - Закрытый + +
+ -
- -
-
- -
+
+
    +
  • +
+
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
-
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/developer/exercise-create.html b/src/main/resources/templates/developer/exercise-create.html new file mode 100644 index 00000000..4e74dced --- /dev/null +++ b/src/main/resources/templates/developer/exercise-create.html @@ -0,0 +1,67 @@ + + + + + + + +
+ + +
+
+

Файлы / Создание Упражнения

+
+
+ + + + +
+
+ + Отмена +
+
+ +
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/developer/exercise-file.html b/src/main/resources/templates/developer/exercise-file.html new file mode 100644 index 00000000..7c03a550 --- /dev/null +++ b/src/main/resources/templates/developer/exercise-file.html @@ -0,0 +1,215 @@ + + + + + + + +
+ + +
+
+

+ Файлы / [Упражнение] + + + + + + + +

+
+

Основная информация

+ +
+ + +
+
Тип
+
+
+
+
Дата создания
+
+
+
+ + + +
+
+
+
+ +
+

История изменений

+ +
+

Загрузить новую версию

+
+ +
+ +
+
+ +

Список версий

+
История пуста.
+ + + + + + + + + + + + + + + + + +
ВерсияИмя файлаДата созданияДействия
+ Скачать +
+
+
+
+ + + + +
+ + + + diff --git a/src/main/resources/templates/developer/polygon-create.html b/src/main/resources/templates/developer/polygon-create.html new file mode 100644 index 00000000..4399f337 --- /dev/null +++ b/src/main/resources/templates/developer/polygon-create.html @@ -0,0 +1,66 @@ + + + + + + + +
+ + +
+
+

Файлы / Создание Полигона

+
+
+ + + + +
+
+ + Отмена +
+
+ +
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/developer/polygon-file.html b/src/main/resources/templates/developer/polygon-file.html new file mode 100644 index 00000000..91db5691 --- /dev/null +++ b/src/main/resources/templates/developer/polygon-file.html @@ -0,0 +1,255 @@ + + + + + + + +
+ + +
+
+

+ Файлы / [Полигон] + + + + + + + + +

+
+

Основная информация

+ +
+ + +
+
Тип
+
+
+
+
Дата создания
+
+
+
+ + + +
+
+
+
+ +
+

Диагностика

+ +
+
+ Статус: + +
+ +

Список результатов

+ + + + + + + + + + + + + + + + +
УровеньОписаниеПуть до тега
+
+
+ +
+

История изменений

+ +
+

Загрузить новую версию

+
+ +
+ +
+
+ +

Список версий

+
История пуста.
+ + + + + + + + + + + + + + + + + +
ВерсияИмя файлаДата созданияДействия
+ Скачать +
+
+
+
+ + + + +
+ + + + diff --git a/src/main/resources/templates/developer/solution-create.html b/src/main/resources/templates/developer/solution-create.html new file mode 100644 index 00000000..b8031c3a --- /dev/null +++ b/src/main/resources/templates/developer/solution-create.html @@ -0,0 +1,104 @@ + + + + + + + +
+ + +
+
+

Файлы / Создание Файла

+
+
+ + + + +
+
+ + Отмена +
+
+
+
+
+ +
+ + + + + diff --git a/src/main/resources/templates/developer/solution-file.html b/src/main/resources/templates/developer/solution-file.html new file mode 100644 index 00000000..0bc1d39e --- /dev/null +++ b/src/main/resources/templates/developer/solution-file.html @@ -0,0 +1,225 @@ + + + + + + + +
+ + +
+
+

+ Файлы / [Эталонное решение] + + + + + + +

+ +
+

Основная информация

+ +
+ + +
+
Тип
+
+
+
+
Дата создания
+
+
+
+ + + +
+
+
+
+ +
+

История изменений

+ +
+

Загрузить новую версию

+
+ +
+ +
+
+ +

Список версий

+
История пуста.
+ + + + + + + + + + + + + + + + + +
ВерсияИмя файлаДата созданияДействия
+ Скачать +
+
+
+
+ + + + +
+ + + + diff --git a/src/main/resources/templates/developer/task-create.html b/src/main/resources/templates/developer/task-create.html new file mode 100644 index 00000000..5718c117 --- /dev/null +++ b/src/main/resources/templates/developer/task-create.html @@ -0,0 +1,41 @@ + + + + + + + +
+ + +
+
+

Задачи / Создание Задачи

+
+
+ + +
+
+ + Отмена +
+
+
+
+
+ +
+ + + + + diff --git a/src/main/resources/templates/developer/task-files.html b/src/main/resources/templates/developer/task-files.html new file mode 100644 index 00000000..771bce0d --- /dev/null +++ b/src/main/resources/templates/developer/task-files.html @@ -0,0 +1,155 @@ + + + + + + + + +
+ + +
+
+

+ Файлы + Справка +

+
+ + +
+

Полигоны

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеОписаниеТипРезультат анализаДата создания
+
+ +
+

Упражнения

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеОписаниеТипДата создания
+
+ +
+

Эталонные Решения

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеОписаниеТипДата создания
+
+ +
+

Условия

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеОписаниеТипДата создания
+
+ +
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/developer/task-tests.html b/src/main/resources/templates/developer/task-tests.html new file mode 100644 index 00000000..4f54b071 --- /dev/null +++ b/src/main/resources/templates/developer/task-tests.html @@ -0,0 +1,48 @@ + + + + + + +
+ + +
+
+

Задачи / / Результаты тестирования

+
+
Пока нет тестовых решений.
+ + + + + + + + + + + + + + + + + + + + + +
IDОписаниеСтатусБаллДата созданияРезультаты
+ Скачать + +
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/developer/task.html b/src/main/resources/templates/developer/task.html index e729b924..c280fdaf 100644 --- a/src/main/resources/templates/developer/task.html +++ b/src/main/resources/templates/developer/task.html @@ -1,257 +1,566 @@ - + + + + + +
+ -
+
+
+

+ Задачи / -
- -
-
Задание
+ -
-
-

Информация о Задании

-
+ +

- -
-
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- +
+
+ + +
- - - + + +
-
-
- -
-
-
+ -
-
Файлы
+
+

Доступ для Групп Пользователей

+
+
Доступно только Вам
+
    +
  • + +
  • +
-
-
-

Список прикрепленных Файлов

-
-
- - - - - - - - - - - - - - - - - - - - -
- ID - Тип - Название - Дополнительная информацияЗаметка
- Эталонное решение - Упражнение - Полигон - Условие - -
- - - - -
-
+
+
+ + +
+
-
-
-

Список доступных к прикреплению Файлов

+
+

Тестирование

+
+
+ Готовность к тестированию: + + (Полигоны: ; Решения: )
-
- - - - - - - - - - - - - - - - - - - - -
- ID - Тип - Название - Дополнительная информацияЗаметка
- Эталонное решение - Упражнение - Полигон - Условие - -
- - - - -
-
+
+ Статус тестирования: +
+
+ Результаты тестирования +
+ +
+
+
Для тестирования требуется минимум один Полигон и одно Эталонное Решение.
+
Для прикрепления к Туру требуется минимум один Полигон, одно Эталонное Решение и одно Упражнение.
+ +
+ +

Настройка баллов для Эталонных решений

+
Нет прикреплённых решений для настройки.
+ + + + + + + + + + + + + + + + + + + + + +
IDНазваниеТип решенияОжидаемый баллПолученный баллРезультат
+ + + + + +
+ + + +
+
+ Скачать + +
+
+
+ +
+
+

Полигоны

+
+ +

Прикрепленные

+
Пока нет прикреплённых Полигонов.
+ + + + + + + + + + + + + + + + + +
IDНазваниеСтатус анализа
+
+ +
+
+ +
+ +

Доступные к прикреплению

+
Больше нет доступных к прикреплению Полигонов.
+ + + + + + + + + + + + + + + + + +
IDНазваниеСтатус анализа
+
+ + +
+
+
+
+ +
+

Эталонные решения

+
+ +

Прикрепленные

+
Пока нет прикреплённых Эталонных решений.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ +
+
+ +
+ +

Доступные к прикреплению

+
Больше нет доступных к прикреплению Эталонных решений.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ + +
+
+
-
-
Тестирование
+
+
+

Упражнение

+
-
-
-

Запуски тестирования

-
-
- - - - - - - - - - - - - - - - - - - - - - -
IDДата и время запускаДополнительная информацияСтатусРезультат
- Успешно - Неуспешно - Ошибка - Тестируется - Ожидает тестирования - - - - -
-
+

Прикрепленные

+
Пока нет прикреплённых Упражнение.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ +
+
+ +
+ +

Доступные к прикреплению

+
Больше нет доступных к прикреплению Упражнений.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ + +
+
+
+ +
+

Условия

+
+ +

Прикрепленные

+
Пока нет прикреплённых Условий.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ +
+
+ +
+ +

Доступные к прикреплению

+
Больше нет доступных к прикреплению Условий.
+ + + + + + + + + + + + + + + + + +
IDНазваниеТип
+
+ + +
+
+
+
+
+

Туры, к которым прикреплена

+
+
Пока нет прикреплённых Туров.
+ + + + + + + + + + + + + + + + + +
IDНазваниеНачалоОкончание
+
+
+ + + + +
+ + - \ No newline at end of file + + + + diff --git a/src/main/resources/templates/developer/taskFile.html b/src/main/resources/templates/developer/taskFile.html deleted file mode 100644 index b75a1094..00000000 --- a/src/main/resources/templates/developer/taskFile.html +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- -
- -
-
Задание
- -
-
-

Информация о Задании

-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
- -
- - - -
-
-
-
- -
-
История изменений файлов
- -
-
-

Изменить файл

-
- - -
-
-
- - - - -
- - - -
-
-
- -
-
-

Список изменений

-
-
- - - - - - - - - - - - - - -
- Дата и время - - Дополнительная информация -
- - - -
-
-
-
- -
- - - \ No newline at end of file diff --git a/src/main/resources/templates/developer/taskFiles.html b/src/main/resources/templates/developer/taskFiles.html deleted file mode 100644 index 5ff34b3e..00000000 --- a/src/main/resources/templates/developer/taskFiles.html +++ /dev/null @@ -1,391 +0,0 @@ - - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- -
- - -
-
Полигоны
- -
-
-

Создание Полигона

-
- - -
-
- -
-
-
-
-
-
- - - -
- - - -
-
-
- -
-
-

Список Полигонов

-
-
- - - - - - - - - - - - - - - - - - -
- ID - - Название - Дополнительная информацияЗаметка
- - - -
- -
-
-
-
- -
-
- -
-
Упражнения
- -
-
-

Создание Упражнения

-
- - -
-
- -
-
-
-
-
-
- - - -
- - - -
-
-
- -
-
-

Список Упражнений

-
-
- - - - - - - - - - - - - - - - - - -
- ID - - Название - Дополнительная информацияЗаметка
- - - -
- -
-
-
-
- -
-
- -
-
Эталонные решения
- -
-
-

Создание Эталонного решения

-
- - -
-
- -
-
-
-
-
-
- - - - -
- - - -
-
-
- -
-
-

Список Эталонных решений

-
-
- - - - - - - - - - - - - - - - - - -
- ID - - Название - Дополнительная информацияЗаметка
- - - -
- -
-
-
-
- -
-
- -
-
Условия
- -
-
-

Создание Условия

-
- - -
-
- -
-
-
-
-
-
- - - - -
- - - -
-
-
- -
-
-

Список Условий

-
-
- - - - - - - - - - - - - - - - - - -
- ID - - Название - Дополнительная информацияЗаметка
- - - -
- -
-
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/developer/tasks.html b/src/main/resources/templates/developer/tasks.html index 792c18c0..1c147ed2 100644 --- a/src/main/resources/templates/developer/tasks.html +++ b/src/main/resources/templates/developer/tasks.html @@ -1,118 +1,93 @@ - - - - - - - - + - -
- +
+ -
- -
- -
- -
- -
- -
- -
-
Задания
- -
-
-

Создание Задания

+
+
+

+ Задачи + Справка +

+
+ - - -
-
-
-
-
-
-
- -
- - - -
-
-
- -
-
-

Список Заданий

-
-
- - +
+

Созданные Задачи

+
Вы пока не создали ни одной Задачи.
+
+ - - - - - - + + + + + - - - - - - - - - + + + + + + -
- ID - - Название - Дополнительная информацияЗаметка - Результат тестирования - IDНазваниеОписаниеСтатус тестированияДата создания
-
- -
+ +
+
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
-
+ + + +
- \ No newline at end of file + + + diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 00000000..c98d7b0e --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,28 @@ + + + + + + + + +
+ + + + + + + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index 9d59f8a7..864bf7db 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -17,8 +17,8 @@

Ошибка

diff --git a/src/main/resources/templates/fragments/form.html b/src/main/resources/templates/fragments/form.html index 42f8ab1d..e5cec181 100644 --- a/src/main/resources/templates/fragments/form.html +++ b/src/main/resources/templates/fragments/form.html @@ -34,7 +34,7 @@ th:required="${isRequired}" th:placeholder="${labelName} + ${isRequired ? ' (обязательно)' : ''} + '...'" th:title="${labelName}" - readonly/> + th:attr="readonly=${isReadonly} ? 'readonly' : null"/>
diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html new file mode 100644 index 00000000..e841d8ea --- /dev/null +++ b/src/main/resources/templates/fragments/layout.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + TRIK TestSys + + +
+ + +
+
+
+ + + +
+ © TRIK TestSys +
+ + + + diff --git a/src/main/resources/templates/fragments/main.html b/src/main/resources/templates/fragments/main.html index dac18e57..ac8b28af 100644 --- a/src/main/resources/templates/fragments/main.html +++ b/src/main/resources/templates/fragments/main.html @@ -21,7 +21,8 @@ -
+
+
@@ -32,12 +33,15 @@
- +
- diff --git a/src/main/resources/templates/fragments/menu.html b/src/main/resources/templates/fragments/menu.html index cc66b03d..3d9d4d49 100644 --- a/src/main/resources/templates/fragments/menu.html +++ b/src/main/resources/templates/fragments/menu.html @@ -2,7 +2,6 @@ - @@ -21,6 +20,7 @@
+
  • diff --git a/src/main/resources/templates/group-admin/group-create.html b/src/main/resources/templates/group-admin/group-create.html new file mode 100644 index 00000000..aea5ce0a --- /dev/null +++ b/src/main/resources/templates/group-admin/group-create.html @@ -0,0 +1,36 @@ + + + + + + +
    + + +
    +
    +

    Группы Пользователей / Создание группы

    +
    +
    + + +
    +
    + + Отмена +
    +
    +
    +
    +
    + +
    + + + diff --git a/src/main/resources/templates/group-admin/group.html b/src/main/resources/templates/group-admin/group.html new file mode 100644 index 00000000..68e1173b --- /dev/null +++ b/src/main/resources/templates/group-admin/group.html @@ -0,0 +1,119 @@ + + + + + + + +
    + + +
    +
    +

    Группы Пользователей /

    +
    +
    +

    Основная информация

    +
    +
    ID
    +
    +
    Название
    +
    +
    Описание
    +
    + Системная группа (PUBLIC) + +
    +
    +
    +
    + +
    +

    Пользователи

    +
    + +

    Список доступных к добавлению

    +
    +
    Нет пользователей для добавления.
    + + + + + + + + + + + + + + + + + + + +
    IDПсевдонимДата созданияРолиДействия
    +
      +
    • +
    +
    +
    + + +
    +
    +
    +
    +
    +

    Список добавленных

    +
    В группе пока нет участников.
    + + + + + + + + + + + + + + + + + + + + + +
    IDПсевдонимДата созданияКод-доступаРолиДействия
    + + +
      +
    • +
    +
    +
    + + +
    + Владелец + Нельзя удалить из PUBLIC +
    +
    +
    +
    +
    + +
    + + + + diff --git a/src/main/resources/templates/group-admin/groups.html b/src/main/resources/templates/group-admin/groups.html new file mode 100644 index 00000000..c57e4e19 --- /dev/null +++ b/src/main/resources/templates/group-admin/groups.html @@ -0,0 +1,56 @@ + + + + + + + +
    + + +
    +
    +

    + Группы Пользователей + Справка +

    +
    +
    + +
    +
    У вас пока нет групп.
    +

    Созданные Группы

    + + + + + + + + + + + + + + + + + + + +
    IDНазваниеОписаниеДата созданияКоличество пользователей
    + +
    +
    +
    +
    +
    + +
    + + + diff --git a/src/main/resources/templates/group-admin/user-create.html b/src/main/resources/templates/group-admin/user-create.html new file mode 100644 index 00000000..19bbb8da --- /dev/null +++ b/src/main/resources/templates/group-admin/user-create.html @@ -0,0 +1,45 @@ + + + + + + + +
    + + +
    +
    +

    Группы Пользователей / / Создание пользователя

    +
    +
    + + +
    +
    + + Отмена +
    +
    +
    +
    +
    + +
    + + + + diff --git a/src/main/resources/templates/help/block.html b/src/main/resources/templates/help/block.html deleted file mode 100644 index 28aba164..00000000 --- a/src/main/resources/templates/help/block.html +++ /dev/null @@ -1,27 +0,0 @@ - - - -
    - -
    -
    Мой профиль
    -
    - - -
    -
    Настройки конфиденциальности
    -

    Выберите, кто может видеть вашу личную информацию, управлять подписками и настройками безопасности.

    -
    - - -
    -
    История заказов
    -

    Просматривайте историю своих покупок и статусы текущих заказов.

    -
    - - -
    -
    Поддержка
    -

    Свяжитесь с нашей службой поддержки, чтобы решить любые вопросы, связанные с вашим аккаунтом.

    -
    -
    \ No newline at end of file diff --git a/src/main/resources/templates/help/form.html b/src/main/resources/templates/help/form.html deleted file mode 100644 index 5d4c55d6..00000000 --- a/src/main/resources/templates/help/form.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - -
    - -
    - - - -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    - - -
    - - - -
    -
    -
    \ No newline at end of file diff --git a/src/main/resources/templates/help/menu.html b/src/main/resources/templates/help/menu.html deleted file mode 100644 index a2d11e9c..00000000 --- a/src/main/resources/templates/help/menu.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - -
    - - -
    - - \ No newline at end of file diff --git a/src/main/resources/templates/help/popup.html b/src/main/resources/templates/help/popup.html deleted file mode 100644 index d1e91ac8..00000000 --- a/src/main/resources/templates/help/popup.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/judge.html b/src/main/resources/templates/judge.html deleted file mode 100644 index 733d87da..00000000 --- a/src/main/resources/templates/judge.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - -
    - - - -
    - -
    - -
    - -
    - -
    - - -
    - -
    -
    Профиль
    - -
    -
    -

    Информация о профиле

    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/src/main/resources/templates/judge/solution.html b/src/main/resources/templates/judge/solution.html new file mode 100644 index 00000000..1d7a5565 --- /dev/null +++ b/src/main/resources/templates/judge/solution.html @@ -0,0 +1,70 @@ + + + + + + +
    + + +
    +
    +

    Посылки /

    +
    +
    +
    +
    ID:
    +
    Автор:
    +
    +
    +
    Задача:
    +
    Тур:
    +
    +
    +
    Статус:
    +
    Дата создания:
    +
    +
    + + + +
    + +

    Вердикты

    +
    Пока нет вердиктов.
    + + + + + + + + + + + + + + + +
    IDЗначениеДата создания
    + +
    + +

    Обновить релевантный вердикт

    +
    + + +
    +
    +
    +
    + +
    + + + + diff --git a/src/main/resources/templates/judge/solutions.html b/src/main/resources/templates/judge/solutions.html new file mode 100644 index 00000000..edd2c07a --- /dev/null +++ b/src/main/resources/templates/judge/solutions.html @@ -0,0 +1,222 @@ + + + + + + + + +
    + + +
    +
    +

    + Посылки + Справка +

    +
    +

    Посылки Участников

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + Сбросить +
    +
    +
    + + +
    + Заполните фильтры и нажмите «Найти» для отображения посылок. +
    + + +
    + Посылки не найдены по заданным фильтрам. +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    IDID НаблюдателяID ОрганизатораID Группы УчастниковID УчастникаДата созданияИтоговый баллДействия
    + + +
    + Скачать посылку + Скачать zip + +
    + +
    +
    +
    + + + + +
    + +

    Список перезапусков

    +
    Пока нет перезапусков.
    + + + + + + + + + + + + + + + + + + + +
    IDСтатусРезультатДата созданияДействия
    + + +
    + Скачать посылку + Скачать zip + +
    + +
    +
    +
    +
    +
    +
    + +
    + + diff --git a/src/main/resources/templates/judge/student.html b/src/main/resources/templates/judge/student.html deleted file mode 100644 index ef8638db..00000000 --- a/src/main/resources/templates/judge/student.html +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - - - - - - - -
    - - - -
    - -
    - -
    - -
    - -
    - - -
    - -
    -
    Участник
    - -
    -
    -

    Информация об Участнике

    -
    - - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    Посылки
    - -
    -
    -

    Фильтр

    -
    - - -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    - -
    -
    -

    Список Посылок

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - -
    - ID - - Задание - - Дата и время отправки - - Статус - - Результат - Дополнительная информация
    - Успешно - Неуспешно - Ошибка - Тестируется - Ожидает тестирования - - - - -
    - -
    - - - -
    -
    -
    -
    - -
    -
    Вердикты
    - -
    -
    -

    Фильтр

    -
    - - -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    - -
    - - - -
    -
    -
    - -
    -
    -

    Список Вердиктов

    -
    -
    - - - - - - - - - - - - - - - - - - - - - -
    - ID - - Дата и время создания - - Задание - - Результат - Дополнительная информация
    -
    - -
    -
    -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/src/main/resources/templates/judge/students.html b/src/main/resources/templates/judge/students.html deleted file mode 100644 index f78f289c..00000000 --- a/src/main/resources/templates/judge/students.html +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - -
    - - - -
    - -
    - -
    - -
    - -
    - - -
    - -
    -
    Участники
    - -
    -
    -

    Фильтр

    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    - -
    -
    -

    Список Участников

    -
    -
    - - - - - - - - - - - - - - - - - - - - -
    - ID Организатора - - ID Группы - - ID - - Псевдоним - Дополнительная информация
    -
    - -
    -
    -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 0fc3f5fb..2e681348 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -6,48 +6,56 @@ + + - +
    +
    + \ No newline at end of file diff --git a/src/main/resources/templates/main.html b/src/main/resources/templates/main.html index f702b074..4d634e3b 100644 --- a/src/main/resources/templates/main.html +++ b/src/main/resources/templates/main.html @@ -2,35 +2,28 @@ - - - - +
    -
    - +
    + - \ No newline at end of file + + + diff --git a/src/main/resources/templates/reg.html b/src/main/resources/templates/reg.html new file mode 100644 index 00000000..72304d01 --- /dev/null +++ b/src/main/resources/templates/reg.html @@ -0,0 +1,35 @@ + + + + + + + + +
    + + + +
    + + + + + diff --git a/src/main/resources/templates/registration.html b/src/main/resources/templates/registration.html deleted file mode 100644 index 865f1e1d..00000000 --- a/src/main/resources/templates/registration.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - -
    - -
    - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/student.html b/src/main/resources/templates/student.html deleted file mode 100644 index 34f094e5..00000000 --- a/src/main/resources/templates/student.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - -
    - - - -
    - -
    - -
    - -
    - -
    - - -
    - -
    -
    Профиль
    - -
    -
    -

    Информация о профиле

    -
    - - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    - - - -
    -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/src/main/resources/templates/student/contest.html b/src/main/resources/templates/student/contest.html index 5ef948d4..321ed0b0 100644 --- a/src/main/resources/templates/student/contest.html +++ b/src/main/resources/templates/student/contest.html @@ -1,195 +1,58 @@ - - - - - - - - - + - -
    - +
    + -
    - -
    - -
    - -
    - -
    +
    +
    +

    Туры / Тур

    +
    - -
    - -
    -
    Тур
    +

    Основная информация

    -
    -
    -

    Информация о Туре

    -
    - - -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    +
    +
    +
    Начало:
    +
    Окончание:
    -
    +
    +
    Время на прохождение:
    +
    Осталось времени:
    - -
    -
    - - -
    -
    Задания
    - -
    -
    -

    Список Заданий

    -
    -
    - - - - - - - - - - - - - - - - -
    - ID - - Название - Дополнительная информация
    - - - - - - -
    -
    -
    -
    - - -
    -
    Посылки
    - -
    -
    -

    Отправка решения

    - -
    - - - - - - -
    - - - -
    -
    +
    + +

    Задачи

    +
    Пока нет Задач.
    + + + + + + + + + + + + + + + + + +
    IDНазваниеИнфоЛучший результат
    +
    +
    -
    -
    -

    Список Посылок

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - -
    IDЗаданиеДата и время запускаДополнительная информацияСтатусРезультат
    - Успешно - Неуспешно - Ошибка - Тестируется - Ожидает тестирования - - - - -
    -
    -
    -
-
- +
- \ No newline at end of file + + + diff --git a/src/main/resources/templates/student/contests.html b/src/main/resources/templates/student/contests.html index b962fcdb..4c4d2bb9 100644 --- a/src/main/resources/templates/student/contests.html +++ b/src/main/resources/templates/student/contests.html @@ -1,139 +1,108 @@ - - - - - - - - - - + - -
- +
+ -
+
+
+

+ Туры + Справка +

-
+
+

Доступные к прохождению

+
Пока нет доступных Туров.
+ + + + + + + + + + + + + + + + + + + + + +
IDНазваниеНачалоОкончаниеВремя на прохождениеОсталось времени
+ + +
-
+
-
+

Предстоящие

+
Пока нет предстоящих Туров.
+ + + + + + + + + + + + + + + + + + + +
IDНазваниеНачалоОкончаниеВремя на прохождение
-
+
- -
- -
-
Туры
+

Завершенные

+
Пока нет завершенных Туров.
+ + + + + + + + + + + + + + + + + + + +
IDНазваниеНачалоОкончаниеВремя на прохождение
-
-
-

Список доступных Туров

-
-
- - - - - - - - - - - - - - - - - - - - - - -
- Название - Дополнительная информация - Дата и время начала - - Дата и время окончания - - Время на выполнение - - Идет - -
-
- -
-
-
+
+
-
-
-

Список оконченных Туров

-
-
- - - - - - - - - - - - - - - - - - - - - - -
- Название - Дополнительная информация - Дата и время начала - - Дата и время окончания - - Время на выполнение - - Идет - -
-
- - -
-
-
-
-
-
- +
- \ No newline at end of file + + + diff --git a/src/main/resources/templates/student/solutions.html b/src/main/resources/templates/student/solutions.html new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/templates/student/task.html b/src/main/resources/templates/student/task.html new file mode 100644 index 00000000..caed72bb --- /dev/null +++ b/src/main/resources/templates/student/task.html @@ -0,0 +1,108 @@ + + + + + + +
+ + +
+
+

Туры / Тур / Задача

+
+

Основная информация

+
+
+
ID:
+
Название:
+
Инфо:
+
Максимальный балл:
+
+
+ +
+ +

Файлы Задачи

+ + +
+ +

Загрузить решение

+
+ + + + + + +
+
+ +
+
+ + +
+ +

Мои решения

+
Пока нет загруженных решений.
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDСтатусРезультатСоздано
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/superuser.html b/src/main/resources/templates/superuser.html deleted file mode 100644 index a849fc45..00000000 --- a/src/main/resources/templates/superuser.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- - -
- -
-
Профиль
- -
-
-

Информация о профиле

-
- - -
-
- -
-
-
-
-
-
-
-
-
-
- -
- - - -
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/superuser/emergency-messages.html b/src/main/resources/templates/superuser/emergency-messages.html deleted file mode 100644 index d9d0ee84..00000000 --- a/src/main/resources/templates/superuser/emergency-messages.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- - -
- -
-
Экстренные сообщения
- -
-
-

Создание Сообщения

-
- - -
- - - - -
-
- -
- - - -
-
-
- -
-
-

Список сообщений

-
-
- - - - - - - - - - - - - - - - -
- ID - - Тип пользователей - Дополнительная информация
-
- -
-
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/superuser/user-create.html b/src/main/resources/templates/superuser/user-create.html new file mode 100644 index 00000000..cdcd5935 --- /dev/null +++ b/src/main/resources/templates/superuser/user-create.html @@ -0,0 +1,45 @@ + + + + + + + +
+ + +
+
+

Пользователи / Создание пользователя

+
+
+ + +
+
+ + Отмена +
+
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/superuser/users.html b/src/main/resources/templates/superuser/users.html index 48b97436..24bc224e 100644 --- a/src/main/resources/templates/superuser/users.html +++ b/src/main/resources/templates/superuser/users.html @@ -1,324 +1,165 @@ - - - - - - - - - - -
- + + + + -
- -
- -
- -
- -
- - -
- -
-
Наблюдатели
- -
-
-

Создание Наблюдателя

-
- - -
-
- -
-
-
-
- -
- - - -
-
-
- -
-
-

Список Наблюдателей

+
+ + +
+
+

+ Пользователи + Справка +

+
+
+ -
- - +
+

Список созданных Пользователей

+
+ - - - - - + + + + + + - - - - - - - - + + + + + + + + + -
- ID - - Псевдоним - Дополнительная информация - Код-доступа - - Код-регистрации - IDИмяДата созданияКод-доступаРолиДействия
+ + +
    +
  • +
+
+
+
+ Добавить роль +
+ + + +
+
+
+ + +
+
+
-
-
- -
-
+ + -
-
Организаторы
- -
-
-

Создание Организатора

-
- - -
-
- -
-
-
-
- - - - -
- - - -
-
-
- -
-
-

Список Организаторов

-
-
- +
+
+

Активные Пользователи

+
- - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + + +
- ID - - Псевдоним - - Наблюдатель - Дополнительная информация - Код-доступа -
IDИмяДата созданияКод-доступаРолиДействия
+ + +
    +
  • +
+
+
+
+ Добавить роль +
+ + + +
+
+
+ + +
+
+
-
-
- -
-
- -
-
Разработчики
- -
-
-

Создание Разработчика

-
- -
-
- -
-
-
-
- -
- - - -
-
-
- -
-
-

Список Разработчиков

-
-
- - - - - - - - - - - - - - - - -
- ID - - Псевдоним - Дополнительная информация - Код-доступа -
-
-
- -
-
- -
-
Судьи
- -
-
-

Создание Судьи

-
- - -
-
- -
-
-
-
- -
- - - -
-
-
- -
-
-

Список Судей

-
-
- +
+

Удаленные Пользователи

+
- - - - + + + + + - - - - - - + + + + + + + +
- ID - - Псевдоним - Дополнительная информация - Код-доступа - IDИмяДата созданияКод-доступаРоли
+ + +
    +
  • +
+
-
+ + + -
+
\ No newline at end of file diff --git a/src/main/resources/templates/token-restore.html b/src/main/resources/templates/token-restore.html new file mode 100644 index 00000000..f080db8e --- /dev/null +++ b/src/main/resources/templates/token-restore.html @@ -0,0 +1,33 @@ + + + + + + + + +
+ + + + + + + diff --git a/src/main/resources/templates/user.html b/src/main/resources/templates/user.html new file mode 100644 index 00000000..1863ba74 --- /dev/null +++ b/src/main/resources/templates/user.html @@ -0,0 +1,137 @@ + + + + + + + + + + + +
+ + +
+
+

+ Обзор + Справка +

+
+
+

Основная информация

+
+
ID
+
+
Псевдоним
+
+
+ + +
+ +
+
Код-доступа
+
+
Почта
+
+ +
+ + + + + +
+ +
+
+ + + + + +
+ + +
+
+ +
+
+ +
Последний вход
+
+
+
+
+

Роли

+
    +
  • +
+
+
+
+

Группы пользователей

+
+
Нет групп.
+
    +
  • + + + (Владелец) +
  • +
+
+
+
+
+ +
+ + + + + diff --git a/src/main/resources/templates/user/groups.html b/src/main/resources/templates/user/groups.html new file mode 100644 index 00000000..769e46f5 --- /dev/null +++ b/src/main/resources/templates/user/groups.html @@ -0,0 +1,57 @@ + + + + + + + + +
+ + +
+
+

Группы пользователей

+
+
+
Нет групп.
+ + + + + + + + + + + + + + + + + + + +
IDНазваниеОписаниеДата созданияДействия
+
+ +
+
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/user/privileges.html b/src/main/resources/templates/user/privileges.html new file mode 100644 index 00000000..60c9b200 --- /dev/null +++ b/src/main/resources/templates/user/privileges.html @@ -0,0 +1,44 @@ + + + + + + + +
+ + +
+
+

Роли пользователя

+
+
+
Нет прав.
+ + + + + + + + + + + + + + + +
НазваниеВозможностиСтраница
+ Перейти + +
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/viewer.html b/src/main/resources/templates/viewer.html deleted file mode 100644 index f835db01..00000000 --- a/src/main/resources/templates/viewer.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - -
- - - -
- -
- -
- -
- -
- -
- -
-
Профиль
- -
-
-

Информация о профиле

-
- - -
-
- -
-
-
-
-
-
-
-
-
-
-
-
- - -
- - - -
-
-
-
-
- - - \ No newline at end of file diff --git a/src/main/resources/templates/viewer/admins.html b/src/main/resources/templates/viewer/admins.html index e31f9496..75717fca 100644 --- a/src/main/resources/templates/viewer/admins.html +++ b/src/main/resources/templates/viewer/admins.html @@ -1,79 +1,64 @@ - - - - - - - - + + - -
- +
+ -
- -
- -
- -
- -
+
+
+

+ Организаторы + Справка +

+
+
+
+ Код-регистрации + +
+
+ Токен не выдан +
+
+ Этот токен нужно выдать будущему Организатору. Он сможет зарегистрироваться в системе, указав этот токен на странице регистрации. +
+ +
-
- -
-
Организаторы
+

Список Организаторов

-
-
-

Список Организаторов

+
+

У вас пока нет Организаторов.

-
- + +
+
- - - - - - + + + + + - - - - - - - + + + + + + +
- ID - - Псевдоним - Дополнительная информацияДата регистрации
IDПсевдонимДата создания
+
+ +
- - Экспорт результатов (UTF-8) - - - - Экспорт результатов (Windows-1251) - -
-
- +
\ No newline at end of file diff --git a/src/main/resources/templates/viewer/export.html b/src/main/resources/templates/viewer/export.html new file mode 100644 index 00000000..5624bc5e --- /dev/null +++ b/src/main/resources/templates/viewer/export.html @@ -0,0 +1,32 @@ + + + + + + +
+ + +
+
+

+ Результаты + Справка +

+
+

+ Здесь вы можете выгрузить сводную таблицу результатов Участников всех Организаторов, за которыми вы наблюдаете. +

+
+
+ +
+
+
+
+ +
+ + + + diff --git a/src/main/resources/templates/viewer/token.html b/src/main/resources/templates/viewer/token.html new file mode 100644 index 00000000..da7d3cd7 --- /dev/null +++ b/src/main/resources/templates/viewer/token.html @@ -0,0 +1,32 @@ + + + + + + +
+ + +
+
+

Код-регистрации

+
+
+
+
Значение
+
+
+
+
+
+ Токен не выдан +
+
+
+
+ +
+ + + + diff --git a/src/test/kotlin/trik/testsys/backoffice/controller/impl/rest/StudentExportControllerTest.kt b/src/test/kotlin/trik/testsys/backoffice/controller/impl/rest/StudentExportControllerTest.kt new file mode 100644 index 00000000..91453a10 --- /dev/null +++ b/src/test/kotlin/trik/testsys/backoffice/controller/impl/rest/StudentExportControllerTest.kt @@ -0,0 +1,271 @@ +//package trik.testsys.backoffice.controller.impl.rest +// +//import org.junit.jupiter.api.Nested +//import org.junit.jupiter.api.Test +//import org.mockito.kotlin.any +//import org.mockito.kotlin.doReturn +//import org.mockito.kotlin.mock +//import org.mockito.kotlin.whenever +//import org.springframework.http.HttpStatus +//import org.springframework.http.ResponseEntity +//import org.springframework.web.multipart.MultipartFile +//import trik.testsys.backoffice.controller.rest.StudentExportController +//import trik.testsys.backoffice.entity.impl.StudentGroup +//import trik.testsys.backoffice.entity.user.impl.Admin +//import trik.testsys.backoffice.entity.user.impl.Student +//import trik.testsys.backoffice.entity.user.impl.Viewer +//import trik.testsys.backoffice.service.entity.impl.GroupService +//import trik.testsys.backoffice.service.entity.user.impl.AdminService +//import trik.testsys.backoffice.service.entity.user.impl.StudentService +//import java.io.ByteArrayInputStream +//import kotlin.test.assertEquals +//import kotlin.test.assertNotNull +// +///** +// * @author Roman Shishkin +// * @since 2.5.0 +// */ +//internal class StudentExportControllerTest { +// +// private val adminService = mock() +// private val groupService = mock() +// private val studentService = mock() +// +// private val studentExportController = StudentExportControllerImpl(adminService, groupService, studentService) +// +// @Nested +// inner class `exportFromCsvFile tests` { +// +// //region Parameters +// private val apiKey = "apiKey" +// private val adminId = 1L +// private val groupId = 1L +// +// private val admin = admin() +// private val group = group(admin = admin) +// //endregion +// +// @Test +// fun `admin does not exist by adminId - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn null +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "Admin with ID $adminId not found", +// ) +// //endregion +// } +// +// @Test +// fun `admin exist, but group does not exist by groupId - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// whenever(groupService.find(groupId)) doReturn null +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "Group with ID $groupId not found", +// ) +// //endregion +// } +// +// @Test +// fun `admin and group exists, but admin is not owner of group - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// +// val admin2 = admin(2L, "admin2", "accessToken2") +// val group2 = group(admin = admin2) +// whenever(groupService.find(groupId)) doReturn group2 +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "Admin with ID $adminId is not the owner of the group with ID $groupId", +// ) +// //endregion +// } +// +// @Test +// fun `admin and group exists, file is empty - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// whenever(groupService.find(groupId)) doReturn group +// +// val file = multipartFile( +// content = "" +// ) +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "File is invalid", +// ) +// //endregion +// } +// +// @Test +// fun `admin and group exists, file contains only header - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// whenever(groupService.find(groupId)) doReturn group +// +// val file = multipartFile( +// content = "Name, Surname, Age" +// ) +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "File is invalid", +// ) +// //endregion +// } +// +// @Test +// fun `admin and group exists, file contains header and invalid rows - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// whenever(groupService.find(groupId)) doReturn group +// +// val file = multipartFile( +// content = "Name, Surname, Age\n" + +// "John, Doe\n" + +// "Jane, Doe, 25, 123" +// ) +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) +// result.assertFailure( +// expectedHttpStatus = HttpStatus.BAD_REQUEST, +// expectedMessage = "File is invalid", +// ) +// //endregion +// } +// +// @Test +// fun `admin and group exists, file contains header and valid rows - return bad request`() { +// //region Mocking +// whenever(adminService.find(adminId)) doReturn admin +// whenever(groupService.find(groupId)) doReturn group +// +// val file = multipartFile( +// content = "Name, Surname, Age\n" + +// "John, Doe, 25\n" + +// "Jane, Doe, 23" +// ) +// val student1 = Student("st-1", "accessToken1").also { +// it.id = 1L +// it.additionalInfo = "Name: John, Surname: Doe, Age: 25" +// it.group = group +// } +// val student2 = Student("st-2", "accessToke2").also { +// it.id = 2L +// it.additionalInfo = "Name: Jane, Surname: Doe, Age: 23" +// it.group = group +// } +// val students = listOf(student1, student2) +// +// whenever(studentService.generate(any>(), any())) doReturn students +// //endregion +// +// //region Asserting +// val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) +// val expectedStudentsInfo = StudentExportController.StudentsInfo( +// adminId = admin.id!!, +// groupId = group.id!!, +// students = students.map { +// StudentExportController.StudentInfo( +// id = it.id!!, +// name = it.name, +// additionalInfo = it.additionalInfo, +// accessToken = it.accessToken +// ) +// } +// ) +// result.assertSuccess( +// expectedHttpStatus = HttpStatus.OK, +// expectedStudentsInfo = expectedStudentsInfo +// ) +// //endregion +// } +// +// private fun ResponseEntity.assert( +// expectedHttpStatus: HttpStatus, +// expectedMessage: String, +// expectedDataStatus: StudentExportController.ResponseData.Status, +// expectedStudentsInfo: StudentExportController.StudentsInfo? +// ) { +// assertEquals(expectedHttpStatus, statusCode) +// +// assertNotNull(body) +// assertEquals(expectedMessage, body!!.message) +// assertEquals(expectedDataStatus, body!!.status) +// assertEquals(expectedStudentsInfo, body!!.studentsInfo) +// } +// +// private fun ResponseEntity.assertFailure( +// expectedHttpStatus: HttpStatus, +// expectedMessage: String +// ) = assert( +// expectedHttpStatus = expectedHttpStatus, +// expectedMessage = expectedMessage, +// expectedDataStatus = StudentExportController.ResponseData.Status.FAILURE, +// expectedStudentsInfo = null +// ) +// +// private fun ResponseEntity.assertSuccess( +// expectedHttpStatus: HttpStatus, +// expectedStudentsInfo: StudentExportController.StudentsInfo +// ) = assert( +// expectedHttpStatus = expectedHttpStatus, +// expectedMessage = "Students have been successfully exported", +// expectedDataStatus = StudentExportController.ResponseData.Status.SUCCESS, +// expectedStudentsInfo = expectedStudentsInfo +// ) +// } +// +// companion object { +// +// private fun admin( +// id: Long = 1L, +// name: String = "admin", +// accessToken: String = "accessToken", +// viewer: Viewer = Viewer("viewer", "accessToken2", "regToken") +// ) = Admin(name, accessToken).also { +// it.id = id +// it.viewer = viewer +// } +// +// private fun group( +// id: Long = 10L, +// name: String = "group", +// regToken: String = "regToken2", +// admin: Admin = admin() +// ) = StudentGroup(name, regToken).also { +// it.id = id +// it.admin = admin +// } +// +// private fun multipartFile( +// content: String = "" +// ) = mock { +// val inputStream = ByteArrayInputStream(content.toByteArray()) +// on { this.inputStream } doReturn inputStream +// } +// } +//} \ No newline at end of file diff --git a/src/test/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecificationsTest.kt b/src/test/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecificationsTest.kt new file mode 100644 index 00000000..8871ca70 --- /dev/null +++ b/src/test/kotlin/trik/testsys/webapp/backoffice/data/repository/support/SolutionSpecificationsTest.kt @@ -0,0 +1,43 @@ +package trik.testsys.webapp.backoffice.data.repository.support + +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import trik.testsys.webapp.backoffice.data.repository.support.SolutionSpecifications + +class SolutionSpecificationsTest { + + @Test + fun `hasStudentPrivilege returns non-null specification`() { + assertNotNull(SolutionSpecifications.hasStudentPrivilege()) + } + + @Test + fun `createdBy returns non-null specification`() { + assertNotNull(SolutionSpecifications.createdBy(1L)) + } + + @Test + fun `inGroup returns non-null specification`() { + assertNotNull(SolutionSpecifications.inGroup(1L)) + } + + @Test + fun `underAdmin returns non-null specification`() { + assertNotNull(SolutionSpecifications.underAdmin(1L)) + } + + @Test + fun `underViewer returns non-null specification`() { + assertNotNull(SolutionSpecifications.underViewer(1L)) + } + + @Test + fun `createdAfter returns non-null specification`() { + assertNotNull(SolutionSpecifications.createdAfter(java.time.Instant.now())) + } + + @Test + fun `createdBefore returns non-null specification`() { + assertNotNull(SolutionSpecifications.createdBefore(java.time.Instant.now())) + } +} diff --git a/src/test/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipServiceTest.kt b/src/test/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipServiceTest.kt new file mode 100644 index 00000000..d3686652 --- /dev/null +++ b/src/test/kotlin/trik/testsys/webapp/backoffice/service/SponsorshipServiceTest.kt @@ -0,0 +1,51 @@ +package trik.testsys.webapp.backoffice.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SponsorshipServiceTest { + + @Test + fun `returns empty list when directory does not exist`() { + val service = SponsorshipService("/nonexistent/path/") + assertTrue(service.getImageNames().isEmpty()) + } + + @Test + fun `returns empty list when directory is empty`(@TempDir dir: Path) { + val service = SponsorshipService(dir.toString()) + assertTrue(service.getImageNames().isEmpty()) + } + + @Test + fun `returns image filenames sorted`(@TempDir dir: Path) { + dir.resolve("b_logo.png").toFile().createNewFile() + dir.resolve("a_logo.svg").toFile().createNewFile() + dir.resolve("readme.txt").toFile().createNewFile() + + val service = SponsorshipService(dir.toString()) + assertEquals(listOf("a_logo.svg", "b_logo.png"), service.getImageNames()) + } + + @Test + fun `returns only image files by extension`(@TempDir dir: Path) { + listOf("a.png", "b.jpg", "c.jpeg", "d.svg", "e.gif", "f.webp", "g.txt", "h.pdf") + .forEach { dir.resolve(it).toFile().createNewFile() } + + val service = SponsorshipService(dir.toString()) + assertEquals(listOf("a.png", "b.jpg", "c.jpeg", "d.svg", "e.gif", "f.webp"), service.getImageNames()) + } + + @Test + fun `returns image files with uppercase extensions`(@TempDir dir: Path) { + dir.resolve("logo.PNG").toFile().createNewFile() + dir.resolve("banner.JPG").toFile().createNewFile() + dir.resolve("icon.SVG").toFile().createNewFile() + + val service = SponsorshipService(dir.toString()) + assertEquals(listOf("banner.JPG", "icon.SVG", "logo.PNG"), service.getImageNames()) + } +} diff --git a/src/test/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerTest.kt b/src/test/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerTest.kt deleted file mode 100644 index 03a6ac43..00000000 --- a/src/test/kotlin/trik/testsys/webclient/controller/impl/rest/StudentExportControllerTest.kt +++ /dev/null @@ -1,271 +0,0 @@ -package trik.testsys.webclient.controller.impl.rest - -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.multipart.MultipartFile -import trik.testsys.webclient.controller.rest.StudentExportController -import trik.testsys.webclient.entity.impl.Group -import trik.testsys.webclient.entity.user.impl.Admin -import trik.testsys.webclient.entity.user.impl.Student -import trik.testsys.webclient.entity.user.impl.Viewer -import trik.testsys.webclient.service.entity.impl.GroupService -import trik.testsys.webclient.service.entity.user.impl.AdminService -import trik.testsys.webclient.service.entity.user.impl.StudentService -import java.io.ByteArrayInputStream -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** - * @author Roman Shishkin - * @since 2.5.0 - */ -internal class StudentExportControllerTest { - - private val adminService = mock() - private val groupService = mock() - private val studentService = mock() - - private val studentExportController = StudentExportControllerImpl(adminService, groupService, studentService) - - @Nested - inner class `exportFromCsvFile tests` { - - //region Parameters - private val apiKey = "apiKey" - private val adminId = 1L - private val groupId = 1L - - private val admin = admin() - private val group = group(admin = admin) - //endregion - - @Test - fun `admin does not exist by adminId - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn null - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "Admin with ID $adminId not found", - ) - //endregion - } - - @Test - fun `admin exist, but group does not exist by groupId - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - whenever(groupService.find(groupId)) doReturn null - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "Group with ID $groupId not found", - ) - //endregion - } - - @Test - fun `admin and group exists, but admin is not owner of group - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - - val admin2 = admin(2L, "admin2", "accessToken2") - val group2 = group(admin = admin2) - whenever(groupService.find(groupId)) doReturn group2 - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, multipartFile()) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "Admin with ID $adminId is not the owner of the group with ID $groupId", - ) - //endregion - } - - @Test - fun `admin and group exists, file is empty - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - whenever(groupService.find(groupId)) doReturn group - - val file = multipartFile( - content = "" - ) - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "File is invalid", - ) - //endregion - } - - @Test - fun `admin and group exists, file contains only header - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - whenever(groupService.find(groupId)) doReturn group - - val file = multipartFile( - content = "Name, Surname, Age" - ) - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "File is invalid", - ) - //endregion - } - - @Test - fun `admin and group exists, file contains header and invalid rows - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - whenever(groupService.find(groupId)) doReturn group - - val file = multipartFile( - content = "Name, Surname, Age\n" + - "John, Doe\n" + - "Jane, Doe, 25, 123" - ) - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) - result.assertFailure( - expectedHttpStatus = HttpStatus.BAD_REQUEST, - expectedMessage = "File is invalid", - ) - //endregion - } - - @Test - fun `admin and group exists, file contains header and valid rows - return bad request`() { - //region Mocking - whenever(adminService.find(adminId)) doReturn admin - whenever(groupService.find(groupId)) doReturn group - - val file = multipartFile( - content = "Name, Surname, Age\n" + - "John, Doe, 25\n" + - "Jane, Doe, 23" - ) - val student1 = Student("st-1", "accessToken1").also { - it.id = 1L - it.additionalInfo = "Name: John, Surname: Doe, Age: 25" - it.group = group - } - val student2 = Student("st-2", "accessToke2").also { - it.id = 2L - it.additionalInfo = "Name: Jane, Surname: Doe, Age: 23" - it.group = group - } - val students = listOf(student1, student2) - - whenever(studentService.generate(any>(), any())) doReturn students - //endregion - - //region Asserting - val result = studentExportController.exportFromCsvFile(apiKey, adminId, groupId, file) - val expectedStudentsInfo = StudentExportController.StudentsInfo( - adminId = admin.id!!, - groupId = group.id!!, - students = students.map { - StudentExportController.StudentInfo( - id = it.id!!, - name = it.name, - additionalInfo = it.additionalInfo, - accessToken = it.accessToken - ) - } - ) - result.assertSuccess( - expectedHttpStatus = HttpStatus.OK, - expectedStudentsInfo = expectedStudentsInfo - ) - //endregion - } - - private fun ResponseEntity.assert( - expectedHttpStatus: HttpStatus, - expectedMessage: String, - expectedDataStatus: StudentExportController.ResponseData.Status, - expectedStudentsInfo: StudentExportController.StudentsInfo? - ) { - assertEquals(expectedHttpStatus, statusCode) - - assertNotNull(body) - assertEquals(expectedMessage, body!!.message) - assertEquals(expectedDataStatus, body!!.status) - assertEquals(expectedStudentsInfo, body!!.studentsInfo) - } - - private fun ResponseEntity.assertFailure( - expectedHttpStatus: HttpStatus, - expectedMessage: String - ) = assert( - expectedHttpStatus = expectedHttpStatus, - expectedMessage = expectedMessage, - expectedDataStatus = StudentExportController.ResponseData.Status.FAILURE, - expectedStudentsInfo = null - ) - - private fun ResponseEntity.assertSuccess( - expectedHttpStatus: HttpStatus, - expectedStudentsInfo: StudentExportController.StudentsInfo - ) = assert( - expectedHttpStatus = expectedHttpStatus, - expectedMessage = "Students have been successfully exported", - expectedDataStatus = StudentExportController.ResponseData.Status.SUCCESS, - expectedStudentsInfo = expectedStudentsInfo - ) - } - - companion object { - - private fun admin( - id: Long = 1L, - name: String = "admin", - accessToken: String = "accessToken", - viewer: Viewer = Viewer("viewer", "accessToken2", "regToken") - ) = Admin(name, accessToken).also { - it.id = id - it.viewer = viewer - } - - private fun group( - id: Long = 10L, - name: String = "group", - regToken: String = "regToken2", - admin: Admin = admin() - ) = Group(name, regToken).also { - it.id = id - it.admin = admin - } - - private fun multipartFile( - content: String = "" - ) = mock { - val inputStream = ByteArrayInputStream(content.toByteArray()) - on { this.inputStream } doReturn inputStream - } - } -} \ No newline at end of file