diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt index 5d69c72..882fff2 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminController.kt @@ -27,6 +27,43 @@ import ru.ysu.lms.web.api.LevelPayload import ru.ysu.lms.web.api.LevelSavePayload import ru.ysu.lms.web.api.StudentRowDto import ru.ysu.lms.web.api.VariantSavePayload +import ru.ysu.lms.web.api.addLevel +import ru.ysu.lms.web.api.assignDefense +import ru.ysu.lms.web.api.assignVariant +import ru.ysu.lms.web.api.createGroup +import ru.ysu.lms.web.api.createLab +import ru.ysu.lms.web.api.createStudent +import ru.ysu.lms.web.api.credentials +import ru.ysu.lms.web.api.deleteLab +import ru.ysu.lms.web.api.deleteLevel +import ru.ysu.lms.web.api.deleteVariant +import ru.ysu.lms.web.api.distribute +import ru.ysu.lms.web.api.downloadSubmissionAsAdmin +import ru.ysu.lms.web.api.enrollStudent +import ru.ysu.lms.web.api.getLabAssignments +import ru.ysu.lms.web.api.getLevels +import ru.ysu.lms.web.api.groupCandidates +import ru.ysu.lms.web.api.groupDetail +import ru.ysu.lms.web.api.groupPublications +import ru.ysu.lms.web.api.importGroup +import ru.ysu.lms.web.api.importLab +import ru.ysu.lms.web.api.issueDefenseCode +import ru.ysu.lms.web.api.labDefenses +import ru.ysu.lms.web.api.labDetail +import ru.ysu.lms.web.api.labSubmissions +import ru.ysu.lms.web.api.listGrades +import ru.ysu.lms.web.api.listGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.publishGrades +import ru.ysu.lms.web.api.renameLevel +import ru.ysu.lms.web.api.resetDefense +import ru.ysu.lms.web.api.resetPassword +import ru.ysu.lms.web.api.saveVariant +import ru.ysu.lms.web.api.setGrade +import ru.ysu.lms.web.api.setLevel +import ru.ysu.lms.web.api.toggleLabStatus +import ru.ysu.lms.web.api.unenrollStudent +import ru.ysu.lms.web.api.updateLab import ru.ysu.lms.web.session.SessionKeys import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index a841728..470bd3a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -17,6 +17,22 @@ import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ArticlesReorderDto import ru.ysu.lms.web.api.ContentReorderDto import ru.ysu.lms.web.api.SectionsReorderDto +import ru.ysu.lms.web.api.createArticle +import ru.ysu.lms.web.api.createChapter +import ru.ysu.lms.web.api.createSection +import ru.ysu.lms.web.api.deleteChapter +import ru.ysu.lms.web.api.deleteContentArticle +import ru.ysu.lms.web.api.deleteSection +import ru.ysu.lms.web.api.deleteSectionWithMove +import ru.ysu.lms.web.api.importContent +import ru.ysu.lms.web.api.moveArticle +import ru.ysu.lms.web.api.renameChapter +import ru.ysu.lms.web.api.renameSection +import ru.ysu.lms.web.api.reorderArticles +import ru.ysu.lms.web.api.reorderContent +import ru.ysu.lms.web.api.reorderSections +import ru.ysu.lms.web.api.setContentPublished +import ru.ysu.lms.web.api.updateContentArticle import ru.ysu.lms.web.session.SessionKeys @Controller diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 99b55c8..9f162fa 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -1,12 +1,8 @@ package ru.ysu.lms.web.api import org.springframework.beans.factory.annotation.Value -import org.springframework.core.ParameterizedTypeReference -import org.springframework.core.io.ByteArrayResource import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType import org.springframework.stereotype.Component -import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestClient import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientResponseException @@ -17,222 +13,18 @@ import java.nio.charset.StandardCharsets class ApiClient( @Value("\${lms.gateway.base-url}") baseUrl: String, ) { - private val client: RestClient = RestClient.builder().baseUrl(baseUrl).build() + internal val client: RestClient = RestClient.builder().baseUrl(baseUrl).build() - fun adminExists(): Boolean = - call { - client - .get() - .uri("/api/v1/auth/health") - .retrieve() - .body(HealthDto::class.java) - }.adminExists + internal fun bearer(token: String): String = "Bearer $token" - fun setup( - login: String, - password: String, - password2: String, - ): AuthResponseDto = - call { - client - .post() - .uri("/api/v1/auth/setup") - .contentType(MediaType.APPLICATION_JSON) - .body(SetupPayload(login, password, password2)) - .retrieve() - .body(AuthResponseDto::class.java) - } - - fun login( - login: String, - password: String, - ): AuthResponseDto = - call { - client - .post() - .uri("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .body(LoginPayload(login, password)) - .retrieve() - .body(AuthResponseDto::class.java) - } - - fun changePassword( - token: String, - old: String, - new: String, - new2: String, - ) { - call { - client - .post() - .uri("/api/v1/auth/change-password") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(ChangePasswordPayload(old, new, new2)) - .retrieve() - .toBodilessEntity() - } - } - - fun listGroups(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/groups") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun createGroup( - token: String, - name: String, - ): GroupDto = - call { - client - .post() - .uri("/api/v1/groups") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(CreateGroupPayload(name)) - .retrieve() - .body(GroupDto::class.java) - } - - fun importGroup( - token: String, - name: String?, - filename: String, - bytes: ByteArray, - ): ImportResultDto { - val parts = LinkedMultiValueMap() - parts.add( - "file", - object : ByteArrayResource(bytes) { - override fun getFilename(): String = filename - }, - ) - if (!name.isNullOrBlank()) { - parts.add("name", name) - } - return call { - client - .post() - .uri("/api/v1/groups/import") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(parts) - .retrieve() - .body(ImportResultDto::class.java) - } - } - - fun groupDetail( - token: String, - groupId: String, - ): GroupDetailDto = - call { - client - .get() - .uri("/api/v1/groups/{id}", groupId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(GroupDetailDto::class.java) - } - - fun groupCandidates( - token: String, - groupId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/groups/{id}/candidates", groupId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun enrollStudent( - token: String, - groupId: String, - studentId: String, - ) { - call { - client - .post() - .uri("/api/v1/groups/{id}/students/{sid}/enroll", groupId, studentId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun unenrollStudent( - token: String, - groupId: String, - studentId: String, - ) { - call { - client - .delete() - .uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun resetPassword( - token: String, - studentId: String, - ): ResetPasswordDto = - call { - client - .post() - .uri("/api/v1/students/{id}/reset-password", studentId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(ResetPasswordDto::class.java) - } - - fun credentials( - token: String, - groupId: String, - format: String, - ): DownloadDto { - val entity = - call { - client - .get() - .uri { builder -> - builder.path("/api/v1/groups/{id}/credentials").queryParam("format", format).build(groupId) - }.header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toEntity(ByteArray::class.java) - } - val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty() - val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "credentials.$format" } - val filename = URLDecoder.decode(rawName, StandardCharsets.UTF_8) - val contentType = entity.headers.contentType?.toString() ?: "application/octet-stream" - return DownloadDto(entity.body ?: ByteArray(0), filename, contentType) - } - - private fun bearer(token: String): String = "Bearer $token" - - private fun callOrNull(block: () -> T?): T? = + internal fun callOrNull(block: () -> T?): T? = try { block() } catch (ignore: RestClientResponseException) { null } - private fun call(block: () -> T?): T = + internal fun call(block: () -> T?): T = try { block() ?: throw ApiException(502, "Пустой ответ сервиса") } catch (ex: ApiException) { @@ -243,7 +35,7 @@ class ApiClient( throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.") } - private fun detail(ex: RestClientResponseException): String { + internal fun detail(ex: RestClientResponseException): String { val body = ex.responseBodyAsString val match = DETAIL_PATTERN.find(body) val parsed = @@ -255,434 +47,7 @@ class ApiClient( return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})" } - fun listLabs(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/labs") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun labDetail( - token: String, - labId: String, - ): LabDetailDto = - call { - client - .get() - .uri("/api/v1/labs/{id}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(LabDetailDto::class.java) - } - - fun importLab( - token: String, - filename: String, - bytes: ByteArray, - ): LabImportResultDto { - val parts = LinkedMultiValueMap() - parts.add( - "file", - object : ByteArrayResource(bytes) { - override fun getFilename(): String = filename - }, - ) - return call { - client - .post() - .uri("/api/v1/labs/import") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(parts) - .retrieve() - .body(LabImportResultDto::class.java) - } - } - - fun deleteLab( - token: String, - labId: String, - ) { - call { - client - .delete() - .uri("/api/v1/labs/{id}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun toggleLabStatus( - token: String, - labId: String, - ) { - call { - client - .post() - .uri("/api/v1/labs/{id}/toggle-status", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun getLevels( - token: String, - labId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/levels?labId={labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun setLevel( - token: String, - payload: LevelAssignPayload, - ): LevelAssignmentResultDto = - call { - client - .post() - .uri("/api/v1/levels") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(LevelAssignmentResultDto::class.java) - } - - fun distribute( - token: String, - payload: DistributeRequestPayload, - ): DistributeResultDto = - call { - client - .post() - .uri("/api/v1/distribute") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(DistributeResultDto::class.java) - } - - fun assignVariant( - token: String, - payload: AssignVariantPayload, - ): VariantAssignmentResultDto = - call { - client - .post() - .uri("/api/v1/assign") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(VariantAssignmentResultDto::class.java) - } - - fun getLabAssignments( - token: String, - labId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/assignments?labId={labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun meAssignments(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/me/assignments") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun updateLab( - token: String, - labId: String, - payload: LabUpdatePayload, - ): LabDetailDto = - call { - client - .put() - .uri("/api/v1/labs/{id}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(LabDetailDto::class.java) - } - - fun saveVariant( - token: String, - labId: String, - variantId: String?, - payload: VariantSavePayload, - ): LabDetailDto = - if (variantId.isNullOrBlank()) { - addVariant(token, labId, payload) - } else { - call { - client - .put() - .uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(LabDetailDto::class.java) - } - } - - fun addVariant( - token: String, - labId: String, - payload: VariantSavePayload, - ): LabDetailDto = - call { - client - .post() - .uri("/api/v1/labs/{id}/variants", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(LabDetailDto::class.java) - } - - fun deleteVariant( - token: String, - labId: String, - variantId: String, - ): LabDetailDto = - call { - client - .delete() - .uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(LabDetailDto::class.java) - } - - fun listGrades(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/grades") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun setGrade( - token: String, - payload: GradeSetPayload, - ): GradeDto = - call { - client - .post() - .uri("/api/v1/grades") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(GradeDto::class.java) - } - - fun suggestComments( - token: String, - query: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/comment-templates?q={q}", query) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun myGrades(token: String): List = myGrades(token, null) - - fun myGrades( - token: String, - groupId: String?, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - val uri = - if (groupId.isNullOrBlank()) { - "/api/v1/me/grades" - } else { - "/api/v1/me/grades?groupId={gid}" - } - val spec = - client - .get() - .uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId)) - spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type) - } - } - - fun groupPublications( - token: String, - groupId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/grades/publications?groupId={gid}", groupId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun publishGrades( - token: String, - labId: String, - groupId: String, - published: Boolean, - ) { - call { - client - .post() - .uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun myGroup(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/groups/me") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun submitLab( - token: String, - labId: String, - files: List>, - ): List { - val parts = LinkedMultiValueMap() - for ((name, bytes) in files) { - parts.add( - "files", - object : ByteArrayResource(bytes) { - override fun getFilename(): String = name - }, - ) - } - val type = object : ParameterizedTypeReference>() {} - return call { - client - .post() - .uri("/api/v1/me/submissions/{labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(parts) - .retrieve() - .body(type) - } - } - - fun mySubmissions( - token: String, - labId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/me/submissions?labId={labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun labSubmissions( - token: String, - labId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/submissions?labId={labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun downloadMySubmission( - token: String, - submissionId: String, - ): DownloadDto { - val entity = - call { - client - .get() - .uri("/api/v1/me/submissions/{id}/download", submissionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toEntity(ByteArray::class.java) - } - return downloadOf(entity) - } - - fun downloadSubmissionAsAdmin( - token: String, - submissionId: String, - ): DownloadDto { - val entity = - call { - client - .get() - .uri("/api/v1/submissions/{id}/download", submissionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toEntity(ByteArray::class.java) - } - return downloadOf(entity) - } - - private fun downloadOf(entity: org.springframework.http.ResponseEntity): DownloadDto { + internal fun downloadOf(entity: org.springframework.http.ResponseEntity): DownloadDto { val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty() val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" } return DownloadDto( @@ -692,550 +57,6 @@ class ApiClient( ) } - fun contentTree( - token: String?, - all: Boolean = false, - ): ContentTreeNodeDto = - call { - val uri = if (all) "/api/v1/content/tree/all" else "/api/v1/content/tree" - val spec = client.get().uri(uri) - (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) - .retrieve() - .body(ContentTreeNodeDto::class.java) - } - - fun contentArticle( - token: String?, - chapter: String, - section: String, - article: String, - all: Boolean = false, - ): ContentArticleDto = - call { - val uri = if (all) "/api/v1/content/all/{c}/{s}/{a}" else "/api/v1/content/{c}/{s}/{a}" - val spec = client.get().uri(uri, chapter, section, article) - (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) - .retrieve() - .body(ContentArticleDto::class.java) - } - - /** Публикация/скрытие главы, раздела или статьи. kind: chapters|sections|articles */ - fun setContentPublished( - token: String, - kind: String, - id: String, - published: Boolean, - ) { - val path = - when (kind) { - "chapters" -> "/api/v1/content/chapters/{id}/published" - "sections" -> "/api/v1/content/sections/{id}/published" - "articles" -> "/api/v1/content/articles/{id}/published" - else -> throw IllegalArgumentException("Неизвестный тип материала: $kind") - } - call { - client - .put() - .uri(path, id) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("published" to published)) - .retrieve() - .toBodilessEntity() - } - } - - fun contentTreeList(token: String?): List { - val type = object : ParameterizedTypeReference>() {} - return call { - val spec = client.get().uri("/api/v1/content/articles") - (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) - .retrieve() - .body(type) - } - } - - /** Сохраняет новое расположение глав, разделов и статей. */ - fun reorderContent( - token: String, - tree: ContentReorderDto, - ) { - call { - client - .post() - .uri("/api/v1/content/reorder") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(tree) - .retrieve() - .toBodilessEntity() - } - } - - /** Порядок разделов внутри главы (drag & drop на странице главы). */ - fun reorderSections( - token: String, - chapterId: String, - body: SectionsReorderDto, - ) { - call { - client - .post() - .uri("/api/v1/content/chapters/{id}/sections/reorder", chapterId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(body) - .retrieve() - .toBodilessEntity() - } - } - - /** Порядок статей внутри раздела (drag & drop на странице раздела). */ - fun reorderArticles( - token: String, - sectionId: String, - body: ArticlesReorderDto, - ) { - call { - client - .post() - .uri("/api/v1/content/sections/{id}/articles/reorder", sectionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(body) - .retrieve() - .toBodilessEntity() - } - } - - fun importContent( - token: String, - filename: String, - bytes: ByteArray, - sync: Boolean = true, - ): ContentImportResultDto { - val parts = LinkedMultiValueMap() - parts.add("sync", sync.toString()) - parts.add( - "file", - object : ByteArrayResource(bytes) { - override fun getFilename(): String = filename - }, - ) - return call { - client - .post() - .uri("/api/v1/content/import") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(parts) - .retrieve() - .body(ContentImportResultDto::class.java) - } - } - - fun updateContentArticle( - token: String, - articleId: String, - title: String, - bodyMd: String, - ) { - call { - client - .put() - .uri("/api/v1/content/articles/{id}", articleId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title, "bodyMd" to bodyMd)) - .retrieve() - .toBodilessEntity() - } - } - - fun deleteContentArticle( - token: String, - articleId: String, - ) { - call { - client - .delete() - .uri("/api/v1/content/articles/{id}", articleId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun contentAsset(name: String): DownloadDto { - val entity = - call { - client - .get() - .uri("/api/v1/content/assets/{name}", name) - .retrieve() - .toEntity(ByteArray::class.java) - } - return DownloadDto( - bytes = entity.body ?: ByteArray(0), - filename = name, - contentType = entity.headers.contentType?.toString() ?: "application/octet-stream", - ) - } - - fun createLab( - token: String, - payload: LabCreatePayload, - ): LabDetailDto = - call { - client - .post() - .uri("/api/v1/labs") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(LabDetailDto::class.java) - } - - fun addLevel( - token: String, - labId: String, - payload: LevelSavePayload, - ) { - call { - client - .post() - .uri("/api/v1/labs/{id}/levels", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .toBodilessEntity() - } - } - - fun renameLevel( - token: String, - labId: String, - levelId: String, - title: String, - ) { - call { - client - .put() - .uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title)) - .retrieve() - .toBodilessEntity() - } - } - - fun deleteLevel( - token: String, - labId: String, - levelId: String, - ) { - call { - client - .delete() - .uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun createStudent( - token: String, - groupId: String, - payload: CreateStudentRequestDto, - ): CredentialDto = - call { - client - .post() - .uri("/api/v1/groups/{id}/students", groupId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .body(CredentialDto::class.java) - } - - fun createChapter( - token: String, - title: String, - slug: String?, - ): Map { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .post() - .uri("/api/v1/content/chapters") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title, "slug" to slug)) - .retrieve() - .body(type) - } - } - - fun renameChapter( - token: String, - chapterId: String, - title: String, - ) { - call { - client - .put() - .uri("/api/v1/content/chapters/{id}", chapterId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title)) - .retrieve() - .toBodilessEntity() - } - } - - fun deleteChapter( - token: String, - chapterId: String, - ) { - call { - client - .delete() - .uri("/api/v1/content/chapters/{id}", chapterId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun createSection( - token: String, - chapterId: String, - title: String, - slug: String?, - ): Map { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .post() - .uri("/api/v1/content/sections?chapterId={cid}", chapterId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title, "slug" to slug)) - .retrieve() - .body(type) - } - } - - fun renameSection( - token: String, - sectionId: String, - title: String, - ) { - call { - client - .put() - .uri("/api/v1/content/sections/{id}", sectionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("title" to title)) - .retrieve() - .toBodilessEntity() - } - } - - fun deleteSection( - token: String, - sectionId: String, - ) { - call { - client - .delete() - .uri("/api/v1/content/sections/{id}", sectionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun deleteSectionWithMove( - token: String, - sectionId: String, - moveTo: String, - ) { - call { - client - .delete() - .uri { b -> b.path("/api/v1/content/sections/{id}").queryParam("moveTo", moveTo).build(sectionId) } - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun createArticle( - token: String, - chapterId: String?, - sectionId: String?, - newChapterTitle: String?, - newSectionTitle: String?, - title: String, - bodyMd: String, - ): ContentArticleDto = - call { - client - .post() - .uri("/api/v1/content/articles") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body( - mapOf( - "chapterId" to chapterId, - "sectionId" to sectionId, - "newChapterTitle" to newChapterTitle, - "newSectionTitle" to newSectionTitle, - "title" to title, - "bodyMd" to bodyMd, - ), - ).retrieve() - .body(ContentArticleDto::class.java) - } - - fun moveArticle( - token: String, - articleId: String, - newSectionId: String, - ): ContentArticleDto = - call { - client - .post() - .uri("/api/v1/content/articles/{id}/move", articleId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(ArticleMovePayload(newSectionId)) - .retrieve() - .body(ContentArticleDto::class.java) - } - - // ---------- Defense (индивидуальные задания защиты) ---------- - - fun labDefenses( - token: String, - labId: String, - ): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/defenses?labId={labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun assignDefense( - token: String, - studentId: String, - labId: String, - assignmentMd: String, - ): DefenseDto = - call { - client - .post() - .uri("/api/v1/defenses/assign") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("studentId" to studentId, "labId" to labId, "assignmentMd" to assignmentMd)) - .retrieve() - .body(DefenseDto::class.java) - } - - fun issueDefenseCode( - token: String, - sessionId: String, - ): String = - call { - client - .post() - .uri("/api/v1/defenses/{id}/issue-code", sessionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(Map::class.java) - }.get("code").toString() - - fun resetDefense( - token: String, - sessionId: String, - ) { - call { - client - .post() - .uri("/api/v1/defenses/{id}/reset", sessionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .toBodilessEntity() - } - } - - fun myDefenses(token: String): List { - val type = object : ParameterizedTypeReference>() {} - return call { - client - .get() - .uri("/api/v1/me/defenses") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(type) - } - } - - fun myDefense( - token: String, - labId: String, - ): DefenseDto? = - callOrNull { - client - .get() - .uri("/api/v1/me/defenses/{labId}", labId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .retrieve() - .body(DefenseDto::class.java) - } - - fun startDefense( - token: String, - code: String, - ): DefenseDto = - call { - client - .post() - .uri("/api/v1/me/defenses/start") - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .body(mapOf("code" to code)) - .retrieve() - .body(DefenseDto::class.java) - } - - fun finishDefense( - token: String, - sessionId: String, - ): DefenseDto = - call { - client - .post() - .uri("/api/v1/me/defenses/{id}/finish", sessionId) - .header(HttpHeaders.AUTHORIZATION, bearer(token)) - .contentType(MediaType.APPLICATION_JSON) - .retrieve() - .body(DefenseDto::class.java) - } - private companion object { val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt new file mode 100644 index 0000000..c0946c3 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt @@ -0,0 +1,60 @@ +package ru.ysu.lms.web.api + +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType + +fun ApiClient.adminExists(): Boolean = + call { + client + .get() + .uri("/api/v1/auth/health") + .retrieve() + .body(HealthDto::class.java) + }.adminExists + +fun ApiClient.setup( + login: String, + password: String, + password2: String, +): AuthResponseDto = + call { + client + .post() + .uri("/api/v1/auth/setup") + .contentType(MediaType.APPLICATION_JSON) + .body(SetupPayload(login, password, password2)) + .retrieve() + .body(AuthResponseDto::class.java) + } + +fun ApiClient.login( + login: String, + password: String, +): AuthResponseDto = + call { + client + .post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(LoginPayload(login, password)) + .retrieve() + .body(AuthResponseDto::class.java) + } + +fun ApiClient.changePassword( + token: String, + old: String, + new: String, + new2: String, +) { + call { + client + .post() + .uri("/api/v1/auth/change-password") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(ChangePasswordPayload(old, new, new2)) + .retrieve() + .toBodilessEntity() + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt new file mode 100644 index 0000000..feaf3d3 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt @@ -0,0 +1,351 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.util.LinkedMultiValueMap + +fun ApiClient.contentTree( + token: String?, + all: Boolean = false, +): ContentTreeNodeDto = + call { + val uri = if (all) "/api/v1/content/tree/all" else "/api/v1/content/tree" + val spec = client.get().uri(uri) + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve() + .body(ContentTreeNodeDto::class.java) + } + +fun ApiClient.contentArticle( + token: String?, + chapter: String, + section: String, + article: String, + all: Boolean = false, +): ContentArticleDto = + call { + val uri = if (all) "/api/v1/content/all/{c}/{s}/{a}" else "/api/v1/content/{c}/{s}/{a}" + val spec = client.get().uri(uri, chapter, section, article) + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve() + .body(ContentArticleDto::class.java) + } + +fun ApiClient.setContentPublished( + token: String, + kind: String, + id: String, + published: Boolean, +) { + val path = + when (kind) { + "chapters" -> "/api/v1/content/chapters/{id}/published" + "sections" -> "/api/v1/content/sections/{id}/published" + "articles" -> "/api/v1/content/articles/{id}/published" + else -> throw IllegalArgumentException("Неизвестный тип материала: $kind") + } + call { + client + .put() + .uri(path, id) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("published" to published)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.contentTreeList(token: String?): List { + val type = object : ParameterizedTypeReference>() {} + return call { + val spec = client.get().uri("/api/v1/content/articles") + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) + .retrieve() + .body(type) + } +} + +fun ApiClient.reorderContent( + token: String, + tree: ContentReorderDto, +) { + call { + client + .post() + .uri("/api/v1/content/reorder") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(tree) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.reorderSections( + token: String, + chapterId: String, + body: SectionsReorderDto, +) { + call { + client + .post() + .uri("/api/v1/content/chapters/{id}/sections/reorder", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.reorderArticles( + token: String, + sectionId: String, + body: ArticlesReorderDto, +) { + call { + client + .post() + .uri("/api/v1/content/sections/{id}/articles/reorder", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.importContent( + token: String, + filename: String, + bytes: ByteArray, + sync: Boolean = true, +): ContentImportResultDto { + val parts = LinkedMultiValueMap() + parts.add("sync", sync.toString()) + parts.add( + "file", + object : ByteArrayResource(bytes) { + override fun getFilename(): String = filename + }, + ) + return call { + client + .post() + .uri("/api/v1/content/import") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve() + .body(ContentImportResultDto::class.java) + } +} + +fun ApiClient.updateContentArticle( + token: String, + articleId: String, + title: String, + bodyMd: String, +) { + call { + client + .put() + .uri("/api/v1/content/articles/{id}", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "bodyMd" to bodyMd)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteContentArticle( + token: String, + articleId: String, +) { + call { + client + .delete() + .uri("/api/v1/content/articles/{id}", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.contentAsset(name: String): DownloadDto { + val entity = + call { + client + .get() + .uri("/api/v1/content/assets/{name}", name) + .retrieve() + .toEntity(ByteArray::class.java) + } + return DownloadDto( + bytes = entity.body ?: ByteArray(0), + filename = name, + contentType = entity.headers.contentType?.toString() ?: "application/octet-stream", + ) +} + +fun ApiClient.createChapter( + token: String, + title: String, + slug: String?, +): Map { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .post() + .uri("/api/v1/content/chapters") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve() + .body(type) + } +} + +fun ApiClient.renameChapter( + token: String, + chapterId: String, + title: String, +) { + call { + client + .put() + .uri("/api/v1/content/chapters/{id}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteChapter( + token: String, + chapterId: String, +) { + call { + client + .delete() + .uri("/api/v1/content/chapters/{id}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.createSection( + token: String, + chapterId: String, + title: String, + slug: String?, +): Map { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .post() + .uri("/api/v1/content/sections?chapterId={cid}", chapterId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title, "slug" to slug)) + .retrieve() + .body(type) + } +} + +fun ApiClient.renameSection( + token: String, + sectionId: String, + title: String, +) { + call { + client + .put() + .uri("/api/v1/content/sections/{id}", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteSection( + token: String, + sectionId: String, +) { + call { + client + .delete() + .uri("/api/v1/content/sections/{id}", sectionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteSectionWithMove( + token: String, + sectionId: String, + moveTo: String, +) { + call { + client + .delete() + .uri { b -> b.path("/api/v1/content/sections/{id}").queryParam("moveTo", moveTo).build(sectionId) } + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.createArticle( + token: String, + chapterId: String?, + sectionId: String?, + newChapterTitle: String?, + newSectionTitle: String?, + title: String, + bodyMd: String, +): ContentArticleDto = + call { + client + .post() + .uri("/api/v1/content/articles") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body( + mapOf( + "chapterId" to chapterId, + "sectionId" to sectionId, + "newChapterTitle" to newChapterTitle, + "newSectionTitle" to newSectionTitle, + "title" to title, + "bodyMd" to bodyMd, + ), + ).retrieve() + .body(ContentArticleDto::class.java) + } + +fun ApiClient.moveArticle( + token: String, + articleId: String, + newSectionId: String, +): ContentArticleDto = + call { + client + .post() + .uri("/api/v1/content/articles/{id}/move", articleId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(ArticleMovePayload(newSectionId)) + .retrieve() + .body(ContentArticleDto::class.java) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt new file mode 100644 index 0000000..06e23ac --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientIdentity.kt @@ -0,0 +1,173 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.util.LinkedMultiValueMap +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +fun ApiClient.listGroups(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/groups") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.createGroup( + token: String, + name: String, +): GroupDto = + call { + client + .post() + .uri("/api/v1/groups") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(CreateGroupPayload(name)) + .retrieve() + .body(GroupDto::class.java) + } + +fun ApiClient.importGroup( + token: String, + name: String?, + filename: String, + bytes: ByteArray, +): ImportResultDto { + val parts = LinkedMultiValueMap() + parts.add( + "file", + object : ByteArrayResource(bytes) { + override fun getFilename(): String = filename + }, + ) + if (!name.isNullOrBlank()) { + parts.add("name", name) + } + return call { + client + .post() + .uri("/api/v1/groups/import") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve() + .body(ImportResultDto::class.java) + } +} + +fun ApiClient.groupDetail( + token: String, + groupId: String, +): GroupDetailDto = + call { + client + .get() + .uri("/api/v1/groups/{id}", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(GroupDetailDto::class.java) + } + +fun ApiClient.groupCandidates( + token: String, + groupId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/groups/{id}/candidates", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.enrollStudent( + token: String, + groupId: String, + studentId: String, +) { + call { + client + .post() + .uri("/api/v1/groups/{id}/students/{sid}/enroll", groupId, studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.unenrollStudent( + token: String, + groupId: String, + studentId: String, +) { + call { + client + .delete() + .uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.resetPassword( + token: String, + studentId: String, +): ResetPasswordDto = + call { + client + .post() + .uri("/api/v1/students/{id}/reset-password", studentId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(ResetPasswordDto::class.java) + } + +fun ApiClient.credentials( + token: String, + groupId: String, + format: String, +): DownloadDto { + val entity = + call { + client + .get() + .uri { builder -> + builder.path("/api/v1/groups/{id}/credentials").queryParam("format", format).build(groupId) + }.header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toEntity(ByteArray::class.java) + } + val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty() + val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "credentials.$format" } + val filename = URLDecoder.decode(rawName, StandardCharsets.UTF_8) + val contentType = entity.headers.contentType?.toString() ?: "application/octet-stream" + return DownloadDto(entity.body ?: ByteArray(0), filename, contentType) +} + +fun ApiClient.createStudent( + token: String, + groupId: String, + payload: CreateStudentRequestDto, +): CredentialDto = + call { + client + .post() + .uri("/api/v1/groups/{id}/students", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(CredentialDto::class.java) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt new file mode 100644 index 0000000..37a3096 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientLab.kt @@ -0,0 +1,246 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.util.LinkedMultiValueMap + +fun ApiClient.listLabs(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/labs") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.labDetail( + token: String, + labId: String, +): LabDetailDto = + call { + client + .get() + .uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(LabDetailDto::class.java) + } + +fun ApiClient.importLab( + token: String, + filename: String, + bytes: ByteArray, +): LabImportResultDto { + val parts = LinkedMultiValueMap() + parts.add( + "file", + object : ByteArrayResource(bytes) { + override fun getFilename(): String = filename + }, + ) + return call { + client + .post() + .uri("/api/v1/labs/import") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve() + .body(LabImportResultDto::class.java) + } +} + +fun ApiClient.deleteLab( + token: String, + labId: String, +) { + call { + client + .delete() + .uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.toggleLabStatus( + token: String, + labId: String, +) { + call { + client + .post() + .uri("/api/v1/labs/{id}/toggle-status", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.getLevels( + token: String, + labId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/levels?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.setLevel( + token: String, + payload: LevelAssignPayload, +): LevelAssignmentResultDto = + call { + client + .post() + .uri("/api/v1/levels") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LevelAssignmentResultDto::class.java) + } + +fun ApiClient.updateLab( + token: String, + labId: String, + payload: LabUpdatePayload, +): LabDetailDto = + call { + client + .put() + .uri("/api/v1/labs/{id}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LabDetailDto::class.java) + } + +fun ApiClient.saveVariant( + token: String, + labId: String, + variantId: String?, + payload: VariantSavePayload, +): LabDetailDto = + if (variantId.isNullOrBlank()) { + addVariant(token, labId, payload) + } else { + call { + client + .put() + .uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LabDetailDto::class.java) + } + } + +fun ApiClient.addVariant( + token: String, + labId: String, + payload: VariantSavePayload, +): LabDetailDto = + call { + client + .post() + .uri("/api/v1/labs/{id}/variants", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LabDetailDto::class.java) + } + +fun ApiClient.deleteVariant( + token: String, + labId: String, + variantId: String, +): LabDetailDto = + call { + client + .delete() + .uri("/api/v1/labs/{id}/variants/{vid}", labId, variantId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(LabDetailDto::class.java) + } + +fun ApiClient.createLab( + token: String, + payload: LabCreatePayload, +): LabDetailDto = + call { + client + .post() + .uri("/api/v1/labs") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(LabDetailDto::class.java) + } + +fun ApiClient.addLevel( + token: String, + labId: String, + payload: LevelSavePayload, +) { + call { + client + .post() + .uri("/api/v1/labs/{id}/levels", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.renameLevel( + token: String, + labId: String, + levelId: String, + title: String, +) { + call { + client + .put() + .uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("title" to title)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.deleteLevel( + token: String, + labId: String, + levelId: String, +) { + call { + client + .delete() + .uri("/api/v1/labs/{id}/levels/{lid}", labId, levelId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt new file mode 100644 index 0000000..1c0d2cb --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientProgress.kt @@ -0,0 +1,373 @@ +package ru.ysu.lms.web.api + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.util.LinkedMultiValueMap + +fun ApiClient.distribute( + token: String, + payload: DistributeRequestPayload, +): DistributeResultDto = + call { + client + .post() + .uri("/api/v1/distribute") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(DistributeResultDto::class.java) + } + +fun ApiClient.assignVariant( + token: String, + payload: AssignVariantPayload, +): VariantAssignmentResultDto = + call { + client + .post() + .uri("/api/v1/assign") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(VariantAssignmentResultDto::class.java) + } + +fun ApiClient.getLabAssignments( + token: String, + labId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/assignments?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.meAssignments(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/me/assignments") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.listGrades(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/grades") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.setGrade( + token: String, + payload: GradeSetPayload, +): GradeDto = + call { + client + .post() + .uri("/api/v1/grades") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(GradeDto::class.java) + } + +fun ApiClient.suggestComments( + token: String, + query: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/comment-templates?q={q}", query) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.myGrades(token: String): List = myGrades(token, null) + +fun ApiClient.myGrades( + token: String, + groupId: String?, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + val uri = + if (groupId.isNullOrBlank()) { + "/api/v1/me/grades" + } else { + "/api/v1/me/grades?groupId={gid}" + } + val spec = + client + .get() + .uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId)) + spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type) + } +} + +fun ApiClient.groupPublications( + token: String, + groupId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/grades/publications?groupId={gid}", groupId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.publishGrades( + token: String, + labId: String, + groupId: String, + published: Boolean, +) { + call { + client + .post() + .uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.myGroup(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/groups/me") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.submitLab( + token: String, + labId: String, + files: List>, +): List { + val parts = LinkedMultiValueMap() + for ((name, bytes) in files) { + parts.add( + "files", + object : ByteArrayResource(bytes) { + override fun getFilename(): String = name + }, + ) + } + val type = object : ParameterizedTypeReference>() {} + return call { + client + .post() + .uri("/api/v1/me/submissions/{labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve() + .body(type) + } +} + +fun ApiClient.mySubmissions( + token: String, + labId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/me/submissions?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.labSubmissions( + token: String, + labId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/submissions?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.downloadMySubmission( + token: String, + submissionId: String, +): DownloadDto { + val entity = + call { + client + .get() + .uri("/api/v1/me/submissions/{id}/download", submissionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toEntity(ByteArray::class.java) + } + return downloadOf(entity) +} + +fun ApiClient.downloadSubmissionAsAdmin( + token: String, + submissionId: String, +): DownloadDto { + val entity = + call { + client + .get() + .uri("/api/v1/submissions/{id}/download", submissionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toEntity(ByteArray::class.java) + } + return downloadOf(entity) +} + +fun ApiClient.labDefenses( + token: String, + labId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/defenses?labId={labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.assignDefense( + token: String, + studentId: String, + labId: String, + assignmentMd: String, +): DefenseDto = + call { + client + .post() + .uri("/api/v1/defenses/assign") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("studentId" to studentId, "labId" to labId, "assignmentMd" to assignmentMd)) + .retrieve() + .body(DefenseDto::class.java) + } + +fun ApiClient.issueDefenseCode( + token: String, + sessionId: String, +): String = + call { + client + .post() + .uri("/api/v1/defenses/{id}/issue-code", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(Map::class.java) + }.get("code").toString() + +fun ApiClient.resetDefense( + token: String, + sessionId: String, +) { + call { + client + .post() + .uri("/api/v1/defenses/{id}/reset", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .toBodilessEntity() + } +} + +fun ApiClient.myDefenses(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/me/defenses") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + +fun ApiClient.myDefense( + token: String, + labId: String, +): DefenseDto? = + callOrNull { + client + .get() + .uri("/api/v1/me/defenses/{labId}", labId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(DefenseDto::class.java) + } + +fun ApiClient.startDefense( + token: String, + code: String, +): DefenseDto = + call { + client + .post() + .uri("/api/v1/me/defenses/start") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("code" to code)) + .retrieve() + .body(DefenseDto::class.java) + } + +fun ApiClient.finishDefense( + token: String, + sessionId: String, +): DefenseDto = + call { + client + .post() + .uri("/api/v1/me/defenses/{id}/finish", sessionId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .retrieve() + .body(DefenseDto::class.java) + } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index 6fdd110..5675808 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -10,6 +10,10 @@ import org.springframework.web.bind.annotation.RequestParam import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AuthResponseDto +import ru.ysu.lms.web.api.adminExists +import ru.ysu.lms.web.api.changePassword +import ru.ysu.lms.web.api.login +import ru.ysu.lms.web.api.setup import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt index 28956ec..de40f1a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RestController import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.contentAsset /** * Проксирует картинки статей (public path) через BFF: страницы /learn/... diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt index d0bb707..795f64b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/LearnController.kt @@ -10,6 +10,9 @@ import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ContentArticleDto import ru.ysu.lms.web.api.ContentTreeNodeDto +import ru.ysu.lms.web.api.contentArticle +import ru.ysu.lms.web.api.contentTree +import ru.ysu.lms.web.api.contentTreeList import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index b97fc96..2819375 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -18,6 +18,17 @@ import ru.ysu.lms.web.DateUtils import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.SubmissionDto +import ru.ysu.lms.web.api.downloadMySubmission +import ru.ysu.lms.web.api.finishDefense +import ru.ysu.lms.web.api.labDetail +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.api.meAssignments +import ru.ysu.lms.web.api.myDefense +import ru.ysu.lms.web.api.myGrades +import ru.ysu.lms.web.api.myGroup +import ru.ysu.lms.web.api.mySubmissions +import ru.ysu.lms.web.api.startDefense +import ru.ysu.lms.web.api.submitLab import ru.ysu.lms.web.session.SessionKeys import java.math.BigDecimal import java.net.URLEncoder