diff --git a/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt b/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt index 1475aa2..08f0844 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/config/CacheConfig.kt @@ -29,8 +29,7 @@ class CacheConfig { "PointsService.getMaxPoints", "PointsService.calculateAvgTaskPoints", "PointsService.calculateAssignmentMaxPoints", - "EvaluationService.getEvaluation", - "EvaluationService.getEvaluationSummary", + "CourseService.getStudents", ) } } diff --git a/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt b/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt index 8d41a17..40f6a9a 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/controller/CourseController.kt @@ -1,12 +1,12 @@ package ch.uzh.ifi.access.controller import ch.uzh.ifi.access.model.constants.Role -import ch.uzh.ifi.access.model.constants.TaskStatus import ch.uzh.ifi.access.model.dto.* import ch.uzh.ifi.access.projections.* import ch.uzh.ifi.access.service.CourseService import ch.uzh.ifi.access.service.EmitterService import ch.uzh.ifi.access.service.RoleService +import ch.uzh.ifi.access.service.SubmissionService import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -75,6 +75,7 @@ class CourseController( private val courseService: CourseService, private val roleService: RoleService, private val emitterService: EmitterService, + private val submissionService: SubmissionService ) { private val logger = KotlinLogging.logger {} @@ -90,7 +91,6 @@ class CourseController( } @GetMapping("/{course}") - //@PreAuthorize("hasRole(#course) or hasRole(#course+'-supervisor')") @PreAuthorize("hasRole(#course)") fun getCourseWorkspace(@PathVariable course: String?): CourseWorkspace { return courseService.getCourseWorkspaceBySlug(course!!) @@ -127,125 +127,8 @@ class CourseController( ) { val userId = roleService.getUserId(authentication.name) submission.userId = userId - courseService.createSubmission(course, assignment, task!!, submission) - } - - @PostMapping("/{course}/examples/{example}/submit") - @PreAuthorize("hasRole(#course) and (#submission.restricted or hasRole(#course + '-assistant'))") - fun evaluateExampleSubmission( - @PathVariable course: String, - @PathVariable example: String, - @RequestBody submission: SubmissionDTO, - authentication: Authentication - ) { - submission.userId = authentication.name - // Is there a better way than passing null to assignmentSlug? - courseService.createSubmission(course, null, example, submission) - } - - - @GetMapping("/{course}/examples/{example}/users/{user}") - @PreAuthorize("hasRole(#course+'-assistant') or (#user == authentication.name)") - fun getExample( - @PathVariable course: String, - @PathVariable example: String, - @PathVariable user: String - ): TaskWorkspace { - return courseService.getExample(course, example, user) - } - - @GetMapping("/{course}/examples/{example}/information") - @PreAuthorize("hasRole(#course+'-assistant')") - fun getGeneralInformation( - @PathVariable course: String, - @PathVariable example: String, - authentication: Authentication - ): ExampleInformationDTO { - val participantsOnline = roleService.getOnlineCount(course) - val totalParticipants = courseService.getCourseBySlug(course).participantCount - val numberOfStudentsWhoSubmitted = courseService.countStudentsWhoSubmittedExample(course, example) - return ExampleInformationDTO( - participantsOnline, - totalParticipants, - numberOfStudentsWhoSubmitted, - /*TODO: Replace following mock code with actual test pass rate and test names*/ - passRatePerTestCase = mapOf( - "First Test" to 0.85, - "Second Test" to 0.5, - "Some Other Test" to 0.7, - "One More Test" to 0.1, - "Edge Case" to 0.2 - ) - ) - } - - // TODO: Change this to returning TaskOverview, as we don't need more information. However, when changing it, an error occurs in the courses/{course} endpoint. - @GetMapping("/{course}/examples") - @PreAuthorize("hasRole(#course)") - fun getExamples( - @PathVariable course: String, - authentication: Authentication - ): List { - return courseService.getExamples(course) - } - - // Invoked by the teacher when publishing an example to inform the students - @PostMapping("/{course}/examples/{example}/publish") - @PreAuthorize("hasRole(#course+'-supervisor')") - fun publishExample( - @PathVariable course: String, - @PathVariable example: String, - @RequestBody body: ExampleDurationDTO, - ) { - val activeExample = courseService.getExamples(course).firstOrNull { - it.status == TaskStatus.Interactive - } - - if (activeExample != null) { - throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "An interactive example already exists." - ) - } - - val updatedExample = courseService.publishExampleBySlug(course, example, body.duration) - - emitterService.sendMessage(course, "redirect", "/courses/$course/examples/$example") - emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") - } - - // Invoked by the teacher when want to extend the time of an active example by a certain amount of seconds - @PutMapping("/{course}/examples/{example}/extend") - @PreAuthorize("hasRole(#course+'-supervisor')") - fun extendExampleDeadline( - @PathVariable course: String, - @PathVariable example: String, - @RequestBody body: ExampleDurationDTO, - ) { - val updatedExample = courseService.extendExampleDeadlineBySlug(course, example, body.duration) - - emitterService.sendMessage( - course, - "message", - "Submission time extended by the lecturer by ${body.duration} seconds." - ) - emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") - } - // Invoked by the teacher when want to terminate the active example - @PutMapping("/{course}/examples/{example}/terminate") - @PreAuthorize("hasRole(#course+'-supervisor')") - fun terminateExample( - @PathVariable course: String, - @PathVariable example: String - ) { - val updatedExample = courseService.terminateExampleBySlug(course, example) - emitterService.sendMessage( - course, - "message", - "The example has been terminated by the lecturer." - ) - emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") + submissionService.createTaskSubmission(course, assignment, task!!, submission) } // A text event endpoint to publish events to clients diff --git a/src/main/kotlin/ch/uzh/ifi/access/controller/ExampleController.kt b/src/main/kotlin/ch/uzh/ifi/access/controller/ExampleController.kt new file mode 100644 index 0000000..72cd1f5 --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/controller/ExampleController.kt @@ -0,0 +1,141 @@ +package ch.uzh.ifi.access.controller + +import ch.uzh.ifi.access.model.constants.TaskStatus +import ch.uzh.ifi.access.model.dto.ExampleDurationDTO +import ch.uzh.ifi.access.model.dto.ExampleInformationDTO +import ch.uzh.ifi.access.model.dto.SubmissionDTO +import ch.uzh.ifi.access.projections.TaskWorkspace +import ch.uzh.ifi.access.service.EmitterService +import ch.uzh.ifi.access.service.ExampleService +import ch.uzh.ifi.access.service.RoleService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.HttpStatus +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException + + +@RestController +@RequestMapping("/courses/{course}/examples") +@EnableAsync +class ExampleController( + private val exampleService: ExampleService, + private val roleService: RoleService, + private val emitterService: EmitterService, +) { + private val logger = KotlinLogging.logger {} + + // TODO: Change this to returning TaskOverview, as we don't need more information. However, when changing it, an error occurs in the courses/{course} endpoint. + @GetMapping("") + @PreAuthorize("hasRole(#course)") + fun getExamples( + @PathVariable course: String, + authentication: Authentication + ): List { + return exampleService.getExamples(course) + } + + @GetMapping("/{example}/information") + @PreAuthorize("hasRole(#course+'-assistant')") + fun getGeneralInformation( + @PathVariable course: String, + @PathVariable example: String, + authentication: Authentication + ): ExampleInformationDTO { + val participantsOnline = roleService.getOnlineCount(course) + val totalParticipants = exampleService.getCourseBySlug(course).participantCount + val numberOfStudentsWhoSubmitted = exampleService.countStudentsWhoSubmittedExample(course, example) + val passRatePerTestCase = exampleService.getExamplePassRatePerTestCase(course, example) + + return ExampleInformationDTO( + participantsOnline, + totalParticipants, + numberOfStudentsWhoSubmitted, + passRatePerTestCase + ) + } + + @PostMapping("/{example}/submit") + @PreAuthorize("hasRole(#course) and (#submission.restricted or hasRole(#course + '-assistant'))") + fun evaluateExampleSubmission( + @PathVariable course: String, + @PathVariable example: String, + @RequestBody submission: SubmissionDTO, + authentication: Authentication + ) { + submission.userId = authentication.name + // Is there a better way than passing null to assignmentSlug? + exampleService.createExampleSubmission(course, example, submission) + } + + @GetMapping("/{example}/users/{user}") + @PreAuthorize("hasRole(#course+'-assistant') or (#user == authentication.name)") + fun getExample( + @PathVariable course: String, + @PathVariable example: String, + @PathVariable user: String + ): TaskWorkspace { + return exampleService.getExample(course, example, user) + } + + // Invoked by the teacher when publishing an example to inform the students + @PostMapping("/{example}/publish") + @PreAuthorize("hasRole(#course+'-supervisor')") + fun publishExample( + @PathVariable course: String, + @PathVariable example: String, + @RequestBody body: ExampleDurationDTO, + ) { + val activeExample = exampleService.getExamples(course).firstOrNull { + it.status == TaskStatus.Interactive + } + + if (activeExample != null) { + throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "An interactive example already exists." + ) + } + + val updatedExample = exampleService.publishExampleBySlug(course, example, body.duration) + + emitterService.sendMessage(course, "redirect", "/courses/$course/examples/$example") + emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") + } + + // Invoked by the teacher when want to extend the time of an active example by a certain amount of seconds + @PutMapping("/{example}/extend") + @PreAuthorize("hasRole(#course+'-supervisor')") + fun extendExampleDeadline( + @PathVariable course: String, + @PathVariable example: String, + @RequestBody body: ExampleDurationDTO, + ) { + val updatedExample = exampleService.extendExampleDeadlineBySlug(course, example, body.duration) + + emitterService.sendMessage( + course, + "message", + "Submission time extended by the lecturer by ${body.duration} seconds." + ) + emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") + } + + // Invoked by the teacher when want to terminate the active example + @PutMapping("/{example}/terminate") + @PreAuthorize("hasRole(#course+'-supervisor')") + fun terminateExample( + @PathVariable course: String, + @PathVariable example: String + ) { + val updatedExample = exampleService.terminateExampleBySlug(course, example) + emitterService.sendMessage( + course, + "message", + "The example has been terminated by the lecturer." + ) + emitterService.sendMessage(course, "timer-update", "${updatedExample.start}/${updatedExample.end}") + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/model/Submission.kt b/src/main/kotlin/ch/uzh/ifi/access/model/Submission.kt index fb7194d..37deb03 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/model/Submission.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/model/Submission.kt @@ -48,6 +48,10 @@ class Submission { @OneToMany(mappedBy = "submission", cascade = [CascadeType.ALL]) var persistentResultFiles: MutableList = ArrayList() + @Column(name = "test_scores") + @JdbcTypeCode(SqlTypes.JSON) + var testScores: List = ArrayList() + @Column(name = "embedding") @JdbcTypeCode(SqlTypes.JSON) var embedding: List = ArrayList() diff --git a/src/main/kotlin/ch/uzh/ifi/access/model/dao/Results.kt b/src/main/kotlin/ch/uzh/ifi/access/model/dao/Results.kt index 538fc2b..95723f1 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/model/dao/Results.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/model/dao/Results.kt @@ -6,5 +6,6 @@ import lombok.Data class Results( var points: Double? = null, var hints: MutableList = mutableListOf(), - var tests: MutableList = mutableListOf() + var tests: MutableList = mutableListOf(), + var testScores: MutableList = mutableListOf() ) diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt index 773b584..7d74fa5 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/AssignmentOverview.kt @@ -21,7 +21,7 @@ interface AssignmentOverview { val isPastDue: Boolean val isActive: Boolean - @get:Value("#{@pointsService.calculateAssignmentMaxPoints(target.tasks}") + @get:Value("#{@pointsService.calculateAssignmentMaxPoints(target.tasks)}") val maxPoints: Double? @get:Value("#{@courseService.calculateAssignmentPoints(target.tasks)}") diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/CourseWorkspace.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/CourseWorkspace.kt index 33807e1..e685ddb 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/CourseWorkspace.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/CourseWorkspace.kt @@ -9,6 +9,6 @@ interface CourseWorkspace : CourseOverview { @get:Value("#{@courseService.getAssignments(target.slug)}") val assignments: List? - @get:Value("#{@courseService.getExamples(target.slug)}") + @get:Value("#{@exampleService.getExamples(target.slug)}") val examples: List? } diff --git a/src/main/kotlin/ch/uzh/ifi/access/projections/TaskWorkspace.kt b/src/main/kotlin/ch/uzh/ifi/access/projections/TaskWorkspace.kt index fb60bae..b37fd79 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/projections/TaskWorkspace.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/projections/TaskWorkspace.kt @@ -13,7 +13,7 @@ interface TaskWorkspace : TaskOverview { @get:Value("#{@courseService.getTaskFiles(target.id)}") val files: List? - @get:Value("#{@courseService.getSubmissions(target.id, target.userId)}") + @get:Value("#{@submissionService.getSubmissions(target.id, target.userId)}") val submissions: List? @get:Value("#{@courseService.getNextAttemptAt(target.id, target.userId)}") diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/CacheInitService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/CacheInitService.kt new file mode 100644 index 0000000..8579e0d --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/CacheInitService.kt @@ -0,0 +1,50 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.repository.CourseRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service + +@Service +class CacheInitService( + private val courseRepository: CourseRepository, + private val userIdUpdateService: UserIdUpdateService, + private val roleService: RoleService, +) { + + private val logger = KotlinLogging.logger {} + + @Transactional + fun initCache() { + courseRepository.findAllByDeletedFalse().forEach { course -> + course.registeredStudents.map { student -> + roleService.findUserByAllCriteria(student)?.let { + roleService.getRegistrationIDCandidates(it.username) + roleService.getUserId(it.username) + } + } + } + } + + fun renameIDs() { + var evaluationCount = 0 + var submissionCount = 0 + courseRepository.findAll().forEach { + logger.info { "Course ${it?.slug}: changing userIds for evaluations and submissions..." } + it?.registeredStudents?.map { registrationId -> + roleService.findUserByAllCriteria(registrationId)?.let { user -> + //logger.info { "Changing userIds for evaluations and submissions of user ${user.username}" } + val names = roleService.getRegistrationIDCandidates(user.username).toMutableList() + val userId = roleService.getUserId(user.username) + if (userId != null) { + names.remove(userId) + val res = userIdUpdateService.updateID(names, userId) + evaluationCount += res.first + submissionCount += res.second + } + } + } + logger.info { "Course ${it?.slug}: changed the userId for $evaluationCount evaluations and $submissionCount submissions" } + } + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt index 37806be..1579c3c 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/service/CourseService.kt @@ -5,13 +5,22 @@ import ch.uzh.ifi.access.model.constants.Command import ch.uzh.ifi.access.model.constants.Role import ch.uzh.ifi.access.model.dto.* import ch.uzh.ifi.access.projections.* -import ch.uzh.ifi.access.repository.* +import ch.uzh.ifi.access.repository.AssignmentRepository +import ch.uzh.ifi.access.repository.CourseRepository +import ch.uzh.ifi.access.repository.TaskFileRepository +import ch.uzh.ifi.access.repository.TaskRepository +import com.fasterxml.jackson.databind.ObjectMapper import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.transaction.Transactional import jakarta.xml.bind.DatatypeConverter +import org.apache.hc.client5.http.classic.methods.HttpPost +import org.apache.hc.client5.http.config.RequestConfig +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.core5.http.ContentType +import org.apache.hc.core5.http.io.entity.StringEntity import org.keycloak.representations.idm.UserRepresentation -import org.modelmapper.ModelMapper -import org.springframework.cache.CacheManager +import org.springframework.beans.factory.annotation.Value import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Caching @@ -22,153 +31,9 @@ import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException import java.nio.file.Path import java.time.LocalDateTime -import java.util.stream.Stream +import java.util.concurrent.TimeUnit import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec -import com.fasterxml.jackson.databind.ObjectMapper -import org.apache.hc.client5.http.classic.methods.HttpPost -import org.apache.hc.client5.http.config.RequestConfig -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient -import org.apache.hc.client5.http.impl.classic.HttpClients -import org.apache.hc.core5.http.ContentType -import org.apache.hc.core5.http.io.entity.StringEntity -import org.springframework.beans.factory.annotation.Value -import java.util.concurrent.TimeUnit - -@Service -class UserIdUpdateService( - val evaluationRepository: EvaluationRepository, - val submissionRepository: SubmissionRepository -) { - @Transactional - fun updateID(names: List, userId: String): Pair { - return Pair( - evaluationRepository.updateUserId(names, userId), - submissionRepository.updateUserId(names, userId) - ) - } - -} - -@Service -class CacheInitService( - private val courseRepository: CourseRepository, - private val userIdUpdateService: UserIdUpdateService, - private val assignmentRepository: AssignmentRepository, - private val taskRepository: TaskRepository, - private val exampleRepository: ExampleRepository, - private val taskFileRepository: TaskFileRepository, - private val submissionRepository: SubmissionRepository, - private val evaluationRepository: EvaluationRepository, - private val modelMapper: ModelMapper, - private val courseLifecycle: CourseLifecycle, - private val roleService: RoleService, -) { - - private val logger = KotlinLogging.logger {} - - @Transactional - fun initCache() { - courseRepository.findAllByDeletedFalse().forEach { - it.registeredStudents.map { - roleService.findUserByAllCriteria(it)?.let { - roleService.getRegistrationIDCandidates(it.username) - roleService.getUserId(it.username) - } - } - } - } - - fun renameIDs() { - var evaluationCount = 0 - var submissionCount = 0 - courseRepository.findAll().forEach { - logger.info { "Course ${it?.slug}: changing userIds for evaluations and submissions..." } - it?.registeredStudents?.map { registrationId -> - roleService.findUserByAllCriteria(registrationId)?.let { user -> - //logger.info { "Changing userIds for evaluations and submissions of user ${user.username}" } - val names = roleService.getRegistrationIDCandidates(user.username).toMutableList() - val userId = roleService.getUserId(user.username) - if (userId != null) { - names.remove(userId) - val res = userIdUpdateService.updateID(names, userId) - evaluationCount += res.first - submissionCount += res.second - } - } - } - logger.info { "Course ${it?.slug}: changed the userId for $evaluationCount evaluations and $submissionCount submissions" } - } - - } - -} - -@Service -class EvaluationService( - private val evaluationRepository: EvaluationRepository, - private val cacheManager: CacheManager, -) { - @Cacheable("EvaluationService.getEvaluation", key = "#taskId + '-' + #userId") - fun getEvaluation(taskId: Long?, userId: String?): Evaluation? { - val res = evaluationRepository.getTopByTask_IdAndUserIdOrderById(taskId, userId) - // TODO: this brute-force approach loads all files. Takes long when loading a course (i.e. all evaluations) - res?.submissions?.forEach { it.files } - res?.submissions?.forEach { it.persistentResultFiles } - return res - } - - @Cacheable("EvaluationService.getEvaluationSummary", key = "#task.id + '-' + #userId") - fun getEvaluationSummary(task: Task, userId: String): EvaluationSummary? { - val res = evaluationRepository.findTopByTask_IdAndUserIdOrderById(task.id, userId) - // TODO: this brute-force approach loads all files. Takes long when loading a course (i.e. all evaluations) - res?.submissions?.forEach { it.files } - res?.submissions?.forEach { it.persistentResultFiles } - return res - } - -} - -@Service -class PointsService( - private val evaluationService: EvaluationService, - private val assignmentRepository: AssignmentRepository, - private val cacheManager: CacheManager, -) { - @Cacheable(value = ["PointsService.calculateAvgTaskPoints"], key = "#taskSlug") - fun calculateAvgTaskPoints(taskSlug: String?): Double { - return 0.0 - // TODO: re-enable this using a native query - //return evaluationRepository.findByTask_SlugAndBestScoreNotNull(taskSlug).map { - // it.bestScore!! }.average().takeIf { it.isFinite() } ?: 0.0 - } - - @Cacheable(value = ["PointsService.calculateTaskPoints"], key = "#taskId + '-' + #userId") - fun calculateTaskPoints(taskId: Long?, userId: String): Double { - return evaluationService.getEvaluation(taskId, userId)?.bestScore ?: 0.0 - } - - @Cacheable("PointsService.calculateAssignmentMaxPoints") - fun calculateAssignmentMaxPoints(tasks: List): Double { - return tasks.stream().filter { it.enabled }.mapToDouble { it.maxPoints!! }.sum() - } - - @Cacheable("PointsService.getMaxPoints", key = "#courseSlug") - fun getMaxPoints(courseSlug: String?): Double { - return assignmentRepository.findByCourse_SlugOrderByOrdinalNumDesc(courseSlug).sumOf { it.maxPoints!! } - } - - @Caching( - evict = [ - CacheEvict("PointsService.calculateTaskPoints", key = "#taskId + '-' + #userId"), - CacheEvict("EvaluationService.getEvaluation", key = "#taskId + '-' + #userId"), - CacheEvict("EvaluationService.getEvaluationSummary", key = "#taskId + '-' + #userId") - ] - ) - fun evictTaskPoints(taskId: Long, userId: String) { - } - -} // TODO: decide properly which parameters should be nullable @Service @@ -178,17 +43,11 @@ class CourseService( private val assignmentRepository: AssignmentRepository, private val taskRepository: TaskRepository, private val taskFileRepository: TaskFileRepository, - private val submissionRepository: SubmissionRepository, - private val evaluationRepository: EvaluationRepository, - private val modelMapper: ModelMapper, private val courseLifecycle: CourseLifecycle, private val roleService: RoleService, - private val dockerService: ExecutionService, private val proxy: CourseService, private val evaluationService: EvaluationService, private val pointsService: PointsService, - private val cacheManager: CacheManager, - private val exampleRepository: ExampleRepository, private val objectMapper: ObjectMapper, @Value("\${llm.service.url}") private val llmServiceUrl: String ) { @@ -205,6 +64,7 @@ class CourseService( .setDefaultRequestConfig(requestConfig) .build() + @Cacheable("CourseService.getStudents", key = "#courseSlug") fun getStudents(courseSlug: String): List { val course = getCourseBySlug(courseSlug) return course.registeredStudents.map { @@ -267,17 +127,11 @@ class CourseService( return taskRepository.findById(taskId).get() } - fun getTaskFileById(fileId: Long): TaskFile { - return taskFileRepository.findById(fileId).get() - } - fun getCoursesOverview(): List { - //return courseRepository.findCoursesBy() return courseRepository.findCoursesByAndDeletedFalse() } fun getCourses(): List { - //return courseRepository.findCoursesBy() return courseRepository.findAllByDeletedFalse() } @@ -292,131 +146,6 @@ class CourseService( return tasks.filter { it.enabled } } - // TODO: make this return TaskOverview - fun getExamples(courseSlug: String): List { - return exampleRepository.findByCourse_SlugOrderByOrdinalNumDesc(courseSlug) - } - - fun getExample(courseSlug: String, exampleSlug: String, userId: String): TaskWorkspace { - val workspace = exampleRepository.findByCourse_SlugAndSlug(courseSlug, exampleSlug) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "No example found with the URL $exampleSlug" - ) - - workspace.setUserId(userId) - return workspace - } - - fun getExampleBySlug(courseSlug: String, exampleSlug: String): Task { - return exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "No example found with the URL $exampleSlug" - ) - } - - fun publishExampleBySlug(courseSlug: String, exampleSlug: String, duration: Int): Task { - val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "No example found with the URL $exampleSlug" - ) - - if (duration <= 0) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Duration must be a positive value" - ) - - if (example.start != null) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Example already published" - ) - - val now = LocalDateTime.now() - example.start = now - example.end = now.plusSeconds(duration.toLong()) - - exampleRepository.saveAndFlush(example); - - return example - } - - // TODO: Move this to ExampleService once that exists - fun countStudentsWhoSubmittedExample(courseSlug: String, exampleSlug: String): Int { - val students = getStudents(courseSlug) - var submissionCount = 0 - for (student in students) { - val studentId = student.registrationId - val exampleId = getExampleBySlug(courseSlug, exampleSlug).id - val submissions = getSubmissions(exampleId, studentId) - if (submissions.isNotEmpty()) { - submissionCount++ - } - } - return submissionCount - } - - fun extendExampleDeadlineBySlug(courseSlug: String, exampleSlug: String, duration: Int): Task { - val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "No example found with the URL $exampleSlug" - ) - - if (duration <= 0) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Duration must be a positive value" - ) - - val now = LocalDateTime.now() - if (example.start == null || example.start!!.isAfter(now)) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "$exampleSlug has not been published" - ) - } else if (example.end!!.isBefore(now)) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "$exampleSlug is past due" - ) - } - - example.end = example.end!!.plusSeconds(duration.toLong()) - exampleRepository.saveAndFlush(example); - - return example - } - - fun terminateExampleBySlug(courseSlug: String, exampleSlug: String): Task { - val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "No example found with the URL $exampleSlug" - ) - - val now = LocalDateTime.now() - if (example.start == null || example.start!!.isAfter(now)) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "$exampleSlug has not been published" - ) - } else if (example.end!!.isBefore(now)) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "$exampleSlug is past due" - ) - } - - example.end = now - exampleRepository.saveAndFlush(example); - - return example - } - // TODO: Also move to the exampleService (Requires moving some imports / variables as well) fun getImplementationEmbedding(implementation: String): List { logger.info { "Requesting embedding for code snippet from LLM service." } @@ -437,7 +166,10 @@ class CourseService( } else { val errorBody = response.entity?.let { String(it.content.readAllBytes()) } ?: "No error message" logger.error { "LLM service call failed with status $statusCode: $errorBody" } - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "LLM service returned error: $statusCode - $errorBody") + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "LLM service returned error: $statusCode - $errorBody" + ) } } } @@ -486,27 +218,6 @@ class CourseService( return permittedFiles } - fun getSubmissions(taskId: Long?, userId: String?): List { - // run and test submissions include the execution logs - val includingLogs = submissionRepository.findByEvaluation_Task_IdAndUserId(taskId, userId) - includingLogs.forEach { submission -> - submission.logs?.let { output -> - if (submission.command == Command.GRADE) { - submission.output = "Logs:\n$output\n\nHint:\n${submission.output}" - } else { - submission.output = output - } - } - } - // graded submissions do not include the logs unless the user has the assistant role - val restrictedLogs = - submissionRepository.findByEvaluation_Task_IdAndUserIdAndCommand(taskId, userId, Command.GRADE) - return Stream.concat(includingLogs.stream(), restrictedLogs.stream()) - .sorted { obj1, obj2 -> obj2.id!!.compareTo(obj1.id!!) } - .toList() - } - - fun getRemainingAttempts(taskId: Long?, maxAttempts: Int): Int { return evaluationService.getEvaluation(taskId, roleService.getUserId())?.remainingAttempts ?: maxAttempts @@ -529,7 +240,6 @@ class CourseService( return newEvent } - fun calculateTaskPoints(taskId: Long?): Double { val userId = roleService.getUserId() ?: return 0.0 return pointsService.calculateTaskPoints(taskId, userId) @@ -566,114 +276,6 @@ class CourseService( ) } - private fun createSubmissionFile(submission: Submission, fileDTO: SubmissionFileDTO) { - val newSubmissionFile = SubmissionFile() - newSubmissionFile.submission = submission - newSubmissionFile.content = fileDTO.content - newSubmissionFile.taskFile = getTaskFileById(fileDTO.taskFileId!!) - submission.files.add(newSubmissionFile) - submissionRepository.saveAndFlush(submission) - } - - - @Caching( - evict = [ - CacheEvict("getStudent", key = "#courseSlug + '-' + #submissionDTO.userId"), - CacheEvict("PointsService.calculateAvgTaskPoints", key = "#taskSlug"), - ] - ) - fun createSubmission(courseSlug: String, assignmentSlug: String?, taskSlug: String, submissionDTO: SubmissionDTO) { - val submissionLockDuration = 2L - - val task = if (assignmentSlug == null) { - getExampleBySlug(courseSlug, taskSlug) - } else { - getTaskBySlug(courseSlug, assignmentSlug, taskSlug) - } - - // If the user is admin, dont check - val userRoles = roleService.getUserRoles(listOf(submissionDTO.userId!!)) - val isAdmin = - userRoles.contains("$courseSlug-assistant") || - userRoles.contains("$courseSlug-supervisor") - - if (assignmentSlug == null && !isAdmin) { - if (task.start == null || task.end == null) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Example not published yet" - ) - if (submissionDTO.command == Command.GRADE) { - val now = LocalDateTime.now() - - // There should be an interval between each submission - val lastSubmissionDate = - getSubmissions(task.id, submissionDTO.userId).sortedByDescending { it.createdAt } - .firstOrNull()?.createdAt - if (lastSubmissionDate != null && now.isBefore(lastSubmissionDate.plusHours(submissionLockDuration))) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "You must wait for 2 hours before submitting a solution again" - ) - - // Checking if example has ended and is now on the grace period - val afterPublishPeriod = task.end!!.plusHours(submissionLockDuration) - if (now.isAfter(task.end) && now.isBefore((afterPublishPeriod))) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Example submissions disabled until 2 hours after the example publish" - ) - } - } - - submissionDTO.command?.let { - if (!task.hasCommand(it)) throw ResponseStatusException( - HttpStatus.FORBIDDEN, - "Submission rejected - task does not support ${submissionDTO.command} command" - ) - } - // retrieve existing evaluation or if there is none, create a new one - if (submissionDTO.userId == null) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Submission rejected - missing userId" - ) - } - pointsService.evictTaskPoints(task.id!!, submissionDTO.userId!!) - val evaluation = - evaluationService.getEvaluation(task.id, submissionDTO.userId) - ?: task.createEvaluation(submissionDTO.userId) - evaluationRepository.saveAndFlush(evaluation) - // the controller prevents regular users from even submitting with restricted = false - // meaning for regular users, restricted is always true - if (submissionDTO.restricted && submissionDTO.command == Command.GRADE) { - if (evaluation.remainingAttempts == null || evaluation.remainingAttempts!! <= 0) - throw ResponseStatusException( - HttpStatus.FORBIDDEN, - "Submission rejected - no remaining attempts" - ) - } - // at this point, all restrictions have passed and we can create the submission - val submission = evaluation.addSubmission(modelMapper.map(submissionDTO, Submission::class.java)) - submissionRepository.saveAndFlush(submission) - submissionDTO.files.stream().filter { fileDTO -> fileDTO.content != null } - .forEach { fileDTO: SubmissionFileDTO -> createSubmissionFile(submission, fileDTO) } - // RUN and TEST submissions are always valid, GRADE submissions will be validated during execution - submission.valid = !submission.isGraded - val course = getCourseBySlug(courseSlug) - // execute the submission - try { - dockerService.executeSubmission(course, submission, task, evaluation) - } catch (e: Exception) { - submission.output = - "Uncaught ${e::class.simpleName}: ${e.message}. Please report this as a bug and provide as much detail as possible." - } finally { - submissionRepository.save(submission) - evaluationRepository.save(evaluation) - pointsService.evictTaskPoints(task.id!!, submissionDTO.userId!!) - } - } - fun createCourse(course: CourseDTO): Course { return courseLifecycle.createFromRepository(course) } @@ -926,5 +528,4 @@ class CourseService( }.toList() ) } - } diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/EvaluationService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/EvaluationService.kt new file mode 100644 index 0000000..1e31c93 --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/EvaluationService.kt @@ -0,0 +1,29 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.model.Evaluation +import ch.uzh.ifi.access.model.Task +import ch.uzh.ifi.access.projections.EvaluationSummary +import ch.uzh.ifi.access.repository.EvaluationRepository +import org.springframework.stereotype.Service + + +@Service +class EvaluationService( + private val evaluationRepository: EvaluationRepository, +) { + fun getEvaluation(taskId: Long?, userId: String?): Evaluation? { + val res = evaluationRepository.getTopByTask_IdAndUserIdOrderById(taskId, userId) + // TODO: this brute-force approach loads all files. Takes long when loading a course (i.e. all evaluations) + res?.submissions?.forEach { it.files } + res?.submissions?.forEach { it.persistentResultFiles } + return res + } + + fun getEvaluationSummary(task: Task, userId: String): EvaluationSummary? { + val res = evaluationRepository.findTopByTask_IdAndUserIdOrderById(task.id, userId) + // TODO: this brute-force approach loads all files. Takes long when loading a course (i.e. all evaluations) + res?.submissions?.forEach { it.files } + res?.submissions?.forEach { it.persistentResultFiles } + return res + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/ExampleService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/ExampleService.kt new file mode 100644 index 0000000..f66e4ee --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/ExampleService.kt @@ -0,0 +1,237 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.model.Course +import ch.uzh.ifi.access.model.Task +import ch.uzh.ifi.access.model.constants.Command +import ch.uzh.ifi.access.model.dto.SubmissionDTO +import ch.uzh.ifi.access.projections.TaskWorkspace +import ch.uzh.ifi.access.repository.CourseRepository +import ch.uzh.ifi.access.repository.ExampleRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDateTime + + +@Service +class ExampleService( + private val courseRepository: CourseRepository, + private val submissionService: SubmissionService, + private val roleService: RoleService, + private val courseService: CourseService, + private val exampleRepository: ExampleRepository, +) { + + private val logger = KotlinLogging.logger {} + + // TODO: make this return TaskOverview + fun getExamples(courseSlug: String): List { + return exampleRepository.findByCourse_SlugOrderByOrdinalNumDesc(courseSlug) + } + + fun getExample(courseSlug: String, exampleSlug: String, userId: String): TaskWorkspace { + val workspace = exampleRepository.findByCourse_SlugAndSlug(courseSlug, exampleSlug) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No example found with the URL $exampleSlug" + ) + + workspace.setUserId(userId) + return workspace + } + + fun getExampleBySlug(courseSlug: String, exampleSlug: String): Task { + return exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No example found with the URL $exampleSlug" + ) + } + + fun publishExampleBySlug(courseSlug: String, exampleSlug: String, duration: Int): Task { + val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No example found with the URL $exampleSlug" + ) + + if (duration <= 0) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Duration must be a positive value" + ) + + if (example.start != null) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Example already published" + ) + + val now = LocalDateTime.now() + example.start = now + example.end = now.plusSeconds(duration.toLong()) + + exampleRepository.saveAndFlush(example); + + return example + } + + fun extendExampleDeadlineBySlug(courseSlug: String, exampleSlug: String, duration: Int): Task { + val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No example found with the URL $exampleSlug" + ) + + if (duration <= 0) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Duration must be a positive value" + ) + + val now = LocalDateTime.now() + if (example.start == null || example.start!!.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$exampleSlug has not been published" + ) + } else if (example.end!!.isBefore(now)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$exampleSlug is past due" + ) + } + + example.end = example.end!!.plusSeconds(duration.toLong()) + exampleRepository.saveAndFlush(example); + + return example + } + + fun terminateExampleBySlug(courseSlug: String, exampleSlug: String): Task { + val example = exampleRepository.getByCourse_SlugAndSlug(courseSlug, exampleSlug) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No example found with the URL $exampleSlug" + ) + + val now = LocalDateTime.now() + if (example.start == null || example.start!!.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$exampleSlug has not been published" + ) + } else if (example.end!!.isBefore(now)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$exampleSlug is past due" + ) + } + + example.end = now + exampleRepository.saveAndFlush(example); + + return example + } + + fun createExampleSubmission(courseSlug: String, taskSlug: String, submissionDTO: SubmissionDTO) { + val submissionLockDuration = 2L + + val example = getExampleBySlug(courseSlug, taskSlug) + + // If the user is admin, dont check + val userRoles = roleService.getUserRoles(listOf(submissionDTO.userId!!)) + val isAdmin = + userRoles.contains("$courseSlug-assistant") || + userRoles.contains("$courseSlug-supervisor") + + if (!isAdmin) { + if (example.start == null || example.end == null) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Example not published yet" + ) + if (submissionDTO.command == Command.GRADE) { + val now = LocalDateTime.now() + + // There should be an interval between each submission + val lastSubmissionDate = + submissionService.getSubmissions(example.id, submissionDTO.userId) + .filter { it.command == Command.GRADE } + .sortedByDescending { it.createdAt } + .firstOrNull()?.createdAt + if (lastSubmissionDate != null && now.isBefore(lastSubmissionDate.plusHours(submissionLockDuration))) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "You must wait for 2 hours before submitting a solution again" + ) + + // Checking if example has ended and is now on the grace period + val afterPublishPeriod = example.end!!.plusHours(submissionLockDuration) + if (now.isAfter(example.end) && now.isBefore((afterPublishPeriod))) + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Example submissions disabled until 2 hours after the example publish" + ) + } + } + + submissionService.createSubmission(courseSlug, taskSlug, example, submissionDTO) + } + + fun countStudentsWhoSubmittedExample(courseSlug: String, exampleSlug: String): Int { + val students = courseService.getStudents(courseSlug) + val exampleId = getExampleBySlug(courseSlug, exampleSlug).id + var submissionCount = 0 + for (student in students) { + val studentId = student.registrationId + val submissions = submissionService.getSubmissions(exampleId, studentId) + if (submissions.isNotEmpty()) { + submissionCount++ + } + } + return submissionCount + } + + fun getExamplePassRatePerTestCase(courseSlug: String, exampleSlug: String): Map { + val example = getExampleBySlug(courseSlug, exampleSlug) + val students = courseService.getStudents(courseSlug) + + val testCount = example.testNames.size + val testSums = DoubleArray(testCount) { 0.0 } + var submissionCount = 0 + + for (student in students) { + val studentId = student.registrationId + val lastGradeSubmissions = submissionService.getSubmissions(example.id, studentId) + .filter { it.command == Command.GRADE } + .sortedByDescending { it.createdAt } + .firstOrNull() + + if (lastGradeSubmissions != null && lastGradeSubmissions.testScores.size == testCount) { + submissionCount++ + val scores = lastGradeSubmissions.testScores + + for (i in scores.indices) { + testSums[i] += scores[i].toDouble() + } + } + } + + val passRatePerTestCase = if (submissionCount > 0) { + testSums.map { it / submissionCount } + } else { + List(testCount) { 0.0 } + } + + return example.testNames.zip(passRatePerTestCase).toMap() + } + + fun getCourseBySlug(courseSlug: String): Course { + return courseRepository.getBySlug(courseSlug) ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No course found with the URL $courseSlug" + ) + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/ExecutionService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/ExecutionService.kt index e81d337..ae89284 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/service/ExecutionService.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/service/ExecutionService.kt @@ -73,21 +73,22 @@ class ExecutionService( val folderId = submission.id.toString() ?: java.util.UUID.randomUUID().toString() // calculate the embedding in parallel with running the code. - val embeddingFuture: CompletableFuture?> = if (isExample(task) && (submission.command == Command.GRADE)) { - CompletableFuture.supplyAsync { - // Assumption: A submission for an example always consists of only one file, which is the student implementation (to remain language-agnostic + val embeddingFuture: CompletableFuture?> = + if (isExample(task) && (submission.command == Command.GRADE)) { + CompletableFuture.supplyAsync { + // Assumption: A submission for an example always consists of only one file, which is the student implementation (to remain language-agnostic // No testing file for examples / no multi-file examples. - if (submission.files.size == 1) { - val implementation = submission.files[0].content ?: "" - courseService.getImplementationEmbedding(implementation) - } else { - logger.debug { "More than one file found in the task directory of the submission. It is not clear which file contains the student code." } - null + if (submission.files.size == 1) { + val implementation = submission.files[0].content ?: "" + courseService.getImplementationEmbedding(implementation) + } else { + logger.debug { "More than one file found in the task directory of the submission. It is not clear which file contains the student code." } + null + } } + } else { + CompletableFuture.completedFuture(null) } - } else { - CompletableFuture.completedFuture(null) - } dockerClient.createContainerCmd(image).use { containerCmd -> val submissionDir = workingDir.resolve("submissions").resolve(folderId) @@ -276,6 +277,7 @@ class ExecutionService( if (results.points != null) { // only relevant for GRADE submissions (RUN and TEST are always valid) submission.valid = true + submission.testScores = results.testScores // never go over 100%; the number of points is otherwise up to the test suite to determine correctly submission.points = minOf(results.points!!, submission.maxPoints!!) evaluation.update(submission.points) diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/PointsService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/PointsService.kt new file mode 100644 index 0000000..da759d4 --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/PointsService.kt @@ -0,0 +1,45 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.model.Task +import ch.uzh.ifi.access.repository.AssignmentRepository +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.cache.annotation.Caching +import org.springframework.stereotype.Service + +@Service +class PointsService( + private val evaluationService: EvaluationService, + private val assignmentRepository: AssignmentRepository, +) { + @Cacheable(value = ["PointsService.calculateAvgTaskPoints"], key = "#taskSlug") + fun calculateAvgTaskPoints(taskSlug: String?): Double { + return 0.0 + // TODO: re-enable this using a native query + //return evaluationRepository.findByTask_SlugAndBestScoreNotNull(taskSlug).map { + // it.bestScore!! }.average().takeIf { it.isFinite() } ?: 0.0 + } + + @Cacheable(value = ["PointsService.calculateTaskPoints"], key = "#taskId + '-' + #userId") + fun calculateTaskPoints(taskId: Long?, userId: String): Double { + return evaluationService.getEvaluation(taskId, userId)?.bestScore ?: 0.0 + } + + @Cacheable("PointsService.calculateAssignmentMaxPoints") + fun calculateAssignmentMaxPoints(tasks: List): Double { + return tasks.stream().filter { it.enabled }.mapToDouble { it.maxPoints!! }.sum() + } + + @Cacheable("PointsService.getMaxPoints", key = "#courseSlug") + fun getMaxPoints(courseSlug: String?): Double { + return assignmentRepository.findByCourse_SlugOrderByOrdinalNumDesc(courseSlug).sumOf { it.maxPoints!! } + } + + @Caching( + evict = [ + CacheEvict("PointsService.calculateTaskPoints", key = "#taskId + '-' + #userId"), + ] + ) + fun evictTaskPoints(taskId: Long, userId: String) { + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt index cf22918..abed7c1 100644 --- a/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt +++ b/src/main/kotlin/ch/uzh/ifi/access/service/RoleService.kt @@ -334,8 +334,7 @@ class RoleService( @CacheEvict("RoleService.getOnlineCount", key = "#courseSlug") @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) - private fun evictOnlineCount() { + fun evictOnlineCount() { // this just ensures that the online count is cached for only 1 minute } - } diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/SubmissionService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/SubmissionService.kt new file mode 100644 index 0000000..743b96d --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/SubmissionService.kt @@ -0,0 +1,147 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.model.* +import ch.uzh.ifi.access.model.constants.Command +import ch.uzh.ifi.access.model.dto.SubmissionDTO +import ch.uzh.ifi.access.model.dto.SubmissionFileDTO +import ch.uzh.ifi.access.repository.* +import org.modelmapper.ModelMapper +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Caching +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import java.util.stream.Stream + +@Service +class SubmissionService( + private val modelMapper: ModelMapper, + private val courseRepository: CourseRepository, + private val taskRepository: TaskRepository, + private val taskFileRepository: TaskFileRepository, + private val submissionRepository: SubmissionRepository, + private val evaluationRepository: EvaluationRepository, + private val pointsService: PointsService, + private val dockerService: ExecutionService, + private val evaluationService: EvaluationService, +) { + fun getSubmissions(taskId: Long?, userId: String?): List { + // run and test submissions include the execution logs + val includingLogs = submissionRepository.findByEvaluation_Task_IdAndUserId(taskId, userId) + includingLogs.forEach { submission -> + submission.logs?.let { output -> + if (submission.command == Command.GRADE) { + submission.output = "Logs:\n$output\n\nHint:\n${submission.output}" + } else { + submission.output = output + } + } + } + // graded submissions do not include the logs unless the user has the assistant role + val restrictedLogs = + submissionRepository.findByEvaluation_Task_IdAndUserIdAndCommand(taskId, userId, Command.GRADE) + return Stream.concat(includingLogs.stream(), restrictedLogs.stream()) + .sorted { obj1, obj2 -> obj2.id!!.compareTo(obj1.id!!) } + .toList() + } + + fun createTaskSubmission( + courseSlug: String, + assignmentSlug: String, + taskSlug: String, + submissionDTO: SubmissionDTO + ) { + return createSubmission( + courseSlug, + taskSlug, + getTaskBySlug(courseSlug, assignmentSlug, taskSlug), + submissionDTO + ) + } + + @Caching( + evict = [ + CacheEvict("getStudent", key = "#courseSlug + '-' + #submissionDTO.userId"), + CacheEvict("PointsService.calculateAvgTaskPoints", key = "#taskSlug"), + ] + ) + // It only accepts assignment tasks, not examples + fun createSubmission(courseSlug: String, taskSlug: String, task: Task, submissionDTO: SubmissionDTO) { + submissionDTO.command?.let { + if (!task.hasCommand(it)) throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Submission rejected - task does not support ${submissionDTO.command} command" + ) + } + // retrieve existing evaluation or if there is none, create a new one + if (submissionDTO.userId == null) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Submission rejected - missing userId" + ) + } + pointsService.evictTaskPoints(task.id!!, submissionDTO.userId!!) + val evaluation = + evaluationService.getEvaluation(task.id, submissionDTO.userId) + ?: task.createEvaluation(submissionDTO.userId) + evaluationRepository.saveAndFlush(evaluation) + // the controller prevents regular users from even submitting with restricted = false + // meaning for regular users, restricted is always true + if (submissionDTO.restricted && submissionDTO.command == Command.GRADE) { + if (evaluation.remainingAttempts == null || evaluation.remainingAttempts!! <= 0) + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Submission rejected - no remaining attempts" + ) + } + // at this point, all restrictions have passed, and we can create the submission + val submission = evaluation.addSubmission(modelMapper.map(submissionDTO, Submission::class.java)) + submissionRepository.saveAndFlush(submission) + submissionDTO.files.stream().filter { fileDTO -> fileDTO.content != null } + .forEach { fileDTO: SubmissionFileDTO -> createSubmissionFile(submission, fileDTO) } + // RUN and TEST submissions are always valid, GRADE submissions will be validated during execution + submission.valid = !submission.isGraded + val course = getCourseBySlug(courseSlug) + // execute the submission + try { + dockerService.executeSubmission(course, submission, task, evaluation) + } catch (e: Exception) { + submission.output = + "Uncaught ${e::class.simpleName}: ${e.message}. Please report this as a bug and provide as much detail as possible." + } finally { + submissionRepository.save(submission) + evaluationRepository.save(evaluation) + pointsService.evictTaskPoints(task.id!!, submissionDTO.userId!!) + } + } + + private fun createSubmissionFile(submission: Submission, fileDTO: SubmissionFileDTO) { + val newSubmissionFile = SubmissionFile() + newSubmissionFile.submission = submission + newSubmissionFile.content = fileDTO.content + newSubmissionFile.taskFile = getTaskFileById(fileDTO.taskFileId!!) + submission.files.add(newSubmissionFile) + submissionRepository.saveAndFlush(submission) + } + + fun getCourseBySlug(courseSlug: String): Course { + return courseRepository.getBySlug(courseSlug) ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No course found with the URL $courseSlug" + ) + } + + fun getTaskBySlug(courseSlug: String, assignmentSlug: String, taskSlug: String): Task { + return taskRepository.getByAssignment_Course_SlugAndAssignment_SlugAndSlug( + courseSlug, + assignmentSlug, + taskSlug + ) ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, "No task found with the URL $taskSlug" + ) + } + + fun getTaskFileById(fileId: Long): TaskFile { + return taskFileRepository.findById(fileId).get() + } +} diff --git a/src/main/kotlin/ch/uzh/ifi/access/service/UserIdUpdateService.kt b/src/main/kotlin/ch/uzh/ifi/access/service/UserIdUpdateService.kt new file mode 100644 index 0000000..0101967 --- /dev/null +++ b/src/main/kotlin/ch/uzh/ifi/access/service/UserIdUpdateService.kt @@ -0,0 +1,20 @@ +package ch.uzh.ifi.access.service + +import ch.uzh.ifi.access.repository.EvaluationRepository +import ch.uzh.ifi.access.repository.SubmissionRepository +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service + +@Service +class UserIdUpdateService( + val evaluationRepository: EvaluationRepository, + val submissionRepository: SubmissionRepository +) { + @Transactional + fun updateID(names: List, userId: String): Pair { + return Pair( + evaluationRepository.updateUserId(names, userId), + submissionRepository.updateUserId(names, userId) + ) + } +} diff --git a/src/main/resources/db/migration/V3_2__add_test_scores.sql b/src/main/resources/db/migration/V3_2__add_test_scores.sql new file mode 100644 index 0000000..4dd1010 --- /dev/null +++ b/src/main/resources/db/migration/V3_2__add_test_scores.sql @@ -0,0 +1,7 @@ +alter table submission + add column test_scores JSON; + +update submission set test_scores = '[]'::json; + +alter table submission + alter column test_scores set not null;